From 41959a12f957bd3b42d6c1d4be290332dc5dea8e Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 13 Mar 2026 09:29:30 +0100 Subject: [PATCH 1/5] Start on message input view based on NSTextView --- .../{ => ChatInputView}/ChatInputView.swift | 0 .../ChatView/ChatInputView/ChatTextView.swift | 22 +++++++++++++++++++ 2 files changed, 22 insertions(+) rename Mactrix/Views/ChatView/{ => ChatInputView}/ChatInputView.swift (100%) create mode 100644 Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift diff --git a/Mactrix/Views/ChatView/ChatInputView.swift b/Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift similarity index 100% rename from Mactrix/Views/ChatView/ChatInputView.swift rename to Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift diff --git a/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift b/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift new file mode 100644 index 0000000..8bba8f9 --- /dev/null +++ b/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift @@ -0,0 +1,22 @@ +import AppKit +import SwiftUI + +struct ChatTextView: NSViewRepresentable { + typealias NSViewRepresentableType = NSTextView + + func makeNSView(context: Context) -> NSTextView { + let textView = NSTextView() + + textView.delegate = context.coordinator + + return textView + } + + func updateNSView(_ nsView: NSTextView, context: Context) {} + + func makeCoordinator() -> Coordinator { + return Coordinator() + } + + class Coordinator: NSObject, NSTextViewDelegate {} +} From 2de65a3942b4bb6b7e97a182a51a26aedc068bed Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Tue, 17 Mar 2026 08:51:43 +0100 Subject: [PATCH 2/5] More work on NSTextView for input --- Mactrix/Extensions/Logger.swift | 1 + .../ChatInputView/ChatInputView.swift | 33 ++++--- .../ChatView/ChatInputView/ChatTextView.swift | 89 ++++++++++++++++++- 3 files changed, 106 insertions(+), 17 deletions(-) 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/ChatInputView.swift b/Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift index 11f77a3..0b61bf8 100644 --- a/Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift +++ b/Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift @@ -35,7 +35,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 +65,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 +83,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 +95,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,16 +123,21 @@ 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() }}) + // .frame(maxWidth: .infinity) + .border(.red) + /* 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 + */ } + .border(.blue) .font(.system(size: .init(fontSize))) .background(Color(NSColor.textBackgroundColor)) .cornerRadius(4) diff --git a/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift b/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift index 8bba8f9..25e2e98 100644 --- a/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift +++ b/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift @@ -1,22 +1,105 @@ 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) -> NSTextView { let textView = NSTextView() + context.coordinator.textView = textView textView.delegate = context.coordinator return textView } - func updateNSView(_ nsView: NSTextView, context: Context) {} + func updateNSView(_ textView: NSTextView, context: Context) { + context.coordinator.text = text + + if textView.string != text.wrappedValue { + textView.string = text.wrappedValue + } + + 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() + return Coordinator(text: text) } - class Coordinator: NSObject, NSTextViewDelegate {} + 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 + + // Make SwiftUI call sizeThatFits when the text changes + // textView.invalidateIntrinsicContentSize() + } + } + + /* func sizeThatFits(_ proposal: ProposedViewSize, nsView textView: NSTextView, context: Context) -> CGSize? { + guard let container = unsafe textView.textContainer, + let layoutManager = unsafe textView.layoutManager else { return nil } + guard let width = proposal.width, width > 0 else { return nil } + + // Set the container width to the proposed width to force correct wrapping + container.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude) + layoutManager.ensureLayout(for: container) + + let usedRect = layoutManager.usedRect(for: container) + let size = usedRect.size + //let size = CGSize(width: width, height: usedRect.height) + Logger.chatTextView.debug("Size of input view: \(size.width)x\(size.height), proposed width: \(width)") + return size + } */ +} + +// MARK: - Subclass for Intrinsic Sizing + +class DynamicTextView: NSTextView { + // We override this so AppKit/SwiftUI knows our true height requirement + override var intrinsicContentSize: NSSize { + guard let container = textContainer, let manager = 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)) + } + + // Crucial: When the width changes (window resize), we must re-calculate height + override func setFrameSize(_ newSize: NSSize) { + let oldWidth = frame.width + super.setFrameSize(newSize) + + if oldWidth != newSize.width { + invalidateIntrinsicContentSize() + } + } } From 8cc1bbcae211ea93d9d0c5ba8fab3e5b3889adc4 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Tue, 17 Mar 2026 14:08:09 +0100 Subject: [PATCH 3/5] Fix height calculation of new input field --- .../xcshareddata/swiftpm/Package.resolved | 2 +- .../ChatView/ChatInputView/ChatTextView.swift | 46 ++++++++----------- 2 files changed, 21 insertions(+), 27 deletions(-) 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/Views/ChatView/ChatInputView/ChatTextView.swift b/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift index 25e2e98..9c43fe8 100644 --- a/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift +++ b/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift @@ -10,11 +10,20 @@ struct ChatTextView: NSViewRepresentable { let onSubmit: () -> Void func makeNSView(context: Context) -> NSTextView { - let textView = NSTextView() + let textView = DynamicTextView() 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 } @@ -23,6 +32,7 @@ struct ChatTextView: NSViewRepresentable { if textView.string != text.wrappedValue { textView.string = text.wrappedValue + textView.invalidateIntrinsicContentSize() } if textView.isEditable != !disabled { @@ -53,35 +63,15 @@ struct ChatTextView: NSViewRepresentable { guard let textView else { return } text.wrappedValue = textView.string - - // Make SwiftUI call sizeThatFits when the text changes - // textView.invalidateIntrinsicContentSize() } } - - /* func sizeThatFits(_ proposal: ProposedViewSize, nsView textView: NSTextView, context: Context) -> CGSize? { - guard let container = unsafe textView.textContainer, - let layoutManager = unsafe textView.layoutManager else { return nil } - guard let width = proposal.width, width > 0 else { return nil } - - // Set the container width to the proposed width to force correct wrapping - container.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude) - layoutManager.ensureLayout(for: container) - - let usedRect = layoutManager.usedRect(for: container) - let size = usedRect.size - //let size = CGSize(width: width, height: usedRect.height) - Logger.chatTextView.debug("Size of input view: \(size.width)x\(size.height), proposed width: \(width)") - return size - } */ } -// MARK: - Subclass for Intrinsic Sizing - class DynamicTextView: NSTextView { - // We override this so AppKit/SwiftUI knows our true height requirement + static let padding = 4 + override var intrinsicContentSize: NSSize { - guard let container = textContainer, let manager = layoutManager else { + guard let container = unsafe textContainer, let manager = unsafe layoutManager else { return .zero } @@ -90,10 +80,9 @@ class DynamicTextView: NSTextView { 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)) + return NSSize(width: NSView.noIntrinsicMetric, height: ceil(usedRect.height) + CGFloat(2 * Self.padding)) } - // Crucial: When the width changes (window resize), we must re-calculate height override func setFrameSize(_ newSize: NSSize) { let oldWidth = frame.width super.setFrameSize(newSize) @@ -102,4 +91,9 @@ class DynamicTextView: NSTextView { invalidateIntrinsicContentSize() } } + + override func didChangeText() { + super.didChangeText() + invalidateIntrinsicContentSize() + } } From f887fc9384f5ecef87d52c064360a5f5c74602f4 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Wed, 18 Mar 2026 10:55:39 +0100 Subject: [PATCH 4/5] Add support for shift+enter for newlines, and submit otherwise --- .../ChatInputView/ChatInputView.swift | 20 ----------- .../ChatView/ChatInputView/ChatTextView.swift | 34 +++++++++++++++++-- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift b/Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift index 0b61bf8..a647a3c 100644 --- a/Mactrix/Views/ChatView/ChatInputView/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 } @@ -124,33 +123,14 @@ struct ChatInputView: View { } } ChatTextView(text: $chatInput, disabled: !isDraftLoaded, onSubmit: { Task { await sendMessage() }}) - // .frame(maxWidth: .infinity) - .border(.red) - /* 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 - */ } - .border(.blue) .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 index 9c43fe8..07cf226 100644 --- a/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift +++ b/Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift @@ -9,9 +9,11 @@ struct ChatTextView: NSViewRepresentable { let disabled: Bool let onSubmit: () -> Void - func makeNSView(context: Context) -> NSTextView { + func makeNSView(context: Context) -> DynamicTextView { let textView = DynamicTextView() + textView.onSubmit = onSubmit + context.coordinator.textView = textView textView.delegate = context.coordinator @@ -27,9 +29,11 @@ struct ChatTextView: NSViewRepresentable { return textView } - func updateNSView(_ textView: NSTextView, context: Context) { + func updateNSView(_ textView: DynamicTextView, context: Context) { context.coordinator.text = text + textView.onSubmit = onSubmit + if textView.string != text.wrappedValue { textView.string = text.wrappedValue textView.invalidateIntrinsicContentSize() @@ -70,6 +74,8 @@ struct ChatTextView: NSViewRepresentable { 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 @@ -96,4 +102,28 @@ class DynamicTextView: NSTextView { 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) + } } From b86495ea2edfea2f730fa42009227b97b65f11ef Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Wed, 18 Mar 2026 11:16:28 +0100 Subject: [PATCH 5/5] Fix spacing issues with formatted text --- .../AttributedTextView.swift | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) 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) } }