From 6079d9270b560e69b23e77f5c51a56a943a909c8 Mon Sep 17 00:00:00 2001 From: flimsyhat Date: Wed, 4 Jun 2025 12:17:09 +0200 Subject: [PATCH 1/5] remove DARK_GREY Variable is unused, left over from previous version? --- plotdevice/gui/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/plotdevice/gui/views.py b/plotdevice/gui/views.py index 92e3e00..65755af 100644 --- a/plotdevice/gui/views.py +++ b/plotdevice/gui/views.py @@ -10,8 +10,6 @@ from ..gfx.atoms import KEY_ESC from objc import super -DARK_GREY = NSColor.blackColor().blendedColorWithFraction_ofColor_(0.7, NSColor.whiteColor()) - class GraphicsBackdrop(NSView): """A container that sits between the NSClipView and GraphicsView From 2610099f2cbe7a3641c3e022b45cc49c695bf3c8 Mon Sep 17 00:00:00 2001 From: flimsyhat Date: Wed, 4 Jun 2025 17:13:57 +0200 Subject: [PATCH 2/5] simplify zoom lookup use min() instead of the index/loop approach --- plotdevice/gui/views.py | 39 +++++++++++++-------------------------- 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/plotdevice/gui/views.py b/plotdevice/gui/views.py index 65755af..a1a9349 100644 --- a/plotdevice/gui/views.py +++ b/plotdevice/gui/views.py @@ -149,38 +149,25 @@ def _set_zoom(self, zoom): zoom = property(_get_zoom, _set_zoom) @objc.python_method - def findNearestZoomIndex(self, zoom): - """Returns the nearest zoom level, and whether we found a direct, exact - match or a fuzzy match.""" - try: # Search for a direct hit first. - idx = self.zoomLevels.index(zoom) - return idx, True - except ValueError: # Can't find the zoom level, try looking at the indexes. - idx = 0 - try: - while self.zoomLevels[idx] < zoom: - idx += 1 - except KeyError: # End of the list - idx = len(self.zoomLevels) - 1 # Just return the last index. - return idx, False + def _findNearestZoomLevel(self, zoom): + """Find the nearest zoom level to the given zoom value""" + return min(self.zoomLevels, key=lambda x: abs(x - zoom)) @IBAction def zoomIn_(self, sender): - idx, direct = self.findNearestZoomIndex(self.zoom) - # Direct hits are perfect, but indirect hits require a bit of help. - # Because of the way indirect hits are calculated, they are already - # rounded up to the upper zoom level; this means we don't need to add 1. - if direct: - idx += 1 - idx = max(min(idx, len(self.zoomLevels)-1), 0) - self.zoom = self.zoomLevels[idx] + """Zoom in one level""" + current = self._findNearestZoomLevel(self.zoom) + idx = self.zoomLevels.index(current) + new_idx = min(idx + 1, len(self.zoomLevels) - 1) + self.zoom = self.zoomLevels[new_idx] @IBAction def zoomOut_(self, sender): - idx, direct = self.findNearestZoomIndex(self.zoom) - idx -= 1 - idx = max(min(idx, len(self.zoomLevels)-1), 0) - self.zoom = self.zoomLevels[idx] + """Zoom out one level""" + current = self._findNearestZoomLevel(self.zoom) + idx = self.zoomLevels.index(current) + new_idx = max(idx - 1, 0) + self.zoom = self.zoomLevels[new_idx] @IBAction def resetZoom_(self, sender): From fd4e4ebce10c6a2cae1b0f06486b97fdb9167900 Mon Sep 17 00:00:00 2001 From: flimsyhat Date: Wed, 4 Jun 2025 17:53:48 +0200 Subject: [PATCH 3/5] continuous zoom - added pinch-to-zoom gesture support for trackpads - added Cmd + scroll wheel support for mouse both zoom methods are centered on mouse position --- plotdevice/gui/views.py | 115 ++++++++++++++++++++++++++++++++++++++-- plotdevice/lib/cocoa.py | 2 +- 2 files changed, 112 insertions(+), 5 deletions(-) diff --git a/plotdevice/gui/views.py b/plotdevice/gui/views.py index a1a9349..7a6cfdf 100644 --- a/plotdevice/gui/views.py +++ b/plotdevice/gui/views.py @@ -61,6 +61,31 @@ def viewFrameDidChange_(self, note): gfxframe.origin.y = 0 self.gfxView.setFrame_(gfxframe) + def magnifyWithEvent_(self, event): + if self.gfxView: + self.gfxView.magnifyWithEvent_(event) + return None + + def beginGestureWithEvent_(self, event): + if self.gfxView: + self.gfxView.beginGestureWithEvent_(event) + return None + + def endGestureWithEvent_(self, event): + return None + + def acceptsTouchEvents(self): + return True + + def scrollWheel_(self, event): + if self.gfxView: + self.gfxView.scrollWheel_(event) + else: + # Pass the scroll event to the scroll view + scrollview = self.superview() + if scrollview: + scrollview.scrollWheel_(event) + class GraphicsView(NSView): script = IBOutlet() canvas = None @@ -92,6 +117,10 @@ def awakeFromNib(self): # display the placeholder image until we're passed a canvas (and keep it in sync with appearance) self.updatePlaceholder(NSAppearance.currentDrawingAppearance()) + # Enable gesture recognition + self.setWantsRestingTouches_(True) + self.setAcceptsTouchEvents_(True) + @objc.python_method def updatePlaceholder(self, appearance): if self.canvas is None: @@ -153,6 +182,61 @@ def _findNearestZoomLevel(self, zoom): """Find the nearest zoom level to the given zoom value""" return min(self.zoomLevels, key=lambda x: abs(x - zoom)) + @objc.python_method + def _applyZoom(self, delta, mouse_point=None): + """Apply zoom with given delta, centered at mouse_point""" + # calculate new zoom with smoother scaling + new_zoom = self.zoom * (1.0 + (delta * 0.8)) + new_zoom = max(0.1, min(20.0, new_zoom)) + + # if no mouse point provided, just update zoom + if not mouse_point: + self.zoom = new_zoom + return + + # get the clip view and current visible area + clip_view = self.superview().superview() + visible = clip_view.documentVisibleRect() + + # calculate the point in document coordinates that's under the mouse + doc_x = mouse_point.x + doc_y = mouse_point.y + + # store old zoom and apply new zoom + old_zoom = self.zoom + self.zoom = new_zoom + + # calculate how much the document point should move + scale_factor = new_zoom / old_zoom + dx = doc_x * (scale_factor - 1.0) + dy = doc_y * (scale_factor - 1.0) + + # calculate new scroll position to keep mouse point fixed + new_x = visible.origin.x + dx + new_y = visible.origin.y + dy + + # apply scroll + self.scrollPoint_((new_x, new_y)) + + @objc.python_method + def _getMousePointForZoom(self, event): + """Get mouse position for zoom operations, returns None if no window""" + window = self.window() + if window: + mouse_point = window.mouseLocationOutsideOfEventStream() + return self.convertPoint_fromView_(mouse_point, None) + return None + + @objc.python_method + def _calculateZoomDelta(self, event, is_scroll=False): + """Convert event input to a normalized zoom delta""" + if is_scroll: + # For scroll events, normalize the scroll delta + return event.scrollingDeltaY() / 100.0 + else: + # For pinch events, use the magnification directly + return event.magnification() + @IBAction def zoomIn_(self, sender): """Zoom in one level""" @@ -221,10 +305,31 @@ def keyUp_(self, event): self.key = event.characters() self.keycode = event.keyCode() - # def scrollWheel_(self, event): - # NSResponder.scrollWheel_(self, event) - # self.scrollwheel = True - # self.wheeldelta = event.scrollingDeltaY() + def scrollWheel_(self, event): + # check if Command key is pressed for zoom + if event.modifierFlags() & NSEventModifierFlagCommand: + # get zoom delta from scroll + delta = self._calculateZoomDelta(event, is_scroll=True) + mouse_point = self._getMousePointForZoom(event) + self._applyZoom(delta, mouse_point) + else: + # pass the scroll event to the scroll view + scrollview = self.superview().superview() + if scrollview: + scrollview.scrollWheel_(event) + + def magnifyWithEvent_(self, event): + # get zoom delta from pinch + delta = self._calculateZoomDelta(event) + mouse_point = self._getMousePointForZoom(event) + self._applyZoom(delta, mouse_point) + return None + + def beginGestureWithEvent_(self, event): + return None + + def endGestureWithEvent_(self, event): + return None def canBecomeKeyView(self): return True @@ -232,6 +337,8 @@ def canBecomeKeyView(self): def acceptsFirstResponder(self): return True + def acceptsTouchEvents(self): + return True class FullscreenWindow(NSWindow): diff --git a/plotdevice/lib/cocoa.py b/plotdevice/lib/cocoa.py index bf74f74..3a5d195 100644 --- a/plotdevice/lib/cocoa.py +++ b/plotdevice/lib/cocoa.py @@ -48,7 +48,7 @@ NSTrackingMouseMoved, NSUnboldFontMask, NSUnionRect, NSUnitalicFontMask, NSView, \ NSViewFrameDidChangeNotification, NSViewMinXMargin, NSViewWidthSizable, NSWindow, \ NSWindowBackingLocationVideoMemory, NSWindowController, NSWindowTabbingModeAutomatic, \ - NSWindowTabbingModePreferred, NSWorkspace + NSWindowTabbingModePreferred, NSWorkspace, NSEventModifierFlagCommand from Foundation import CIAffineTransform, CIColorMatrix, CIContext, CIFilter, CIImage, CIVector, Foundation, NO, \ NSAffineTransform, NSAffineTransformStruct, NSAttributedString, NSAutoreleasePool, NSBundle, \ NSData, NSDate, NSDateFormatter, NSFileCoordinator, NSFileHandle, \ From 1287c9b11ab5b1b2237a1525a783d4263ccb3b3d Mon Sep 17 00:00:00 2001 From: flimsyhat Date: Wed, 4 Jun 2025 20:26:53 +0200 Subject: [PATCH 4/5] optimize canvas rendering by drawing directly to view Previously we'd pre-render the entire canvas to a bitmap at the current zoom level, convert it to a CALayer's contents, and display that layer. This is replaced by directly drawing to the view's graphics context. Changes: - removed layer-based rendering approach (CALayer and bitmap caching) - added drawRect_ method to draw canvas contents directly to view's graphics context, with viewport clipping - updated placeholder image handling to use direct drawing - removed unused backing scale factor (_dpr) and related bitmap rendering code - canvas contents are now scaled using NSAffineTransform instead of pre-scaling bitmaps --- plotdevice/gui/views.py | 57 ++++++++++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/plotdevice/gui/views.py b/plotdevice/gui/views.py index 7a6cfdf..3c2e4c1 100644 --- a/plotdevice/gui/views.py +++ b/plotdevice/gui/views.py @@ -104,15 +104,6 @@ def awakeFromNib(self): self.key = None self.keycode = None self._zoom = 1.0 - self._dpr = self.window().backingScaleFactor() - # self.scrollwheel = False - # self.wheeldelta = 0.0 - - # set up layer `hosting' and disable implicit anims - self.setLayer_(CALayer.new()) - self.setWantsLayer_(True) - inaction = {k:None for k in ["onOrderOut", "sublayers", "contents", "position", "bounds"]} - self.layer().setActions_(inaction) # display the placeholder image until we're passed a canvas (and keep it in sync with appearance) self.updatePlaceholder(NSAppearance.currentDrawingAppearance()) @@ -129,7 +120,7 @@ def updatePlaceholder(self, appearance): )) if placeholder: self.setFrameSize_(placeholder.size()) - self.layer().setContents_(placeholder) + self.setNeedsDisplay_(True) # trigger a redraw @objc.python_method def setCanvas(self, canvas): @@ -145,10 +136,6 @@ def setCanvas(self, canvas): x_pct = NSMidX(visible) / NSWidth(oldframe) y_pct = NSMidY(visible) / NSHeight(oldframe) - # render (and possibly bomb...) - ns_image = canvas._render_to_image(self.zoom) - bitmap = ns_image.layerContentsForContentsScale_(self._dpr) - # resize w, h = [s*self.zoom for s in canvas.pagesize] self.setFrameSize_([w, h]) @@ -162,11 +149,11 @@ def setCanvas(self, canvas): half_h = NSHeight(visible) / 2.0 self.scrollPoint_( (x_pct*w-half_w, y_pct*h-half_h) ) - # cache the canvas image - self.layer().setContents_(bitmap) - - # keep a reference to the canvas so we can zoom later on + # keep a reference to the canvas self.canvas = canvas + + # trigger a redraw + self.setNeedsDisplay_(True) def _get_zoom(self): return self._zoom @@ -340,6 +327,40 @@ def acceptsFirstResponder(self): def acceptsTouchEvents(self): return True + def drawRect_(self, rect): + if self.canvas is None: + # draw placeholder if no canvas + if placeholder := NSImage.imageNamed_('placeholder-{mode}.pdf'.format( + mode = 'dark' if 'Dark' in NSAppearance.currentDrawingAppearance().name() else 'light' + )): + placeholder.drawInRect_(self.bounds()) + return + + # convert the dirty rect to canvas coordinates + viewToCanvas = NSAffineTransform.transform() + viewToCanvas.scaleBy_(1.0/self.zoom) + canvasRect = viewToCanvas.transformRect_(rect) + + # set up the graphics state for zoomed drawing + NSGraphicsContext.currentContext().saveGraphicsState() + + # apply zoom transform + transform = NSAffineTransform.transform() + transform.scaleBy_(self.zoom) + transform.concat() + + # set up clipping to the intersection of canvas bounds and visible area + canvasBounds = ((0, 0), self.canvas.pagesize) + visibleBounds = NSIntersectionRect(canvasRect, canvasBounds) + clip = NSBezierPath.bezierPathWithRect_(visibleBounds) + clip.addClip() + + # draw the canvas contents + self.canvas.draw() + + # restore the graphics state + NSGraphicsContext.currentContext().restoreGraphicsState() + class FullscreenWindow(NSWindow): def initWithRect_(self, fullRect): From 772a2ecf0ffe9678f2a9a92afbf71e961e5caf8e Mon Sep 17 00:00:00 2001 From: flimsyhat Date: Wed, 4 Jun 2025 20:27:06 +0200 Subject: [PATCH 5/5] remove whitespace --- plotdevice/gui/views.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plotdevice/gui/views.py b/plotdevice/gui/views.py index 3c2e4c1..2181e99 100644 --- a/plotdevice/gui/views.py +++ b/plotdevice/gui/views.py @@ -466,5 +466,4 @@ def acceptsFirstResponder(self): return True def calc_scaling_factor(width, height, maxwidth, maxheight): - return min(float(maxwidth) / width, float(maxheight) / height) - + return min(float(maxwidth) / width, float(maxheight) / height) \ No newline at end of file