From ae6d3439134d9074c8b8265b371aa2b5f6f1eee4 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Tue, 12 Aug 2025 17:56:38 +0200 Subject: [PATCH 1/4] Add a context menu for location items and add remove location functionality --- .../template/components/ContextPopupMenu.kt | 60 ++++++++++++++ .../weatherApp/ui/WeatherAppSample.kt | 80 +++++++++++++++++-- .../messages/ComposeTemplate.properties | 4 +- 3 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 src/main/kotlin/org/jetbrains/plugins/template/components/ContextPopupMenu.kt diff --git a/src/main/kotlin/org/jetbrains/plugins/template/components/ContextPopupMenu.kt b/src/main/kotlin/org/jetbrains/plugins/template/components/ContextPopupMenu.kt new file mode 100644 index 0000000..0bf0bf6 --- /dev/null +++ b/src/main/kotlin/org/jetbrains/plugins/template/components/ContextPopupMenu.kt @@ -0,0 +1,60 @@ +package org.jetbrains.plugins.template.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.onClick +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.PopupPositionProvider +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.Icon +import org.jetbrains.jewel.ui.component.PopupContainer +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.icon.IconKey + +@Composable +fun ContextPopupMenu( + popupPositionProvider: PopupPositionProvider, + onDismissRequest: () -> Unit, + content: @Composable () -> Unit, +) { + PopupContainer( + popupPositionProvider = popupPositionProvider, + modifier = Modifier.wrapContentSize(), + onDismissRequest = { onDismissRequest() }, + horizontalAlignment = Alignment.Start + ) { + content() + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ContextPopupMenuItem( + actionText: String, + actionIcon: IconKey, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .widthIn(min = 100.dp) + .padding(8.dp) + .onClick { onClick() }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + actionIcon, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + Text( + text = actionText, + style = JewelTheme.defaultTextStyle + ) + } +} diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt index 43b6a6d..52f8e34 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSample.kt @@ -1,14 +1,20 @@ package org.jetbrains.plugins.template.weatherApp.ui import androidx.compose.foundation.layout.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.* import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.* +import androidx.compose.ui.window.PopupPositionProvider import org.jetbrains.jewel.foundation.lazy.SelectableLazyColumn import org.jetbrains.jewel.foundation.lazy.SelectionMode import org.jetbrains.jewel.foundation.lazy.itemsIndexed @@ -18,8 +24,10 @@ import org.jetbrains.jewel.ui.component.* import org.jetbrains.jewel.ui.icon.IconKey import org.jetbrains.jewel.ui.icons.AllIconsKeys import org.jetbrains.plugins.template.ComposeTemplateBundle +import org.jetbrains.plugins.template.components.ContextPopupMenu +import org.jetbrains.plugins.template.components.ContextPopupMenuItem import org.jetbrains.plugins.template.weatherApp.model.Location -import org.jetbrains.plugins.template.weatherApp.services.* +import org.jetbrains.plugins.template.weatherApp.services.SearchAutoCompletionItemProvider import org.jetbrains.plugins.template.weatherApp.ui.components.SearchToolbarMenu import org.jetbrains.plugins.template.weatherApp.ui.components.WeatherDetailsCard @@ -119,6 +127,7 @@ private fun EmptyListPlaceholder( } } +@OptIn(ExperimentalComposeUiApi::class) @Composable private fun MyLocationList( myLocationsUIState: LocationsUIState, @@ -158,9 +167,66 @@ private fun MyLocationList( itemsIndexed( items = myLocationsUIState.locations, key = { _, item -> item.label }, - ) { index, item -> + ) { index, locationItem -> - SimpleListItem(text = item.label, isSelected = myLocationsUIState.selectedIndex == index, isActive = isActive) + Box(Modifier.wrapContentSize()) { + val showPopup = remember { mutableStateOf(false) } + val popupPosition = remember { mutableStateOf(IntOffset.Zero) } + val itemPosition = remember { mutableStateOf(Offset.Zero) } + + SimpleListItem( + text = locationItem.label, + isSelected = myLocationsUIState.selectedIndex == index, + isActive = isActive, + modifier = Modifier + .onGloballyPositioned { coordinates -> + itemPosition.value = coordinates.positionInWindow() + } + .onPointerEvent(PointerEventType.Press) { pointerEvent -> + if (!pointerEvent.buttons.isSecondaryPressed) return@onPointerEvent + + // Calculate exact click position + val clickOffset = pointerEvent.changes.first().position + popupPosition.value = IntOffset( + x = (itemPosition.value.x + clickOffset.x).toInt(), + y = (itemPosition.value.y + clickOffset.y).toInt() + ) + + showPopup.value = true + } + ) + + if (showPopup.value) { + val popupPositionProvider = remember(popupPosition.value) { + object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize + ): IntOffset = popupPosition.value + } + } + + ContextPopupMenu( + popupPositionProvider, + onDismissRequest = { + showPopup.value = false + popupPosition.value = IntOffset.Zero + itemPosition.value = Offset.Zero + } + ) { + ContextPopupMenuItem( + ComposeTemplateBundle.getMessage("weather.app.context.menu.delete.option"), + AllIconsKeys.General.Delete + ) { + showPopup.value = false + + myLocationsViewModelApi.onDeleteLocation(locationItem) + } + } + } + } } } } diff --git a/src/main/resources/messages/ComposeTemplate.properties b/src/main/resources/messages/ComposeTemplate.properties index 9ec96a2..ef40e3b 100644 --- a/src/main/resources/messages/ComposeTemplate.properties +++ b/src/main/resources/messages/ComposeTemplate.properties @@ -9,4 +9,6 @@ weather.app.search.toolbar.menu.add.button.text=Add weather.app.search.toolbar.menu.add.button.content.description=Add a place to a watch list. weather.app.7days.forecast.title.text=7-day Forecast -weather.app.clear.button.content.description=Clear \ No newline at end of file +weather.app.context.menu.delete.option=Delete + +weather.app.clear.button.content.description=Clear From 8709e7cf8a15162729b34b5aad42f028f4116630 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 27 Aug 2025 14:25:04 +0200 Subject: [PATCH 2/4] Configure opt-in androidx.compose.foundation.ExperimentalFoundationApi compiler option on a module level --- build.gradle.kts | 9 +++++++++ .../plugins/template/components/ContextPopupMenu.kt | 2 -- .../weatherApp/ui/components/WeatherDetailsCard.kt | 2 -- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index b9e3bf8..f8e5550 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ import org.jetbrains.changelog.Changelog import org.jetbrains.changelog.markdownToHTML import org.jetbrains.intellij.platform.gradle.TestFrameworkType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { id("java") // Java support @@ -15,6 +16,14 @@ version = providers.gradleProperty("pluginVersion").get() kotlin { jvmToolchain(21) + + compilerOptions { + freeCompilerArgs.addAll( + listOf( + "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi" + ) + ) + } } repositories { diff --git a/src/main/kotlin/org/jetbrains/plugins/template/components/ContextPopupMenu.kt b/src/main/kotlin/org/jetbrains/plugins/template/components/ContextPopupMenu.kt index 0bf0bf6..ab92237 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/components/ContextPopupMenu.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/components/ContextPopupMenu.kt @@ -1,6 +1,5 @@ package org.jetbrains.plugins.template.components -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.* import androidx.compose.foundation.onClick import androidx.compose.runtime.Composable @@ -30,7 +29,6 @@ fun ContextPopupMenu( } } -@OptIn(ExperimentalFoundationApi::class) @Composable fun ContextPopupMenuItem( actionText: String, diff --git a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/WeatherDetailsCard.kt b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/WeatherDetailsCard.kt index e790892..88b9272 100644 --- a/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/WeatherDetailsCard.kt +++ b/src/main/kotlin/org/jetbrains/plugins/template/weatherApp/ui/components/WeatherDetailsCard.kt @@ -1,7 +1,6 @@ package org.jetbrains.plugins.template.weatherApp.ui.components import androidx.compose.animation.core.* -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.* @@ -47,7 +46,6 @@ import java.util.* * @param onReloadWeatherData Callback invoked to reload weather data when the refresh action is triggered. It * provides the location for which the weather data should be fetched. */ -@OptIn(ExperimentalFoundationApi::class) @Composable fun WeatherDetailsCard( modifier: Modifier = Modifier, From d6bec6e21cd9f620d0bbf8fcefa74f4cc2e68896 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Wed, 27 Aug 2025 14:49:13 +0200 Subject: [PATCH 3/4] Add UI test to verify adding and removing locations in WeatherApp --- .../weatherApp/ui/WeatherAppSampleUiTest.kt | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 src/test/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSampleUiTest.kt diff --git a/src/test/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSampleUiTest.kt b/src/test/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSampleUiTest.kt new file mode 100644 index 0000000..a3f0352 --- /dev/null +++ b/src/test/kotlin/org/jetbrains/plugins/template/weatherApp/ui/WeatherAppSampleUiTest.kt @@ -0,0 +1,144 @@ +package org.jetbrains.plugins.template.weatherApp.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.ComposeTestRule +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.jetbrains.plugins.template.ComposeBasedTestCase +import org.jetbrains.plugins.template.weatherApp.model.Location +import org.jetbrains.plugins.template.weatherApp.services.SearchAutoCompletionItemProvider +import org.junit.Test + +internal class WeatherAppSampleUiTest : ComposeBasedTestCase() { + + private val fakeSearchProvider = FakeSearchProvider() + private val fakeMyLocationsViewModel = FakeMyLocationsViewModel() + private val fakeWeatherViewModel = FakeWeatherViewModel() + + override val contentUnderTest: @Composable () -> Unit = { + WeatherAppSample( + myLocationViewModel = fakeMyLocationsViewModel, + weatherViewModelApi = fakeWeatherViewModel, + searchAutoCompletionItemProvider = fakeSearchProvider + ) + } + + @Test + fun `add location via search UI then remove it`() = runComposeTest { + val robot = WeatherSampleRobot(this) + + // Add a location via UI: type, select autocomplete, click Add + robot.focusAndTypeInSearchField("Mun") + robot.waitForAutocomplete("Munich, Germany") + robot.clickOnAutocompleteItem("Munich, Germany") + robot.clickAddButton() + + // Verify the item appears selected in My Locations + robot.verifyListItemWithTextIsSelected("Munich, Germany") + + // Remove the item via UI: open context menu with primary click (test mode) and click Delete + robot.rightClickOnListItem("Munich, Germany") + robot.clickDeleteInContextMenu() + + // Verify empty placeholder is shown again + robot.verifyNoLocationsPlaceHolderVisible() + } + + private class WeatherSampleRobot(private val rule: ComposeTestRule) { + fun idle() = rule.waitForIdle() + + fun focusAndTypeInSearchField(text: String) { + val field = rule.onNode(hasSetTextAction()) + field.performClick() + field.performTextInput(text) + } + + fun waitForAutocomplete(itemLabel: String) { + rule.waitUntil(timeoutMillis = 100) { + rule.onAllNodesWithText(itemLabel).fetchSemanticsNodes().isNotEmpty() + } + } + + fun clickOnAutocompleteItem(itemLabel: String) { + rule.onNodeWithText(itemLabel).performClick() + } + + fun clickAddButton() { + rule.onNodeWithText("Add").performClick() + } + + fun rightClickOnListItem(text: String) { + rule.onNodeWithText(text) + .assertExists("No node found with text: $text") + .performMouseInput { rightClick() } + } + + fun clickDeleteInContextMenu() { + rule.onNodeWithText("Delete").performClick() + } + + fun verifyListItemWithTextIsSelected(text: String) { + rule.onNodeWithText(text).assertIsSelected() + } + + fun verifyNoLocationsPlaceHolderVisible() { + rule.onNodeWithText("No locations added yet. Go and add the first location.").assertExists() + } + } + + private class FakeSearchProvider : SearchAutoCompletionItemProvider { + override fun provideSearchableItems(searchTerm: String): List { + if (searchTerm.isBlank()) return emptyList() + // Provide a small fixed set that includes Munich and others regardless of query for simplicity + return listOf( + Location("Munich", "Germany"), + Location("Berlin", "Germany"), + Location("Paris", "France"), + ).filter { it.label.contains(searchTerm, ignoreCase = true) } + } + } + + private class FakeMyLocationsViewModel : MyLocationsViewModelApi { + private val _state = MutableStateFlow(LocationsUIState.empty()) + + override val myLocationsUIStateFlow: Flow + get() = _state.asStateFlow() + + override fun onAddLocation(locationToAdd: Location) { + _state.value = _state.value.withLocationAdded(locationToAdd) + } + + override fun onDeleteLocation(locationToDelete: Location) { + _state.value = _state.value.withLocationDeleted(locationToDelete) + } + + override fun onLocationSelected(selectedLocationIndex: Int) { + _state.value = _state.value.withItemAtIndexSelected(selectedLocationIndex) + } + + override fun dispose() { + // no-op for tests + } + } + + private class FakeWeatherViewModel : WeatherViewModelApi { + private val _weatherState = MutableStateFlow(WeatherForecastUIState.Empty) + + override val weatherForecastUIState: Flow + get() = _weatherState.asStateFlow() + + override fun onLoadWeatherForecast(location: Location) { + // no-op + } + + override fun onReloadWeatherForecast() { + // no-op + } + + override fun dispose() { + // no-op + } + } +} From f81055ec9f832cf53d4948a29f4713f089d7ffe4 Mon Sep 17 00:00:00 2001 From: Nebojsa Vuksic Date: Thu, 28 Aug 2025 16:22:54 +0200 Subject: [PATCH 4/4] Add `skikoAwtRuntimeAll` dependency for testing --- build.gradle.kts | 3 +++ gradle/libs.versions.toml | 11 +++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index f8e5550..2e0afc7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,6 +44,9 @@ dependencies { testImplementation(libs.hamcrest) testImplementation(libs.composeuitest) testImplementation(libs.jewelstandalone) + // Workaround for running tests on Windows and Linux + // It provides necessary Skiko runtime native binaries + testImplementation(libs.skikoAwtRuntimeAll) intellijPlatform { create(providers.gradleProperty("platformType"), providers.gradleProperty("platformVersion")) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 51238e0..b242bbe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,8 +4,9 @@ junit = "4.13.2" opentest4j = "1.3.0" hamcrest = "2.2" # Has to be in sync with IntelliJ Platform -composeuitest="1.8.0-alpha04" -jewelstandalone="0.29.0-251.27828" +composeuitest = "1.8.0-alpha04" +jewelstandalone = "0.29.0-251.27828" +skikoAwtRuntimeAll = "0.9.22" # plugins changelog = "2.2.1" @@ -16,8 +17,10 @@ kotlin = "2.1.20" junit = { group = "junit", name = "junit", version.ref = "junit" } opentest4j = { group = "org.opentest4j", name = "opentest4j", version.ref = "opentest4j" } hamcrest = { group = "org.hamcrest", name = "hamcrest", version.ref = "hamcrest" } -composeuitest = { group = "org.jetbrains.compose.ui", name ="ui-test-junit4-desktop", version.ref="composeuitest" } -jewelstandalone = { group = "org.jetbrains.jewel", name ="jewel-int-ui-standalone", version.ref="jewelstandalone" } +composeuitest = { group = "org.jetbrains.compose.ui", name = "ui-test-junit4-desktop", version.ref = "composeuitest" } +jewelstandalone = { group = "org.jetbrains.jewel", name = "jewel-int-ui-standalone", version.ref = "jewelstandalone" } +skikoAwtRuntimeAll = { group = "org.jetbrains.skiko", name = "skiko-awt-runtime-all", version.ref = "skikoAwtRuntimeAll" } + [plugins] changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" }