Fix Markdown horizontal scrolling

This commit is contained in:
Kaede Akino committed 2026-09-17 23:13:59 +08:00
1 parent b1b6647a8c
commit d15a36d98b
6 files changed
+1373 -110

No files matched your search

File diff suppressed because it is too large. Load diff
+4
View File
@@ -609,6 +609,10 @@ 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 {
+230 -1
View File
@@ -1847,11 +1847,17 @@ private final class NativeMarkdownView: NSView {
}
@MainActor
private final class NativeMarkdownSegment: NSView {
final class NativeMarkdownSegment: NSView {
private let markdown = MarkdownTextView()
private var content: MarkdownContent?
private var measuredSize = NSSize(width: 1, height: 1)
private var heightConstraint: NSLayoutConstraint!
private var horizontalScrollViews: [NSScrollView] = []
private var horizontalTrackingAreas: [ObjectIdentifier: NSTrackingArea] = [:]
private var horizontalScrollerVisibility: [ObjectIdentifier: Bool] = [:]
private var horizontalScrollerAnimationGenerations: [ObjectIdentifier: Int] = [:]
private var pendingHorizontalScrollerFadeOuts: [ObjectIdentifier: Int] = [:]
private weak var hoveredHorizontalScrollView: NSScrollView?
override init(frame: CGRect) {
super.init(frame: frame)
@@ -1912,12 +1918,235 @@ private final class NativeMarkdownSegment: NSView {
let measuredHeight = max(1, ceil(markdown.boundingSize(for: measuredWidth).height))
measuredSize = NSSize(width: measuredWidth, height: measuredHeight)
heightConstraint.constant = measuredHeight
markdown.layoutSubtreeIfNeeded()
synchronizeHorizontalScrollViews()
invalidateIntrinsicContentSize()
}
override func layout() {
super.layout()
markdown.layoutSubtreeIfNeeded()
synchronizeHorizontalScrollViews()
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil { setHoveredHorizontalScrollView(nil, animated: false) }
}
override func mouseEntered(with event: NSEvent) {
guard let scrollView = horizontalScrollView(for: event.trackingArea) else {
super.mouseEntered(with: event)
return
}
setHoveredHorizontalScrollView(scrollView)
}
override func mouseExited(with event: NSEvent) {
guard let scrollView = horizontalScrollView(for: event.trackingArea) else {
super.mouseExited(with: event)
return
}
if hoveredHorizontalScrollView === scrollView {
setHoveredHorizontalScrollView(nil)
}
}
override func hitTest(_ point: NSPoint) -> NSView? {
let localPoint = superview.map { convert(point, from: $0) } ?? point
if let scroller = visibleHorizontalScroller(at: localPoint) {
return scroller
}
return super.hitTest(point)
}
override var intrinsicContentSize: NSSize {
measuredSize
}
func updateHorizontalScrollerHover(at point: NSPoint?) {
setHoveredHorizontalScrollView(point.flatMap(horizontalScrollView(at:)))
}
private func setHoveredHorizontalScrollView(
_ hovered: NSScrollView?,
animated: Bool = true
) {
hoveredHorizontalScrollView = hovered
for scrollView in horizontalScrollViews {
let visible = horizontalOverflow(in: scrollView) && scrollView === hovered
updateHorizontalScrollerVisibility(
visible,
in: scrollView,
animated: animated && window != nil
)
}
}
private func updateHorizontalScrollerVisibility(
_ visible: Bool,
in scrollView: NSScrollView,
animated: Bool
) {
guard let scroller = scrollView.horizontalScroller else { return }
let identifier = ObjectIdentifier(scrollView)
guard horizontalScrollerVisibility[identifier] != visible else {
if visible, scroller.isHidden {
scroller.isHidden = false
scroller.alphaValue = 1
} else if !visible, pendingHorizontalScrollerFadeOuts[identifier] == nil {
scroller.alphaValue = 0
scroller.isHidden = true
}
return
}
horizontalScrollerVisibility[identifier] = visible
let generation = horizontalScrollerAnimationGenerations[identifier, default: 0] + 1
horizontalScrollerAnimationGenerations[identifier] = generation
if visible {
pendingHorizontalScrollerFadeOuts.removeValue(forKey: identifier)
scroller.isHidden = false
}
guard animated else {
pendingHorizontalScrollerFadeOuts.removeValue(forKey: identifier)
scroller.alphaValue = visible ? 1 : 0
scroller.isHidden = !visible
return
}
if visible {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.12
context.allowsImplicitAnimation = true
scroller.animator().alphaValue = 1
}
} else {
pendingHorizontalScrollerFadeOuts[identifier] = generation
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.16
context.allowsImplicitAnimation = true
scroller.animator().alphaValue = 0
} completionHandler: { [weak self, weak scroller] in
Task { @MainActor in
guard let self, let scroller,
self.horizontalScrollerVisibility[identifier] == false,
self.pendingHorizontalScrollerFadeOuts[identifier] == generation
else { return }
self.pendingHorizontalScrollerFadeOuts.removeValue(forKey: identifier)
scroller.isHidden = true
}
}
}
}
private func synchronizeHorizontalScrollViews() {
let previous = horizontalScrollViews
let candidates = descendantScrollViews(in: markdown).filter {
!$0.hasVerticalScroller && $0.documentView != nil
}
horizontalScrollViews = candidates
for scrollView in previous where !candidates.contains(where: { $0 === scrollView }) {
removeHorizontalTrackingArea(from: scrollView)
updateHorizontalScrollerVisibility(false, in: scrollView, animated: false)
horizontalScrollerVisibility.removeValue(forKey: ObjectIdentifier(scrollView))
horizontalScrollerAnimationGenerations.removeValue(forKey: ObjectIdentifier(scrollView))
pendingHorizontalScrollerFadeOuts.removeValue(forKey: ObjectIdentifier(scrollView))
}
for scrollView in candidates {
scrollView.scrollerStyle = .overlay
scrollView.hasHorizontalScroller = true
scrollView.autohidesScrollers = false
scrollView.horizontalScroller?.controlSize = .mini
scrollView.tile()
let overflows = horizontalOverflow(in: scrollView)
let scroller = scrollView.horizontalScroller
scroller?.scrollerStyle = .legacy
scroller?.isEnabled = overflows
if overflows {
installHorizontalTrackingAreaIfNeeded(on: scrollView)
if horizontalScrollerVisibility[ObjectIdentifier(scrollView)] == nil {
updateHorizontalScrollerVisibility(false, in: scrollView, animated: false)
}
} else {
removeHorizontalTrackingArea(from: scrollView)
updateHorizontalScrollerVisibility(false, in: scrollView, animated: false)
}
}
if let hoveredHorizontalScrollView,
!candidates.contains(where: { $0 === hoveredHorizontalScrollView })
|| !horizontalOverflow(in: hoveredHorizontalScrollView)
{
self.hoveredHorizontalScrollView = nil
}
setHoveredHorizontalScrollView(hoveredHorizontalScrollView, animated: false)
}
private func installHorizontalTrackingAreaIfNeeded(on scrollView: NSScrollView) {
let identifier = ObjectIdentifier(scrollView)
guard horizontalTrackingAreas[identifier] == nil else { return }
let area = NSTrackingArea(
rect: .zero,
options: [.activeInKeyWindow, .inVisibleRect, .mouseEnteredAndExited],
owner: self,
userInfo: nil
)
scrollView.addTrackingArea(area)
horizontalTrackingAreas[identifier] = area
}
private func removeHorizontalTrackingArea(from scrollView: NSScrollView) {
guard let area = horizontalTrackingAreas.removeValue(forKey: ObjectIdentifier(scrollView)) else {
return
}
scrollView.removeTrackingArea(area)
}
private func horizontalScrollView(for trackingArea: NSTrackingArea?) -> NSScrollView? {
guard let trackingArea else { return nil }
return horizontalScrollViews.first { scrollView in
horizontalTrackingAreas[ObjectIdentifier(scrollView)] === trackingArea
}
}
private func horizontalScrollView(at point: NSPoint) -> NSScrollView? {
horizontalScrollViews.last { scrollView in
horizontalOverflow(in: scrollView)
&& convert(scrollView.bounds, from: scrollView).contains(point)
}
}
private func visibleHorizontalScroller(at point: NSPoint) -> NSScroller? {
for scrollView in horizontalScrollViews.reversed() {
guard horizontalOverflow(in: scrollView),
let scroller = scrollView.horizontalScroller,
!scroller.isHidden,
scroller.alphaValue > 0.01
else { continue }
let scrollerPoint = scroller.convert(point, from: self)
if scroller.bounds.contains(scrollerPoint) { return scroller }
}
return nil
}
private func horizontalOverflow(in scrollView: NSScrollView) -> Bool {
guard let documentView = scrollView.documentView else { return false }
return documentView.frame.width > scrollView.contentView.bounds.width + 1
}
private func descendantScrollViews(in root: NSView) -> [NSScrollView] {
var result: [NSScrollView] = []
var pending = root.subviews
while let view = pending.popLast() {
if let scrollView = view as? NSScrollView { result.append(scrollView) }
pending.append(contentsOf: view.subviews)
}
return result
}
}
@MainActor
+161
View File
@@ -1118,6 +1118,145 @@ struct TranscriptListViewBridgeTests {
#expect(actions.alphaValue == 0)
}
@Test
func overflowingMarkdownBlocksExposeHoverableHorizontalScrollers() throws {
let coordinator = TranscriptListView.Coordinator()
let listView = coordinator.makeListView()
let id = UUID()
listView.frame = CGRect(x: 0, y: 0, width: 320, height: 800)
let longValue = String(repeating: "wide-content-", count: 24)
let markdown = """
| Name | Value |
| --- | --- |
| table | \(longValue) |
```swift
let value = "\(longValue)"
```
"""
update(coordinator, listView, entries: [assistant(id: id, text: markdown)])
listView.layoutSubtreeIfNeeded()
let row = try #require(listView.rowView(for: id))
let segment = try #require(descendant(in: row, ofType: NativeMarkdownSegment.self))
segment.layoutSubtreeIfNeeded()
let scrollViews = horizontalScrollViews(in: segment)
let allHaveHorizontalScrollers = scrollViews.allSatisfy { $0.hasHorizontalScroller }
let allScrollersAreHidden = scrollViews.allSatisfy {
$0.horizontalScroller?.alphaValue == 0
&& $0.horizontalScroller?.isHidden == true
}
#expect(scrollViews.count == 2)
#expect(allHaveHorizontalScrollers)
#expect(allScrollersAreHidden)
#expect(segment.trackingAreas.allSatisfy { !$0.options.contains(.mouseMoved) })
let trackingAreas = try scrollViews.map { scrollView in
try #require(
scrollView.trackingAreas.first { area in
area.owner === segment
&& area.options.contains(.inVisibleRect)
&& area.options.contains(.mouseEnteredAndExited)
&& !area.options.contains(.mouseMoved)
}
)
}
segment.needsLayout = true
segment.layoutSubtreeIfNeeded()
for (scrollView, trackingArea) in zip(scrollViews, trackingAreas) {
let ownedTrackingAreas = scrollView.trackingAreas.filter { $0.owner === segment }
#expect(ownedTrackingAreas.count == 1)
#expect(ownedTrackingAreas.first === trackingArea)
}
for scrollView in scrollViews {
let scroller = try #require(scrollView.horizontalScroller)
let contentHit = [
CGPoint(x: scrollView.bounds.midX, y: scrollView.bounds.minY + 2),
CGPoint(x: scrollView.bounds.midX, y: scrollView.bounds.maxY - 2),
CGPoint(x: scrollView.bounds.midX, y: scrollView.bounds.midY),
].lazy.compactMap { point -> (NSPoint, NSView)? in
guard !scroller.frame.contains(point) else { return nil }
let localPoint = segment.convert(point, from: scrollView)
guard let superview = segment.superview else { return nil }
let hitPoint = superview.convert(localPoint, from: segment)
guard let hit = segment.hitTest(hitPoint), !(hit is NSScroller) else { return nil }
return (hitPoint, hit)
}.first
let hoverPoint = segment.convert(
CGPoint(x: scrollView.bounds.midX, y: scrollView.bounds.midY),
from: scrollView
)
segment.updateHorizontalScrollerHover(at: hoverPoint)
#expect(scroller.alphaValue == 1)
#expect(!scroller.isHidden)
#expect(scroller.scrollerStyle == .legacy)
#expect(scroller.controlSize == .mini)
#expect(scroller.frame.width > 0)
#expect(scroller.frame.height > 0)
#expect(
scroller.frame.height
<= NSScroller.scrollerWidth(for: .mini, scrollerStyle: .legacy) + 1
)
if let (hitPoint, hitBeforeHover) = contentHit {
#expect(segment.hitTest(hitPoint) === hitBeforeHover)
}
let localScrollerPoint = segment.convert(
CGPoint(x: scroller.bounds.midX, y: scroller.bounds.midY),
from: scroller
)
let scrollerHitPoint = try #require(segment.superview).convert(localScrollerPoint, from: segment)
#expect(segment.hitTest(scrollerHitPoint) === scroller)
let documentView = try #require(scrollView.documentView)
let maximumX = documentView.frame.width - scrollView.contentView.bounds.width
scrollView.contentView.scroll(to: CGPoint(x: maximumX, y: 0))
scrollView.reflectScrolledClipView(scrollView.contentView)
#expect(scrollView.contentView.bounds.minX > 0)
}
segment.updateHorizontalScrollerHover(at: nil)
#expect(scrollViews.allSatisfy { $0.horizontalScroller?.alphaValue == 0 })
#expect(scrollViews.allSatisfy { $0.horizontalScroller?.isHidden == true })
}
@Test
func markdownScrollersStayHiddenWhenTheirContentFits() throws {
let coordinator = TranscriptListView.Coordinator()
let listView = coordinator.makeListView()
let id = UUID()
listView.frame = CGRect(x: 0, y: 0, width: 900, height: 800)
let markdown = """
| Name | Value |
| --- | --- |
| table | short |
```swift
let value = 1
```
"""
update(coordinator, listView, entries: [assistant(id: id, text: markdown)])
listView.layoutSubtreeIfNeeded()
let row = try #require(listView.rowView(for: id))
let segment = try #require(descendant(in: row, ofType: NativeMarkdownSegment.self))
segment.layoutSubtreeIfNeeded()
let scrollViews = descendants(in: segment, ofType: NSScrollView.self)
#expect(scrollViews.count == 2)
segment.updateHorizontalScrollerHover(
at: CGPoint(x: segment.bounds.midX, y: segment.bounds.midY)
)
#expect(scrollViews.allSatisfy { $0.horizontalScroller?.alphaValue == 0 })
#expect(scrollViews.allSatisfy { $0.horizontalScroller?.isHidden == true })
#expect(scrollViews.allSatisfy { $0.horizontalScroller?.isEnabled == false })
}
@Test
func disclosureRefreshRebuildsTheVisiblePlanAndItsHeight() {
let coordinator = TranscriptListView.Coordinator()
@@ -1349,6 +1488,28 @@ struct TranscriptListViewBridgeTests {
return nil
}
private func descendant<View: NSView>(in root: NSView, ofType _: View.Type) -> View? {
descendants(in: root, ofType: View.self).first
}
private func descendants<View: NSView>(in root: NSView, ofType _: View.Type) -> [View] {
var result: [View] = []
var queue = root.subviews
while let view = queue.first {
queue.removeFirst()
if let match = view as? View { result.append(match) }
queue.append(contentsOf: view.subviews)
}
return result
}
private func horizontalScrollViews(in root: NSView) -> [NSScrollView] {
descendants(in: root, ofType: NSScrollView.self).filter { scrollView in
guard let documentView = scrollView.documentView else { return false }
return documentView.frame.width > scrollView.contentView.bounds.width + 1
}
}
private func expectProminentForeground(_ button: NSButton, component: CGFloat) throws {
let titleColor = try #require(
button.attributedTitle.attribute(
+13 -13
View File
@@ -67,6 +67,7 @@
C2D4B372BA5A040DDBCEFC85 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; };
C4AD00000000000000000001 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100015 /* ZIPFoundation */; };
C4AD00000000000000000002 /* MarkdownView in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100017 /* MarkdownView */; };
CE6200000000000000000001 /* TermindRemoteInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 21951230A1FF30987ADC3B6B /* TermindRemoteInfrastructure.framework */; };
CF863393BB8C2B38A9EEE18A /* TermindInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 67B48098792E58C2955768F2 /* TermindInfrastructure.framework */; };
D7D1BBB65020F61F7EFDDFD7 /* GhosttyTerminal in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100012 /* GhosttyTerminal */; };
E635C54CA9F1A63D909CFCE6 /* TermindPersistence.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 244632CCDB1CA43F45C0EB54 /* TermindPersistence.framework */; };
@@ -88,7 +89,6 @@
TRMD0000000000000010000D /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; };
TRMD0000000000000010000E /* AgentKitScrubber in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001C /* AgentKitScrubber */; };
TRMD0000000000000010000F /* AgentKitScrubber in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001C /* AgentKitScrubber */; };
CE6200000000000000000001 /* TermindRemoteInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 21951230A1FF30987ADC3B6B /* TermindRemoteInfrastructure.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -183,6 +183,13 @@
remoteGlobalIDString = A5F3ED08556B8962920EF788;
remoteInfo = Framework;
};
CE6200000000000000000002 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = TRMD00000000000000000090 /* Project object */;
proxyType = 1;
remoteGlobalIDString = BDA6B93652DE90AEBDA7090E;
remoteInfo = TermindRemoteInfrastructure;
};
DB8AB3CC3053A07800C7EFAE /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = TRMD00000000000000000090 /* Project object */;
@@ -386,13 +393,6 @@
remoteGlobalIDString = TRMD00000000000000030050;
remoteInfo = TermindMCPBridge;
};
CE6200000000000000000002 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = TRMD00000000000000000090 /* Project object */;
proxyType = 1;
remoteGlobalIDString = BDA6B93652DE90AEBDA7090E;
remoteInfo = TermindRemoteInfrastructure;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -1603,6 +1603,11 @@
target = A5F3ED08556B8962920EF788 /* TermindApplication */;
targetProxy = DB8AB3DD3053A07900C7EFAE /* PBXContainerItemProxy */;
};
CE6200000000000000000003 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = BDA6B93652DE90AEBDA7090E /* TermindRemoteInfrastructure */;
targetProxy = CE6200000000000000000002 /* PBXContainerItemProxy */;
};
D6444C208365A08727ACC139 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = B243FDB536F5111B356353DA /* TermindDomain */;
@@ -1638,11 +1643,6 @@
target = TRMD00000000000000030050 /* TermindMCPBridge */;
targetProxy = TRMD00000000000000030090 /* PBXContainerItemProxy */;
};
CE6200000000000000000003 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = BDA6B93652DE90AEBDA7090E /* TermindRemoteInfrastructure */;
targetProxy = CE6200000000000000000002 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2700"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "TRMD00000000000000030050"
BuildableName = "termind-mcp"
ReferencedContainer = "container:Termind.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
viewDebuggingEnabled = "No"
queueDebuggingEnabled = "No">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "TRMD00000000000000030050"
BuildableName = "termind-mcp"
ReferencedContainer = "container:Termind.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "TRMD00000000000000030050"
BuildableName = "termind-mcp"
ReferencedContainer = "container:Termind.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>