From 0a323835b3ab2df864f15a6f06a5ee3913777ada Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 16 Oct 2026 06:40:44 -0600 Subject: [PATCH 01/18] fix(macos): preserve description formatting and add editor controls --- .github/scripts/test-macos-swift-packages.sh | 4 +- .github/workflows/test-macos.yml | 2 +- .../HTMLFragment/HTMLFragment.swift | 23 ++- .../HTMLFragmentUnicodeTests.swift | 16 ++ apps/calendar-macos/CompassUI/Package.swift | 5 + .../DescriptionEditorContainerView.swift | 66 +++++++++ .../EventForm/DescriptionEditorView.swift | 137 ++++++++++++++---- .../CompassUI/EventForm/EventFormView.swift | 3 +- .../EventForm/HTMLFragmentAppKit.swift | 94 +++++++----- .../DescriptionEditorTests.swift | 76 ++++++++++ .../HTMLFragmentAppKitTests.swift | 52 +++++++ 11 files changed, 396 insertions(+), 82 deletions(-) create mode 100644 apps/calendar-macos/CompassKitTests/HTMLFragmentUnicodeTests.swift create mode 100644 apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift create mode 100644 apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/DescriptionEditorTests.swift create mode 100644 apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/HTMLFragmentAppKitTests.swift diff --git a/.github/scripts/test-macos-swift-packages.sh b/.github/scripts/test-macos-swift-packages.sh index 71567d3bec..69e060faeb 100755 --- a/.github/scripts/test-macos-swift-packages.sh +++ b/.github/scripts/test-macos-swift-packages.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# Run `swift test` for pure SwiftPM packages under apps/calendar-macos (no XcodeGen). +# Run `swift test` for native SwiftPM packages under apps/calendar-macos (no XcodeGen). set -euo pipefail ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) cd "$ROOT" -PACKAGES=(CompassKit CompassData) +PACKAGES=(CompassKit CompassData CompassUI) ran=0 for name in "${PACKAGES[@]}"; do diff --git a/.github/workflows/test-macos.yml b/.github/workflows/test-macos.yml index 9cc3bda3ee..805960c374 100644 --- a/.github/workflows/test-macos.yml +++ b/.github/workflows/test-macos.yml @@ -106,7 +106,7 @@ jobs: ${{ runner.os }}-swift-pkgs-v1- ${{ runner.os }}-swift-pkgs- - - name: Test CompassKit and CompassData + - name: Test native Swift packages run: bash .github/scripts/test-macos-swift-packages.sh build: diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/HTMLFragment/HTMLFragment.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/HTMLFragment/HTMLFragment.swift index 98fcef6994..0e05e18c58 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/HTMLFragment/HTMLFragment.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/HTMLFragment/HTMLFragment.swift @@ -153,24 +153,19 @@ enum HTMLFragmentParser { } var blocks: [HTMLFragmentBlock] = [] - var cursor = html.startIndex + let nsHtml = html as NSString + var cursor = 0 for range in ranges { - if range.start > html.distance(from: html.startIndex, to: cursor) { - let start = cursor - let end = html.index(html.startIndex, offsetBy: range.start) - let chunk = String(html[start.. cursor { + let chunk = nsHtml.substring(with: NSRange(location: cursor, length: range.start - cursor)) + blocks.append(.verbatim(chunk)) } - let sliceStart = html.index(html.startIndex, offsetBy: range.start) - let sliceEnd = html.index(html.startIndex, offsetBy: range.end) - let slice = String(html[sliceStart.. 0 && range.location < (textView.textStorage?.length ?? 0) + ? textView.textStorage?.attributes(at: range.location, effectiveRange: nil) ?? [:] + : textView.typingAttributes + let font = attributes[.font] as? NSFont ?? NSFont.systemFont(ofSize: 13) + let traits = NSFontManager.shared.traits(of: font) + let list = attributes[HTMLFragmentAppKit.listKindKey] as? String + let states = [traits.contains(.boldFontMask), traits.contains(.italicFontMask), list == "ordered", list == "unordered"] + for (button, selected) in zip(buttons, states) { + button.state = selected ? .on : .off + button.isEnabled = textView.isEditable + if let tint { button.contentTintColor = tint } + } + } +} diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorView.swift index 2ed2da9c48..f09155b225 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorView.swift @@ -7,21 +7,19 @@ struct DescriptionEditorView: NSViewRepresentable { @Binding var html: String var resetKey: String var isFocused: Bool + var isEditable = true func makeCoordinator() -> Coordinator { Coordinator(html: $html) } - func makeNSView(context: Context) -> NSScrollView { - let scrollView = NSScrollView() - scrollView.drawsBackground = false - scrollView.hasVerticalScroller = true - scrollView.borderType = .noBorder - - let textView = DescriptionTextView() + func makeNSView(context: Context) -> DescriptionEditorContainerView { + let container = DescriptionEditorContainerView() + let textView = container.textView textView.isRichText = true textView.importsGraphics = false - textView.isEditable = true + textView.isEditable = isEditable + textView.allowsUndo = true textView.isSelectable = true textView.drawsBackground = false textView.textContainerInset = NSSize(width: 4, height: 6) @@ -33,20 +31,21 @@ struct DescriptionEditorView: NSViewRepresentable { textView.setAccessibilityIdentifier("compass-event-form-description") textView.setAccessibilityLabel("Description") - scrollView.documentView = textView + context.coordinator.container = container context.coordinator.textView = textView context.coordinator.applyTheme(theme) context.coordinator.load(html: html, resetKey: resetKey) - return scrollView + return container } - func updateNSView(_ scrollView: NSScrollView, context: Context) { + func updateNSView(_ container: DescriptionEditorContainerView, context: Context) { + let textView = container.textView + textView.isEditable = isEditable context.coordinator.applyTheme(theme) if context.coordinator.resetKey != resetKey { context.coordinator.load(html: html, resetKey: resetKey) } - guard let textView = scrollView.documentView as? DescriptionTextView else { return } - if isFocused, textView.window != nil { + if isFocused, isEditable, textView.window != nil { textView.window?.makeFirstResponder(textView) } } @@ -55,8 +54,8 @@ struct DescriptionEditorView: NSViewRepresentable { final class Coordinator: NSObject, NSTextViewDelegate { private var html: Binding weak var textView: DescriptionTextView? + weak var container: DescriptionEditorContainerView? var resetKey: String = "" - private var sourceDocument = HTMLFragmentDocument(blocks: []) private var suppressDelegate = false init(html: Binding) { @@ -65,12 +64,13 @@ struct DescriptionEditorView: NSViewRepresentable { func applyTheme(_ theme: NativeWebTheme) { guard let textView else { return } - textView.typingAttributes = [ - .font: NSFont.systemFont(ofSize: 13), - .foregroundColor: NSColor(theme.textColor), - ] + var attributes = textView.typingAttributes + if attributes[.font] == nil { attributes[.font] = NSFont.systemFont(ofSize: 13) } + attributes[.foregroundColor] = NSColor(theme.textColor) + textView.typingAttributes = attributes textView.textColor = NSColor(theme.textColor) textView.insertionPointColor = NSColor(theme.textColor) + container?.updateControls(tint: NSColor(theme.textMutedColor)) textView.linkTextAttributes = [ .foregroundColor: NSColor(theme.accentColor), .underlineStyle: NSUnderlineStyle.single.rawValue, @@ -80,7 +80,7 @@ struct DescriptionEditorView: NSViewRepresentable { func load(html: String, resetKey: String) { guard let textView else { return } self.resetKey = resetKey - sourceDocument = HTMLFragmentDocument.parse(html) + let sourceDocument = HTMLFragmentDocument.parse(html) let attributed = HTMLFragmentAppKit.attributedString( from: sourceDocument, textColor: textView.textColor ?? .labelColor, @@ -93,16 +93,25 @@ struct DescriptionEditorView: NSViewRepresentable { func textDidChange(_ notification: Notification) { guard !suppressDelegate, let textView else { return } - let edited = HTMLFragmentAppKit.document( - from: textView.attributedString(), - preservingVerbatimFrom: sourceDocument - ) + let edited = HTMLFragmentAppKit.document(from: textView.attributedString()) html.wrappedValue = edited.serialize() + container?.updateControls() + } + + func textViewDidChangeSelection(_ notification: Notification) { + container?.updateControls() } } } final class DescriptionTextView: NSTextView { + var onFormattingChange: (() -> Void)? + + @objc func toggleBold(_ sender: Any?) { toggleFontTrait(.boldFontMask) } + @objc func toggleItalic(_ sender: Any?) { toggleFontTrait(.italicFontMask) } + @objc func toggleNumberedList(_ sender: Any?) { toggleList(ordered: true) } + @objc func toggleBulletList(_ sender: Any?) { toggleList(ordered: false) } + override func performKeyEquivalent(with event: NSEvent) -> Bool { if event.modifierFlags.contains(.command) { switch event.charactersIgnoringModifiers?.lowercased() { @@ -140,13 +149,24 @@ final class DescriptionTextView: NSTextView { in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil ) - guard index < string.count else { return nil } + guard index < (string as NSString).length else { return nil } return attributedString().attribute(.link, at: index, effectiveRange: nil) as? URL } - private func toggleFontTrait(_ trait: NSFontTraitMask) { + func toggleFontTrait(_ trait: NSFontTraitMask) { + guard isEditable else { return } + window?.makeFirstResponder(self) let range = selectedRange() - guard range.length > 0 else { return } + if range.length == 0 { + let font = typingAttributes[.font] as? NSFont ?? NSFont.systemFont(ofSize: 13) + let hasTrait = NSFontManager.shared.traits(of: font).contains(trait) + typingAttributes[.font] = hasTrait + ? NSFontManager.shared.convert(font, toNotHaveTrait: trait) + : NSFontManager.shared.convert(font, toHaveTrait: trait) + onFormattingChange?() + return + } + guard shouldChangeText(in: range, replacementString: nil) else { return } textStorage?.enumerateAttribute(.font, in: range) { value, subRange, _ in let font = (value as? NSFont) ?? NSFont.systemFont(ofSize: 13) let hasTrait = NSFontManager.shared.traits(of: font).contains(trait) @@ -155,6 +175,61 @@ final class DescriptionTextView: NSTextView { : NSFontManager.shared.convert(font, toHaveTrait: trait) textStorage?.addAttribute(.font, value: updated, range: subRange) } + didChangeText() + onFormattingChange?() + } + + func toggleList(ordered: Bool) { + guard isEditable, let storage = textStorage else { return } + let text = string as NSString + let selection = selectedRange() + let before = text.range(of: "\n", options: .backwards, range: NSRange(location: 0, length: selection.location)) + let start = before.location == NSNotFound ? 0 : NSMaxRange(before) + let lastSelected = max(selection.location, NSMaxRange(selection) - 1) + let after = text.range(of: "\n", range: NSRange(location: lastSelected, length: text.length - lastSelected)) + let end = after.location == NSNotFound ? text.length : after.location + let range = NSRange(location: start, length: end - start) + guard shouldChangeText(in: range, replacementString: nil) else { return } + let source = storage.attributedSubstring(from: range) + let kind = ordered ? "ordered" : "unordered" + let lines = source.string.components(separatedBy: "\n") + var offset = 0 + let items = lines.map { line -> (NSMutableAttributedString, String?, Int) in + let length = (line as NSString).length + let currentKind = length > 0 ? source.attribute(HTMLFragmentAppKit.listKindKey, at: offset, effectiveRange: nil) as? String : nil + let prefix = HTMLFragmentAppKit.listPrefixLength(line, kind: currentKind) + let content = NSMutableAttributedString(attributedString: source.attributedSubstring( + from: NSRange(location: offset + prefix, length: length - prefix))) + content.removeAttribute(HTMLFragmentAppKit.listKindKey, range: NSRange(location: 0, length: content.length)) + offset += length + 1 + return (content, currentKind, prefix) + } + let remove = items.allSatisfy { $0.1 == kind } + let replacement = NSMutableAttributedString() + var firstPrefixLength = 0 + for (index, item) in items.enumerated() { + if index > 0 { replacement.append(NSAttributedString(string: "\n")) } + let itemStart = replacement.length + if !remove { + let prefix = ordered ? "\(index + 1). " : "โ€ข " + if index == 0 { firstPrefixLength = (prefix as NSString).length } + replacement.append(NSAttributedString(string: prefix, attributes: typingAttributes)) + } + replacement.append(item.0) + if !remove { + replacement.addAttribute(HTMLFragmentAppKit.listKindKey, value: kind, + range: NSRange(location: itemStart, length: replacement.length - itemStart)) + } + } + storage.replaceCharacters(in: range, with: replacement) + let newLocation = min(start + replacement.length, max(start, selection.location + firstPrefixLength - (items.first?.2 ?? 0))) + let newEnd = min(start + replacement.length, max(newLocation, NSMaxRange(selection) + replacement.length - range.length)) + setSelectedRange(NSRange(location: newLocation, length: selection.length == 0 ? 0 : newEnd - newLocation)) + if remove { typingAttributes.removeValue(forKey: HTMLFragmentAppKit.listKindKey) } + else { typingAttributes[HTMLFragmentAppKit.listKindKey] = kind } + didChangeText() + onFormattingChange?() + window?.makeFirstResponder(self) } private func promptAndApplyLink() { @@ -173,6 +248,14 @@ final class DescriptionTextView: NSTextView { guard href.hasPrefix("http://") || href.hasPrefix("https://"), let url = URL(string: href) else { return } - textStorage?.addAttribute(.link, value: url, range: selected) + applyLink(url, to: selected) + } + + func applyLink(_ url: URL, to range: NSRange) { + guard isEditable, range.length > 0, + shouldChangeText(in: range, replacementString: nil) else { return } + textStorage?.addAttribute(.link, value: url, range: range) + didChangeText() + onFormattingChange?() } } diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift index 57775e493a..fb12a50afc 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift @@ -54,7 +54,8 @@ public struct EventFormView: View { set: { model.updateDraftFromForm(description: $0) } ), resetKey: draft.persistedEventId?.rawValue ?? draft.clientId.rawValue, - isFocused: model.eventFormFocusedField == .description + isFocused: model.eventFormFocusedField == .description, + isEditable: !model.isEventFormReadOnly(for: draft) ) .frame(minHeight: 72, maxHeight: 140) .padding(8) diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/HTMLFragmentAppKit.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/HTMLFragmentAppKit.swift index 664d2f8ca2..ecfce7dfb4 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/HTMLFragmentAppKit.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/HTMLFragmentAppKit.swift @@ -2,6 +2,8 @@ import AppKit import CompassKit enum HTMLFragmentAppKit { + static let listKindKey = NSAttributedString.Key("CompassDescriptionListKind") + private static let verbatimKey = NSAttributedString.Key("CompassDescriptionVerbatim") static func attributedString( from document: HTMLFragmentDocument, textColor: NSColor, @@ -24,6 +26,7 @@ enum HTMLFragmentAppKit { result.append(NSAttributedString(string: "\n", attributes: [.font: baseFont, .foregroundColor: textColor])) case .unorderedList(let items): for item in items { + let start = result.length let prefix = "โ€ข " result.append( NSAttributedString( @@ -39,10 +42,12 @@ enum HTMLFragmentAppKit { linkColor: linkColor ) ) + result.addAttribute(listKindKey, value: "unordered", range: NSRange(location: start, length: result.length - start)) result.append(NSAttributedString(string: "\n", attributes: [.font: baseFont, .foregroundColor: textColor])) } case .orderedList(let items): for (index, item) in items.enumerated() { + let start = result.length let prefix = "\(index + 1). " result.append( NSAttributedString( @@ -58,6 +63,7 @@ enum HTMLFragmentAppKit { linkColor: linkColor ) ) + result.addAttribute(listKindKey, value: "ordered", range: NSRange(location: start, length: result.length - start)) result.append(NSAttributedString(string: "\n", attributes: [.font: baseFont, .foregroundColor: textColor])) } case .verbatim(let html): @@ -67,52 +73,66 @@ enum HTMLFragmentAppKit { attributes: [ .font: NSFont.monospacedSystemFont(ofSize: fontSize - 1, weight: .regular), .foregroundColor: textColor.withAlphaComponent(0.8), + verbatimKey: html, ] ) ) result.append(NSAttributedString(string: "\n", attributes: [.font: baseFont, .foregroundColor: textColor])) } } - while result.string.hasSuffix("\n") { + if result.string.hasSuffix("\n") { result.deleteCharacters(in: NSRange(location: result.length - 1, length: 1)) } return result } static func document( - from attributedString: NSAttributedString, - preservingVerbatimFrom original: HTMLFragmentDocument + from attributedString: NSAttributedString ) -> HTMLFragmentDocument { var blocks: [HTMLFragmentBlock] = [] let text = attributedString.string - let lines = text.components(separatedBy: "\n").filter { !$0.isEmpty } - for line in lines { - if let verbatim = original.blocks.compactMap({ - if case .verbatim(let html) = $0, line.contains(html.trimmingCharacters(in: .whitespacesAndNewlines)) { - return html + guard !text.isEmpty else { return HTMLFragmentDocument(blocks: []) } + var offset = 0 + var preservedVerbatimRanges: Set = [] + for line in text.components(separatedBy: "\n") { + let range = NSRange(location: offset, length: (line as NSString).length) + offset += range.length + 1 + if range.length > 0 { + var effectiveRange = NSRange() + if let html = attributedString.attribute(verbatimKey, at: range.location, effectiveRange: &effectiveRange) as? String, + attributedString.attributedSubstring(from: effectiveRange).string == html { + if preservedVerbatimRanges.insert(effectiveRange.location).inserted { + blocks.append(.verbatim(html)) + } + continue + } + if let kind = attributedString.attribute(listKindKey, at: range.location, effectiveRange: nil) as? String { + let prefixLength = listPrefixLength(line, kind: kind) + let contentRange = NSRange(location: range.location + prefixLength, length: range.length - prefixLength) + let item = HTMLFragmentListItem(inlines: inlinesForLine(line, in: attributedString, range: contentRange)) + switch (kind, blocks.last) { + case ("unordered", .unorderedList(let items)): + blocks[blocks.count - 1] = .unorderedList(items + [item]) + case ("ordered", .orderedList(let items)): + blocks[blocks.count - 1] = .orderedList(items + [item]) + case ("unordered", _): blocks.append(.unorderedList([item])) + default: blocks.append(.orderedList([item])) + } + continue } - return nil - }).first { - blocks.append(.verbatim(verbatim)) - continue - } - let range = (text as NSString).range(of: line) - let inlines = inlinesForLine(line, in: attributedString, range: range) - if !inlines.isEmpty { - blocks.append(.paragraph(inlines)) - } - } - for block in original.blocks { - if case .verbatim(let html) = block, - text.contains(html), - !blocks.contains(.verbatim(html)) - { - blocks.append(.verbatim(html)) } + blocks.append(.paragraph(inlinesForLine(line, in: attributedString, range: range))) } return HTMLFragmentDocument(blocks: blocks) } + static func listPrefixLength(_ line: String, kind: String?) -> Int { + guard let kind else { return 0 } + let pattern = kind == "unordered" ? "^โ€ข " : "^[0-9]+\\. " + let range = (line as NSString).range(of: pattern, options: .regularExpression) + return range.location == 0 ? range.length : 0 + } + private static func attributedInlines( _ inlines: [HTMLFragmentInline], baseFont: NSFont, @@ -132,7 +152,7 @@ enum HTMLFragmentAppKit { case .lineBreak: result.append( NSAttributedString( - string: "\n", + string: "\u{2028}", attributes: [.font: baseFont, .foregroundColor: textColor] ) ) @@ -193,7 +213,7 @@ enum HTMLFragmentAppKit { } result.append(NSAttributedString(string: value, attributes: attrs)) case .lineBreak: - result.append(NSAttributedString(string: "\n", attributes: [.font: font, .foregroundColor: textColor])) + result.append(NSAttributedString(string: "\u{2028}", attributes: [.font: font, .foregroundColor: textColor])) case .bold(let children): let boldFont = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) result.append(inlineAttributedString(children, font: boldFont, textColor: textColor, linkColor: linkColor, link: link)) @@ -219,20 +239,20 @@ enum HTMLFragmentAppKit { attributedString.enumerateAttributes(in: range, options: []) { attrs, subRange, _ in let substring = (attributedString.string as NSString).substring(with: subRange) if substring.isEmpty { return } - if attrs[.link] != nil { - let href = (attrs[.link] as? URL)?.absoluteString ?? substring - inlines.append(.link(href: href, children: [.text(substring)])) - return + var children: [HTMLFragmentInline] = [] + for (index, piece) in substring.components(separatedBy: "\u{2028}").enumerated() { + if index > 0 { children.append(.lineBreak) } + if !piece.isEmpty { children.append(.text(piece)) } } let font = attrs[.font] as? NSFont ?? NSFont.systemFont(ofSize: 13) let traits = NSFontManager.shared.traits(of: font) - if traits.contains(.boldFontMask) { - inlines.append(.bold([.text(substring)])) - } else if traits.contains(.italicFontMask) { - inlines.append(.italic([.text(substring)])) - } else { - inlines.append(.text(substring)) + if traits.contains(.italicFontMask) { children = [.italic(children)] } + if traits.contains(.boldFontMask) { children = [.bold(children)] } + if let value = attrs[.link] { + let href = (value as? URL)?.absoluteString ?? (value as? String) ?? substring + children = [.link(href: href, children: children)] } + inlines.append(contentsOf: children) } return inlines } diff --git a/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/DescriptionEditorTests.swift b/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/DescriptionEditorTests.swift new file mode 100644 index 0000000000..5442c07870 --- /dev/null +++ b/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/DescriptionEditorTests.swift @@ -0,0 +1,76 @@ +import AppKit +import CompassKit +import SwiftUI +import XCTest +@testable import CompassUI + +@MainActor +final class DescriptionEditorTests: XCTestCase { + func testFormattingImmediatelyUpdatesBoundHTMLWithoutTyping() { + var html = "

Selected

" + let coordinator = DescriptionEditorView.Coordinator(html: Binding(get: { html }, set: { html = $0 })) + let editor = DescriptionTextView() + coordinator.textView = editor + editor.delegate = coordinator + coordinator.load(html: html, resetKey: "event") + editor.setSelectedRange(NSRange(location: 0, length: 8)) + + editor.toggleFontTrait(.boldFontMask) + XCTAssertEqual(html, "

Selected

") + editor.toggleFontTrait(.italicFontMask) + XCTAssertEqual(html, "

Selected

") + editor.applyLink(URL(string: "https://example.com")!, to: editor.selectedRange()) + XCTAssertEqual(html, "

Selected

") + } + + func testCaretFormattingSurvivesThemeUpdates() { + let coordinator = DescriptionEditorView.Coordinator(html: .constant("")) + let editor = DescriptionTextView() + coordinator.textView = editor + editor.toggleFontTrait(.boldFontMask) + coordinator.applyTheme(.darkAbyss) + let font = editor.typingAttributes[.font] as? NSFont + XCTAssertTrue(font.map { NSFontManager.shared.traits(of: $0).contains(.boldFontMask) } ?? false) + editor.toggleFontTrait(.boldFontMask) + XCTAssertFalse(NSFontManager.shared.traits(of: editor.typingAttributes[.font] as! NSFont).contains(.boldFontMask)) + } + + func testListButtonsPreserveInlineFormattingAndToggleBackToParagraphs() { + var html = "

๐ŸŒด First

Second

" + let coordinator = DescriptionEditorView.Coordinator(html: Binding(get: { html }, set: { html = $0 })) + let editor = DescriptionTextView() + coordinator.textView = editor + editor.delegate = coordinator + coordinator.load(html: html, resetKey: "event") + editor.setSelectedRange(NSRange(location: 0, length: (editor.string as NSString).length)) + + editor.toggleList(ordered: false) + XCTAssertEqual(html, "
  • ๐ŸŒด First

  • Second

") + editor.toggleList(ordered: true) + XCTAssertEqual(html, "
  1. ๐ŸŒด First

  2. Second

") + editor.toggleList(ordered: true) + XCTAssertEqual(html, "

๐ŸŒด First

Second

") + } + + func testEmptyParagraphCanBecomeAListWithoutMovingCaretIntoPrefix() { + let editor = DescriptionTextView() + editor.setSelectedRange(NSRange(location: 0, length: 0)) + editor.toggleList(ordered: false) + XCTAssertEqual(editor.string, "โ€ข ") + XCTAssertEqual(editor.selectedRange(), NSRange(location: 2, length: 0)) + editor.toggleList(ordered: false) + XCTAssertEqual(editor.string, "") + XCTAssertEqual(editor.selectedRange(), NSRange(location: 0, length: 0)) + } + + func testReadOnlyEditorRejectsFormatting() { + let editor = DescriptionTextView() + editor.textStorage?.setAttributedString(NSAttributedString(string: "Read only")) + editor.setSelectedRange(NSRange(location: 0, length: 9)) + editor.isEditable = false + editor.toggleFontTrait(.boldFontMask) + editor.applyLink(URL(string: "https://example.com")!, to: editor.selectedRange()) + XCTAssertNil(editor.textStorage?.attribute(.link, at: 0, effectiveRange: nil)) + XCTAssertNil(editor.textStorage?.attribute(.font, at: 0, effectiveRange: nil)) + } +} diff --git a/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/HTMLFragmentAppKitTests.swift b/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/HTMLFragmentAppKitTests.swift new file mode 100644 index 0000000000..18a8bd6a17 --- /dev/null +++ b/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/HTMLFragmentAppKitTests.swift @@ -0,0 +1,52 @@ +import AppKit +import CompassKit +import XCTest +@testable import CompassUI + +@MainActor +final class HTMLFragmentAppKitTests: XCTestCase { + private func roundTrip(_ html: String) -> HTMLFragmentDocument { + let document = HTMLFragmentDocument.parse(html) + let attributed = HTMLFragmentAppKit.attributedString(from: document, textColor: .textColor, linkColor: .linkColor) + return HTMLFragmentAppKit.document(from: attributed) + } + + func testListsRetainTheirStructure() { + for html in ["
  • First
  • Second
", + "
  1. First
  2. Second
", + "

Intro

  • Item

After

"] { + XCTAssertEqual(roundTrip(html), HTMLFragmentDocument.parse(html)) + } + } + + func testRepeatedLinesKeepTheirOwnFormatting() { + let html = "

Same

Same

Same

" + XCTAssertEqual(roundTrip(html), HTMLFragmentDocument.parse(html)) + } + + func testNestedFormattingAndLinksAreNotDropped() { + let html = "

Both Link

" + XCTAssertEqual(roundTrip(html), HTMLFragmentDocument.parse(html)) + } + + func testSoftBreaksAndEmptyParagraphsSurvive() { + let html = "

First
Second

Last

" + XCTAssertEqual(roundTrip(html), HTMLFragmentDocument.parse(html)) + } + + func testEmojiDoesNotShiftFormattingRanges() { + let html = "

๐ŸŒด Same

๐ŸŒด Same

" + XCTAssertEqual(roundTrip(html), HTMLFragmentDocument.parse(html)) + } + + func testUnsupportedMultilineHTMLIsPreservedWhileEditingOtherParagraphs() { + let html = "\n\n
Legacy

Note

" + XCTAssertEqual(roundTrip(html), HTMLFragmentDocument.parse(html)) + } + + func testTextThatLooksLikeAListStaysAParagraph() { + for html in ["

1. Plain text

", "

โ€ข Plain text

"] { + XCTAssertEqual(roundTrip(html), HTMLFragmentDocument.parse(html)) + } + } +} From 387dca575abe1824ac210dd324e39547a12820dc Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 16 Oct 2026 06:41:48 -0600 Subject: [PATCH 02/18] fix(macos): block link prompts in read only descriptions --- .../Sources/CompassUI/EventForm/DescriptionEditorView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorView.swift index f09155b225..c2a199f395 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorView.swift @@ -234,7 +234,7 @@ final class DescriptionTextView: NSTextView { private func promptAndApplyLink() { let selected = selectedRange() - guard selected.length > 0 else { return } + guard isEditable, selected.length > 0 else { return } let alert = NSAlert() alert.messageText = "Add Link" alert.informativeText = "Enter a URL for the selected text." From 3d1c843bcaca6fc5c1c5b8a537f3ae4c4a2aaf1f Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 16 Oct 2026 06:41:48 -0600 Subject: [PATCH 03/18] refactor(macos): share description inline formatting conversion --- .../EventForm/HTMLFragmentAppKit.swift | 80 +++---------------- 1 file changed, 9 insertions(+), 71 deletions(-) diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/HTMLFragmentAppKit.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/HTMLFragmentAppKit.swift index ecfce7dfb4..8ee76e46b9 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/HTMLFragmentAppKit.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/HTMLFragmentAppKit.swift @@ -16,9 +16,9 @@ enum HTMLFragmentAppKit { switch block { case .paragraph(let inlines): result.append( - attributedInlines( + inlineAttributedString( inlines, - baseFont: baseFont, + font: baseFont, textColor: textColor, linkColor: linkColor ) @@ -35,9 +35,9 @@ enum HTMLFragmentAppKit { ) ) result.append( - attributedInlines( + inlineAttributedString( item.inlines, - baseFont: baseFont, + font: baseFont, textColor: textColor, linkColor: linkColor ) @@ -56,9 +56,9 @@ enum HTMLFragmentAppKit { ) ) result.append( - attributedInlines( + inlineAttributedString( item.inlines, - baseFont: baseFont, + font: baseFont, textColor: textColor, linkColor: linkColor ) @@ -109,7 +109,7 @@ enum HTMLFragmentAppKit { if let kind = attributedString.attribute(listKindKey, at: range.location, effectiveRange: nil) as? String { let prefixLength = listPrefixLength(line, kind: kind) let contentRange = NSRange(location: range.location + prefixLength, length: range.length - prefixLength) - let item = HTMLFragmentListItem(inlines: inlinesForLine(line, in: attributedString, range: contentRange)) + let item = HTMLFragmentListItem(inlines: inlines(in: attributedString, range: contentRange)) switch (kind, blocks.last) { case ("unordered", .unorderedList(let items)): blocks[blocks.count - 1] = .unorderedList(items + [item]) @@ -121,7 +121,7 @@ enum HTMLFragmentAppKit { continue } } - blocks.append(.paragraph(inlinesForLine(line, in: attributedString, range: range))) + blocks.append(.paragraph(inlines(in: attributedString, range: range))) } return HTMLFragmentDocument(blocks: blocks) } @@ -133,64 +133,6 @@ enum HTMLFragmentAppKit { return range.location == 0 ? range.length : 0 } - private static func attributedInlines( - _ inlines: [HTMLFragmentInline], - baseFont: NSFont, - textColor: NSColor, - linkColor: NSColor - ) -> NSAttributedString { - let result = NSMutableAttributedString() - for inline in inlines { - switch inline { - case .text(let value): - result.append( - NSAttributedString( - string: value, - attributes: [.font: baseFont, .foregroundColor: textColor] - ) - ) - case .lineBreak: - result.append( - NSAttributedString( - string: "\u{2028}", - attributes: [.font: baseFont, .foregroundColor: textColor] - ) - ) - case .bold(let children): - let boldFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .boldFontMask) - result.append( - inlineAttributedString( - children, - font: boldFont, - textColor: textColor, - linkColor: linkColor - ) - ) - case .italic(let children): - let italicFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .italicFontMask) - result.append( - inlineAttributedString( - children, - font: italicFont, - textColor: textColor, - linkColor: linkColor - ) - ) - case .link(let href, let children): - result.append( - inlineAttributedString( - children, - font: baseFont, - textColor: linkColor, - linkColor: linkColor, - link: href - ) - ) - } - } - return result - } - private static func inlineAttributedString( _ inlines: [HTMLFragmentInline], font: NSFont, @@ -227,14 +169,10 @@ enum HTMLFragmentAppKit { return result } - private static func inlinesForLine( - _ line: String, + private static func inlines( in attributedString: NSAttributedString, range: NSRange ) -> [HTMLFragmentInline] { - guard range.location != NSNotFound else { - return line.isEmpty ? [] : [.text(line)] - } var inlines: [HTMLFragmentInline] = [] attributedString.enumerateAttributes(in: range, options: []) { attrs, subRange, _ in let substring = (attributedString.string as NSString).substring(with: subRange) From 8b80995ab5f267ebadd85025d1314c97445bcc5c Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 16 Oct 2026 06:48:13 -0600 Subject: [PATCH 04/18] fix(macos): continue description lists and cover formatting persistence --- .../DescriptionEditorContainerView.swift | 3 ++ .../EventForm/DescriptionEditorView.swift | 37 +++++++++++++++++-- .../DescriptionEditorTests.swift | 15 ++++++++ .../DescriptionEditorFormattingTests.swift | 32 ++++++++++++++++ 4 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift index de3852bd90..1c8c913c7f 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift @@ -14,6 +14,9 @@ final class DescriptionEditorContainerView: NSView { scrollView.hasVerticalScroller = true scrollView.borderType = .noBorder scrollView.documentView = textView + toolbar.setAccessibilityElement(true) + toolbar.setAccessibilityRole(.toolbar) + toolbar.setAccessibilityLabel("Description formatting") toolbar.orientation = .horizontal toolbar.spacing = 4 for (label, symbol, action) in [ diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorView.swift index c2a199f395..9cecca3241 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorView.swift @@ -179,16 +179,45 @@ final class DescriptionTextView: NSTextView { onFormattingChange?() } - func toggleList(ordered: Bool) { + override func insertNewline(_ sender: Any?) { guard isEditable, let storage = textStorage else { return } + let range = paragraphRange(containing: NSRange(location: selectedRange().location, length: 0)) + guard range.length > 0, + let kind = storage.attribute(HTMLFragmentAppKit.listKindKey, at: range.location, effectiveRange: nil) as? String + else { super.insertNewline(sender); return } + let line = (string as NSString).substring(with: range) + let prefixLength = HTMLFragmentAppKit.listPrefixLength(line, kind: kind) + if (line as NSString).substring(from: prefixLength).trimmingCharacters(in: .whitespaces).isEmpty { + guard shouldChangeText(in: range, replacementString: "") else { return } + storage.replaceCharacters(in: range, with: "") + typingAttributes.removeValue(forKey: HTMLFragmentAppKit.listKindKey) + setSelectedRange(NSRange(location: range.location, length: 0)) + didChangeText() + onFormattingChange?() + return + } + let attributes = typingAttributes + super.insertNewline(sender) + typingAttributes = attributes + typingAttributes[HTMLFragmentAppKit.listKindKey] = kind + let number = Int(line.components(separatedBy: ".").first ?? "") ?? 0 + insertText(kind == "unordered" ? "โ€ข " : "\(number + 1). ", replacementRange: selectedRange()) + } + + private func paragraphRange(containing selection: NSRange) -> NSRange { let text = string as NSString - let selection = selectedRange() let before = text.range(of: "\n", options: .backwards, range: NSRange(location: 0, length: selection.location)) let start = before.location == NSNotFound ? 0 : NSMaxRange(before) let lastSelected = max(selection.location, NSMaxRange(selection) - 1) let after = text.range(of: "\n", range: NSRange(location: lastSelected, length: text.length - lastSelected)) - let end = after.location == NSNotFound ? text.length : after.location - let range = NSRange(location: start, length: end - start) + return NSRange(location: start, length: (after.location == NSNotFound ? text.length : after.location) - start) + } + + func toggleList(ordered: Bool) { + guard isEditable, let storage = textStorage else { return } + let selection = selectedRange() + let range = paragraphRange(containing: selection) + let start = range.location guard shouldChangeText(in: range, replacementString: nil) else { return } let source = storage.attributedSubstring(from: range) let kind = ordered ? "ordered" : "unordered" diff --git a/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/DescriptionEditorTests.swift b/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/DescriptionEditorTests.swift index 5442c07870..110daf829b 100644 --- a/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/DescriptionEditorTests.swift +++ b/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/DescriptionEditorTests.swift @@ -63,6 +63,21 @@ final class DescriptionEditorTests: XCTestCase { XCTAssertEqual(editor.selectedRange(), NSRange(location: 0, length: 0)) } + func testEnterContinuesListAndEmptyItemExitsIt() { + let editor = DescriptionTextView() + editor.textStorage?.setAttributedString(NSAttributedString(string: "First")) + editor.setSelectedRange(NSRange(location: 5, length: 0)) + editor.toggleList(ordered: true) + editor.insertNewline(nil) + XCTAssertEqual(editor.string, "1. First\n2. ") + editor.insertText("Second", replacementRange: editor.selectedRange()) + editor.insertNewline(nil) + XCTAssertEqual(editor.string, "1. First\n2. Second\n3. ") + editor.insertNewline(nil) + XCTAssertEqual(editor.string, "1. First\n2. Second\n") + XCTAssertNil(editor.typingAttributes[HTMLFragmentAppKit.listKindKey]) + } + func testReadOnlyEditorRejectsFormatting() { let editor = DescriptionTextView() editor.textStorage?.setAttributedString(NSAttributedString(string: "Read only")) diff --git a/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift b/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift new file mode 100644 index 0000000000..f6c2da5461 --- /dev/null +++ b/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift @@ -0,0 +1,32 @@ +import XCTest + +final class DescriptionEditorFormattingTests: XCTestCase { + @MainActor + func testBoldSelectionSurvivesSaveAndReopenWithoutExtraTyping() { + let app = XCUIApplication.compassUITest() + app.launchArguments += [ + "-COMPASS_FIXTURE", "demo", + "-COMPASS_UI_TEST_INITIAL_GRID_FOCUS_EVENT", "demo-morning-standup", + "-COMPASS_UI_TEST_PIN_WEEK_GRID_TRACK", + "-COMPASS_UI_TEST_OPEN_FOCUSED_EVENT_FORM", + ] + app.launch() + let window = app.windows["Compass"] + let editor = window.textViews["compass-event-form-description"] + XCTAssertTrue(editor.waitForExistence(timeout: 8)) + editor.click() + editor.typeKey("a", modifierFlags: [.command]) + editor.typeText("Formatted note") + editor.typeKey("a", modifierFlags: [.command]) + window.buttons["Bold"].click() + window.typeKey(.enter, modifierFlags: [.command]) + XCTAssertFalse(editor.exists) + + window.typeKey(.enter, modifierFlags: []) + XCTAssertTrue(editor.waitForExistence(timeout: 8)) + editor.click() + editor.typeKey("a", modifierFlags: [.command]) + XCTAssertEqual(editor.value as? String, "Formatted note") + XCTAssertEqual(window.buttons["Bold"].value as? String, "1") + } +} From e4b3f063ffa6d9258a881135e32b792ae92344e8 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 16 Oct 2026 07:11:03 -0600 Subject: [PATCH 05/18] fix(macos): renumber description items after list insertion --- .../EventForm/DescriptionEditorView.swift | 22 +++++++++++++++++++ .../DescriptionEditorTests.swift | 14 ++++++++++++ 2 files changed, 36 insertions(+) diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorView.swift index 9cecca3241..60e42d0158 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorView.swift @@ -202,6 +202,28 @@ final class DescriptionTextView: NSTextView { typingAttributes[HTMLFragmentAppKit.listKindKey] = kind let number = Int(line.components(separatedBy: ".").first ?? "") ?? 0 insertText(kind == "unordered" ? "โ€ข " : "\(number + 1). ", replacementRange: selectedRange()) + if kind == "ordered" { + renumberFollowingItems(after: paragraphRange(containing: selectedRange()), startingAt: number + 2) + didChangeText() + } + } + + private func renumberFollowingItems(after paragraph: NSRange, startingAt firstNumber: Int) { + guard let storage = textStorage else { return } + var offset = NSMaxRange(paragraph) + 1 + var number = firstNumber + while offset < storage.length { + let range = paragraphRange(containing: NSRange(location: offset, length: 0)) + guard range.length > 0, + storage.attribute(HTMLFragmentAppKit.listKindKey, at: offset, effectiveRange: nil) as? String == "ordered" + else { break } + let line = (storage.string as NSString).substring(with: range) + let prefixLength = HTMLFragmentAppKit.listPrefixLength(line, kind: "ordered") + let prefix = NSAttributedString(string: "\(number). ", attributes: storage.attributes(at: offset, effectiveRange: nil)) + storage.replaceCharacters(in: NSRange(location: offset, length: prefixLength), with: prefix) + offset = NSMaxRange(range) + prefix.length - prefixLength + 1 + number += 1 + } } private func paragraphRange(containing selection: NSRange) -> NSRange { diff --git a/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/DescriptionEditorTests.swift b/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/DescriptionEditorTests.swift index 110daf829b..642205638f 100644 --- a/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/DescriptionEditorTests.swift +++ b/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/DescriptionEditorTests.swift @@ -78,6 +78,20 @@ final class DescriptionEditorTests: XCTestCase { XCTAssertNil(editor.typingAttributes[HTMLFragmentAppKit.listKindKey]) } + func testInsertingAnOrderedItemRenumbersFollowingItems() { + let coordinator = DescriptionEditorView.Coordinator(html: .constant("")) + let editor = DescriptionTextView() + coordinator.textView = editor + coordinator.load(html: "
  1. First

  2. Second

Separate

", resetKey: "event") + editor.setSelectedRange(NSRange(location: 8, length: 0)) + editor.insertNewline(nil) + editor.insertText("New", replacementRange: editor.selectedRange()) + XCTAssertEqual(editor.string, "1. First\n2. New\n3. Second\nSeparate") + let second = (editor.string as NSString).range(of: "Second") + let font = editor.textStorage?.attribute(.font, at: second.location, effectiveRange: nil) as? NSFont + XCTAssertTrue(font.map { NSFontManager.shared.traits(of: $0).contains(.boldFontMask) } ?? false) + } + func testReadOnlyEditorRejectsFormatting() { let editor = DescriptionTextView() editor.textStorage?.setAttributedString(NSAttributedString(string: "Read only")) From b14438ab041c0f2670d97c69a152dc11152fabbf Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 14:01:05 +0000 Subject: [PATCH 06/18] fix(macos): expose description formatting control identifiers for ui tests Co-authored-by: Tyler Dane --- .../CompassUI/EventForm/DescriptionEditorContainerView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift index 1c8c913c7f..b3b7969bdc 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift @@ -31,6 +31,7 @@ final class DescriptionEditorContainerView: NSView { button.bezelStyle = .inline button.isBordered = false button.setAccessibilityLabel(label) + button.setAccessibilityIdentifier(label) button.toolTip = label button.widthAnchor.constraint(equalToConstant: 24).isActive = true button.heightAnchor.constraint(equalToConstant: 24).isActive = true From 22cb33abd8af40ca9b66f3f2d4ea0c83a9eaf8ef Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 16 Oct 2026 06:30:43 -0600 Subject: [PATCH 07/18] test(macos): isolate up next undo regression from sample events --- .../NativeCalendarRootModelUpNextTests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelUpNextTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelUpNextTests.swift index 9cbb496086..e851299979 100644 --- a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelUpNextTests.swift +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelUpNextTests.swift @@ -56,10 +56,11 @@ final class NativeCalendarRootModelUpNextTests: XCTestCase { /// agenda still showed the event ("Untitled event in 2m"). func testUndoRedoUndoOfACreateClearsUpNext() async throws { let environment = try NativeCalendarEnvironment(inMemoryDatabase: true) + // Start with a local calendar but no sample events competing for Up next. + try environment.userMetadataRepository.upsert(key: DemoDataSeeder.migrationKey, value: true) let model = NativeCalendarRootModel(environment: environment) await model.start() - // The redo re-creates on a writable calendar, so reuse the seed's. - let calendarId = try XCTUnwrap(model.loadedEvents.first?.calendarId.rawValue) + let calendarId = try XCTUnwrap(model.calendars.first?.id) let event = try Self.timedEvent(title: "Just created", startingIn: 2 * 60, calendarId: calendarId) let upNextTitle = { model.upNextState.snapshot.upNext?.title } try environment.localEventRepository.putMany([ From 27c8cf023d7320485976ebc4ff14e4df2a45c08c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 14:11:38 +0000 Subject: [PATCH 08/18] fix(macos): mirror description formatting controls for xcuitest Co-authored-by: Tyler Dane --- ...tionFormattingAccessibilityProbeView.swift | 131 ++++++++++++++++++ .../Compass/NativeRootController.swift | 2 + ...stDescriptionFormattingAccessibility.swift | 25 ++++ .../NativeCalendarRootModelUpNextTests.swift | 2 +- .../DescriptionEditorContainerView.swift | 43 ++++++ 5 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 apps/calendar-macos/Compass/DescriptionFormattingAccessibilityProbeView.swift create mode 100644 apps/calendar-macos/CompassData/Sources/CompassData/UITestDescriptionFormattingAccessibility.swift diff --git a/apps/calendar-macos/Compass/DescriptionFormattingAccessibilityProbeView.swift b/apps/calendar-macos/Compass/DescriptionFormattingAccessibilityProbeView.swift new file mode 100644 index 0000000000..32ae302ca4 --- /dev/null +++ b/apps/calendar-macos/Compass/DescriptionFormattingAccessibilityProbeView.swift @@ -0,0 +1,131 @@ +import AppKit +import CompassData + +/// Hittable mirrors of description formatting toolbar buttons for XCUITest. +@MainActor +final class DescriptionFormattingAccessibilityProbeView: NSView { + private struct Control { + let identifier: String + let action: UITestDescriptionFormattingAccessibility.Action + let button: NSButton + } + + private var controls: [Control] = [] + private var observer: NSObjectProtocol? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + isHidden = true + var x: CGFloat = 420 + for (identifier, action) in [ + ("Bold", UITestDescriptionFormattingAccessibility.Action.bold), + ("Italic", UITestDescriptionFormattingAccessibility.Action.italic), + ("Numbered list", UITestDescriptionFormattingAccessibility.Action.numberedList), + ("Bullet list", UITestDescriptionFormattingAccessibility.Action.bulletList), + ] { + let button = NSButton(title: identifier, target: self, action: #selector(performAction(_:))) + button.setButtonType(.pushOnPushOff) + button.bezelStyle = .inline + button.isBordered = false + button.setAccessibilityIdentifier(identifier) + button.setAccessibilityLabel(identifier) + button.frame = NSRect(x: x, y: 360, width: 80, height: 24) + button.tag = controls.count + addSubview(button) + controls.append(Control(identifier: identifier, action: action, button: button)) + x += 84 + } + observer = NotificationCenter.default.addObserver( + forName: UITestDescriptionFormattingAccessibility.syncNotification, + object: nil, + queue: .main + ) { [weak self] notification in + self?.applySync(notification) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + } + + func update(visible: Bool) { + isHidden = !visible + for control in controls { + control.button.isHidden = !visible + control.button.isEnabled = visible + } + if visible { + NSAccessibility.post(element: self, notification: .layoutChanged) + } + } + + @objc private func performAction(_ sender: NSButton) { + guard sender.tag >= 0, sender.tag < controls.count else { return } + let action = controls[sender.tag].action + NotificationCenter.default.post( + name: UITestDescriptionFormattingAccessibility.actionNotification, + object: nil, + userInfo: [UITestDescriptionFormattingAccessibility.Key.action: action.rawValue] + ) + } + + private func applySync(_ notification: Notification) { + guard let userInfo = notification.userInfo else { return } + let visible = (userInfo[UITestDescriptionFormattingAccessibility.Key.visible] as? Bool) ?? false + update(visible: visible) + guard visible else { return } + let states = [ + userInfo[UITestDescriptionFormattingAccessibility.Key.boldOn] as? Bool ?? false, + userInfo[UITestDescriptionFormattingAccessibility.Key.italicOn] as? Bool ?? false, + userInfo[UITestDescriptionFormattingAccessibility.Key.numberedListOn] as? Bool ?? false, + userInfo[UITestDescriptionFormattingAccessibility.Key.bulletListOn] as? Bool ?? false, + ] + for (control, selected) in zip(controls, states) { + control.button.state = selected ? .on : .off + control.button.setAccessibilityValue(selected ? "1" : "0") + } + postLayoutChanged() + } + + private func postLayoutChanged() { + guard let window else { return } + NSAccessibility.post(element: self, notification: .layoutChanged) + NSAccessibility.post(element: window, notification: .layoutChanged) + } +} + +@MainActor +enum DescriptionFormattingAccessibilityProbe { + private static weak var probe: DescriptionFormattingAccessibilityProbeView? + + static func attach(to window: NSWindow) { + guard UITestLaunchPolicy.attachesAccessibilityProbes, let host = window.contentView else { return } + attach(to: host) + } + + static func attach(to hostView: NSView) { + guard UITestLaunchPolicy.attachesAccessibilityProbes else { return } + if let probe, probe.superview === hostView { return } + probe?.removeFromSuperview() + let view = DescriptionFormattingAccessibilityProbeView(frame: .zero) + hostView.addSubview(view, positioned: .above, relativeTo: nil) + probe = view + } + + static func publish(visible: Bool) { + guard UITestLaunchPolicy.attachesAccessibilityProbes else { return } + if probe == nil, let window = NSApp.keyWindow ?? NSApp.mainWindow { + attach(to: window) + } + probe?.update(visible: visible) + } +} diff --git a/apps/calendar-macos/Compass/NativeRootController.swift b/apps/calendar-macos/Compass/NativeRootController.swift index 915088857a..d9578a4971 100644 --- a/apps/calendar-macos/Compass/NativeRootController.swift +++ b/apps/calendar-macos/Compass/NativeRootController.swift @@ -62,6 +62,7 @@ final class NativeRootController: NSHostingController { ? { [weak model] newTitle in model?.updateDraftFromForm(title: newTitle) } : nil ) + DescriptionFormattingAccessibilityProbe.publish(visible: visible) } model.onEventFormTitleAccessibilityProbeTitleSync = { title in EventFormAccessibilityProbe.syncTitle(title) @@ -122,6 +123,7 @@ final class NativeRootController: NSHostingController { model?.updateDraftFromForm(title: newTitle) } ) + DescriptionFormattingAccessibilityProbe.publish(visible: model.isEventFormVisible) NativeOverlayProbes.statusToast.attach(to: window) NativeOverlayProbes.shortcutsLegend.attach(to: window) } diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/UITestDescriptionFormattingAccessibility.swift b/apps/calendar-macos/CompassData/Sources/CompassData/UITestDescriptionFormattingAccessibility.swift new file mode 100644 index 0000000000..b86c9b5717 --- /dev/null +++ b/apps/calendar-macos/CompassData/Sources/CompassData/UITestDescriptionFormattingAccessibility.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Cross-process notifications for mirroring description formatting controls in +/// XCUITest. AppKit toolbar buttons inside `NSViewRepresentable` are not exposed +/// in CI; the Compass app publishes hittable mirrors the editor keeps in sync. +public enum UITestDescriptionFormattingAccessibility { + public static let syncNotification = Notification.Name("compassUITestDescriptionFormattingSync") + public static let actionNotification = Notification.Name("compassUITestDescriptionFormattingAction") + + public enum Action: String, Sendable { + case bold + case italic + case numberedList + case bulletList + } + + public enum Key { + public static let visible = "visible" + public static let action = "action" + public static let boldOn = "boldOn" + public static let italicOn = "italicOn" + public static let numberedListOn = "numberedListOn" + public static let bulletListOn = "bulletListOn" + } +} diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelUpNextTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelUpNextTests.swift index 9cbb496086..92ffef9782 100644 --- a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelUpNextTests.swift +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelUpNextTests.swift @@ -65,7 +65,7 @@ final class NativeCalendarRootModelUpNextTests: XCTestCase { try environment.localEventRepository.putMany([ LocalEventRecord(id: event.id, event: event, isDemo: false), ]) - model.loadedEvents = try environment.eventsStore.fetchAllEvents() + model.loadedEvents = [event] model.recordCreateUndo(for: event) XCTAssertEqual(upNextTitle(), "Just created") diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift index b3b7969bdc..780463b3d3 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift @@ -1,10 +1,12 @@ import AppKit +import CompassData final class DescriptionEditorContainerView: NSView { let textView = DescriptionTextView() private let scrollView = NSScrollView() private let toolbar = NSStackView() private var buttons: [NSButton] = [] + private var formattingActionObserver: NSObjectProtocol? override var isFlipped: Bool { true } @@ -39,6 +41,13 @@ final class DescriptionEditorContainerView: NSView { buttons.append(button) } textView.onFormattingChange = { [weak self] in self?.updateControls() } + formattingActionObserver = NotificationCenter.default.addObserver( + forName: UITestDescriptionFormattingAccessibility.actionNotification, + object: nil, + queue: .main + ) { [weak self] notification in + self?.performFormattingAction(notification) + } addSubview(toolbar) addSubview(scrollView) } @@ -46,6 +55,12 @@ final class DescriptionEditorContainerView: NSView { @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + deinit { + if let formattingActionObserver { + NotificationCenter.default.removeObserver(formattingActionObserver) + } + } + override func layout() { super.layout() toolbar.frame = NSRect(x: 0, y: 0, width: 108, height: 24) @@ -66,5 +81,33 @@ final class DescriptionEditorContainerView: NSView { button.isEnabled = textView.isEditable if let tint { button.contentTintColor = tint } } + publishFormattingProbeSync(states: states) + } + + private func publishFormattingProbeSync(states: [Bool]) { + guard UITestLaunchPolicy.attachesAccessibilityProbes else { return } + NotificationCenter.default.post( + name: UITestDescriptionFormattingAccessibility.syncNotification, + object: textView, + userInfo: [ + UITestDescriptionFormattingAccessibility.Key.visible: !textView.isHidden, + UITestDescriptionFormattingAccessibility.Key.boldOn: states[0], + UITestDescriptionFormattingAccessibility.Key.italicOn: states[1], + UITestDescriptionFormattingAccessibility.Key.numberedListOn: states[2], + UITestDescriptionFormattingAccessibility.Key.bulletListOn: states[3], + ] + ) + } + + private func performFormattingAction(_ notification: Notification) { + guard let raw = notification.userInfo?[UITestDescriptionFormattingAccessibility.Key.action] as? String, + let action = UITestDescriptionFormattingAccessibility.Action(rawValue: raw) + else { return } + switch action { + case .bold: textView.toggleBold(nil) + case .italic: textView.toggleItalic(nil) + case .numberedList: textView.toggleNumberedList(nil) + case .bulletList: textView.toggleBulletList(nil) + } } } From 8722d11ac1a32f8684304692c82674fce08939c4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 14:31:41 +0000 Subject: [PATCH 09/18] fix(macos): satisfy swift 6 ui test hooks and stabilize up next test Co-authored-by: Tyler Dane --- .../CompassDataTests/NativeCalendarRootModelUpNextTests.swift | 3 +++ .../CompassUI/EventForm/DescriptionEditorContainerView.swift | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelUpNextTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelUpNextTests.swift index 92ffef9782..247f9a6022 100644 --- a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelUpNextTests.swift +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelUpNextTests.swift @@ -60,6 +60,9 @@ final class NativeCalendarRootModelUpNextTests: XCTestCase { await model.start() // The redo re-creates on a writable calendar, so reuse the seed's. let calendarId = try XCTUnwrap(model.loadedEvents.first?.calendarId.rawValue) + let demoIds = try environment.localEventRepository.fetchAll().filter(\.isDemo).map(\.id) + try environment.localEventRepository.delete(ids: demoIds) + model.loadedEvents = model.loadedEvents.filter { !demoIds.contains($0.id) } let event = try Self.timedEvent(title: "Just created", startingIn: 2 * 60, calendarId: calendarId) let upNextTitle = { model.upNextState.snapshot.upNext?.title } try environment.localEventRepository.putMany([ diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift index 780463b3d3..634f7e6cd9 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift @@ -46,7 +46,9 @@ final class DescriptionEditorContainerView: NSView { object: nil, queue: .main ) { [weak self] notification in - self?.performFormattingAction(notification) + guard let raw = notification.userInfo?[UITestDescriptionFormattingAccessibility.Key.action] as? String + else { return } + self?.performFormattingAction(raw) } addSubview(toolbar) addSubview(scrollView) From 6f43041d29e9a939c92448870fc03d2079a95162 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 14:31:52 +0000 Subject: [PATCH 10/18] fix(macos): move formatting observer teardown off deinit Co-authored-by: Tyler Dane --- .../EventForm/DescriptionEditorContainerView.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift index 634f7e6cd9..24634d6b94 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift @@ -57,10 +57,12 @@ final class DescriptionEditorContainerView: NSView { @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - deinit { - if let formattingActionObserver { + override func willMove(toWindow newWindow: NSWindow?) { + if newWindow == nil, let formattingActionObserver { NotificationCenter.default.removeObserver(formattingActionObserver) + self.formattingActionObserver = nil } + super.willMove(toWindow: newWindow) } override func layout() { @@ -101,10 +103,8 @@ final class DescriptionEditorContainerView: NSView { ) } - private func performFormattingAction(_ notification: Notification) { - guard let raw = notification.userInfo?[UITestDescriptionFormattingAccessibility.Key.action] as? String, - let action = UITestDescriptionFormattingAccessibility.Action(rawValue: raw) - else { return } + private func performFormattingAction(_ raw: String) { + guard let action = UITestDescriptionFormattingAccessibility.Action(rawValue: raw) else { return } switch action { case .bold: textView.toggleBold(nil) case .italic: textView.toggleItalic(nil) From fcc1daca7262fb705f8846e284a4f59666e4410b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 15:06:55 +0000 Subject: [PATCH 11/18] ci(macos): retrigger workflow after ci fixes Co-authored-by: Tyler Dane From c3b5f6d1e491fdde0fed2e020066670fdbdea7b5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 16 Oct 2026 15:25:31 +0000 Subject: [PATCH 12/18] ci(macos): touch macos paths to run workflow Co-authored-by: Tyler Dane --- .../CompassData/UITestDescriptionFormattingAccessibility.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/UITestDescriptionFormattingAccessibility.swift b/apps/calendar-macos/CompassData/Sources/CompassData/UITestDescriptionFormattingAccessibility.swift index b86c9b5717..8c26bb5385 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/UITestDescriptionFormattingAccessibility.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/UITestDescriptionFormattingAccessibility.swift @@ -23,3 +23,4 @@ public enum UITestDescriptionFormattingAccessibility { public static let bulletListOn = "bulletListOn" } } + From 460225a5f84d810a56937249d75184ae83c505fd Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 16 Oct 2026 11:31:29 -0600 Subject: [PATCH 13/18] test(macos): query description formatting as named toggle controls --- .../CompassUITests/DescriptionEditorFormattingTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift b/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift index f6c2da5461..db13fecf61 100644 --- a/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift +++ b/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift @@ -18,7 +18,7 @@ final class DescriptionEditorFormattingTests: XCTestCase { editor.typeKey("a", modifierFlags: [.command]) editor.typeText("Formatted note") editor.typeKey("a", modifierFlags: [.command]) - window.buttons["Bold"].click() + window.descendants(matching: .any)["Bold"].click() window.typeKey(.enter, modifierFlags: [.command]) XCTAssertFalse(editor.exists) @@ -27,6 +27,6 @@ final class DescriptionEditorFormattingTests: XCTestCase { editor.click() editor.typeKey("a", modifierFlags: [.command]) XCTAssertEqual(editor.value as? String, "Formatted note") - XCTAssertEqual(window.buttons["Bold"].value as? String, "1") + XCTAssertEqual(window.descendants(matching: .any)["Bold"].value as? String, "1") } } From 90112f7ec6e57289f8c386f9574141209366be89 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 16 Oct 2026 11:32:54 -0600 Subject: [PATCH 14/18] fix(macos): preserve native editor input during interaction tests --- .../Compass/NativeGridKeyboardRouter.swift | 4 ++++ .../Notifications/CompassNotificationCenter.swift | 3 +++ .../Sources/CompassData/UITestLaunchPolicy.swift | 9 +++++++++ .../Tests/CompassDataTests/UITestLaunchPolicyTests.swift | 6 ++++++ .../EventForm/DescriptionEditorContainerView.swift | 1 + .../DescriptionEditorFormattingTests.swift | 2 +- 6 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift index 487b746f12..26776e0a30 100644 --- a/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift +++ b/apps/calendar-macos/Compass/NativeGridKeyboardRouter.swift @@ -316,6 +316,10 @@ final class NativeGridKeyboardRouter { /// Probes mirror via `EventFormAccessibilityProbeView` when `-COMPASS_UI_TEST` is set. private func handleUITestEventFormTyping(_ keyEvent: KeyEvent) -> Bool { guard UITestLaunchPolicy.mirrorsEventFormTitleTyping else { return false } + // The title probe is a fallback, never a replacement for a real text editor. + if let editor = NSApp.keyWindow?.firstResponder as? NSTextView, editor.isEditable { + return false + } if keyEvent.modifiers == [.command], case .character(let char) = keyEvent.key, char.lowercased() == "v", diff --git a/apps/calendar-macos/Compass/Notifications/CompassNotificationCenter.swift b/apps/calendar-macos/Compass/Notifications/CompassNotificationCenter.swift index 57c97da62a..3d02b288d8 100644 --- a/apps/calendar-macos/Compass/Notifications/CompassNotificationCenter.swift +++ b/apps/calendar-macos/Compass/Notifications/CompassNotificationCenter.swift @@ -1,4 +1,5 @@ import AppKit +import CompassData import CompassKit import UserNotifications @@ -25,6 +26,7 @@ final class CompassNotificationCenter { } func requestAuthorizationIfNeeded() async { + guard UITestLaunchPolicy.allowsSystemNotifications else { return } let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() if permissionString(for: settings) == "default", !didRequestAuthorization { @@ -35,6 +37,7 @@ final class CompassNotificationCenter { @discardableResult func showNative(_ payload: DesktopNotificationPayload) async -> Bool { + guard UITestLaunchPolicy.allowsSystemNotifications else { return false } let center = UNUserNotificationCenter.current() let settings = await center.notificationSettings() guard permissionString(for: settings) == "granted" else { diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/UITestLaunchPolicy.swift b/apps/calendar-macos/CompassData/Sources/CompassData/UITestLaunchPolicy.swift index 1521d551ff..e9f2a034b1 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/UITestLaunchPolicy.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/UITestLaunchPolicy.swift @@ -17,6 +17,15 @@ public enum UITestLaunchPolicy { arguments.contains(uiTestLaunchArgument) } + /// Fixture interaction tests must not prompt for or post real system notifications. + public static var allowsSystemNotifications: Bool { + allowsSystemNotifications(arguments: ProcessInfo.processInfo.arguments) + } + + public static func allowsSystemNotifications(arguments: [String]) -> Bool { + !arguments.contains(uiTestLaunchArgument) + } + /// When set, focuses this grid event once the demo fixture layout is ready (no pointer hint). public static var initialGridFocusEventId: String? { let args = ProcessInfo.processInfo.arguments diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/UITestLaunchPolicyTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/UITestLaunchPolicyTests.swift index 8a6368193b..7a3435f25c 100644 --- a/apps/calendar-macos/CompassData/Tests/CompassDataTests/UITestLaunchPolicyTests.swift +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/UITestLaunchPolicyTests.swift @@ -19,6 +19,12 @@ final class UITestLaunchPolicyTests: XCTestCase { arguments: ["Compass", "-COMPASS_UI_TEST", "-COMPASS_UI_TEST_OPEN_FOCUSED_EVENT_FORM"])) } + func testOnlyInteractionTestsSuppressSystemNotificationSideEffects() { + XCTAssertTrue(UITestLaunchPolicy.allowsSystemNotifications(arguments: ["/Applications/Compass.app"])) + XCTAssertTrue(UITestLaunchPolicy.allowsSystemNotifications(arguments: ["Compass", "-COMPASS_FIXTURE", "demo"])) + XCTAssertFalse(UITestLaunchPolicy.allowsSystemNotifications(arguments: ["Compass", "-COMPASS_UI_TEST"])) + } + func testUITestLaunchAttachesAccessibilityProbes() { XCTAssertTrue( UITestLaunchPolicy.attachesAccessibilityProbes(arguments: ["Compass", "-COMPASS_UI_TEST"])) diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift index 1c8c913c7f..54faa9a164 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift @@ -14,6 +14,7 @@ final class DescriptionEditorContainerView: NSView { scrollView.hasVerticalScroller = true scrollView.borderType = .noBorder scrollView.documentView = textView + toolbar.frame = NSRect(x: 0, y: 0, width: 108, height: 24) toolbar.setAccessibilityElement(true) toolbar.setAccessibilityRole(.toolbar) toolbar.setAccessibilityLabel("Description formatting") diff --git a/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift b/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift index db13fecf61..9d422a2df9 100644 --- a/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift +++ b/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift @@ -20,7 +20,7 @@ final class DescriptionEditorFormattingTests: XCTestCase { editor.typeKey("a", modifierFlags: [.command]) window.descendants(matching: .any)["Bold"].click() window.typeKey(.enter, modifierFlags: [.command]) - XCTAssertFalse(editor.exists) + XCTAssertTrue(editor.waitForNonExistence(timeout: 8)) window.typeKey(.enter, modifierFlags: []) XCTAssertTrue(editor.waitForExistence(timeout: 8)) From 3ebcc7c53cf167ab2841278b6e42c2663059bfca Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 16 Oct 2026 11:41:55 -0600 Subject: [PATCH 15/18] fix(macos): scroll to form fields and normalize keypad enter --- .../Compass/NSEvent+KeyEvent.swift | 6 +- .../CompassKitTests/NativeKeyEventTests.swift | 17 +++ .../CompassUI/EventForm/EventFormView.swift | 140 ++++++++++-------- .../DescriptionEditorFormattingTests.swift | 17 ++- apps/calendar-macos/project.yml | 1 + 5 files changed, 114 insertions(+), 67 deletions(-) create mode 100644 apps/calendar-macos/CompassKitTests/NativeKeyEventTests.swift diff --git a/apps/calendar-macos/Compass/NSEvent+KeyEvent.swift b/apps/calendar-macos/Compass/NSEvent+KeyEvent.swift index 2fbde94a3c..fff9801616 100644 --- a/apps/calendar-macos/Compass/NSEvent+KeyEvent.swift +++ b/apps/calendar-macos/Compass/NSEvent+KeyEvent.swift @@ -25,7 +25,7 @@ extension KeyToken { init?(nsEvent: NSEvent) { switch nsEvent.keyCode { case 53: self = .named(.escape) - case 36: self = .named(.enter) + case 36, 76: self = .named(.enter) case 48: self = .named(.tab) case 126: self = .named(.arrowUp) case 125: self = .named(.arrowDown) @@ -38,7 +38,9 @@ extension KeyToken { guard let chars = nsEvent.charactersIgnoringModifiers?.lowercased(), let first = chars.first else { return nil } - if first.isLetter || first.isNumber { + if first == "\r" || first == "\n" || first == "\u{3}" { + self = .named(.enter) + } else if first.isLetter || first.isNumber { self = .character(first) } else { self = .punctuation(String(first)) diff --git a/apps/calendar-macos/CompassKitTests/NativeKeyEventTests.swift b/apps/calendar-macos/CompassKitTests/NativeKeyEventTests.swift new file mode 100644 index 0000000000..a8e1a1bd4d --- /dev/null +++ b/apps/calendar-macos/CompassKitTests/NativeKeyEventTests.swift @@ -0,0 +1,17 @@ +import AppKit +import CompassKit +import XCTest + +final class NativeKeyEventTests: XCTestCase { + func testReturnAndKeypadEnterBothReachTheSaveChord() throws { + for (code, characters) in [(UInt16(36), "\r"), (UInt16(76), "\u{3}"), (UInt16(0), "\r"), (UInt16(0), "\u{3}")] { + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, location: .zero, modifierFlags: [.command, .numericPad], + timestamp: 0, windowNumber: 0, context: nil, characters: characters, + charactersIgnoringModifiers: characters, isARepeat: false, keyCode: code)) + let normalized = try XCTUnwrap(KeyEvent(nsEvent: event)) + XCTAssertEqual(normalized.key, .named(.enter)) + XCTAssertTrue(normalized.matches(KeyChord(modifiers: [.command], token: .named(.enter)))) + } + } +} diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift index c53ae6913f..a7df078d93 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift @@ -48,70 +48,84 @@ public struct EventFormView: View { .accessibilityIdentifier("compass-event-form-title") .pageJumpChipAnchor(id: "form-title") - ScrollView { - VStack(alignment: .leading, spacing: 12) { - EventFormScheduleSection( - model: model, - draft: draft, - focusField: model.eventFormFocusedField - ) - EventFormRecurrenceSection( - model: model, - draft: draft, - focusField: model.eventFormFocusedField - ) - EventFormCalendarSection( - model: model, - draft: draft, - focusField: model.eventFormFocusedField - ) - EventFormColorSection( - model: model, - draft: draft, - focusField: model.eventFormFocusedField - ) - EventFormLocationField( - model: model, - draft: draft, - focusField: model.eventFormFocusedField - ) - DescriptionEditorView( - html: Binding( - get: { draft.description }, - set: { model.updateDraftFromForm(description: $0) } - ), - resetKey: draft.persistedEventId?.rawValue ?? draft.clientId.rawValue, - isFocused: model.eventFormFocusedField == .description, - isEditable: !model.isEventFormReadOnly(for: draft) - ) - .frame(minHeight: 72, maxHeight: 140) - .padding(8) - .background(theme.surfacePanelColor) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke( - model.eventFormFocusedField == .description ? theme.accentColor : theme.borderColor, - lineWidth: 1 - ) - ) - .pageJumpChipAnchor(id: "form-description") + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 12) { + EventFormScheduleSection( + model: model, + draft: draft, + focusField: model.eventFormFocusedField + ) + .id(EventFormField.start) .id(EventFormField.end) + EventFormRecurrenceSection( + model: model, + draft: draft, + focusField: model.eventFormFocusedField + ) + .id(EventFormField.recurrence) + EventFormCalendarSection( + model: model, + draft: draft, + focusField: model.eventFormFocusedField + ) + .id(EventFormField.calendar) + EventFormColorSection( + model: model, + draft: draft, + focusField: model.eventFormFocusedField + ) + .id(EventFormField.color) + EventFormLocationField( + model: model, + draft: draft, + focusField: model.eventFormFocusedField + ) + .id(EventFormField.location) + DescriptionEditorView( + html: Binding( + get: { draft.description }, + set: { model.updateDraftFromForm(description: $0) } + ), + resetKey: draft.persistedEventId?.rawValue ?? draft.clientId.rawValue, + isFocused: model.eventFormFocusedField == .description, + isEditable: !model.isEventFormReadOnly(for: draft) + ) + .frame(minHeight: 72, maxHeight: 140) + .padding(8) + .background(theme.surfacePanelColor) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke( + model.eventFormFocusedField == .description ? theme.accentColor : theme.borderColor, + lineWidth: 1 + ) + ) + .pageJumpChipAnchor(id: "form-description") + .id(EventFormField.description) - EventFormConferenceSection( - model: model, - draft: draft, - focusField: model.eventFormFocusedField - ) - EventFormAttendeesSection( - model: model, - draft: draft, - focusField: model.eventFormFocusedField - ) - EventFormRsvpSection( - model: model, - draft: draft, - focusField: model.eventFormFocusedField - ) + EventFormConferenceSection( + model: model, + draft: draft, + focusField: model.eventFormFocusedField + ) + .id(EventFormField.conference) + EventFormAttendeesSection( + model: model, + draft: draft, + focusField: model.eventFormFocusedField + ) + .id(EventFormField.attendees) + EventFormRsvpSection( + model: model, + draft: draft, + focusField: model.eventFormFocusedField + ) + .id(EventFormField.rsvp) + } + } + .onChange(of: model.eventFormFocusedField) { _, field in + proxy.scrollTo(field, anchor: .center) } } if !model.isEventFormReadOnly(for: draft) { diff --git a/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift b/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift index 9d422a2df9..a8e25964b5 100644 --- a/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift +++ b/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift @@ -12,21 +12,34 @@ final class DescriptionEditorFormattingTests: XCTestCase { ] app.launch() let window = app.windows["Compass"] + defer { + if (testRun?.failureCount ?? 0) > 0 { + let hierarchy = XCTAttachment(string: window.debugDescription) + hierarchy.lifetime = .keepAlways + add(hierarchy) + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.lifetime = .keepAlways + add(screenshot) + } + } let editor = window.textViews["compass-event-form-description"] XCTAssertTrue(editor.waitForExistence(timeout: 8)) + window.typeKey("9", modifierFlags: [.command]) editor.click() editor.typeKey("a", modifierFlags: [.command]) editor.typeText("Formatted note") + XCTAssertEqual(editor.value as? String, "Formatted note") editor.typeKey("a", modifierFlags: [.command]) - window.descendants(matching: .any)["Bold"].click() + window.checkBoxes["Bold"].click() window.typeKey(.enter, modifierFlags: [.command]) XCTAssertTrue(editor.waitForNonExistence(timeout: 8)) window.typeKey(.enter, modifierFlags: []) XCTAssertTrue(editor.waitForExistence(timeout: 8)) + window.typeKey("9", modifierFlags: [.command]) editor.click() editor.typeKey("a", modifierFlags: [.command]) XCTAssertEqual(editor.value as? String, "Formatted note") - XCTAssertEqual(window.descendants(matching: .any)["Bold"].value as? String, "1") + XCTAssertEqual(String(describing: window.checkBoxes["Bold"].value ?? ""), "1") } } diff --git a/apps/calendar-macos/project.yml b/apps/calendar-macos/project.yml index a5dd974c8b..f79654cb6b 100644 --- a/apps/calendar-macos/project.yml +++ b/apps/calendar-macos/project.yml @@ -61,6 +61,7 @@ targets: MACOSX_DEPLOYMENT_TARGET: "14.0" sources: - CompassKitTests + - Compass/NSEvent+KeyEvent.swift resources: - path: CompassKitTests/Fixtures type: folder From 1d6a73ba77dd55e1d025e052ca0884b5aea55361 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 16 Oct 2026 11:43:20 -0600 Subject: [PATCH 16/18] test(macos): keep appkit adapter coverage in the native test target --- .../NativeKeyEventTests.swift | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/calendar-macos/{CompassKitTests => CompassNativeTests}/NativeKeyEventTests.swift (100%) diff --git a/apps/calendar-macos/CompassKitTests/NativeKeyEventTests.swift b/apps/calendar-macos/CompassNativeTests/NativeKeyEventTests.swift similarity index 100% rename from apps/calendar-macos/CompassKitTests/NativeKeyEventTests.swift rename to apps/calendar-macos/CompassNativeTests/NativeKeyEventTests.swift From 77667e291c69fba72a71b0920d199071e13a3653 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 16 Oct 2026 11:43:28 -0600 Subject: [PATCH 17/18] test(macos): include native adapter tests in the xcode target --- apps/calendar-macos/project.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/calendar-macos/project.yml b/apps/calendar-macos/project.yml index f79654cb6b..f0da360e70 100644 --- a/apps/calendar-macos/project.yml +++ b/apps/calendar-macos/project.yml @@ -61,6 +61,7 @@ targets: MACOSX_DEPLOYMENT_TARGET: "14.0" sources: - CompassKitTests + - CompassNativeTests - Compass/NSEvent+KeyEvent.swift resources: - path: CompassKitTests/Fixtures From 44075d32e2b254cb7e12eb595f4727b56d973ecd Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Fri, 16 Oct 2026 11:47:52 -0600 Subject: [PATCH 18/18] refactor(macos): test description formatting through its native controls --- ...tionFormattingAccessibilityProbeView.swift | 131 ------------------ .../Compass/NativeRootController.swift | 2 - ...stDescriptionFormattingAccessibility.swift | 26 ---- .../DescriptionEditorContainerView.swift | 45 ------ 4 files changed, 204 deletions(-) delete mode 100644 apps/calendar-macos/Compass/DescriptionFormattingAccessibilityProbeView.swift delete mode 100644 apps/calendar-macos/CompassData/Sources/CompassData/UITestDescriptionFormattingAccessibility.swift diff --git a/apps/calendar-macos/Compass/DescriptionFormattingAccessibilityProbeView.swift b/apps/calendar-macos/Compass/DescriptionFormattingAccessibilityProbeView.swift deleted file mode 100644 index 32ae302ca4..0000000000 --- a/apps/calendar-macos/Compass/DescriptionFormattingAccessibilityProbeView.swift +++ /dev/null @@ -1,131 +0,0 @@ -import AppKit -import CompassData - -/// Hittable mirrors of description formatting toolbar buttons for XCUITest. -@MainActor -final class DescriptionFormattingAccessibilityProbeView: NSView { - private struct Control { - let identifier: String - let action: UITestDescriptionFormattingAccessibility.Action - let button: NSButton - } - - private var controls: [Control] = [] - private var observer: NSObjectProtocol? - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - wantsLayer = true - layer?.backgroundColor = NSColor.clear.cgColor - isHidden = true - var x: CGFloat = 420 - for (identifier, action) in [ - ("Bold", UITestDescriptionFormattingAccessibility.Action.bold), - ("Italic", UITestDescriptionFormattingAccessibility.Action.italic), - ("Numbered list", UITestDescriptionFormattingAccessibility.Action.numberedList), - ("Bullet list", UITestDescriptionFormattingAccessibility.Action.bulletList), - ] { - let button = NSButton(title: identifier, target: self, action: #selector(performAction(_:))) - button.setButtonType(.pushOnPushOff) - button.bezelStyle = .inline - button.isBordered = false - button.setAccessibilityIdentifier(identifier) - button.setAccessibilityLabel(identifier) - button.frame = NSRect(x: x, y: 360, width: 80, height: 24) - button.tag = controls.count - addSubview(button) - controls.append(Control(identifier: identifier, action: action, button: button)) - x += 84 - } - observer = NotificationCenter.default.addObserver( - forName: UITestDescriptionFormattingAccessibility.syncNotification, - object: nil, - queue: .main - ) { [weak self] notification in - self?.applySync(notification) - } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - if let observer { - NotificationCenter.default.removeObserver(observer) - } - } - - func update(visible: Bool) { - isHidden = !visible - for control in controls { - control.button.isHidden = !visible - control.button.isEnabled = visible - } - if visible { - NSAccessibility.post(element: self, notification: .layoutChanged) - } - } - - @objc private func performAction(_ sender: NSButton) { - guard sender.tag >= 0, sender.tag < controls.count else { return } - let action = controls[sender.tag].action - NotificationCenter.default.post( - name: UITestDescriptionFormattingAccessibility.actionNotification, - object: nil, - userInfo: [UITestDescriptionFormattingAccessibility.Key.action: action.rawValue] - ) - } - - private func applySync(_ notification: Notification) { - guard let userInfo = notification.userInfo else { return } - let visible = (userInfo[UITestDescriptionFormattingAccessibility.Key.visible] as? Bool) ?? false - update(visible: visible) - guard visible else { return } - let states = [ - userInfo[UITestDescriptionFormattingAccessibility.Key.boldOn] as? Bool ?? false, - userInfo[UITestDescriptionFormattingAccessibility.Key.italicOn] as? Bool ?? false, - userInfo[UITestDescriptionFormattingAccessibility.Key.numberedListOn] as? Bool ?? false, - userInfo[UITestDescriptionFormattingAccessibility.Key.bulletListOn] as? Bool ?? false, - ] - for (control, selected) in zip(controls, states) { - control.button.state = selected ? .on : .off - control.button.setAccessibilityValue(selected ? "1" : "0") - } - postLayoutChanged() - } - - private func postLayoutChanged() { - guard let window else { return } - NSAccessibility.post(element: self, notification: .layoutChanged) - NSAccessibility.post(element: window, notification: .layoutChanged) - } -} - -@MainActor -enum DescriptionFormattingAccessibilityProbe { - private static weak var probe: DescriptionFormattingAccessibilityProbeView? - - static func attach(to window: NSWindow) { - guard UITestLaunchPolicy.attachesAccessibilityProbes, let host = window.contentView else { return } - attach(to: host) - } - - static func attach(to hostView: NSView) { - guard UITestLaunchPolicy.attachesAccessibilityProbes else { return } - if let probe, probe.superview === hostView { return } - probe?.removeFromSuperview() - let view = DescriptionFormattingAccessibilityProbeView(frame: .zero) - hostView.addSubview(view, positioned: .above, relativeTo: nil) - probe = view - } - - static func publish(visible: Bool) { - guard UITestLaunchPolicy.attachesAccessibilityProbes else { return } - if probe == nil, let window = NSApp.keyWindow ?? NSApp.mainWindow { - attach(to: window) - } - probe?.update(visible: visible) - } -} diff --git a/apps/calendar-macos/Compass/NativeRootController.swift b/apps/calendar-macos/Compass/NativeRootController.swift index d9578a4971..915088857a 100644 --- a/apps/calendar-macos/Compass/NativeRootController.swift +++ b/apps/calendar-macos/Compass/NativeRootController.swift @@ -62,7 +62,6 @@ final class NativeRootController: NSHostingController { ? { [weak model] newTitle in model?.updateDraftFromForm(title: newTitle) } : nil ) - DescriptionFormattingAccessibilityProbe.publish(visible: visible) } model.onEventFormTitleAccessibilityProbeTitleSync = { title in EventFormAccessibilityProbe.syncTitle(title) @@ -123,7 +122,6 @@ final class NativeRootController: NSHostingController { model?.updateDraftFromForm(title: newTitle) } ) - DescriptionFormattingAccessibilityProbe.publish(visible: model.isEventFormVisible) NativeOverlayProbes.statusToast.attach(to: window) NativeOverlayProbes.shortcutsLegend.attach(to: window) } diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/UITestDescriptionFormattingAccessibility.swift b/apps/calendar-macos/CompassData/Sources/CompassData/UITestDescriptionFormattingAccessibility.swift deleted file mode 100644 index 8c26bb5385..0000000000 --- a/apps/calendar-macos/CompassData/Sources/CompassData/UITestDescriptionFormattingAccessibility.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation - -/// Cross-process notifications for mirroring description formatting controls in -/// XCUITest. AppKit toolbar buttons inside `NSViewRepresentable` are not exposed -/// in CI; the Compass app publishes hittable mirrors the editor keeps in sync. -public enum UITestDescriptionFormattingAccessibility { - public static let syncNotification = Notification.Name("compassUITestDescriptionFormattingSync") - public static let actionNotification = Notification.Name("compassUITestDescriptionFormattingAction") - - public enum Action: String, Sendable { - case bold - case italic - case numberedList - case bulletList - } - - public enum Key { - public static let visible = "visible" - public static let action = "action" - public static let boldOn = "boldOn" - public static let italicOn = "italicOn" - public static let numberedListOn = "numberedListOn" - public static let bulletListOn = "bulletListOn" - } -} - diff --git a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift index 8101aafd5a..5581087cb4 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/DescriptionEditorContainerView.swift @@ -1,12 +1,10 @@ import AppKit -import CompassData final class DescriptionEditorContainerView: NSView { let textView = DescriptionTextView() private let scrollView = NSScrollView() private let toolbar = NSStackView() private var buttons: [NSButton] = [] - private var formattingActionObserver: NSObjectProtocol? override var isFlipped: Bool { true } @@ -42,15 +40,6 @@ final class DescriptionEditorContainerView: NSView { buttons.append(button) } textView.onFormattingChange = { [weak self] in self?.updateControls() } - formattingActionObserver = NotificationCenter.default.addObserver( - forName: UITestDescriptionFormattingAccessibility.actionNotification, - object: nil, - queue: .main - ) { [weak self] notification in - guard let raw = notification.userInfo?[UITestDescriptionFormattingAccessibility.Key.action] as? String - else { return } - self?.performFormattingAction(raw) - } addSubview(toolbar) addSubview(scrollView) } @@ -58,14 +47,6 @@ final class DescriptionEditorContainerView: NSView { @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - override func willMove(toWindow newWindow: NSWindow?) { - if newWindow == nil, let formattingActionObserver { - NotificationCenter.default.removeObserver(formattingActionObserver) - self.formattingActionObserver = nil - } - super.willMove(toWindow: newWindow) - } - override func layout() { super.layout() toolbar.frame = NSRect(x: 0, y: 0, width: 108, height: 24) @@ -86,31 +67,5 @@ final class DescriptionEditorContainerView: NSView { button.isEnabled = textView.isEditable if let tint { button.contentTintColor = tint } } - publishFormattingProbeSync(states: states) - } - - private func publishFormattingProbeSync(states: [Bool]) { - guard UITestLaunchPolicy.attachesAccessibilityProbes else { return } - NotificationCenter.default.post( - name: UITestDescriptionFormattingAccessibility.syncNotification, - object: textView, - userInfo: [ - UITestDescriptionFormattingAccessibility.Key.visible: !textView.isHidden, - UITestDescriptionFormattingAccessibility.Key.boldOn: states[0], - UITestDescriptionFormattingAccessibility.Key.italicOn: states[1], - UITestDescriptionFormattingAccessibility.Key.numberedListOn: states[2], - UITestDescriptionFormattingAccessibility.Key.bulletListOn: states[3], - ] - ) - } - - private func performFormattingAction(_ raw: String) { - guard let action = UITestDescriptionFormattingAccessibility.Action(rawValue: raw) else { return } - switch action { - case .bold: textView.toggleBold(nil) - case .italic: textView.toggleItalic(nil) - case .numberedList: textView.toggleNumberedList(nil) - case .bulletList: textView.toggleBulletList(nil) - } } }