diff --git a/src/com/android/settings/connecteddevice/display/DisplayTopology.kt b/src/com/android/settings/connecteddevice/display/DisplayTopology.kt index 81559027837..d483f4624d7 100644 --- a/src/com/android/settings/connecteddevice/display/DisplayTopology.kt +++ b/src/com/android/settings/connecteddevice/display/DisplayTopology.kt @@ -19,9 +19,108 @@ package com.android.settings.connecteddevice.display 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) { + /** 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 00000000000..e02cd40650c --- /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))) + } +}