diff --git a/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 002b87c..baa3e37 100644 --- a/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mactrix.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "cef1eb81361acd9acea13764bdc16f4787935d4b09f5dcc6bd8e53e93b503c29", + "originHash" : "2a437ca29320ff65154ec5a0ec9d5acc42dc07fb02366dcc6b167b1cddc56c19", "pins" : [ { "identity" : "matrix-rust-components-swift", diff --git a/Mactrix/Extensions/Logger.swift b/Mactrix/Extensions/Logger.swift index 4e7ee35..f87790a 100644 --- a/Mactrix/Extensions/Logger.swift +++ b/Mactrix/Extensions/Logger.swift @@ -12,6 +12,7 @@ extension Logger { static let liveTimeline = Logger(subsystem: subsystem, category: "live-timeline") static let timelineTableView = Logger(subsystem: subsystem, category: "timeline-table-view") static let SidebarRoom = Logger(subsystem: subsystem, category: "sidebar-room") + static let chatTextView = Logger(subsystem: subsystem, category: "chat-text-view") static let viewCycle = Logger(subsystem: subsystem, category: "viewcycle") diff --git a/Mactrix/Views/ChatView/ChatInputView.swift b/Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift similarity index 83% rename from Mactrix/Views/ChatView/ChatInputView.swift rename to Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift index 11f77a3..a647a3c 100644 --- a/Mactrix/Views/ChatView/ChatInputView.swift +++ b/Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift @@ -10,7 +10,6 @@ struct ChatInputView: View { @State private var isDraftLoaded: Bool = false @State private var chatInput: String = "" - @FocusState private var chatFocused: Bool func sendMessage() async { guard !chatInput.isEmpty else { return } @@ -35,7 +34,7 @@ struct ChatInputView: View { private func saveDraft() async { guard isDraftLoaded else { return } // avoid saving a draft hasn't yet been restored - if chatInput.isEmpty && replyTo == nil { + if chatInput.isEmpty, replyTo == nil { Logger.viewCycle.debug("clearing draft") do { try await room.clearComposerDraft(threadRoot: timeline.focusedThreadId) @@ -65,14 +64,14 @@ struct ChatInputView: View { } private func loadDraft() async { - guard !isDraftLoaded else { return } // don't load a draft more than once + guard !isDraftLoaded else { return } // don't load a draft more than once do { guard let draft = try await room.loadComposerDraft(threadRoot: timeline.focusedThreadId) else { // no draft to load isDraftLoaded = true return } - self.chatInput = draft.plainText + chatInput = draft.plainText switch draft.draftType { case .reply(eventId: let eventId): // we need a timeline to be able to populate the reply; return false so we can try again @@ -83,7 +82,7 @@ struct ChatInputView: View { do { let item = try await innerTimeline.getEventTimelineItemByEventId(eventId: eventId) - self.timeline.sendReplyTo = item + timeline.sendReplyTo = item } catch { Logger.viewCycle.error("failed to resolve reply target: \(error)") } @@ -95,7 +94,7 @@ struct ChatInputView: View { } catch { Logger.viewCycle.error("failed to load draft: \(error)") } - isDraftLoaded = true // so we don't try again + isDraftLoaded = true // so we don't try again } private func chatInputChanged() async { @@ -123,29 +122,15 @@ struct ChatInputView: View { replyTo = nil } } - TextField("Message room", text: $chatInput, axis: .vertical) - .focused($chatFocused) - .onSubmit { Task { await sendMessage() } } - .textFieldStyle(.plain) - .lineLimit(nil) - .scrollContentBackground(.hidden) - .background(.clear) - .padding(10) - .disabled(!isDraftLoaded) // avoid inputs until we've tried to load a draft + ChatTextView(text: $chatInput, disabled: !isDraftLoaded, onSubmit: { Task { await sendMessage() }}) } .font(.system(size: .init(fontSize))) .background(Color(NSColor.textBackgroundColor)) .cornerRadius(4) - .lineSpacing(2) - .frame(minHeight: 20) .overlay( RoundedRectangle(cornerRadius: 4) .stroke(Color(NSColor.separatorColor), lineWidth: 1) ) - // .shadow(color: .black.opacity(0.1), radius: 4) - .onTapGesture { - chatFocused = true - } .task(id: chatInput) { await chatInputChanged() } diff --git a/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift b/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift new file mode 100644 index 0000000..07cf226 --- /dev/null +++ b/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift @@ -0,0 +1,129 @@ +import AppKit +import OSLog +import SwiftUI + +struct ChatTextView: NSViewRepresentable { + typealias NSViewRepresentableType = NSTextView + + let text: Binding + let disabled: Bool + let onSubmit: () -> Void + + func makeNSView(context: Context) -> DynamicTextView { + let textView = DynamicTextView() + + textView.onSubmit = onSubmit + + context.coordinator.textView = textView + textView.delegate = context.coordinator + + textView.textContainerInset = NSSize(width: DynamicTextView.padding, height: DynamicTextView.padding) + + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + unsafe textView.textContainer?.widthTracksTextView = true + + textView.setContentHuggingPriority(.required, for: .vertical) + textView.setContentCompressionResistancePriority(.required, for: .vertical) + + return textView + } + + func updateNSView(_ textView: DynamicTextView, context: Context) { + context.coordinator.text = text + + textView.onSubmit = onSubmit + + if textView.string != text.wrappedValue { + textView.string = text.wrappedValue + textView.invalidateIntrinsicContentSize() + } + + if textView.isEditable != !disabled { + textView.isEditable = !disabled + textView.isSelectable = !disabled + textView.alphaValue = !disabled ? 1.0 : 0.5 + + if disabled { + // resign first responder + unsafe textView.window?.makeFirstResponder(nil) + } + } + } + + func makeCoordinator() -> Coordinator { + return Coordinator(text: text) + } + + class Coordinator: NSObject, NSTextViewDelegate { + var textView: NSTextView? + var text: Binding + + init(text: Binding) { + self.text = text + } + + func textDidChange(_ notification: Notification) { + guard let textView else { return } + + text.wrappedValue = textView.string + } + } +} + +class DynamicTextView: NSTextView { + static let padding = 4 + + var onSubmit: (() -> Void)? + + override var intrinsicContentSize: NSSize { + guard let container = unsafe textContainer, let manager = unsafe layoutManager else { + return .zero + } + + // Force the layout for the current width + manager.ensureLayout(for: container) + let usedRect = manager.usedRect(for: container) + + // Return a flexible width but a fixed height based on text + return NSSize(width: NSView.noIntrinsicMetric, height: ceil(usedRect.height) + CGFloat(2 * Self.padding)) + } + + override func setFrameSize(_ newSize: NSSize) { + let oldWidth = frame.width + super.setFrameSize(newSize) + + if oldWidth != newSize.width { + invalidateIntrinsicContentSize() + } + } + + override func didChangeText() { + super.didChangeText() + invalidateIntrinsicContentSize() + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + // Always submit on cmd+enter + if (event.specialKey == .enter || event.specialKey == .carriageReturn) + && event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command] + { + onSubmit?() + return true + } + + return super.performKeyEquivalent(with: event) + } + + override func keyDown(with event: NSEvent) { + // Handle enter as submit instead of newline + if event.specialKey == .enter || event.specialKey == .carriageReturn, + event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [] + { + onSubmit?() + return + } + + super.keyDown(with: event) + } +} diff --git a/MactrixLibrary/Sources/MessageFormatting/AttributedTextView.swift b/MactrixLibrary/Sources/MessageFormatting/AttributedTextView.swift index 10d8c61..73a4d9a 100644 --- a/MactrixLibrary/Sources/MessageFormatting/AttributedTextView.swift +++ b/MactrixLibrary/Sources/MessageFormatting/AttributedTextView.swift @@ -4,7 +4,7 @@ public struct AttributedTextView: NSViewRepresentable { public let attributedString: NSAttributedString public init(attributedString: NSAttributedString) { - self.attributedString = attributedString + self.attributedString = attributedString.trimmed } public func makeNSView(context: Context) -> NSTextField { @@ -31,6 +31,26 @@ public struct AttributedTextView: NSViewRepresentable { guard let width = proposal.width, width > 0, width != .infinity else { return nil } textField.preferredMaxLayoutWidth = width - return textField.cell?.cellSize(forBounds: NSRect(x: 0, y: 0, width: width, height: CGFloat.greatestFiniteMagnitude)) + guard let size = textField.cell?.cellSize(forBounds: NSRect(x: 0, y: 0, width: width, height: CGFloat.greatestFiniteMagnitude)) else { + return nil + } + + return CGSize(width: ceil(size.width), height: ceil(size.height)) + } +} + +extension NSAttributedString { + var trimmed: NSAttributedString { + let nonWhitespaces = CharacterSet.whitespacesAndNewlines.inverted + let startRange = string.rangeOfCharacter(from: nonWhitespaces) + let endRange = string.rangeOfCharacter(from: nonWhitespaces, options: .backwards) + guard let startLocation = startRange?.upperBound, let endLocation = endRange?.lowerBound else { + return self + } + let location = string.distance(from: string.startIndex, to: startLocation) - 1 + let length = string.distance(from: startLocation, to: endLocation) + 2 + let range = NSRange(location: location, length: length) + + return attributedSubstring(from: range) } }