Move SSD review offset clamping to typed Kotlin

ober

607ad968f1d553b341773adcc8fa533103851546

diff --git a/templates/ssd-review.ss b/templates/ssd-review.ss
index 5596cc6..2fb5cc5 100644
--- a/templates/ssd-review.ss
+++ b/templates/ssd-review.ss
@@ -1260,6 +1260,7 @@
                 nullableFirstFloatArrayPresent nullableFirstFloatArrayOrEmpty
                 nullableBitmapWidthOrZero nullableBitmapHeightOrZero
                 captureViewportState restorableViewportState
+                clampedViewportState
                 bitmapViewReady bitmapViewNotReady bitmapViewNotReadyForBitmap
                 viewportMatchesImage viewportImageMismatch
                 motionActionIsDown motionActionIsMove
@@ -1398,6 +1399,39 @@
                     rotationDegrees)
                 (nullable-none ViewportState)
                 (nullable-some saved)))))
+        (def (clampedViewportState (bitmap : (Nullable Bitmap))
+                                   (viewportWidth : Int32)
+                                   (viewportHeight : Int32)
+                                   (rotationDegrees : Int32)
+                                   (scale : Float32)
+                                   (offsetX : Float32)
+                                   (offsetY : Float32)) : (Nullable ViewportState)
+          (if (or (nullable-null? bitmap)
+                  (viewDimensionsMissing viewportWidth viewportHeight))
+            (nullable-none ViewportState)
+            (let ((img (nullable-get bitmap)))
+              (let ((imageWidth (reviewBitmapWidth img))
+                    (imageHeight (reviewBitmapHeight img)))
+                (let ((displayWidth
+                        (rotatedDisplayWidthFloat
+                          rotationDegrees
+                          imageWidth
+                          imageHeight))
+                      (displayHeight
+                        (rotatedDisplayHeightFloat
+                          rotationDegrees
+                          imageWidth
+                          imageHeight))
+                      (viewportWidthF (float32 viewportWidth))
+                      (viewportHeightF (float32 viewportHeight)))
+                  (nullable-some
+                    (make-ViewportState
+                      scale
+                      (clampViewportOffset offsetX viewportWidthF (* displayWidth scale))
+                      (clampViewportOffset offsetY viewportHeightF (* displayHeight scale))
+                      rotationDegrees
+                      imageWidth
+                      imageHeight)))))))
         (def (viewSizeChangeNeedsFitForBitmap (oldWidth : Int32)
                                               (oldHeight : Int32)
                                               (bitmap : (Nullable Bitmap))) : Bool
@@ -8729,15 +8763,11 @@
 	       "    }"
 	       ""
 	       "    private fun clampOffsets() {"
-	       "        if (!bitmapLoaded(bitmap)) return"
-	       "        val img = checkNotNull(bitmap)"
-	       "        if (viewDimensionsMissing(width, height)) return"
-	       "        val displayWidth = rotatedDisplayWidthFloat(rotationDegrees, img.width, img.height)"
-	       "        val displayHeight = rotatedDisplayHeightFloat(rotationDegrees, img.width, img.height)"
-	       "        val scaledWidth = displayWidth * scale"
-	       "        val scaledHeight = displayHeight * scale"
-	       "        offsetX = clampViewportOffset(offsetX, width.toFloat(), scaledWidth)"
-	       "        offsetY = clampViewportOffset(offsetY, height.toFloat(), scaledHeight)"
+	       "        val clamped = clampedViewportState(bitmap, width, height, rotationDegrees, scale, offsetX, offsetY)"
+	       "        if (!viewportStatePresent(clamped)) return"
+	       "        val viewport = viewportStateOrEmpty(clamped)"
+	       "        offsetX = viewport.offsetX"
+	       "        offsetY = viewport.offsetY"
 	       "    }"
        ""
        "    private fun viewToImage(x: Float, y: Float): FloatArray {"