TopologyScale values for scaling the topology pane
TopologyScale helps to size the topology pane and properly size and
place the display blocks inside it. It is based on the algorithm
described in the design doc.
Test: TopologyScaleTest
Flag: com.android.settings.flags.display_topology_pane_in_display_list
Bug: b/352648432
Change-Id: I8932e80c2b490fab0097fa315e536b292376d4a8
diff --git a/src/com/android/settings/connecteddevice/display/DisplayTopology.kt b/src/com/android/settings/connecteddevice/display/DisplayTopology.kt
index 8155902..d483f46 100644
--- a/src/com/android/settings/connecteddevice/display/DisplayTopology.kt
+++ b/src/com/android/settings/connecteddevice/display/DisplayTopology.kt
@@ -19,9 +19,108 @@
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
+
+ // 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())
+ }
+
+ // 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.
+ paneHeight = max(
+ paneWidth.toDouble() / displayBounds.width() * displayBounds.height(),
+ displayBounds.height() * blockRatio / 0.6).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..e02cd40
--- /dev/null
+++ b/tests/robotests/src/com/android/settings/connecteddevice/display/TopologyScaleTest.kt
@@ -0,0 +1,79 @@
+/*
+ * 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,216 paneHeight=480}", "" + scale)
+
+ assertEquals(Point(352, 264), scale.displayToPaneCoor(PointF(640f, 480f)))
+ assertEquals(Point(320, 240), scale.displayToPaneCoor(PointF(320f, 240f)))
+ assertEquals(PointF(640f, 480f), scale.paneToDisplayCoor(Point(352, 264)))
+ }
+
+ @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)))
+ }
+}