feat(chat): redesign attachment picker tray (#1130)

This commit is contained in:
M57 authored and GitHub committed 2026-09-15 21:56:06 +03:00
1 parent 984053b0e7
commit afffcd95e8
9 files changed
+417 -81

No files matched your search

@@ -3,11 +3,13 @@ package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.getUnclippedBoundsInRoot
@@ -20,7 +22,9 @@ import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.test.swipeLeft
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.test.espresso.Espresso.pressBack
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.ws.CommandCatalog import com.m57.hermescontrol.data.ws.CommandCatalog
@@ -44,6 +48,9 @@ class ComposerInteractionTest {
private var sends = 0 private var sends = 0
private var modelTaps = 0 private var modelTaps = 0
private var selectedLevel: String? = null private var selectedLevel: String? = null
private var cameraTaps = 0
private var photosTaps = 0
private var fileTaps = 0
/** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */ /** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
private fun setComposer( private fun setComposer(
@@ -51,44 +58,63 @@ class ComposerInteractionTest {
model: String = "openai/gpt-5.5", model: String = "openai/gpt-5.5",
composerWidth: Dp? = null, composerWidth: Dp? = null,
modelAfterTap: String? = null, modelAfterTap: String? = null,
layoutDirection: LayoutDirection = LayoutDirection.Ltr,
) { ) {
composeTestRule.setContent { composeTestRule.setContent {
var input by remember { mutableStateOf(TextFieldValue("")) } var input by remember { mutableStateOf(TextFieldValue("")) }
var listening by remember { mutableStateOf(false) } var listening by remember { mutableStateOf(false) }
var currentModel by remember { mutableStateOf(model) } var currentModel by remember { mutableStateOf(model) }
val composer: @Composable () -> Unit = {
ChatInputBar( CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
inputFieldValue = input, val composer: @Composable () -> Unit = {
onInputChange = { input = it }, ChatInputBar(
onSend = { inputFieldValue = input,
sends++ onInputChange = { input = it },
input = TextFieldValue("") onSend = {
}, sends++
onMicTap = { input = TextFieldValue("")
micTaps++ },
listening = !listening onMicTap = {
}, micTaps++
isListening = listening, listening = !listening
isAgentTyping = false, },
isConnected = true, isListening = listening,
commandCatalog = CommandCatalog(), isAgentTyping = false,
currentSessionModel = currentModel, isConnected = true,
reasoningLevel = reasoningLevel, commandCatalog = CommandCatalog(),
onModelTap = { currentSessionModel = currentModel,
modelTaps++ reasoningLevel = reasoningLevel,
modelAfterTap?.let { currentModel = it } onModelTap = {
}, modelTaps++
onReasoningTap = { selectedLevel = it }, modelAfterTap?.let { currentModel = it }
) },
} onReasoningTap = { selectedLevel = it },
if (composerWidth == null) { onCameraTap = { cameraTaps++ },
composer() onImageTap = { photosTaps++ },
} else { onFileTap = { fileTaps++ },
Box(Modifier.width(composerWidth).testTag("composer_toolbar")) { composer() } )
}
if (composerWidth == null) {
composer()
} else {
Box(Modifier.width(composerWidth).testTag("composer_toolbar")) { composer() }
}
} }
} }
} }
private fun openAttachmentTray() {
composeTestRule.onNodeWithTag("attachment_button").performClick()
composeTestRule.mainClock.advanceTimeBy(300)
composeTestRule.onNodeWithTag("attachment_tray").assertIsDisplayed()
}
private fun closeAttachmentTray() {
composeTestRule.onNodeWithTag("attachment_button").performClick()
composeTestRule.mainClock.advanceTimeBy(300)
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist()
}
@Test @Test
fun emptyInput_actionButtonStartsDictation() { fun emptyInput_actionButtonStartsDictation() {
setComposer() setComposer()
@@ -247,4 +273,74 @@ class ComposerInteractionTest {
assertEquals("model selection should still invoke the picker", 1, modelTaps) assertEquals("model selection should still invoke the picker", 1, modelTaps)
} }
} }
@Test
fun attachmentButton_togglesHorizontalTray() {
setComposer()
openAttachmentTray()
composeTestRule.onNodeWithText("Camera").assertIsDisplayed()
composeTestRule.onNodeWithText("Photos").assertIsDisplayed()
composeTestRule.onNodeWithText("File").assertIsDisplayed()
closeAttachmentTray()
}
@Test
fun attachmentTray_shrinksWithinConstrainedWidth() {
setComposer(composerWidth = 180.dp)
openAttachmentTray()
composeTestRule.onNodeWithText("Camera").assertIsDisplayed()
composeTestRule.onNodeWithText("Photos").assertIsDisplayed()
composeTestRule.onNodeWithText("File").assertIsDisplayed()
}
@Test
fun attachmentTray_rtlKeepsActionsAccessible() {
setComposer(layoutDirection = LayoutDirection.Rtl)
openAttachmentTray()
composeTestRule.onNodeWithText("Camera").assertIsDisplayed()
composeTestRule.onNodeWithText("Photos").assertIsDisplayed()
composeTestRule.onNodeWithText("File").assertIsDisplayed()
}
@Test
fun attachmentTray_actionsDispatchAndCloseTray() {
setComposer()
openAttachmentTray()
composeTestRule.onNodeWithTag("attachment_action_camera").performClick()
composeTestRule.mainClock.advanceTimeBy(300)
composeTestRule.runOnIdle {
assertEquals("Camera must dispatch its callback", 1, cameraTaps)
assertEquals("Camera must not dispatch Photos", 0, photosTaps)
assertEquals("Camera must not dispatch File", 0, fileTaps)
}
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist()
openAttachmentTray()
composeTestRule.onNodeWithTag("attachment_action_photos").performClick()
composeTestRule.mainClock.advanceTimeBy(300)
composeTestRule.runOnIdle { assertEquals("Photos must dispatch its callback", 1, photosTaps) }
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist()
openAttachmentTray()
composeTestRule.onNodeWithTag("attachment_action_file").performClick()
composeTestRule.mainClock.advanceTimeBy(300)
composeTestRule.runOnIdle { assertEquals("File must dispatch its callback", 1, fileTaps) }
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist()
}
@Test
fun attachmentTray_dismissesOnBackPress() {
setComposer()
openAttachmentTray()
pressBack()
composeTestRule.mainClock.advanceTimeBy(300)
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist()
}
} }
@@ -0,0 +1,229 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import com.m57.hermescontrol.R
/**
* Compact attachment tray that extends the composer without looking like a
* separate system menu. It is hosted in a focusable popup so outside taps and
* the system back action dismiss it naturally.
*/
@Composable
internal fun AttachmentTray(
visible: Boolean,
onDismissRequest: () -> Unit,
onCameraTap: () -> Unit,
onImageTap: () -> Unit,
onFileTap: () -> Unit,
) {
val transitionState = remember { MutableTransitionState(visible) }
LaunchedEffect(visible) {
transitionState.targetState = visible
}
if (!visible && !transitionState.currentState) {
return
}
val marginPx = with(LocalDensity.current) { 8.dp.roundToPx() }
val trayPositionProvider =
remember(marginPx) {
AttachmentTrayPositionProvider(marginPx = marginPx)
}
Popup(
popupPositionProvider = trayPositionProvider,
onDismissRequest = onDismissRequest,
properties =
PopupProperties(
focusable = visible,
dismissOnBackPress = visible,
dismissOnClickOutside = visible,
),
) {
AnimatedVisibility(
visibleState = transitionState,
enter =
fadeIn(animationSpec = tween(durationMillis = 160)) +
slideInVertically(
animationSpec = tween(durationMillis = 160),
initialOffsetY = { it / 4 },
) +
scaleIn(
animationSpec = tween(durationMillis = 160),
initialScale = 0.96f,
),
exit =
fadeOut(animationSpec = tween(durationMillis = 120)) +
slideOutVertically(
animationSpec = tween(durationMillis = 120),
targetOffsetY = { it / 4 },
) +
scaleOut(
animationSpec = tween(durationMillis = 120),
targetScale = 0.96f,
),
) {
val palette = composerPalette()
Surface(
modifier =
Modifier
.widthIn(max = 280.dp)
.testTag("attachment_tray"),
shape = MaterialTheme.shapes.large,
color = palette.card,
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
AttachmentTrayAction(
icon = Icons.Filled.CameraAlt,
label = stringResource(R.string.chat_attachment_camera),
onClick = {
onDismissRequest()
onCameraTap()
},
modifier = Modifier.weight(1f).testTag("attachment_action_camera"),
)
AttachmentTrayAction(
icon = Icons.Filled.PhotoLibrary,
label = stringResource(R.string.chat_attachment_photos),
onClick = {
onDismissRequest()
onImageTap()
},
modifier = Modifier.weight(1f).testTag("attachment_action_photos"),
)
AttachmentTrayAction(
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
label = stringResource(R.string.chat_attachment_file),
onClick = {
onDismissRequest()
onFileTap()
},
modifier = Modifier.weight(1f).testTag("attachment_action_file"),
)
}
}
}
}
}
@Composable
private fun AttachmentTrayAction(
icon: ImageVector,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier =
modifier
.heightIn(min = 72.dp)
.clickable(onClick = onClick)
.semantics(mergeDescendants = true) {
contentDescription = label
role = Role.Button
}.padding(horizontal = 4.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
softWrap = true,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
internal class AttachmentTrayPositionProvider(
private val marginPx: Int,
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
val minX = marginPx
val maxX =
(windowSize.width - popupContentSize.width - marginPx)
.coerceAtLeast(minX)
val x =
if (layoutDirection == LayoutDirection.Ltr) {
anchorBounds.left + marginPx
} else {
anchorBounds.right - popupContentSize.width - marginPx
}
val yAbove = anchorBounds.top - popupContentSize.height - marginPx
val yBelow = anchorBounds.bottom + marginPx
val maxY = (windowSize.height - popupContentSize.height).coerceAtLeast(0)
val y = if (yAbove >= 0) yAbove else yBelow
return IntOffset(
x = x.coerceIn(minX, maxX),
y = y.coerceIn(0, maxY),
)
}
}
@@ -29,7 +29,6 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -57,7 +56,6 @@ import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment import com.m57.hermescontrol.data.model.Attachment
@@ -107,8 +105,8 @@ fun ChatInputBar(
// dropped. Slash commands were already allowed; regular prompts now are too. // dropped. Slash commands were already allowed; regular prompts now are too.
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected) val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected)
// Attachment menu state // Attachment tray state
var showAttachmentMenu by remember { mutableStateOf(false) } var showAttachmentTray by remember { mutableStateOf(false) }
val palette = composerPalette() val palette = composerPalette()
AnimatedVisibility( AnimatedVisibility(
@@ -299,7 +297,7 @@ fun ChatInputBar(
isListening = isListening, isListening = isListening,
canSend = canSend, canSend = canSend,
onSend = onSend, onSend = onSend,
onAttachTap = { showAttachmentMenu = true }, onAttachTap = { showAttachmentTray = !showAttachmentTray },
onModelTap = onModelTap, onModelTap = onModelTap,
onReasoningSelected = onReasoningTap, onReasoningSelected = onReasoningTap,
onMicTap = onMicTap, onMicTap = onMicTap,
@@ -312,52 +310,13 @@ fun ChatInputBar(
onToggleFastMode = onToggleFastMode, onToggleFastMode = onToggleFastMode,
) )
// Attachment dropdown (anchored to the attach button in ComposerToolbar) AttachmentTray(
// Shown overlaid at the toolbar level visible = showAttachmentTray,
DropdownMenu( onDismissRequest = { showAttachmentTray = false },
expanded = showAttachmentMenu, onCameraTap = onCameraTap,
onDismissRequest = { showAttachmentMenu = false }, onImageTap = onImageTap,
) { onFileTap = onFileTap,
DropdownMenuItem( )
text = { Text("Camera") },
onClick = {
showAttachmentMenu = false
onCameraTap()
},
leadingIcon = {
Text(
text = "📷",
fontSize = 18.sp,
)
},
)
DropdownMenuItem(
text = { Text("Image") },
onClick = {
showAttachmentMenu = false
onImageTap()
},
leadingIcon = {
Text(
text = "🖼️",
fontSize = 18.sp,
)
},
)
DropdownMenuItem(
text = { Text("File") },
onClick = {
showAttachmentMenu = false
onFileTap()
},
leadingIcon = {
Text(
text = "📄",
fontSize = 18.sp,
)
},
)
}
} }
} }
} }
@@ -123,7 +123,7 @@ fun ComposerToolbar(
onClick = onAttachTap, onClick = onAttachTap,
enabled = isConnected, enabled = isConnected,
colors = flatIconButtonColors(palette), colors = flatIconButtonColors(palette),
modifier = Modifier.size(ControlSize), modifier = Modifier.size(ControlSize).testTag("attachment_button"),
) { ) {
Icon( Icon(
imageVector = Icons.Default.Add, imageVector = Icons.Default.Add,
+3
View File
@@ -1084,6 +1084,9 @@
<string name="channels_expiry_format">ينتهي في: %1$s</string> <string name="channels_expiry_format">ينتهي في: %1$s</string>
<string name="channels_search_placeholder">البحث في القنوات…</string> <string name="channels_search_placeholder">البحث في القنوات…</string>
<string name="chat_attach_file">إرفاق ملف</string> <string name="chat_attach_file">إرفاق ملف</string>
<string name="chat_attachment_camera">الكاميرا</string>
<string name="chat_attachment_photos">الصور</string>
<string name="chat_attachment_file">ملف</string>
<string name="chat_fast_mode_label">سريع</string> <string name="chat_fast_mode_label">سريع</string>
<string name="chat_fast_mode_desc">الوضع السريع</string> <string name="chat_fast_mode_desc">الوضع السريع</string>
<string name="chat_fast_mode_unavailable">الوضع السريع غير مدعوم لهذا النموذج</string> <string name="chat_fast_mode_unavailable">الوضع السريع غير مدعوم لهذا النموذج</string>
+3
View File
@@ -928,6 +928,9 @@
<string name="subagent_status_cancelled">취소됨</string> <string name="subagent_status_cancelled">취소됨</string>
<string name="subagent_status_failed">실패</string> <string name="subagent_status_failed">실패</string>
<string name="subagent_duration">%1$.1f초</string> <string name="subagent_duration">%1$.1f초</string>
<string name="chat_attachment_camera">카메라</string>
<string name="chat_attachment_photos">사진</string>
<string name="chat_attachment_file">파일</string>
<string name="chat_fast_mode_label">빠름</string> <string name="chat_fast_mode_label">빠름</string>
<string name="chat_fast_mode_desc">빠른 모드</string> <string name="chat_fast_mode_desc">빠른 모드</string>
<string name="chat_fast_mode_unavailable">이 모델에서는 빠른 모드를 사용할 수 없습니다</string> <string name="chat_fast_mode_unavailable">이 모델에서는 빠른 모드를 사용할 수 없습니다</string>
+3
View File
@@ -1067,6 +1067,9 @@
<string name="channels_expiry_format">过期时间:%1$s</string> <string name="channels_expiry_format">过期时间:%1$s</string>
<string name="channels_search_placeholder">搜索渠道…</string> <string name="channels_search_placeholder">搜索渠道…</string>
<string name="chat_attach_file">附加文件</string> <string name="chat_attach_file">附加文件</string>
<string name="chat_attachment_camera">相机</string>
<string name="chat_attachment_photos">照片</string>
<string name="chat_attachment_file">文件</string>
<string name="chat_fast_mode_label">快速</string> <string name="chat_fast_mode_label">快速</string>
<string name="chat_fast_mode_desc">快速模式</string> <string name="chat_fast_mode_desc">快速模式</string>
<string name="chat_fast_mode_unavailable">此模型不支持快速模式</string> <string name="chat_fast_mode_unavailable">此模型不支持快速模式</string>
+3
View File
@@ -1244,6 +1244,9 @@
<string name="channels_expiry_format">Expires: %1$s</string> <string name="channels_expiry_format">Expires: %1$s</string>
<string name="channels_search_placeholder">Search channels…</string> <string name="channels_search_placeholder">Search channels…</string>
<string name="chat_attach_file">Attach file</string> <string name="chat_attach_file">Attach file</string>
<string name="chat_attachment_camera">Camera</string>
<string name="chat_attachment_photos">Photos</string>
<string name="chat_attachment_file">File</string>
<string name="chat_fast_mode_label">Fast</string> <string name="chat_fast_mode_label">Fast</string>
<string name="chat_fast_mode_desc">Fast mode</string> <string name="chat_fast_mode_desc">Fast mode</string>
<string name="chat_fast_mode_unavailable">Fast mode unavailable for this model</string> <string name="chat_fast_mode_unavailable">Fast mode unavailable for this model</string>
@@ -0,0 +1,40 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import org.junit.Assert.assertEquals
import org.junit.Test
class AttachmentTrayPositionProviderTest {
private val provider = AttachmentTrayPositionProvider(marginPx = 16)
private val windowSize = IntSize(width = 500, height = 1_000)
private val popupSize = IntSize(width = 280, height = 100)
@Test
fun ltr_clampsToVisibleWindowWithHorizontalMargin() {
val position =
provider.calculatePosition(
anchorBounds = IntRect(left = 400, top = 700, right = 500, bottom = 900),
windowSize = windowSize,
layoutDirection = LayoutDirection.Ltr,
popupContentSize = popupSize,
)
assertEquals(IntOffset(x = 204, y = 584), position)
}
@Test
fun rtl_clampsToVisibleWindowWithHorizontalMargin() {
val position =
provider.calculatePosition(
anchorBounds = IntRect(left = 0, top = 700, right = 100, bottom = 900),
windowSize = windowSize,
layoutDirection = LayoutDirection.Rtl,
popupContentSize = popupSize,
)
assertEquals(IntOffset(x = 16, y = 584), position)
}
}