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/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/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/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..60e42d0158 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,11 +175,117 @@ final class DescriptionTextView: NSTextView { : NSFontManager.shared.convert(font, toHaveTrait: trait) textStorage?.addAttribute(.font, value: updated, range: subRange) } + didChangeText() + onFormattingChange?() + } + + 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()) + 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 { + let text = string as NSString + 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)) + 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" + 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() { 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." @@ -173,6 +299,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 391579c2f0..a7df078d93 100644 --- a/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift +++ b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/EventFormView.swift @@ -48,69 +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 - ) - .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/CompassUI/Sources/CompassUI/EventForm/HTMLFragmentAppKit.swift b/apps/calendar-macos/CompassUI/Sources/CompassUI/EventForm/HTMLFragmentAppKit.swift index 664d2f8ca2..8ee76e46b9 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, @@ -14,9 +16,9 @@ enum HTMLFragmentAppKit { switch block { case .paragraph(let inlines): result.append( - attributedInlines( + inlineAttributedString( inlines, - baseFont: baseFont, + font: baseFont, textColor: textColor, linkColor: linkColor ) @@ -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( @@ -32,17 +35,19 @@ enum HTMLFragmentAppKit { ) ) result.append( - attributedInlines( + inlineAttributedString( item.inlines, - baseFont: baseFont, + font: baseFont, textColor: textColor, 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( @@ -51,13 +56,14 @@ enum HTMLFragmentAppKit { ) ) result.append( - attributedInlines( + inlineAttributedString( item.inlines, - baseFont: baseFont, + font: baseFont, textColor: textColor, 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,108 +73,64 @@ 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: inlines(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(inlines(in: attributedString, range: range))) } return HTMLFragmentDocument(blocks: blocks) } - 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: "\n", - 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 + 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 inlineAttributedString( @@ -193,7 +155,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)) @@ -207,32 +169,28 @@ 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) 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..642205638f --- /dev/null +++ b/apps/calendar-macos/CompassUI/Tests/CompassUIUnitTests/DescriptionEditorTests.swift @@ -0,0 +1,105 @@ +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 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 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")) + 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)) + } + } +} diff --git a/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift b/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift new file mode 100644 index 0000000000..a8e25964b5 --- /dev/null +++ b/apps/calendar-macos/CompassUITests/DescriptionEditorFormattingTests.swift @@ -0,0 +1,45 @@ +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"] + 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.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(String(describing: window.checkBoxes["Bold"].value ?? ""), "1") + } +} diff --git a/apps/calendar-macos/project.yml b/apps/calendar-macos/project.yml index a5dd974c8b..f0da360e70 100644 --- a/apps/calendar-macos/project.yml +++ b/apps/calendar-macos/project.yml @@ -61,6 +61,8 @@ targets: MACOSX_DEPLOYMENT_TARGET: "14.0" sources: - CompassKitTests + - CompassNativeTests + - Compass/NSEvent+KeyEvent.swift resources: - path: CompassKitTests/Fixtures type: folder