Merge changes Icd3e0c93,I8932e80c into main

* changes:
  TopologyScale: limit vertical padding
  TopologyScale values for scaling the topology pane
diff --git a/src/com/android/settings/connecteddevice/display/DisplayTopology.kt b/src/com/android/settings/connecteddevice/display/DisplayTopology.kt
index 8155902..162d9d2 100644
--- a/src/com/android/settings/connecteddevice/display/DisplayTopology.kt
+++ b/src/com/android/settings/connecteddevice/display/DisplayTopology.kt
@@ -19,9 +19,118 @@
 import com.android.settings.R
 
 import android.content.Context
+import android.graphics.Point
+import android.graphics.PointF
+import android.graphics.RectF
 
 import androidx.preference.Preference
 
+import java.util.Locale
+
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * Contains the parameters needed for transforming global display coordinates to and from topology
+ * pane coordinates. This is necessary for implementing an interactive display topology pane. The
+ * pane allows dragging and dropping display blocks into place to define the topology. Conversion to
+ * pane coordinates is necessary when rendering the original topology. Conversion in the other
+ * direction, to display coordinates, is necessary for resolve a drag position to display space.
+ *
+ * The topology pane coordinates are integral and represent the relative position from the upper-
+ * left corner of the pane. It uses a scale optimized for showing all displays with minimal or no
+ * scrolling. The display coordinates are floating point and the origin can be in any position. In
+ * practice the origin will be the upper-left coordinate of the primary display.
+ */
+class TopologyScale(paneWidth : Int, displaysPos : Collection<RectF>) {
+    /** Scale of block sizes to real-world display sizes. Should be less than 1. */
+    val blockRatio : Float
+
+    /** Height of topology pane needed to allow all display blocks to appear with some padding. */
+    val paneHeight : Int
+
+    /** Pane's X view coordinate that corresponds with topology's X=0 coordinate. */
+    val originPaneX : Int
+
+    /** Pane's Y view coordinate that corresponds with topology's Y=0 coordinate. */
+    val originPaneY : Int
+
+    init {
+        val displayBounds = RectF(
+                Float.MAX_VALUE, Float.MAX_VALUE, Float.MIN_VALUE, Float.MIN_VALUE)
+        var smallestDisplayDim = Float.MAX_VALUE
+        var biggestDisplayHeight = Float.MIN_VALUE
+
+        // displayBounds is the smallest rect encompassing all displays, in display space.
+        // smallestDisplayDim is the size of the smallest display edge, in display space.
+        for (pos in displaysPos) {
+            displayBounds.union(pos)
+            smallestDisplayDim = minOf(smallestDisplayDim, pos.height(), pos.width())
+            biggestDisplayHeight = max(biggestDisplayHeight, pos.height())
+        }
+
+        // Set height according to the width and the aspect ratio of the display bounds.
+        // 0.05 is a reasonable limit to the size of display blocks. It appears to match the
+        // ratio used in the ChromeOS topology editor. It prevents blocks from being too large,
+        // which would make dragging and dropping awkward.
+        val rawBlockRatio = min(0.05, paneWidth.toDouble() * 0.6 / displayBounds.width())
+
+        // If the `ratio` is set too low because one of the displays will have an edge less than
+        // 48dp long, increase it such that the smallest edge is that long. This may override the
+        // 0.05 limit since it is more important than it.
+        blockRatio = max(48.0 / smallestDisplayDim, rawBlockRatio).toFloat()
+
+        // Essentially, we just set the pane height based on the pre-determined pane width and the
+        // aspect ratio of the display bounds. But we may need to increase it slightly to achieve
+        // 20% padding above and below the display bounds - this is where the 0.6 comes from.
+        val rawPaneHeight = max(
+                paneWidth.toDouble() / displayBounds.width() * displayBounds.height(),
+                displayBounds.height() * blockRatio / 0.6)
+
+        // It is easy for the aspect ratio to result in an excessively tall pane, since the width is
+        // pre-determined and may be considerably wider than necessary. So we prevent the height
+        // from growing too large here, by limiting vertical padding to the size of the tallest
+        // display. This improves results for very tall display bounds.
+        paneHeight = min(
+                rawPaneHeight.toInt(),
+                (blockRatio * (displayBounds.height() + biggestDisplayHeight * 2f)).toInt())
+
+        // Set originPaneXY (the location of 0,0 in display space in the pane's coordinate system)
+        // such that the display bounds rect is centered in the pane.
+        // It is unlikely that either of these coordinates will be negative since blockRatio has
+        // been chosen to allow 20% padding around each side of the display blocks. However, the
+        // a11y requirement applied above (48.0 / smallestDisplayDim) may cause the blocks to not
+        // fit. This should be rare in practice, and can be worked around by moving the settings UI
+        // to a larger display.
+        val blockMostLeft = (paneWidth - displayBounds.width() * blockRatio) / 2
+        val blockMostTop = (paneHeight - displayBounds.height() * blockRatio) / 2
+
+        originPaneX = (blockMostLeft - displayBounds.left * blockRatio).toInt()
+        originPaneY = (blockMostTop - displayBounds.top * blockRatio).toInt()
+    }
+
+    /** Transforms coordinates in view pane space to display space. */
+    fun paneToDisplayCoor(panePos : Point) : PointF {
+        return PointF(
+                (panePos.x - originPaneX).toFloat() / blockRatio,
+                (panePos.y - originPaneY).toFloat() / blockRatio)
+    }
+
+    /** Transforms coordinates in display space to view pane space. */
+    fun displayToPaneCoor(displayPos : PointF) : Point {
+        return Point(
+                (displayPos.x * blockRatio).toInt() + originPaneX,
+                (displayPos.y * blockRatio).toInt() + originPaneY)
+    }
+
+    override fun toString() : String {
+        return String.format(
+                Locale.ROOT,
+                "{TopoScale blockRatio=%f originPaneXY=%d,%d paneHeight=%d}",
+                blockRatio, originPaneX, originPaneY, paneHeight)
+    }
+}
+
 const val PREFERENCE_KEY = "display_topology_preference"
 
 /**
diff --git a/tests/robotests/src/com/android/settings/connecteddevice/display/TopologyScaleTest.kt b/tests/robotests/src/com/android/settings/connecteddevice/display/TopologyScaleTest.kt
new file mode 100644
index 0000000..0784362
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/display/TopologyScaleTest.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.settings.connecteddevice.display
+
+import android.graphics.Point
+import android.graphics.PointF
+import android.graphics.RectF
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+fun assertPointF(x: Float, y: Float, delta: Float, actual: PointF) {
+    assertEquals(x, actual.x, delta)
+    assertEquals(y, actual.y, delta)
+}
+
+@RunWith(RobolectricTestRunner::class)
+class TopologyScaleTest {
+    @Test
+    fun oneDisplay4to3Aspect() {
+        val scale = TopologyScale(
+                /* paneWidth= */ 640,
+                listOf(RectF(0f, 0f, 640f, 480f)))
+
+        // blockRatio is higher than 0.05 in order to make the smallest display edge (480 dp) 48dp
+        // in the pane.
+        assertEquals(
+                "{TopoScale blockRatio=0.100000 originPaneXY=288,48 paneHeight=144}", "" + scale)
+
+        assertEquals(Point(352, 96), scale.displayToPaneCoor(PointF(640f, 480f)))
+        assertEquals(Point(320, 72), scale.displayToPaneCoor(PointF(320f, 240f)))
+        assertEquals(PointF(640f, 480f), scale.paneToDisplayCoor(Point(352, 96)))
+    }
+
+    @Test
+    fun twoUnalignedDisplays() {
+        val scale = TopologyScale(
+                /* paneWidth= */ 300,
+                listOf(RectF(0f, 0f, 1920f, 1200f), RectF(1920f, -300f, 3840f, 900f)))
+
+        assertEquals(
+                "{TopoScale blockRatio=0.046875 originPaneXY=60,37 paneHeight=117}", "" + scale)
+
+        assertEquals(Point(78, 55), scale.displayToPaneCoor(PointF(400f, 400f)))
+        assertEquals(Point(42, 37), scale.displayToPaneCoor(PointF(-400f, 0f)))
+        assertPointF(-384f, 106.6666f, 0.001f, scale.paneToDisplayCoor(Point(42, 42)))
+    }
+
+    @Test
+    fun twoDisplaysBlockRatioBumpedForGarSizeMinimumHorizontal() {
+        val scale = TopologyScale(
+                /* paneWidth= */ 192,
+                listOf(RectF(0f, 0f, 240f, 320f), RectF(-240f, -320f, 0f, 0f)))
+
+        // blockRatio is higher than 0.05 in order to make the smallest display edge (240 dp) 48dp
+        // in the pane.
+        assertEquals(
+                "{TopoScale blockRatio=0.200000 originPaneXY=96,128 paneHeight=256}", "" + scale)
+
+        assertEquals(Point(192, 256), scale.displayToPaneCoor(PointF(480f, 640f)))
+        assertEquals(Point(96, 64), scale.displayToPaneCoor(PointF(0f, -320f)))
+        assertPointF(220f, -430f, 0.001f, scale.paneToDisplayCoor(Point(140, 42)))
+    }
+
+    @Test
+    fun paneVerticalPaddingLimitedByTallestDisplay() {
+        val scale = TopologyScale(
+                /* paneWidth= */ 300,
+                listOf(
+                        RectF(0f, 0f, 640f, 480f),
+                        RectF(0f, 480f, 640f, 960f),
+                        RectF(0f, 960f, 640f, 1440f),
+                        RectF(0f, 1440f, 640f, 1920f),
+                        RectF(0f, 1920f, 640f, 2400f),
+                        RectF(0f, 2400f, 640f, 2880f)))
+
+        assertEquals(
+                "{TopoScale blockRatio=0.100000 originPaneXY=118,48 paneHeight=384}", "" + scale)
+        assertEquals(Point(150, 48), scale.displayToPaneCoor(PointF(320f, 0f)))
+        assertPointF(-180f, 2880f, 0.001f, scale.paneToDisplayCoor(Point(100, 336)))
+    }
+}