From 21597fecaf751cf3a7587893709d2688eae190ea Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Fri, 18 Sep 2026 09:07:28 +0800 Subject: [PATCH] Fix chat composer scroll --- TFM/Features/AI/ConversationViews.swift | 70 +++++++++++++++++-- TFMTests/ChatComposerStateTests.swift | 90 +++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/TFM/Features/AI/ConversationViews.swift b/TFM/Features/AI/ConversationViews.swift index bda3e243..6b9734c3 100644 --- a/TFM/Features/AI/ConversationViews.swift +++ b/TFM/Features/AI/ConversationViews.swift @@ -91,6 +91,71 @@ struct TextViewMarkedTextObserver: NSViewRepresentable { } } +/// Makes AppKit hide the TextEditor's vertical scroller while its document fits. +/// +/// SwiftUI's scroll-indicator visibility does not remove the disabled scroller +/// from a macOS TextEditor when the system is configured to always show scroll +/// bars. The underlying NSScrollView already knows whether its document exceeds +/// the clip view, so leave that decision to `autohidesScrollers` instead of +/// duplicating it from the composer's approximate text-height measurement. +struct TextEditorScrollViewConfigurator: NSViewRepresentable { + final class ResolverView: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + configureAfterLayout() + } + + override func layout() { + super.layout() + configureScrollView() + } + + func configureAfterLayout() { + Task { @MainActor [weak self] in self?.configureScrollView() } + } + + private func configureScrollView() { + guard let contentView = window?.contentView, !bounds.isEmpty, + let scrollView = textEditorScrollView(in: contentView) + else { return } + + guard !scrollView.autohidesScrollers || !scrollView.hasVerticalScroller else { return } + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.tile() + } + + private func textEditorScrollView(in root: NSView) -> NSScrollView? { + let targetFrame = convert(bounds, to: root) + let targetCenter = NSPoint(x: targetFrame.midX, y: targetFrame.midY) + return descendantScrollViews(in: root).first { scrollView in + guard let textView = scrollView.documentView as? NSTextView, textView.isEditable else { + return false + } + return scrollView.convert(scrollView.bounds, to: root).contains(targetCenter) + } + } + + private func descendantScrollViews(in root: NSView) -> [NSScrollView] { + var result: [NSScrollView] = [] + func visit(_ view: NSView) { + if let scrollView = view as? NSScrollView { result.append(scrollView) } + view.subviews.forEach(visit) + } + visit(root) + return result + } + } + + func makeNSView(context: Context) -> ResolverView { + ResolverView(frame: .zero) + } + + func updateNSView(_ nsView: ResolverView, context: Context) { + nsView.configureAfterLayout() + } +} + // MARK: - Context chips /// The row of user-visible attachments sent to the LLM endpoint. @@ -609,10 +674,6 @@ struct ComposerView: View { TextEditor(text: draft) .font(.body) .scrollContentBackground(.hidden) - .scrollIndicators( - draftHeight > maximumDraftHeight ? .automatic : .hidden, - axes: .vertical - ) .focused($isFocused) .overlay(alignment: .topLeading) { if draft.wrappedValue.isEmpty, !hasMarkedText { @@ -643,6 +704,7 @@ struct ComposerView: View { paste: { isFocused && handleContextPaste() } ) } + .background { TextEditorScrollViewConfigurator() } .onPasteCommand(of: [.fileURL, .image, .text]) { _ in if !handleContextPaste() { insertShortPasteIntoEditor() } } diff --git a/TFMTests/ChatComposerStateTests.swift b/TFMTests/ChatComposerStateTests.swift index 9b0f3579..fc277de9 100644 --- a/TFMTests/ChatComposerStateTests.swift +++ b/TFMTests/ChatComposerStateTests.swift @@ -1,5 +1,7 @@ import AgentKit +import AppKit import Foundation +import SwiftUI import TermindApplication import TermindDomain import TermindInfrastructure @@ -51,6 +53,42 @@ private actor ConversationCancellationProbe { @Suite @MainActor struct ChatComposerStateTests { + @Test + func textEditorScrollerAppearsOnlyWhenItsDocumentOverflows() async throws { + let state = ComposerScrollerTestState() + let hosting = NSHostingView( + rootView: ComposerScrollerTestView(state: state) + .frame(width: 320, height: 80) + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 80), + styleMask: .borderless, + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = hosting + defer { window.close() } + + let emptyScroller = try await requireComposerScroller(in: hosting) + #expect(emptyScroller.autohidesScrollers) + #expect(emptyScroller.hasVerticalScroller) + #expect(emptyScroller.verticalScroller?.isHidden == true) + #expect(emptyScroller.verticalScroller?.isEnabled == false) + + state.text = Array(repeating: "A line of composer text", count: 40).joined(separator: "\n") + let overflowingScroller = try await requireComposerScroller(in: hosting) { + $0.verticalScroller?.isEnabled == true + } + #expect(overflowingScroller.autohidesScrollers) + + state.text = "Short again" + let shortScroller = try await requireComposerScroller(in: hosting) { + $0.verticalScroller?.isEnabled == false + } + #expect(shortScroller.verticalScroller?.isHidden == true) + } + @Test func largePasteThresholdUsesCharactersOrLines() { #expect(!ContextAttachmentImporter.shouldAttachPastedText(String(repeating: "x", count: 1_999))) @@ -2212,3 +2250,55 @@ struct ChatComposerStateTests { } } } + +@Observable +@MainActor +private final class ComposerScrollerTestState { + var text = "" +} + +private struct ComposerScrollerTestView: View { + @Bindable var state: ComposerScrollerTestState + + var body: some View { + TextEditor(text: $state.text) + .background { TextEditorScrollViewConfigurator() } + } +} + +@MainActor +private func requireComposerScroller( + in root: NSView, + condition: (NSScrollView) -> Bool = { _ in true } +) async throws -> NSScrollView { + for _ in 0..<100 { + root.layoutSubtreeIfNeeded() + if let scrollView = composerScrollViews(in: root).first(where: condition), + scrollView.autohidesScrollers + { + return scrollView + } + try await Task.sleep(for: .milliseconds(10)) + } + Issue.record("The configured composer scroll view did not reach the expected state") + throw ComposerScrollerTestError.expectedStateNotReached +} + +private func composerScrollViews(in root: NSView) -> [NSScrollView] { + var result: [NSScrollView] = [] + func visit(_ view: NSView) { + if let scrollView = view as? NSScrollView, + let textView = scrollView.documentView as? NSTextView, + textView.isEditable + { + result.append(scrollView) + } + view.subviews.forEach(visit) + } + visit(root) + return result +} + +private enum ComposerScrollerTestError: Error { + case expectedStateNotReached +}