diff --git a/native/swift/Sources/Deview/Frame.swift b/native/swift/Sources/Deview/Frame.swift index f5a42d1f..d674f238 100644 --- a/native/swift/Sources/Deview/Frame.swift +++ b/native/swift/Sources/Deview/Frame.swift @@ -5,7 +5,9 @@ import Foundation /// /// Copied rather than read in place, because the pointers in `DeviewScreen` are only valid for the /// duration of the call that carried them, and the view redraws whenever AppKit says so. -struct Frame { +/// +/// Equatable so a frame that changes nothing is not drawn: see `Runtime.present`. +struct Frame: Equatable { var title = "" var subtitle = "" var status = "" @@ -24,7 +26,7 @@ struct Frame { var menu: [String] = [] var menuRow: Int32 = -1 - struct Row { + struct Row: Equatable { var kind: Int32 = 0 var lineNumber: Int32 = -1 var text = "" @@ -37,7 +39,7 @@ struct Frame { var selectLength: Int32 = 0 } - struct Pane { + struct Pane: Equatable { var header = "" var rows: [Row] = [] @@ -54,7 +56,7 @@ struct Frame { var imageHeight: Int32 = 0 } - struct QueueItem { + struct QueueItem: Equatable { var label = "" var selected = false var failed = false @@ -64,7 +66,7 @@ struct Frame { var tooltip = "" } - struct Button { + struct Button: Equatable { var label = "" var enabled = false } diff --git a/native/swift/Sources/Deview/Renderer.swift b/native/swift/Sources/Deview/Renderer.swift index cbe753d2..42bcf8a7 100644 --- a/native/swift/Sources/Deview/Renderer.swift +++ b/native/swift/Sources/Deview/Renderer.swift @@ -63,6 +63,11 @@ final class Renderer { var image: CGImage? var modified: Date var length: UInt64 + + /// The picture scaled down to the device pixels it last filled. Drawing a large picture + /// scaled costs a resample of every source pixel, and a window showing one did that on + /// every redraw; this is a copy. + var scaled: CGImage? } /// One character cell. Measured from the font that was actually loaded, which is what the ABI @@ -402,9 +407,10 @@ final class Renderer { checker(bounds, in: context) + let device = context.convertToDeviceSpace(bounds).size context.saveGState() context.interpolationQuality = .high - context.draw(picture, in: bounds) + context.draw(fitted(pane.imagePath, picture, device: device), in: bounds) context.restoreGState() // An outline, so a picture whose edges are the colour of the pane still has visible extent. @@ -438,6 +444,86 @@ final class Renderer { } } + /// `picture` scaled down to `device` pixels, kept until the size or the picture changes, or + /// `picture` itself where it is not being scaled down: drawing at or above its own size costs + /// little, and drawing the original there leaves what a capture shows exactly as it was. + private func fitted(_ path: String, _ picture: CGImage, device: CGSize) -> CGImage { + let width = Int(abs(device.width).rounded()) + let height = Int(abs(device.height).rounded()) + guard width > 0, + height > 0, + width < picture.width || height < picture.height + else { + return picture + } + + if let scaled = pictures[path]?.scaled, + scaled.width == width, + scaled.height == height { + return scaled + } + + guard let space = CGColorSpace(name: CGColorSpace.sRGB), + let bitmap = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: space, + bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue | CGBitmapInfo.byteOrder32Little.rawValue) + else { + return picture + } + + bitmap.interpolationQuality = .high + bitmap.draw(picture, in: CGRect(x: 0, y: 0, width: width, height: height)) + guard let scaled = bitmap.makeImage() else { + return picture + } + + pictures[path]?.scaled = scaled + return scaled + } + + /// Whether a picture `frame` shows is not the one last drawn: its file was rewritten, has + /// appeared, or has gone. `Runtime.present` redraws on this as well as on a changed frame, + /// since a re-run can rewrite a received image with the same size and dimensions, which is an + /// identical frame. + func picturesChanged(_ frame: Frame) -> Bool { + for pane in [frame.left, frame.right] { + guard !pane.imagePath.isEmpty, + pane.imageWidth > 0, + pane.imageHeight > 0 + else { + continue + } + + let cached = pictures[pane.imagePath] + let attributes = try? FileManager.default.attributesOfItem(atPath: pane.imagePath) + guard let modified = attributes?[.modificationDate] as? Date, + let length = attributes?[.size] as? UInt64 + else { + // Gone: worth a redraw only to take away what was drawn + if cached != nil { + return true + } + + continue + } + + guard let cached else { + return true + } + + if cached.modified != modified || cached.length != length { + return true + } + } + + return false + } + private func picture(_ path: String) -> CGImage? { guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), let modified = attributes[.modificationDate] as? Date, diff --git a/native/swift/Sources/Deview/Runtime.swift b/native/swift/Sources/Deview/Runtime.swift index 5df94017..c84ffbdb 100644 --- a/native/swift/Sources/Deview/Runtime.swift +++ b/native/swift/Sources/Deview/Runtime.swift @@ -139,8 +139,17 @@ final class Runtime { return } - view.model = frame - view.needsDisplay = true + // Drawn only when there is something new to draw. The managed loop presents at 60 fps + // whether or not anything changed, and redrawing the whole window every time kept a core + // busy for a viewer nobody was touching. What is on screen is the frame plus the pictures + // it names, whose files can be rewritten under an unchanged frame. Anything else - a + // resize, a splitter drag, a move to a display of another scale - is invalidated by + // AppKit or by the view as it happens. + if view.model != frame || renderer?.picturesChanged(frame) == true { + view.model = frame + view.needsDisplay = true + } + view.displayIfNeeded() // After drawing, because both read where the last frame put the queue rows. view.refreshToolTips() diff --git a/native/swift/Sources/Deview/ViewerView.swift b/native/swift/Sources/Deview/ViewerView.swift index d060f3f2..f234230f 100644 --- a/native/swift/Sources/Deview/ViewerView.swift +++ b/native/swift/Sources/Deview/ViewerView.swift @@ -37,6 +37,13 @@ final class ViewerView: NSView, NSViewToolTipOwner { override var acceptsFirstResponder: Bool { true } + /// A move to a display of another scale changes the device pixels a picture fills, and so + /// the scaled copy the renderer keeps of it, under a frame that has not changed. + override func viewDidChangeBackingProperties() { + super.viewDidChangeBackingProperties() + needsDisplay = true + } + override func draw(_ dirtyRect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext else { return diff --git a/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib b/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib index bda1104e..7a4b62de 100644 Binary files a/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib and b/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib differ diff --git a/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib b/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib index bda1104e..7a4b62de 100644 Binary files a/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib and b/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib differ diff --git a/todo.md b/todo.md index 8bf0a0d7..2b01505c 100644 --- a/todo.md +++ b/todo.md @@ -23,8 +23,3 @@ Native - [ ] **macOS App Nap can stall the loop while the window is covered** (cannot verify here) - Nothing opts out (no `beginActivity`, `NSAppSleepDisabled` or power assertion), the only wait is `nextEvent(until: now + 1/60)` (`Runtime.swift:248-253`), and a Focus queued by an arriving patch waits for the next managed frame. - Check on a Mac: cover the viewer for a minute, confirm Activity Monitor shows App Nap, then time how long a failing inline test takes to bring it forward against an uncovered window. - - -## Perf - -- [ ] macOS repaints the whole window every frame (`native/swift/Sources/Deview/Runtime.swift:139-140`). Redraw only when the frame, bounds or a picture stamp change, and cache scaled pictures.