android, desktop: refine RTL fix and add design doc

Surgical refinements on top of the initial RTL fix, plus a design doc
in plans/.

- chatItemShape (ChatItemView.kt): extract `val tailOnRight = ...` so
  the predicate names what the conditional does. Comment trimmed to
  the single load-bearing invariant ("default path draws tail at
  bottom-left") since the rest is implied by the variable name.

- SubscriptionStatusIcon.kt: hoist `val iconModifier =
  modifier.mirrorIfRtl()` once at the top of the composable and reuse
  it at all five Icon call sites. Calling mirrorIfRtl five times or
  once produces the same Modifier within one composition.

- GoToItemInnerButton (ChatItemView.kt): add `mirror: Boolean = false`
  parameter and pass `mirror = true` at the directional call site
  (ic_arrow_forward, "go to forwarded source"). The other call site
  (ic_search) keeps the default and is unchanged.

- Modifiers.kt, FramedItemView.kt: drop two explanatory comments that
  restated framework-primitive behavior (mirrorIfRtl's own name, and
  the documented behavior of placeRelative).

plans/2026-05-13-rtl-layout-issues.md documents the three independent
mechanisms behind the fix (bubble shape, app-bar slot placement,
asymmetric drawables), the bubble-tail XOR predicate, and four
considered-and-rejected alternatives.
This commit is contained in:
Narasimha-sc
2026-05-13 17:29:41 +00:00
parent 326c42e3d6
commit 2c5702a271
5 changed files with 232 additions and 16 deletions
@@ -238,7 +238,7 @@ fun ChatItemView(
}
@Composable
fun GoToItemInnerButton(alignStart: Boolean, icon: ImageResource, iconSize: Dp = 22.dp, parentActivated: State<Boolean>, onClick: () -> Unit) {
fun GoToItemInnerButton(alignStart: Boolean, icon: ImageResource, iconSize: Dp = 22.dp, parentActivated: State<Boolean>, mirror: Boolean = false, onClick: () -> Unit) {
val buttonInteractionSource = remember { MutableInteractionSource() }
val buttonHovered = buttonInteractionSource.collectIsHoveredAsState()
val buttonPressed = buttonInteractionSource.collectIsPressedAsState()
@@ -273,7 +273,7 @@ fun ChatItemView(
.size(22.dp),
interactionSource = buttonInteractionSource
) {
Icon(painterResource(icon), null, Modifier.size(iconSize), tint = iconTint)
Icon(painterResource(icon), null, Modifier.size(iconSize).then(if (mirror) Modifier.mirrorIfRtl() else Modifier), tint = iconTint)
}
}
@@ -289,7 +289,7 @@ fun ChatItemView(
}
}
} else if (chatTypeApiIdMsgId != null) {
GoToItemInnerButton(alignStart, MR.images.ic_arrow_forward, 22.dp, parentActivated) {
GoToItemInnerButton(alignStart, MR.images.ic_arrow_forward, 22.dp, parentActivated, mirror = true) {
val (chatType, apiId, msgId) = chatTypeApiIdMsgId
withBGApi {
openChat(secondaryChatsCtx = null, rhId, chatType, apiId, msgId)
@@ -1283,10 +1283,9 @@ private fun chatItemShape(roundness: Float, density: Density, tailVisible: Boole
quadraticBezierTo(bubbleInitialX, 0f, bubbleInitialX + rx, 0f) // Top-left corner
}
// The default path draws the tail at the bottom-left corner. We mirror the path so the tail
// ends up on the side closer to the message author's profile in chat: right for sent in LTR,
// left for received in LTR — and the opposite under RTL where chat sides are mirrored.
if (sent != (layoutDirection == LayoutDirection.Rtl)) {
// Default path draws the tail at the bottom-left corner.
val tailOnRight = sent != (layoutDirection == LayoutDirection.Rtl)
if (tailOnRight) {
val matrix = Matrix()
matrix.scale(-1f, 1f)
this.transform(matrix)
@@ -592,8 +592,6 @@ fun CenteredRowLayout(
val second = measureable[1].measure(constraints.copy(minWidth = 0, minHeight = 0, maxWidth = (constraints.maxWidth - first.measuredWidth - third.measuredWidth).coerceAtLeast(0)))
// Limit width for every other element to width of important element and height for a sum of all elements.
layout(constraints.maxWidth, constraints.maxHeight) {
// placeRelative mirrors x under RTL so the leading slot (first) and trailing slot (third)
// swap visual sides automatically when the locale flips.
first.placeRelative(0, ((constraints.maxHeight - first.measuredHeight) / 2).coerceAtLeast(0))
second.placeRelative((constraints.maxWidth - second.measuredWidth) / 2, ((constraints.maxHeight - second.measuredHeight) / 2).coerceAtLeast(0))
third.placeRelative(constraints.maxWidth - third.measuredWidth, ((constraints.maxHeight - third.measuredHeight) / 2).coerceAtLeast(0))
@@ -12,8 +12,6 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import kotlin.math.roundToInt
// Horizontally flips an asymmetric directional drawable (back/forward arrows, chevrons, signal
// waves) when the layout direction is RTL. Apply to the Icon's modifier at each call site.
@Composable
fun Modifier.mirrorIfRtl(): Modifier =
if (LocalLayoutDirection.current == LayoutDirection.Rtl) this.scale(scaleX = -1f, scaleY = 1f) else this
@@ -14,28 +14,29 @@ fun SubscriptionStatusIcon(
variableValue: Float,
modifier: Modifier = Modifier
) {
val iconModifier = modifier.mirrorIfRtl()
@Composable
fun ZeroIcon() {
Icon(painterResource(MR.images.ic_radiowaves_up_forward_4_bar), null, tint = color.copy(alpha = 0.33f), modifier = modifier.mirrorIfRtl())
Icon(painterResource(MR.images.ic_radiowaves_up_forward_4_bar), null, tint = color.copy(alpha = 0.33f), modifier = iconModifier)
}
when {
variableValue <= 0f -> ZeroIcon()
variableValue > 0f && variableValue <= 0.25f -> Box {
ZeroIcon()
Icon(painterResource(MR.images.ic_radiowaves_up_forward_1_bar), null, tint = color, modifier = modifier.mirrorIfRtl())
Icon(painterResource(MR.images.ic_radiowaves_up_forward_1_bar), null, tint = color, modifier = iconModifier)
}
variableValue > 0.25f && variableValue <= 0.5f -> Box {
ZeroIcon()
Icon(painterResource(MR.images.ic_radiowaves_up_forward_2_bar), null, tint = color, modifier = modifier.mirrorIfRtl())
Icon(painterResource(MR.images.ic_radiowaves_up_forward_2_bar), null, tint = color, modifier = iconModifier)
}
variableValue > 0.5f && variableValue <= 0.75f -> Box {
ZeroIcon()
Icon(painterResource(MR.images.ic_radiowaves_up_forward_3_bar), null, tint = color, modifier = modifier.mirrorIfRtl())
Icon(painterResource(MR.images.ic_radiowaves_up_forward_3_bar), null, tint = color, modifier = iconModifier)
}
else -> Icon(painterResource(MR.images.ic_radiowaves_up_forward_4_bar), null, tint = color, modifier = modifier.mirrorIfRtl())
else -> Icon(painterResource(MR.images.ic_radiowaves_up_forward_4_bar), null, tint = color, modifier = iconModifier)
}
}