Fix chat composer scroll
This commit is contained in:
1 parent
0e89afd1e1
commit
21597fecaf
2 files changed
+156
-4
No files matched your search
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user