android, desktop: fix several RTL layout issues

Fixes https://github.com/simplex-chat/simplex-chat/issues/5448

The original report documents four user-visible regressions under an RTL
locale (Arabic, Persian, Hebrew): chat bubble tails on the wrong side,
chat header call/menu buttons not mirrored, settings back arrow pointing
the wrong way, and the chat list profile avatar / signal icon laid out
in their LTR positions. This commit addresses all of them and a handful
of related directional-icon mirroring sites discovered during the audit.

- Chat bubble tail direction: chatItemShape now reads layoutDirection
  from the GenericShape lambda and conditions the matrix mirror on
  sent != isRtl, so sent-bubble tails point toward the user's profile
  and received-bubble tails toward the contact's profile under both
  LTR and RTL. The bubble-tail paddings in FramedItemView and ChatView
  already use direction-aware start/end and now align with the
  mirrored shape automatically.

- App-bar slot mirroring: CenteredRowLayout in FramedItemView switches
  from place(...) to placeRelative(...) so the navigation icon and the
  action buttons swap visual sides under RTL. Both the chat header
  (call/menu buttons) and the chat list header (profile avatar, signal
  icon) flow through this layout, so one fix covers both.

- Asymmetric directional drawables: add Modifier.mirrorIfRtl()
  (Modifier.scale(-1f, 1f) when LocalLayoutDirection.current is Rtl)
  and apply it to the back arrow in NavigationButtonBack, every variant
  of the SubscriptionStatusIcon waves, the WhatsNewView pagination
  arrows, the back arrow and submenu chevron in CommandsMenuView, the
  back arrow in OnboardingCards and ImageFullScreenView.desktop, the
  share-profile chevron in NewChatView, and the sent-via-proxy arrow
  in CIMetaView and ChatItemInfoView.

DropdownMenu offsets in ChatView (DpOffset(-width, ...)) are left
unchanged: DropdownMenuPositionProvider already negates the content
offset under RTL, so menus anchor and open correctly once
CenteredRowLayout is fixed.
This commit is contained in:
Narasimha-sc
2026-04-28 15:53:22 +00:00
parent a7d871d683
commit 326c42e3d6
12 changed files with 35 additions and 16 deletions
@@ -359,6 +359,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
Icon(
painterResource(MR.images.ic_arrow_forward),
contentDescription = null,
modifier = Modifier.mirrorIfRtl(),
tint = CurrentColors.value.colors.secondary
)
}
@@ -25,6 +25,7 @@ import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF
import chat.simplex.common.views.chat.group.*
import chat.simplex.common.views.chat.item.sendCommandMsg
import chat.simplex.common.views.helpers.commandMenuAnimSpec
import chat.simplex.common.views.helpers.mirrorIfRtl
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.launch
@@ -126,6 +127,7 @@ fun CommandsMenuView(
Icon(
painterResource(MR.images.ic_arrow_back_ios_new),
contentDescription = null,
modifier = Modifier.mirrorIfRtl(),
tint = MaterialTheme.colors.secondary
)
Spacer(Modifier.width(DEFAULT_PADDING_HALF))
@@ -205,6 +207,7 @@ fun CommandsMenuView(
Icon(
painterResource(MR.images.ic_chevron_right),
contentDescription = null,
modifier = Modifier.mirrorIfRtl(),
tint = MaterialTheme.colors.secondary
)
}
@@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.isInDarkTheme
import chat.simplex.common.views.helpers.mirrorIfRtl
import chat.simplex.res.MR
import kotlinx.datetime.Clock
@@ -87,7 +88,7 @@ private fun CIMetaText(
}
if (showViaProxy && meta.sentViaProxy == true) {
Spacer(Modifier.width(4.dp))
Icon(painterResource(MR.images.ic_arrow_forward), null, Modifier.height(17.dp), tint = MaterialTheme.colors.secondary)
Icon(painterResource(MR.images.ic_arrow_forward), null, Modifier.height(17.dp).mirrorIfRtl(), tint = MaterialTheme.colors.secondary)
}
if (showStatus) {
Spacer(Modifier.width(4.dp))
@@ -1231,7 +1231,7 @@ fun Modifier.clipChatItem(chatItem: ChatItem? = null, tailVisible: Boolean = fal
return this.clip(shape)
}
private fun chatItemShape(roundness: Float, density: Density, tailVisible: Boolean, sent: Boolean = false): GenericShape = GenericShape { size, _ ->
private fun chatItemShape(roundness: Float, density: Density, tailVisible: Boolean, sent: Boolean = false): GenericShape = GenericShape { size, layoutDirection ->
val (msgTailWidth, msgBubbleMaxRadius) = with(density) { Pair(msgTailWidthDp.toPx(), msgBubbleMaxRadius.toPx()) }
val width = size.width
val height = size.height
@@ -1283,7 +1283,10 @@ private fun chatItemShape(roundness: Float, density: Density, tailVisible: Boole
quadraticBezierTo(bubbleInitialX, 0f, bubbleInitialX + rx, 0f) // Top-left corner
}
if (sent) {
// 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)) {
val matrix = Matrix()
matrix.scale(-1f, 1f)
this.transform(matrix)
@@ -592,9 +592,11 @@ 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) {
first.place(0, ((constraints.maxHeight - first.measuredHeight) / 2).coerceAtLeast(0))
second.place((constraints.maxWidth - second.measuredWidth) / 2, ((constraints.maxHeight - second.measuredHeight) / 2).coerceAtLeast(0))
third.place(constraints.maxWidth - third.measuredWidth, ((constraints.maxHeight - third.measuredHeight) / 2).coerceAtLeast(0))
// 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))
}
}
}
@@ -128,7 +128,7 @@ fun CallAppBar(
fun NavigationButtonBack(onButtonClicked: (() -> Unit)?, tintColor: Color = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, height: Dp = 24.dp) {
IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) {
Icon(
painterResource(MR.images.ic_arrow_back_ios_new), stringResource(MR.strings.back), Modifier.height(height), tint = tintColor
painterResource(MR.images.ic_arrow_back_ios_new), stringResource(MR.strings.back), Modifier.height(height).mirrorIfRtl(), tint = tintColor
)
}
}
@@ -5,12 +5,19 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalLayoutDirection
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
fun Modifier.badgeLayout() =
layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
@@ -16,26 +16,26 @@ fun SubscriptionStatusIcon(
) {
@Composable
fun ZeroIcon() {
Icon(painterResource(MR.images.ic_radiowaves_up_forward_4_bar), null, tint = color.copy(alpha = 0.33f), modifier = modifier)
Icon(painterResource(MR.images.ic_radiowaves_up_forward_4_bar), null, tint = color.copy(alpha = 0.33f), modifier = modifier.mirrorIfRtl())
}
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)
Icon(painterResource(MR.images.ic_radiowaves_up_forward_1_bar), null, tint = color, modifier = modifier.mirrorIfRtl())
}
variableValue > 0.25f && variableValue <= 0.5f -> Box {
ZeroIcon()
Icon(painterResource(MR.images.ic_radiowaves_up_forward_2_bar), null, tint = color, modifier = modifier)
Icon(painterResource(MR.images.ic_radiowaves_up_forward_2_bar), null, tint = color, modifier = modifier.mirrorIfRtl())
}
variableValue > 0.5f && variableValue <= 0.75f -> Box {
ZeroIcon()
Icon(painterResource(MR.images.ic_radiowaves_up_forward_3_bar), null, tint = color, modifier = modifier)
Icon(painterResource(MR.images.ic_radiowaves_up_forward_3_bar), null, tint = color, modifier = modifier.mirrorIfRtl())
}
else -> Icon(painterResource(MR.images.ic_radiowaves_up_forward_4_bar), null, tint = color, modifier = modifier)
else -> Icon(painterResource(MR.images.ic_radiowaves_up_forward_4_bar), null, tint = color, modifier = modifier.mirrorIfRtl())
}
}
@@ -568,6 +568,7 @@ private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contact
painter = painterResource(MR.images.ic_arrow_forward_ios),
contentDescription = stringResource(MR.strings.new_chat_share_profile),
tint = MaterialTheme.colors.secondary,
modifier = Modifier.mirrorIfRtl(),
)
}
}
@@ -263,7 +263,7 @@ private fun BackButton(modifier: Modifier = Modifier, color: Color = MaterialThe
painterResource(MR.images.ic_arrow_back_ios_new),
contentDescription = stringResource(MR.strings.back),
tint = color,
modifier = Modifier.height(24.dp)
modifier = Modifier.height(24.dp).mirrorIfRtl()
)
Text(stringResource(MR.strings.back), color = color)
}
@@ -112,7 +112,7 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool
.clickable { currentVersion.value = prev }
.padding(8.dp)
) {
Icon(painterResource(MR.images.ic_arrow_back_ios_new), "previous", tint = MaterialTheme.colors.primary)
Icon(painterResource(MR.images.ic_arrow_back_ios_new), "previous", Modifier.mirrorIfRtl(), tint = MaterialTheme.colors.primary)
Text(versionDescriptions[prev].version, color = MaterialTheme.colors.primary)
}
}
@@ -129,7 +129,7 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool
.padding(8.dp)
) {
Text(versionDescriptions[next].version, color = MaterialTheme.colors.primary)
Icon(painterResource(MR.images.ic_arrow_forward_ios), "next", tint = MaterialTheme.colors.primary)
Icon(painterResource(MR.images.ic_arrow_forward_ios), "next", Modifier.mirrorIfRtl(), tint = MaterialTheme.colors.primary)
}
}
}
@@ -11,6 +11,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.getBitmapFromByteArray
import chat.simplex.common.views.helpers.mirrorIfRtl
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -33,7 +34,7 @@ actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: (
Box(Modifier.fillMaxSize().padding(bottom = 50.dp)) {
SurfaceFromPlayer(player, modifier)
IconButton(onClick = close, Modifier.padding(top = 5.dp)) {
Icon(painterResource(MR.images.ic_arrow_back_ios_new), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary)
Icon(painterResource(MR.images.ic_arrow_back_ios_new), null, Modifier.size(30.dp).mirrorIfRtl(), tint = MaterialTheme.colors.primary)
}
}
Controls(player)