diff --git a/Makefile b/Makefile index a9726d19..ae719f6b 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ XCBEAUTIFY = ./BuildTools/.build/release/xcbeautify XCODEBUILD = set -o pipefail && xcodebuild XCPROJ = Prose/Prose.xcodeproj XCSCHEME = Prose -PREVIEW_SCHEMES = ConversationFeaturePreview EditProfileFeaturePreview +PREVIEW_SCHEMES = ConversationFeaturePreview EditProfileFeaturePreview ProseUIPreview preflight: lint test release_build build_preview_apps diff --git a/Prose/Prose.xcodeproj/project.pbxproj b/Prose/Prose.xcodeproj/project.pbxproj index e268bdab..2a2d63d9 100644 --- a/Prose/Prose.xcodeproj/project.pbxproj +++ b/Prose/Prose.xcodeproj/project.pbxproj @@ -15,6 +15,9 @@ 2CBFFE77287D7D1900A53992 /* XCUIApplication+Prose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CBFFE76287D7D1900A53992 /* XCUIApplication+Prose.swift */; }; 2CBFFE79287D7D2B00A53992 /* RosterSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CBFFE78287D7D2B00A53992 /* RosterSelectionTests.swift */; }; 2CF10E9D287D8D0B0006DCFF /* UITestHostApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF10E9C287D8D0B0006DCFF /* UITestHostApp.swift */; }; + 4688166F28B67A7400916EC6 /* ProseUIPreviewApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4688166E28B67A7400916EC6 /* ProseUIPreviewApp.swift */; }; + 4688167128B67A7400916EC6 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4688167028B67A7400916EC6 /* ContentView.swift */; }; + 4688168028B67ADF00916EC6 /* Previews in Frameworks */ = {isa = PBXBuildFile; productRef = 4688167F28B67ADF00916EC6 /* Previews */; }; 46A46C7428635B8E00F27E40 /* ConversationFeaturePreviewApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46A46C7328635B8E00F27E40 /* ConversationFeaturePreviewApp.swift */; }; 46A46C7628635B8E00F27E40 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46A46C7528635B8E00F27E40 /* ContentView.swift */; }; 46A46C7828635B9000F27E40 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 46A46C7728635B9000F27E40 /* Assets.xcassets */; }; @@ -58,6 +61,10 @@ 2CBFFE76287D7D1900A53992 /* XCUIApplication+Prose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCUIApplication+Prose.swift"; sourceTree = ""; }; 2CBFFE78287D7D2B00A53992 /* RosterSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RosterSelectionTests.swift; sourceTree = ""; }; 2CF10E9C287D8D0B0006DCFF /* UITestHostApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestHostApp.swift; sourceTree = ""; }; + 4688166C28B67A7400916EC6 /* ProseUIPreview.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ProseUIPreview.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 4688166E28B67A7400916EC6 /* ProseUIPreviewApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProseUIPreviewApp.swift; sourceTree = ""; }; + 4688167028B67A7400916EC6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 4688167728B67A7600916EC6 /* ProseUIPreview.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ProseUIPreview.entitlements; sourceTree = ""; }; 46A46C7128635B8E00F27E40 /* ConversationFeaturePreview.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ConversationFeaturePreview.app; sourceTree = BUILT_PRODUCTS_DIR; }; 46A46C7328635B8E00F27E40 /* ConversationFeaturePreviewApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationFeaturePreviewApp.swift; sourceTree = ""; }; 46A46C7528635B8E00F27E40 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -89,6 +96,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4688166928B67A7400916EC6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4688168028B67ADF00916EC6 /* Previews in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 46A46C6E28635B8E00F27E40 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -176,6 +191,16 @@ path = Helpers; sourceTree = ""; }; + 4688166D28B67A7400916EC6 /* ProseUIPreview */ = { + isa = PBXGroup; + children = ( + 4688166E28B67A7400916EC6 /* ProseUIPreviewApp.swift */, + 4688167028B67A7400916EC6 /* ContentView.swift */, + 4688167728B67A7600916EC6 /* ProseUIPreview.entitlements */, + ); + path = ProseUIPreview; + sourceTree = ""; + }; 46A46C7228635B8E00F27E40 /* ConversationFeaturePreview */ = { isa = PBXGroup; children = ( @@ -215,6 +240,7 @@ 46D306E9287C249100CCA25F /* EditProfileFeaturePreview */, 2CBFFE4F287D6DFE00A53992 /* UITestHost */, 2CBFFE68287D7B0B00A53992 /* ProseUITests */, + 4688166D28B67A7400916EC6 /* ProseUIPreview */, 52EA116F274C23C2007DA1C5 /* Products */, 2C4F3D0E27F716C2004B79F3 /* Frameworks */, ); @@ -229,6 +255,7 @@ 46D306E8287C249100CCA25F /* EditProfileFeaturePreview.app */, 2CBFFE4E287D6DFE00A53992 /* UITestHost.app */, 2CBFFE67287D7B0B00A53992 /* ProseUITests.xctest */, + 4688166C28B67A7400916EC6 /* ProseUIPreview.app */, ); name = Products; sourceTree = ""; @@ -288,6 +315,26 @@ productReference = 2CBFFE67287D7B0B00A53992 /* ProseUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + 4688166B28B67A7400916EC6 /* ProseUIPreview */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4688167A28B67A7600916EC6 /* Build configuration list for PBXNativeTarget "ProseUIPreview" */; + buildPhases = ( + 4688166828B67A7400916EC6 /* Sources */, + 4688166928B67A7400916EC6 /* Frameworks */, + 4688166A28B67A7400916EC6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = ProseUIPreview; + packageProductDependencies = ( + 4688167F28B67ADF00916EC6 /* Previews */, + ); + productName = ProseUIPreview; + productReference = 4688166C28B67A7400916EC6 /* ProseUIPreview.app */; + productType = "com.apple.product-type.application"; + }; 46A46C7028635B8E00F27E40 /* ConversationFeaturePreview */ = { isa = PBXNativeTarget; buildConfigurationList = 46A46C7F28635B9000F27E40 /* Build configuration list for PBXNativeTarget "ConversationFeaturePreview" */; @@ -366,6 +413,9 @@ CreatedOnToolsVersion = 14.0; TestTargetID = 2CBFFE4D287D6DFE00A53992; }; + 4688166B28B67A7400916EC6 = { + CreatedOnToolsVersion = 14.0; + }; 46A46C7028635B8E00F27E40 = { CreatedOnToolsVersion = 14.0; }; @@ -397,6 +447,7 @@ 46D306E7287C249100CCA25F /* EditProfileFeaturePreview */, 2CBFFE4D287D6DFE00A53992 /* UITestHost */, 2CBFFE66287D7B0B00A53992 /* ProseUITests */, + 4688166B28B67A7400916EC6 /* ProseUIPreview */, ); }; /* End PBXProject section */ @@ -418,6 +469,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4688166A28B67A7400916EC6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 46A46C6F28635B8E00F27E40 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -463,6 +521,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 4688166828B67A7400916EC6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4688167128B67A7400916EC6 /* ContentView.swift in Sources */, + 4688166F28B67A7400916EC6 /* ProseUIPreviewApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 46A46C6D28635B8E00F27E40 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -596,6 +663,58 @@ }; name = Release; }; + 4688167828B67A7600916EC6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = ProseUIPreview/ProseUIPreview.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Prose. All rights reserved."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.prose.ProseUIPreview; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 4688167928B67A7600916EC6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = ProseUIPreview/ProseUIPreview.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2022 Prose. All rights reserved."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 12.5; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.prose.ProseUIPreview; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; 46A46C7D28635B9000F27E40 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -898,6 +1017,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 4688167A28B67A7600916EC6 /* Build configuration list for PBXNativeTarget "ProseUIPreview" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4688167828B67A7600916EC6 /* Debug */, + 4688167928B67A7600916EC6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 46A46C7F28635B9000F27E40 /* Build configuration list for PBXNativeTarget "ConversationFeaturePreview" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -945,6 +1073,10 @@ isa = XCSwiftPackageProductDependency; productName = TestHostApp; }; + 4688167F28B67ADF00916EC6 /* Previews */ = { + isa = XCSwiftPackageProductDependency; + productName = Previews; + }; 46A46C80286362B300F27E40 /* App */ = { isa = XCSwiftPackageProductDependency; productName = App; diff --git a/Prose/ProseLib/Sources/ProseUI/TCATextView/TCATextView.swift b/Prose/ProseLib/Sources/ProseUI/TCATextView/TCATextView.swift new file mode 100644 index 00000000..d3ee995a --- /dev/null +++ b/Prose/ProseLib/Sources/ProseUI/TCATextView/TCATextView.swift @@ -0,0 +1,574 @@ +// +// This file is part of prose-app-macos. +// Copyright (c) 2022 Prose Foundation +// + +import AppKit +import Combine +import ComposableArchitecture +import SwiftUI +import Toolbox + +// MARK: - View + +public struct TCATextView: View { + public typealias State = TCATextViewState + public typealias Action = TCATextViewAction + + private let store: Store + @ObservedObject private var viewStore: ViewStore + + public init(store: Store) { + self.store = store + self.viewStore = ViewStore(store) + } + + public var body: some View { + _TCATextView(store: self.store) + .focusable() + .overlay(alignment: .topLeading) { + if let placeholder = self.viewStore.placeholder, self.viewStore.text.characters.isEmpty { + Text(placeholder) + .padding(self.viewStore.textContainerInset.prose_edgeInsets) + .allowsHitTesting(false) + .accessibility(hidden: true) + } + } + } +} + +struct _TCATextView: NSViewRepresentable { + typealias State = TCATextViewState + typealias Action = TCATextViewAction + + private let store: Store + @ObservedObject private var viewStore: ViewStore + + init(store: Store) { + self.store = store + self.viewStore = ViewStore(store) + } + + func makeNSView(context: Context) -> MyScrollableTextView { + let view = MyScrollableTextView(frame: .zero, coordinator: context.coordinator) + view.textView.textStorage?.setAttributedString(NSAttributedString(self.viewStore.text)) + return view + } + + func updateNSView(_ view: MyScrollableTextView, context: Context) { + let textView = view.textView + + let textHasChanged = !context.coordinator.isSendingTextChangeToStore + && self.viewStore.text != AttributedString(textView.attributedString()) + if textHasChanged { + logger.debug("Updating text…") + // Update the text storage + assert(textView.textStorage != nil) + textView.textStorage?.setAttributedString(NSAttributedString(self.viewStore.text)) + } + + view._updateSize() + + // Scroll to the caret + textView.scrollRangeToVisible(textView.selectedRange()) + } + + func makeCoordinator() -> Coordinator { + Coordinator(viewStore: self.viewStore) + } + + final class Coordinator: NSObject, NSTextViewDelegate { + let textContentStorage: NSTextContentStorage + let textLayoutManager: NSTextLayoutManager + + let viewStore: ViewStore + + var cancellables = Set() + + var isSendingTextChangeToStore = false + /// We need to send changes of the cursor position as well as any text changes to the ViewStore. + /// Somehow the UITextViewDelegate receives a call to `textViewDidChangeSelection` before + /// `textViewDidChange`. Also `textViewDidChangeSelection` is called every time the text changed. + /// In order to not send duplicate events to the ViewStore but also get the order right (when + /// the selection changed, we should know the new text already otherwise the indices wouldn't + /// make sense) we keep track of what's going on. + var textViewIsChanging = false + + init(viewStore: ViewStore) { + self.viewStore = viewStore + + // Create and initialize the supporting layout, container, and storage management. + self.textLayoutManager = NSTextLayoutManager() + self.textLayoutManager.usesFontLeading = false + let textContainer = NSTextContainer() + self.textLayoutManager.textContainer = textContainer + self.textContentStorage = NSTextContentStorage() + self.textContentStorage.addTextLayoutManager(self.textLayoutManager) + } + + func textDidChange(_: Notification) { + self.textViewIsChanging = false + + assert(self.textContentStorage.attributedString != nil) + guard let attributedString: NSAttributedString = self.textContentStorage.attributedString + else { + logger.error("\(#function): `textContentStorage.attributedString` is `nil`.") + return + } + + self.isSendingTextChangeToStore = true + self.viewStore.send(.textDidChange(AttributedString(attributedString))) + let selectionRange: NSRange? = self.textContentStorage.prose_selectionRange() + self.viewStore.send(.selectionDidChange(selectionRange)) + self.isSendingTextChangeToStore = false + } + + func textViewDidChangeSelection(_: Notification) { + guard !self.textViewIsChanging else { return } + + let selectionRange: NSRange? = self.textContentStorage.prose_selectionRange() + self.viewStore.send(.selectionDidChange(selectionRange)) + } + + func textView( + _: NSTextView, + doCommandBy commandSelector: Selector + ) -> Bool { + let event: KeyEvent + switch commandSelector { + case #selector(NSResponder.moveUp(_:)): + event = .up + case #selector(NSResponder.moveDown(_:)): + event = .down + case #selector(NSResponder.insertNewline(_:)): + event = .newline + default: + return false + } + + if self.viewStore.interceptedEvents.contains(event) { + self.viewStore.send(.keyboardEventReceived(event)) + // NOTE: We cannot know if the event was handled here… so let's suppose it was. + // This means the user cannot type a new line, even if we don't handle the action. + // We could work around this **if needed**. + return true + } else { + return false + } + } + } +} + +final class MyScrollableTextView: NSView { + fileprivate let textView: MyTextView + var textViewHeightConstraint: NSLayoutConstraint! + + weak var coordinator: _TCATextView.Coordinator? + + var textContainerHeight: CGFloat { + guard let coordinator = self.coordinator else { + assertionFailure("`self.coordinator` is `nil`") + return self.textView.frame.height + } + guard let textContainer = coordinator.textLayoutManager.textContainer else { + assertionFailure("`textLayoutManager.textContainer` is `nil`") + return self.textView.frame.height + } + + let string = self.textView.attributedString() + let bounds = string.boundingRect(with: textContainer.size, options: [.usesLineFragmentOrigin]) + + let height = bounds.height + coordinator.viewStore.textContainerInset.height * 2 + return height + } + + init(frame frameRect: NSRect, coordinator: _TCATextView.Coordinator) { + self.coordinator = coordinator + + // Create scroll view + + let scrollView = NSTextView.scrollableTextView() + scrollView.drawsBackground = true + scrollView.backgroundColor = .textBackgroundColor + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.verticalScrollElasticity = .none + + // Create text view + + let textContainer: NSTextContainer? = coordinator.textLayoutManager.textContainer + assert(textContainer != nil) + let textView = MyTextView( + frame: .init(origin: .zero, size: frameRect.size), + textContainer: textContainer + ) + textContainer?.size = frameRect.size + self.textView = textView + textView.delegate = coordinator + textView.typingAttributes = coordinator.viewStore.typingAttributes.attributes + textView.hasFocusRing = coordinator.viewStore.showFocusRing + textView.drawsBackground = false + // Remove default 5pt horizontal padding + textContainer?.lineFragmentPadding = 0 + textView.textContainerInset = coordinator.viewStore.textContainerInset + textView.isHorizontallyResizable = true + + super.init(frame: frameRect) + + // Create border + + self.wantsLayer = true + assert(self.layer != nil) + self.layer?.borderWidth = coordinator.viewStore.borderWidth + self.layer?.borderColor = NSColor.separatorColor.cgColor + self.layer?.cornerRadius = coordinator.viewStore.cornerRadius + + // Add views + + textView.viewWillMove(toSuperview: scrollView.documentView) + assert(textView.enclosingScrollView == nil) + scrollView.documentView = textView + assert(textView.enclosingScrollView != nil) + textView.viewDidMoveToSuperview() + self.addSubview(scrollView) + + // Add constraints + + let height = self.textContainerHeight + self.textViewHeightConstraint = textView.heightAnchor + .constraint(equalToConstant: height) + self.textViewHeightConstraint.priority = .defaultHigh + textView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + textView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor), + textView.bottomAnchor.constraint(equalTo: scrollView.contentView.bottomAnchor), + textView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor), + self.textViewHeightConstraint, + ]) + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.contentView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: self.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + scrollView.contentView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + scrollView.contentView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + ]) + + self.translatesAutoresizingMaskIntoConstraints = false + let heightConstraint = self.heightAnchor + .constraint(equalTo: textView.heightAnchor) + heightConstraint.priority = .defaultHigh + heightConstraint.isActive = true + if let minHeight = coordinator.viewStore.minHeight { + #if DEBUG + if let maxHeight = coordinator.viewStore.maxHeight { + assert(minHeight <= maxHeight) + } + #endif + let minHeightConstraint = self.heightAnchor + .constraint(greaterThanOrEqualToConstant: minHeight) + minHeightConstraint.priority = .required + minHeightConstraint.isActive = true + } + if let maxHeight = coordinator.viewStore.maxHeight { + let maxHeightConstraint = self.heightAnchor + .constraint(lessThanOrEqualToConstant: maxHeight) + maxHeightConstraint.priority = .required + maxHeightConstraint.isActive = true + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func resize(withOldSuperviewSize oldSize: NSSize) { + super.resize(withOldSuperviewSize: oldSize) + // Allow live resize of the text field + self._updateSize() + } + + override func viewDidEndLiveResize() { + super.viewDidEndLiveResize() + // FIX: For some reason, when resizing quickly, the last live resize + // (`resize(withOldSuperviewSize:)`) doesn't seem to have the correct size. + // This ensures the text is correctly laid out after the operation. + self._updateSize() + // Scroll to the caret, but not during live resize to avoid weird behaviors + self.textView.scrollRangeToVisible(self.textView.selectedRange()) + } + + func _updateSize() { + self._resizeTextContainer() + let height = self.textContainerHeight + self.textViewHeightConstraint.constant = height + // Make sure we layout the text again + // FIX: This fixes the text not appearing sometimes after an insert from `updateNSView` + if let textLayoutManager = self.textView.textLayoutManager { + textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) + } + } + + func _resizeTextContainer() { + // Update the container width and let it resize vertically + let width = self.frame.width - self.textView.textContainerInset.width * 2 + self.textView.textContainer?.size.width = width + } +} + +private final class MyTextView: NSTextView { + override class var defaultFocusRingType: NSFocusRingType { .exterior } + override var focusRingMaskBounds: NSRect { self.bounds } + + var hasFocusRing: Bool = false { + didSet { + self.focusRingType = self.hasFocusRing ? Self.defaultFocusRingType : .none + } + } + + override func drawFocusRingMask() { + assert(self.layer != nil) + if let radius = self.layer?.cornerRadius { + // Draw the default rounded `NSTextField` focus ring + NSBezierPath(roundedRect: self.focusRingMaskBounds, xRadius: radius, yRadius: radius).fill() + } else { + super.drawFocusRingMask() + } + } +} + +// MARK: - The Composable Architecture + +// MARK: Reducer + +public let textViewReducer = Reducer< + TCATextViewState, + TCATextViewAction, + Void +> { state, action, _ in + switch action { + case let .textDidChange(text): + state.text = text + return .none + + case let .selectionDidChange(range): + state.selection = range + return .none + + case .keyboardEventReceived: + return .none + } +} + +// MARK: State + +public struct TCATextViewState: Equatable { + static let defaultAttributes = AttributeContainer([ + .font: NSFont.systemFont(ofSize: 12), + .foregroundColor: NSColor.textColor, + ]) + + public var text: AttributedString + let placeholder: AttributedString? + var typingAttributes: AttributeContainer + var selection: NSRange? + /// NOTE: [Rémi Bardon] It's not ideal having the sizes here, but I tried using SwiftUI's + /// `.frame` modifier, but I couldn't make it work correctly. + var minHeight, maxHeight: CGFloat? + var borderWidth: CGFloat + var cornerRadius: CGFloat + var textContainerInset: NSSize + var showFocusRing: Bool + var interceptedEvents: Set + + /// - Note: The default values replicate the rounded `NSTextField` style. + public init( + text: AttributedString? = nil, + placeholder: String? = nil, + typingAttributes: AttributeContainer? = nil, + selection: NSRange? = nil, + minHeight: CGFloat? = nil, + maxHeight: CGFloat? = nil, + borderWidth: CGFloat? = nil, + cornerRadius: CGFloat? = nil, + textContainerInset: NSSize? = nil, + showFocusRing: Bool = true, + interceptEvents: Set = [] + ) { + var typingAttributes = typingAttributes ?? AttributeContainer() + typingAttributes.merge(Self.defaultAttributes, mergePolicy: .keepCurrent) + self.text = text ?? AttributedString("", attributes: typingAttributes) + self.placeholder = placeholder.map { string in + let placeholderAttributes = typingAttributes.merging(AttributeContainer([ + .foregroundColor: NSColor.secondaryLabelColor, + ]), mergePolicy: .keepNew) + return AttributedString(string, attributes: placeholderAttributes) + } + self.typingAttributes = typingAttributes + self.selection = selection + self.minHeight = minHeight + self.maxHeight = maxHeight + self.borderWidth = borderWidth ?? 1 + self.cornerRadius = cornerRadius ?? 6 + self.textContainerInset = textContainerInset ?? NSSize(width: 5, height: 5) + self.showFocusRing = showFocusRing + self.interceptedEvents = interceptEvents + } + + public init( + text: String, + placeholder: String? = nil, + typingAttributes: AttributeContainer? = nil, + selection: NSRange? = nil, + minHeight: CGFloat? = nil, + maxHeight: CGFloat? = nil, + borderWidth: CGFloat? = nil, + cornerRadius: CGFloat? = nil, + textContainerInset: NSSize? = nil, + showFocusRing: Bool = true, + interceptEvents: Set = [] + ) { + self.init( + text: AttributedString(text, attributes: Self.defaultAttributes), + placeholder: placeholder, + typingAttributes: typingAttributes, + selection: selection, + minHeight: minHeight, + maxHeight: maxHeight, + borderWidth: borderWidth, + cornerRadius: cornerRadius, + textContainerInset: textContainerInset, + showFocusRing: showFocusRing, + interceptEvents: interceptEvents + ) + } +} + +// MARK: Actions + +public enum TCATextViewAction: Equatable { + case textDidChange(AttributedString) + case selectionDidChange(NSRange?) + case keyboardEventReceived(KeyEvent) +} + +#if DEBUG + + // MARK: - Previews + + struct TCATextView_Previews: PreviewProvider { + private struct Preview: View { + let state: TCATextViewState + let height: CGFloat? + + init( + state: TCATextViewState, + height: CGFloat? = nil + ) { + self.state = state + self.height = height + } + + var body: some View { + TCATextView(store: Store( + initialState: state, + reducer: textViewReducer, + environment: () + )) + .frame(width: 500) + .frame(minHeight: self.height) + .padding(8) + } + } + + static var previews: some View { + let previews = ScrollView(.vertical) { + VStack(spacing: 16) { + GroupBox("Simple message") { + Preview(state: .init( + text: "This is a message that was written.", + placeholder: "Message Valerian", + interceptEvents: [.newline] + )) + } + GroupBox("Long message") { + Preview(state: .init( + text: "This is a \(Array(repeating: "very", count: 20).joined(separator: " ")) long message that was written.", + placeholder: "Message Valerian", + interceptEvents: [.newline] + )) + } + GroupBox("Long username") { + Preview(state: .init( + placeholder: "Very \(Array(repeating: "very", count: 20).joined(separator: " ")) long placeholder", + interceptEvents: [.newline] + )) + } + GroupBox("Empty") { + Preview(state: .init()) + } + GroupBox("High, multi line") { + Preview(state: .init( + placeholder: "Message Valerian", + minHeight: 128 + )) + } + GroupBox("Multi line, small corners") { + Preview(state: .init( + placeholder: "Message Valerian", + cornerRadius: 4 + )) + } + GroupBox("Single line vs multi line") { + VStack(spacing: 0) { + Preview(state: .init( + text: "This is a message that was written.", + placeholder: "Message Valerian" + )) + .overlay { + Preview(state: .init( + text: "This is a message that was written.", + placeholder: "Message Valerian" + )) + .blendMode(.difference) +// .blendMode(.multiply) +// .opacity(0.5) + } + Preview(state: .init( + placeholder: "Message Valerian" + )) + .overlay { + Preview(state: .init( + placeholder: "Message Valerian" + )) +// .blendMode(.difference) + .blendMode(.multiply) +// .opacity(0.5) + } + } + } + GroupBox("Colorful background") { + Preview(state: .init( + placeholder: "Message Valerian" + )) + .background(Color.pink) + } + } + .padding(8) + } + .frame(minHeight: 720) + previews + .preferredColorScheme(.light) + .previewDisplayName("Light") + previews + .preferredColorScheme(.dark) + .previewDisplayName("Dark") + } + } +#endif diff --git a/Prose/ProseLib/Sources/Toolbox/AttributeContainer+Prose.swift b/Prose/ProseLib/Sources/Toolbox/AttributeContainer+Prose.swift new file mode 100644 index 00000000..481d4abb --- /dev/null +++ b/Prose/ProseLib/Sources/Toolbox/AttributeContainer+Prose.swift @@ -0,0 +1,13 @@ +// +// This file is part of prose-app-macos. +// Copyright (c) 2022 Prose Foundation +// + +import Foundation + +public extension AttributeContainer { + var attributes: [NSAttributedString.Key: Any] { + NSAttributedString(AttributedString(" ", attributes: self)) + .attributes(at: 0, effectiveRange: nil) + } +} diff --git a/Prose/ProseLib/Sources/Toolbox/NSTextContentStorage+Prose.swift b/Prose/ProseLib/Sources/Toolbox/NSTextContentStorage+Prose.swift new file mode 100644 index 00000000..f91eca1b --- /dev/null +++ b/Prose/ProseLib/Sources/Toolbox/NSTextContentStorage+Prose.swift @@ -0,0 +1,103 @@ +// +// This file is part of prose-app-macos. +// Copyright (c) 2022 Prose Foundation +// + +import Cocoa + +private extension NSRange { + init(_ textRange: NSTextRange, in textContentManager: NSTextContentManager) { + let offset = textContentManager.offset( + from: textContentManager.documentRange.location, + to: textRange.location + ) + let length = textContentManager.offset( + from: textRange.location, + to: textRange.endLocation + ) + self.init(location: offset, length: length) + } + + init(_ textLocation: NSTextLocation, in textContentManager: NSTextContentManager) { + let offset = textContentManager.offset( + from: textContentManager.documentRange.location, + to: textLocation + ) + self.init(location: offset, length: 0) + } +} + +@available(macOS 12.0, *) +public extension NSTextContentStorage { + /// - Warning: This implementation is naive. If there are multiple selections, we take the last range. + func prose_selectionRange() -> NSRange? { + guard let textLayoutManager = self.textLayoutManagers.first else { + assertionFailure("`textLayoutManagers` is empty.") + return nil + } + + guard let textRange = textLayoutManager.textSelections.last?.textRanges.last else { + // There is no selection + return nil + } + + return NSRange(textRange, in: self) + } + + func prose_endIndexOfLastAttachment(before startLocation: NSTextLocation? = nil) -> Int? { + guard let textLayoutManager = self.textLayoutManagers.first else { + assertionFailure("`textLayoutManagers` is empty.") + return nil + } + guard let attributedString = self.attributedString else { + assertionFailure("You cannot search for attachment in a non-attributed string.") + return nil + } + + let startLocation: NSTextLocation = startLocation ?? self.documentRange.endLocation + + var endOfLastAttachment: Int? + textLayoutManager.enumerateSubstrings( + from: startLocation, + options: [.byCaretPositions, .reverse] + ) { (_, textRange: NSTextRange, _, stop: UnsafeMutablePointer) in + // Transform `NSTextRange` into a `NSRange` + let range = NSRange(textRange, in: self) + + // Find attachment + let attachment = attributedString.attribute( + .attachment, + at: range.location, + effectiveRange: nil + ) + + // Stop enumerating if we found an attachment + if attachment != nil { + endOfLastAttachment = range.upperBound + stop.pointee = true + } + } + + return endOfLastAttachment ?? 0 + } + + func prose_rangeFromLastAttachment(to endLocation: NSTextLocation? = nil) -> NSRange { + let endLocation = endLocation ?? self.documentRange.endLocation + + let start: Int = self.prose_endIndexOfLastAttachment(before: endLocation) ?? 0 + let end = NSRange(endLocation, in: self) + return NSRange(location: start, length: end.upperBound - start) + } + + /// - Note: If there are multiple selections, we take the last selection caret. + func prose_rangeFromLastAttachmentToCaret() -> NSRange { + guard let textLayoutManager = self.textLayoutManagers.first else { + assertionFailure("`textLayoutManagers` is empty.") + return NSRange(location: NSNotFound, length: 0) + } + let location: NSTextLocation? = textLayoutManager.textSelections.last?.textRanges.last? + .endLocation + + return self.prose_rangeFromLastAttachment(to: location) + } +} diff --git a/Prose/ProseLib/Sources/Toolbox/Size+EdgeInsets.swift b/Prose/ProseLib/Sources/Toolbox/Size+EdgeInsets.swift new file mode 100644 index 00000000..2aaf9b8b --- /dev/null +++ b/Prose/ProseLib/Sources/Toolbox/Size+EdgeInsets.swift @@ -0,0 +1,12 @@ +// +// This file is part of prose-app-macos. +// Copyright (c) 2022 Prose Foundation +// + +import SwiftUI + +public extension NSSize { + var prose_edgeInsets: EdgeInsets { + EdgeInsets(top: self.height, leading: self.width, bottom: self.height, trailing: self.width) + } +} diff --git a/Prose/ProseUIPreview/ContentView.swift b/Prose/ProseUIPreview/ContentView.swift new file mode 100644 index 00000000..944342a8 --- /dev/null +++ b/Prose/ProseUIPreview/ContentView.swift @@ -0,0 +1,91 @@ +// +// This file is part of prose-app-macos. +// Copyright (c) 2022 Prose Foundation +// + +import ComposableArchitecture +@testable import ProseUI +import SwiftUI + +struct ContentView: View { + struct Preview: View { + let store: Store + var body: some View { + VStack(alignment: .leading) { + TCATextView(store: self.store) + WithViewStore(self.store) { viewStore in + VStack(alignment: .leading) { + let attributedString = NSAttributedString(viewStore.text) + + Text( + "Text: \"\(attributedString.string.replacingOccurrences(of: "\n", with: #"\n"#))\"" + ) + Text("Length: \(attributedString.length)") + Text( + "Contains attachments: \(String(describing: attributedString.containsAttachments))" + ) + + Text("Selection range: \(String(describing: viewStore.selection))") + + let selectedText: String? = viewStore.selection + .map(attributedString.attributedSubstring(from:)) + .map { $0.string.replacingOccurrences(of: "\n", with: #"\n"#) } + Text("Selected text: \(String(describing: selectedText))") + +// let rangeFromLastAttachment: NSRange? = viewStore.selection +// .map(attributedString.prose_rangeFromLastAttachmentToCaret(selectionRange:)) +// Text("Range to last attachment: \(String(describing: rangeFromLastAttachment))") + +// let textFromLastAttachment: NSAttributedString? = rangeFromLastAttachment +// .map(attributedString.attributedSubstring(from:)) +// Text("Text to last attachment: \(String(describing: textFromLastAttachment?.string))") + } + } + .frame(maxHeight: .infinity, alignment: .top) + } + .padding() + .background(Color(nsColor: .windowBackgroundColor)) + .frame(minWidth: 300) +// .fixedSize() + } + } + + var body: some View { + Preview(store: Store( + initialState: TCATextViewState( + maxHeight: 128 + ), + reducer: textViewReducer, + environment: () + )) + .previewDisplayName("Default") +// Preview(store: Store( +// initialState: TCATextViewState( +// height: 24, +// cornerRadius: 12, +// textContainerInset: NSSize(width: 8, height: 5), +// showFocusRing: false +// ), +// reducer: textViewReducer, +// environment: () +// )) +// .previewDisplayName("Rounded") +// Preview(store: Store( +// initialState: TCATextViewState( +// height: 48, +// borderWidth: 4, +// cornerRadius: 0, +// showFocusRing: false +// ), +// reducer: textViewReducer, +// environment: () +// )) +// .previewDisplayName("Squared") + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } +} diff --git a/Prose/ProseUIPreview/ProseUIPreview.entitlements b/Prose/ProseUIPreview/ProseUIPreview.entitlements new file mode 100644 index 00000000..f2ef3ae0 --- /dev/null +++ b/Prose/ProseUIPreview/ProseUIPreview.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + diff --git a/Prose/ProseUIPreview/ProseUIPreviewApp.swift b/Prose/ProseUIPreview/ProseUIPreviewApp.swift new file mode 100644 index 00000000..a891b0ec --- /dev/null +++ b/Prose/ProseUIPreview/ProseUIPreviewApp.swift @@ -0,0 +1,15 @@ +// +// This file is part of prose-app-macos. +// Copyright (c) 2022 Prose Foundation +// + +import SwiftUI + +@main +struct ProseUIPreviewApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +}