diff --git a/KJamsClone/DJLoopEditing.swift b/KJamsClone/DJLoopEditing.swift index 24ab206..62e6015 100644 --- a/KJamsClone/DJLoopEditing.swift +++ b/KJamsClone/DJLoopEditing.swift @@ -180,7 +180,7 @@ final class DJNativeSpectrumView: DJWaveMouseView { private var hoverX: Double? private var tracking: NSTrackingArea? override init(frame: NSRect) { - super.init(frame:frame) + super.init(frame:frame);clipsToBounds = true freeze = { [weak self] start in self?.frozenViewport = start;self?.needsDisplay = true } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -223,6 +223,12 @@ final class DJNativeSpectrumView: DJWaveMouseView { super.mouseDown(with:event) } override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext else { return } + context.saveGState() + defer { context.restoreGState() } + // Cached sections extend beyond the viewport while scrolling. Neither + // those sections nor their markers may paint into adjacent deck controls. + context.clip(to:bounds) NSColor.black.setFill();bounds.fill() guard let player,player.duration > 0,span > 0,!player.waveform.isEmpty else { return } let time = player.displayTime,start = frozenViewport ?? (time-span/2),width = bounds.width,height = bounds.height diff --git a/KJamsClone/DJWorkstation.swift b/KJamsClone/DJWorkstation.swift index f944256..adbb1ae 100644 --- a/KJamsClone/DJWorkstation.swift +++ b/KJamsClone/DJWorkstation.swift @@ -1249,6 +1249,7 @@ struct DJScrollingSpectrum: View { var spectralWaveforms = true var body: some View { DJNativeSpectrum(player:player,bpm:bpm,color:NSColor(color),seconds:seconds,label:label,showTimeGrid:showTimeGrid,spectralWaveforms:spectralWaveforms) + .clipped() .accessibilityLabel(accessibilityName).accessibilityValue(DJStyle.stamp(player.time)) .help("Hold the left button and drag left to advance or right to rewind. Shift-drag creates a loop; drag green loop edges to resize or its centre to move it.") } diff --git a/KJamsCloneTests/KJamsCloneTests.swift b/KJamsCloneTests/KJamsCloneTests.swift index 33b4430..28f28d5 100644 --- a/KJamsCloneTests/KJamsCloneTests.swift +++ b/KJamsCloneTests/KJamsCloneTests.swift @@ -2501,3 +2501,42 @@ extension KJamsCloneTests { XCTAssertGreaterThan(view.waveformTileBuildCount,builds,"Changing window size must rebuild sharp waveform tiles") } } + + +extension KJamsCloneTests { + @MainActor func testSpectrumCachedSectionsCannotPaintOutsideViewport() async throws { + let file = try XCTUnwrap(Bundle(for:Self.self).resourceURL?.appendingPathComponent("Fixtures/Demo.mp3")) + let player = Player(persistsMix:false);defer { player.stop() } + try player.load(.init(id:1,title:"Clip",audio:file.path,cdg:nil,rating:0,plays:0),startPlaying:false) + for _ in 0..<100 where player.waveformLoading { try await Task.sleep(nanoseconds:20_000_000) } + let view = DJNativeSpectrumView(frame:NSRect(x:0,y:0,width:300,height:80)) + view.player = player;view.span = 0.5;view.bpm = 120 + XCTAssertTrue(view.clipsToBounds) + let bitmap = try XCTUnwrap(NSBitmapImageRep(bitmapDataPlanes:nil,pixelsWide:380,pixelsHigh:140,bitsPerSample:8,samplesPerPixel:4,hasAlpha:true,isPlanar:false,colorSpaceName:.deviceRGB,bytesPerRow:0,bitsPerPixel:0)) + let graphics = try XCTUnwrap(NSGraphicsContext(bitmapImageRep:bitmap)) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = graphics + let context = graphics.cgContext + context.setFillColor(CGColor(red:1,green:0,blue:1,alpha:1));context.fill(CGRect(x:0,y:0,width:380,height:140)) + let sentinel = try XCTUnwrap(bitmap.colorAt(x:0,y:0)?.usingColorSpace(.deviceRGB)) + context.translateBy(x:40,y:30) + for position in [0.25,0.49,0.51,1.0] { + player.seek(position) + // Deliberately omit AppKit's implicit clipping: draw() must constrain + // its own cached tiles, including at a tile-boundary crossing. + view.draw(view.bounds) + } + NSGraphicsContext.restoreGraphicsState() + for y in 0..<140 { + for x in 0..<380 where x < 40 || x >= 340 || y < 30 || y >= 110 { + let color = try XCTUnwrap(bitmap.colorAt(x:x,y:y)?.usingColorSpace(.deviceRGB)) + guard abs(color.redComponent-sentinel.redComponent) < 0.001,abs(color.greenComponent-sentinel.greenComponent) < 0.001,abs(color.blueComponent-sentinel.blueComponent) < 0.001,abs(color.alphaComponent-sentinel.alphaComponent) < 0.001 else { + return XCTFail("Spectrum drew outside its viewport at \(x),\(y): \(color)") + } + } + } + XCTAssertGreaterThan(view.waveformTileBuildCount,0) + let inside = try XCTUnwrap(bitmap.colorAt(x:190,y:70)?.usingColorSpace(.deviceRGB)) + XCTAssertLessThan(inside.redComponent*inside.blueComponent,0.99,"Spectrum must still paint inside its bounds") + } +}