diff --git a/build.gradle.kts b/build.gradle.kts index 5e8436f..6420b04 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "com.tpuahsiew.wirc" -version = "0.1.12" +version = "0.1.13" // No project-level repositories{} block here: settings.gradle.kts sets // dependencyResolutionManagement.repositoriesMode = FAIL_ON_PROJECT_REPOS and already @@ -38,7 +38,7 @@ compose.desktop { appResourcesRootDir.set(project.file("installerResources")) targetFormats(TargetFormat.Msi, TargetFormat.Exe) packageName = "wIRC" - packageVersion = "0.1.12" + packageVersion = "0.1.13" windows { menuGroup = "wIRC" upgradeUuid = "8f14e45f-ceea-4a5e-9c3b-5b1f0e8c2b7a" diff --git a/src/main/kotlin/ui/ChannelScreen.kt b/src/main/kotlin/ui/ChannelScreen.kt index c95ff05..94146e8 100644 --- a/src/main/kotlin/ui/ChannelScreen.kt +++ b/src/main/kotlin/ui/ChannelScreen.kt @@ -61,6 +61,7 @@ import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString @@ -1059,7 +1060,10 @@ fun MessageScrollback( } } - Column(modifier = Modifier.fillMaxWidth()) { + // Every row is kept a whole number of lines tall (nearest whole line, to absorb + // the pixel or two a wrapped message can be off by), so the line snapping above + // stays lined up all the way to the top of the window instead of drifting. + Column(modifier = Modifier.fillMaxWidth().snapHeightToLines(lineHeightPx, roundUp = false)) { // Timestamp + nick are one text and the body another, both BasicText with // the same explicit style - BasicText never merges the ambient LocalTextStyle, // so neither picks up MaterialTheme's bodyLarge 24sp lineHeight (which once @@ -1085,14 +1089,19 @@ fun MessageScrollback( } if (displaySettings.showImagePreviews) { extractImageUrls(msg.rawText).forEach { url -> - UrlImagePreview( - url = url, - modifier = Modifier - .padding(start = 8.dp, top = 2.dp, bottom = 4.dp) - .heightIn(max = 200.dp) - .clip(RoundedCornerShape(4.dp)), - onClick = { openUrlInBrowser(url) } - ) + // A thumbnail can be any height, so its slot is rounded UP to whole lines + // (never cropping the picture); otherwise every line above it in the + // window would sit a fraction of a line off and show cut in half at the top. + Box(modifier = Modifier.snapHeightToLines(lineHeightPx, roundUp = true)) { + UrlImagePreview( + url = url, + modifier = Modifier + .padding(start = 8.dp, top = 2.dp, bottom = 4.dp) + .heightIn(max = 200.dp) + .clip(RoundedCornerShape(4.dp)), + onClick = { openUrlInBrowser(url) } + ) + } } } } @@ -1106,6 +1115,20 @@ fun MessageScrollback( } } +// Reports this element's height as a whole number of text lines ([linePx] each): rounded up +// for image slots (the picture is never cut), to the nearest line for text rows (a wrapped +// message can measure a pixel or two off an exact multiple). Content is drawn from the top. +private fun Modifier.snapHeightToLines(linePx: Int, roundUp: Boolean): Modifier = layout { measurable, constraints -> + val placeable = measurable.measure(constraints.copy(minHeight = 0)) + val h = placeable.height + val snapped = when { + linePx <= 0 || h == 0 -> h + roundUp -> ((h + linePx - 1) / linePx) * linePx + else -> (((h + linePx / 2) / linePx).coerceAtLeast(1)) * linePx + }.coerceIn(constraints.minHeight, constraints.maxHeight.coerceAtLeast(constraints.minHeight)) + layout(placeable.width, snapped) { placeable.place(0, 0) } +} + // Which of a row's two texts a TextPos is in: the timestamp/nick prefix, or the body. private const val PART_PREFIX = 0 private const val PART_BODY = 1