Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ extensions.configure<ApplicationExtension> {
resValues = true
}

// Compose UI tests run on the JVM with Robolectric
testOptions {
unitTests.isIncludeAndroidResources = true
}

kotlin {
compilerOptions {
freeCompilerArgs.addAll(
Expand Down Expand Up @@ -184,6 +189,11 @@ dependencies {
testImplementation(libs.junit4)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(testFixtures(project(":auth")))
// Compose UI tests on the JVM
testImplementation(libs.robolectric)
testImplementation(platform(libs.androidx.compose.bom))
testImplementation(libs.androidx.compose.ui.test.junit4)
debugImplementation(libs.androidx.compose.ui.test.manifest)
// androidTestImplementation("androidx.test:runner:1.5.2")
// androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ internal fun CodeEditorLayout(
overlay = { textLayoutResult ->
RemotePeersOverlay(
textLayoutResult = textLayoutResult,
text = text.text,
remotePeers = remotePeers,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
Expand All @@ -22,16 +23,24 @@ import kotlin.math.roundToInt
* Must be used within the overlay of the KodeEditor, so it moves with zoom and scroll.
*
* @param textLayoutResult the current text layout of the editor
* @param text the current text of the editor, which [remotePeers] were resolved against
* @param remotePeers the other users in this document
*/
@Composable
internal fun BoxScope.RemotePeersOverlay(
textLayoutResult: TextLayoutResult,
text: String,
remotePeers: List<RemotePeer>,
) {
val textLength = textLayoutResult.layoutInput.text.length
val laidOutPeers = remember { LaidOutPeers() }
val peers = laidOutPeers.select(
laidOutText = textLayoutResult.layoutInput.text.text,
text = text,
peers = remotePeers,
)

remotePeers.forEach { peer ->
peers.forEach { peer ->
val color = peer.composeColor
// indices of the peer can be ahead of the laid out text for a moment
val selections = peer.selections.map {
Expand Down Expand Up @@ -71,6 +80,32 @@ internal fun BoxScope.RemotePeersOverlay(
}
}

/**
* Pairs the peers with the text layout they belong to.
*
* The editor reports the layout of a new text one frame after it got the text, while the
* peers are resolved against the new text right away. Drawing new positions on the old
* layout puts a cursor at the end of a line onto the start of the next line for one frame,
* so the label of a typing user jumps with every change. Until the new text is laid out,
* the peers that belong to the laid out text are kept.
*/
internal class LaidOutPeers {
private var peers: List<RemotePeer> = emptyList()

/**
* @param laidOutText the text of the current layout
* @param text the current editor text
* @param peers the peers, resolved against [text]
* @return the peers to draw on the current layout
*/
fun select(laidOutText: String, text: String, peers: List<RemotePeer>): List<RemotePeer> {
if (laidOutText == text) {
this.peers = peers
}
return this.peers
}
}

/**
* The name of a user, shown above its cursor (or below, on the first line)
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package de.markusressel.mkdocseditor.feature.editor

import android.app.Application
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import de.markusressel.mkdocseditor.feature.editor.ui.compose.CodeEditorLayout
import de.markusressel.mkdocseditor.feature.editor.ui.compose.LaidOutPeers
import de.markusressel.mkdocsrestclient.sync.automerge.RemotePeer
import de.markusressel.mkdocsrestclient.sync.automerge.RemoteSelection
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import kotlin.test.assertEquals
import kotlin.test.assertTrue

/**
* The name label of another user must not jump while that user types: the peer positions
* belong to the new text, and must never be drawn on the layout of the old text.
*/
@RunWith(AndroidJUnit4::class)
@Config(application = Application::class, sdk = [35])
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class RemotePeersOverlayTest {

@get:Rule
val composeRule = createComposeRule()

private fun peer(head: Int) = RemotePeer(
clientId = "c1",
username = "alice",
displayName = "alice",
color = "#ff0000",
selections = listOf(RemoteSelection(head, head)),
)

@Test
fun `the label stays at the end of the line while the other user types there`() {
var text by mutableStateOf("hello\nworld")
var peers by mutableStateOf(listOf(peer(5)))

composeRule.setContent {
MaterialTheme {
CodeEditorLayout(
modifier = Modifier.size(400.dp, 400.dp),
text = TextFieldValue(text, TextRange.Zero),
onTextChanged = {},
readOnly = true,
remotePeers = peers,
)
}
}
composeRule.waitForIdle()
composeRule.mainClock.autoAdvance = false

// top left corner of the name label in px; useUnmergedTree includes its placement
val positions = mutableListOf<Offset>()
fun record() {
positions += composeRule.onNodeWithText("alice", useUnmergedTree = true).fetchSemanticsNode().positionInRoot
}
record()

// alice types "!!!" at the end of the first line, one change at a time; the session
// updates the text and the resolved peers in the same main thread message
repeat(3) {
val head = text.indexOf('\n')
text = text.substring(0, head) + "!" + text.substring(head)
peers = listOf(peer(head + 1))
repeat(3) {
composeRule.mainClock.advanceTimeByFrame()
record()
}
}

assertEquals(1, positions.map { it.y }.distinct().size, "the label changed lines: $positions")
assertTrue(positions.zipWithNext().all { (a, b) -> b.x >= a.x }, "the label moved back: $positions")
assertTrue(positions.last().x > positions.first().x, "the label did not follow: $positions")
}

@Test
fun `keeps the peers of the laid out text until the new text is laid out`() {
val laidOutPeers = LaidOutPeers()
val before = listOf(peer(5))
val after = listOf(peer(6))
assertEquals(before, laidOutPeers.select(laidOutText = "hello", text = "hello", peers = before))
assertEquals(before, laidOutPeers.select(laidOutText = "hello", text = "hello!", peers = after))
assertEquals(after, laidOutPeers.select(laidOutText = "hello!", text = "hello!", peers = after))
}

@Test
fun `draws nothing before the first matching layout`() {
assertEquals(emptyList(), LaidOutPeers().select(laidOutText = "", text = "hello", peers = listOf(peer(1))))
}
}
4 changes: 4 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ hiltNavigationCompose = "1.3.0"
iconicsVersion = "5.6.0"
javaxAnnotationVersion = "10.0-b28"
junit4 = "4.13.2"
robolectric = "4.16"
kotlin = "2.4.21"
kotlinCoroutinesCore = "1.11.0"
kotlinCoroutinesAndroid = "1.11.0"
Expand Down Expand Up @@ -87,6 +88,8 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-text = { group = "androidx.compose.ui", name = "ui-text" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiToolingVersion" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "uiToolingVersion" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" }
Expand Down Expand Up @@ -207,6 +210,7 @@ zoomlayout = { module = "com.otaliastudios:zoomlayout", version.ref = "zoomlayou
junit4 = { group = "junit", name = "junit", version.ref = "junit4" }
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExtJunit" }
androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxTestEspressoCore" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }

[plugins]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
Expand Down
Loading