From b7cd0bdb75bdb16bb05d7799537d5b861fa8446f Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Sun, 24 May 2026 12:33:53 +0530 Subject: [PATCH 1/3] multi patch source minor error fix --- src/main/kotlin/app/morphe/gui/App.kt | 13 +++++ .../gui/data/repository/PatchSourceManager.kt | 25 ++++++++++ .../ui/components/SourceManagementSheet.kt | 36 +++++++++++--- .../gui/ui/screens/home/HomeViewModel.kt | 35 ++++++++++++-- .../ui/screens/quick/QuickPatchViewModel.kt | 33 +++++++++++-- .../morphe/gui/util/EnabledSourcesLoader.kt | 20 ++++++-- .../morphe/gui/util/PatchLoadErrorMessage.kt | 47 +++++++++++++++++++ 7 files changed, 192 insertions(+), 17 deletions(-) create mode 100644 src/main/kotlin/app/morphe/gui/util/PatchLoadErrorMessage.kt diff --git a/src/main/kotlin/app/morphe/gui/App.kt b/src/main/kotlin/app/morphe/gui/App.kt index 4ccd9d25..e28ddf51 100644 --- a/src/main/kotlin/app/morphe/gui/App.kt +++ b/src/main/kotlin/app/morphe/gui/App.kt @@ -22,6 +22,7 @@ import app.morphe.gui.ui.components.SakuraPetals import app.morphe.gui.util.applyTitleBarTint import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.transitions.SlideTransition +import app.morphe.gui.data.repository.ActiveMode import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchSourceManager import app.morphe.gui.di.appModule @@ -84,6 +85,11 @@ private fun AppContent( val config = configRepository.loadConfig() themePreference = config.getThemePreference() isSimplifiedMode = config.useSimplifiedMode + // Publish the initial active mode BEFORE the VMs subscribe so their + // activeMode listener fires with the correct value on first emit. + patchSourceManager.setActiveMode( + if (isSimplifiedMode) ActiveMode.QUICK else ActiveMode.EXPERT + ) isLoading = false } @@ -99,6 +105,13 @@ private fun AppContent( // Callback for changing mode val onModeChange: (Boolean) -> Unit = { simplified -> isSimplifiedMode = simplified + // Update the manager immediately so the now-visible mode's VM + // starts reacting to source changes and the now-hidden one stops — + // prevents duplicate parallel loads and the cancellation cascade + // that comes with them. + patchSourceManager.setActiveMode( + if (simplified) ActiveMode.QUICK else ActiveMode.EXPERT + ) scope.launch { configRepository.setUseSimplifiedMode(simplified) Logger.info("Mode changed to: ${if (simplified) "Simplified" else "Full"}") diff --git a/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt b/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt index 64e2e5d5..496c62a4 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt @@ -15,6 +15,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +/** + * Which top-level UI mode is currently visible. Used by [PatchSourceManager] + * to gate per-VM patch loading so only the visible mode's VM does the work. + */ +enum class ActiveMode { QUICK, EXPERT } + /** * Manages PatchRepository instances for different patch sources. * Creates and caches a PatchRepository per GitHub-based source. @@ -47,6 +53,25 @@ class PatchSourceManager( private val _allSources = MutableStateFlow>(emptyList()) val allSources: StateFlow> = _allSources.asStateFlow() + /** + * Which mode's ViewModel is currently driving the UI. Used by both + * [HomeViewModel] (EXPERT) and [QuickPatchViewModel] (QUICK) to skip + * patch-loading when they're not visible — both VMs can be alive + * simultaneously (QuickVM is `remember`-scoped to App.kt; HomeVM is + * created by Voyager when the Navigator branch composes), and without + * this gate they'd race to download the same sources twice on every + * cache clear / source toggle. + */ + private val _activeMode = MutableStateFlow(ActiveMode.QUICK) + val activeMode: StateFlow = _activeMode.asStateFlow() + + fun setActiveMode(mode: ActiveMode) { + if (_activeMode.value != mode) { + Logger.info("PatchSourceManager: active mode → $mode") + _activeMode.value = mode + } + } + /** * Load the active source from config and cache its PatchRepository. * Call once at app startup (from a LaunchedEffect). diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt b/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt index 2e144e17..759275d7 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt @@ -6,6 +6,7 @@ package app.morphe.gui.ui.components import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -14,6 +15,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -106,12 +108,22 @@ fun SourceManagementSheet( ) }, text = { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .widthIn(min = 360.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { + // Hoisted so the scrollbar can share the same state as the + // scrolling Column. The scrollbar renders only when the + // content actually overflows (maxValue > 0) — keeps the + // dialog clean for the common case of a handful of sources. + val scrollState = rememberScrollState() + Box { + Column( + modifier = Modifier + .verticalScroll(scrollState) + .widthIn(min = 360.dp) + // Reserve space so rows don't get covered by the + // scrollbar when it appears, plus a bit of breathing + // room so the scrollbar isn't flush against the rows. + .padding(end = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { Text( text = when { !enabled -> "Disabled while patching" @@ -170,6 +182,18 @@ fun SourceManagementSheet( letterSpacing = 0.5.sp ) } + } + + if (scrollState.maxValue > 0) { + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .padding(vertical = 4.dp), + adapter = rememberScrollbarAdapter(scrollState), + style = morpheScrollbarStyle() + ) + } } }, confirmButton = { diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt index 864a8269..3ee85b97 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import app.morphe.engine.util.ApkManifestReader @@ -29,6 +30,8 @@ import app.morphe.gui.util.Logger import app.morphe.gui.util.PatchService import app.morphe.gui.util.SupportedAppExtractor import app.morphe.gui.util.VersionStatus +import app.morphe.gui.data.repository.ActiveMode +import app.morphe.gui.util.humanizePatchLoadError import java.io.File class HomeViewModel( @@ -68,9 +71,6 @@ class HomeViewModel( ?: emptyList() init { - // Auto-fetch patches on startup - loadPatchesAndSupportedApps() - // Background CLI update check — non-blocking, banner only. screenModelScope.launch { val config = configRepository.loadConfig() @@ -85,9 +85,30 @@ class HomeViewModel( ) } + // Load patches whenever EXPERT becomes the active mode. StateFlow + // emits its current value on subscribe, so this also covers the + // "VM was just created while EXPERT is active" case — replaces the + // unconditional init-block load that used to fire even when the + // user was actually in Quick mode (we don't construct HomeVM in + // pure Quick sessions today, but Voyager keeps it alive across + // mode switches, so the gate prevents wasted reloads on return). + screenModelScope.launch { + patchSourceManager.activeMode.collect { mode -> + if (mode == ActiveMode.EXPERT) { + loadPatchesAndSupportedApps() + } + } + } + // Observe source changes — drop(1) to skip the initial value screenModelScope.launch { patchSourceManager.sourceVersion.drop(1).collect { + // Skip when Quick mode is active — QuickPatchViewModel will + // handle the reload for its (single) active source. Without + // this gate both VMs fire parallel loads on every cache + // clear, doubling network traffic and tripling the + // cancellation cascade surface on slow connections. + if (patchSourceManager.activeMode.value != ActiveMode.EXPERT) return@collect Logger.info("HomeVM: Source changed, reloading patches...") patchRepository = patchSourceManager.getActiveRepositorySync() localPatchFilePath = patchSourceManager.getLocalFilePath() @@ -250,11 +271,17 @@ class HomeViewModel( patchLoadError = null ) reanalyzeSelectedApk() + } catch (e: CancellationException) { + // Cancellation is normal coroutine bookkeeping (a newer load + // superseded this one, or the screen left composition). Do NOT + // write UI state — otherwise a stale "Job was cancelled" can + // clobber the in-flight successor's loading/success state. + throw e } catch (e: Exception) { Logger.error("Failed to load patches and supported apps", e) _uiState.value = _uiState.value.copy( isLoadingPatches = false, - patchLoadError = e.message ?: "Unknown error" + patchLoadError = humanizePatchLoadError(e), ) } } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt index fd8855d7..cb076971 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt @@ -17,6 +17,7 @@ import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchRepository import app.morphe.gui.data.repository.PatchSourceManager import app.morphe.gui.data.repository.UpdateCheckRepository +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -31,6 +32,8 @@ import app.morphe.gui.util.Logger import app.morphe.gui.util.PatchService import app.morphe.gui.util.SupportedAppExtractor import app.morphe.gui.util.VersionStatus +import app.morphe.gui.util.humanizePatchLoadError +import app.morphe.gui.data.repository.ActiveMode import java.io.File /** @@ -73,9 +76,6 @@ class QuickPatchViewModel( private var cachedSourcesResult: EnabledSourcesLoader.Result? = null init { - // Load patches on startup to get dynamic app info - loadPatchesAndSupportedApps() - // Background CLI update check — non-blocking, banner only. screenModelScope.launch { val info = updateCheckRepository.getUpdateInfo() @@ -86,9 +86,29 @@ class QuickPatchViewModel( ) } + // Load patches whenever QUICK becomes the active mode. StateFlow + // replays the current value on subscribe, so this covers the + // "VM was just constructed while QUICK is active" case (replacing + // the old unconditional init-block load) AND the "user switched + // back to Quick after being in Expert" case. + screenModelScope.launch { + patchSourceManager.activeMode.collect { mode -> + if (mode == ActiveMode.QUICK) { + loadPatchesAndSupportedApps() + } + } + } + // Observe source changes screenModelScope.launch { patchSourceManager.sourceVersion.drop(1).collect { + // Skip when Expert mode is active — HomeViewModel will handle + // the multi-source reload. QuickVM still lives in memory + // (it's `remember`-scoped to App.kt) but staying silent here + // halves the parallel HTTP traffic and removes the duplicate + // request for the active source that BOTH VMs would otherwise + // fire simultaneously. + if (patchSourceManager.activeMode.value != ActiveMode.QUICK) return@collect Logger.info("QuickVM: Source changed, reloading patches...") patchRepository = patchSourceManager.getActiveRepositorySync() localPatchFilePath = patchSourceManager.getLocalFilePath() @@ -213,11 +233,16 @@ class QuickPatchViewModel( patchLoadError = null, isOffline = isOffline ) + } catch (e: CancellationException) { + // See HomeViewModel for the rationale: never overwrite UI + // state from a cancelled load — the cancellation race would + // clobber a successor's progress with a stale error. + throw e } catch (e: Exception) { Logger.error("Quick mode: Failed to load patches", e) _uiState.value = _uiState.value.copy( isLoadingPatches = false, - patchLoadError = "Failed to load patches: ${e.message}" + patchLoadError = humanizePatchLoadError(e), ) } } diff --git a/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt b/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt index a85e7fa0..8364a712 100644 --- a/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt +++ b/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt @@ -9,10 +9,11 @@ import app.morphe.engine.MultiSourceLoader import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.PatchSourceType import app.morphe.gui.data.repository.PatchRepository +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import java.io.File @@ -72,9 +73,22 @@ object EnabledSourcesLoader { enabled: List>, patchService: PatchService, preferredVersionsBySource: Map = emptyMap(), - ): Result = coroutineScope { + ): Result = supervisorScope { + // supervisorScope (not coroutineScope) so a single source's failure + // doesn't cancel the other in-flight resolves. Each async catches its + // own exceptions and returns a failed ResolvedSource — failures + // become data, not control flow. Cancellation still propagates from + // the caller (e.g. ViewModel cancelling its loadJob). val resolved = enabled.map { (source, repo) -> - async(Dispatchers.IO) { resolve(source, repo, preferredVersionsBySource[source.id]) } + async(Dispatchers.IO) { + try { + resolve(source, repo, preferredVersionsBySource[source.id]) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + ResolvedSource(source = source, error = e.message ?: e.javaClass.simpleName) + } + } }.awaitAll() val inputs = resolved.mapNotNull { res -> diff --git a/src/main/kotlin/app/morphe/gui/util/PatchLoadErrorMessage.kt b/src/main/kotlin/app/morphe/gui/util/PatchLoadErrorMessage.kt new file mode 100644 index 00000000..551b5a8e --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/PatchLoadErrorMessage.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.util + +import io.ktor.client.network.sockets.ConnectTimeoutException +import io.ktor.client.network.sockets.SocketTimeoutException +import io.ktor.client.plugins.HttpRequestTimeoutException +import java.io.IOException +import java.net.UnknownHostException + +/** + * Map a load failure exception to a short, user-readable line. + * + * The raw `Exception.message` is hostile when the underlying cause is a + * coroutine/Ktor internal — users see "StandaloneCoroutine was cancelled" + * and assume the app crashed. This translates the common network/IO failures + * into plain English and falls back to the original message for anything we + * don't recognize. + * + * Intentionally does NOT handle CancellationException — that should never + * reach the UI; callers must re-throw it from their catch blocks instead of + * surfacing it as an error. + */ +fun humanizePatchLoadError(e: Throwable): String = when (e) { + is HttpRequestTimeoutException, + is SocketTimeoutException, + is ConnectTimeoutException -> "Network timeout — check your connection and try again" + + is UnknownHostException -> "Couldn't reach the patch server — check your connection" + + is IOException -> { + val msg = e.message.orEmpty() + when { + msg.contains("rate limit", ignoreCase = true) -> + "GitHub rate limit hit — wait a few minutes and try again" + msg.contains("connection reset", ignoreCase = true) || + msg.contains("connection closed", ignoreCase = true) -> + "Connection dropped while downloading — try again" + else -> msg.ifBlank { "Network error while loading patches" } + } + } + + else -> e.message?.takeIf { it.isNotBlank() } ?: "Could not load patches" +} From f4a868d54e08a9e8711aa0a146c8f61a3338fce1 Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Mon, 25 May 2026 11:59:54 +0530 Subject: [PATCH 2/3] small scrolling fix changing .clickable to .pointerInput, hopefully this should fix this issue? --- .../gui/ui/screens/quick/QuickPatchScreen.kt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index 40699f36..0c47df95 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -43,7 +43,9 @@ import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.SupportedApp import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchSourceManager +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput import app.morphe.gui.ui.components.MorpheErrorBar import app.morphe.gui.ui.components.OfflineBanner import app.morphe.gui.ui.components.SourceManagementSheet @@ -1692,10 +1694,16 @@ private fun SupportedAppsRow( .fillMaxWidth() .then(if (useScrolling) Modifier.horizontalScroll(cardsScrollState) else Modifier) .height(IntrinsicSize.Max) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { focusManager.clearFocus() }, + // detectTapGestures (not .clickable) so scroll-wheel / + // two-finger gestures over this Row aren't swallowed. + // .clickable wraps the modifier chain in a pointer-input + // node that consumes scroll events on Linux/Skiko, + // breaking both the inner horizontalScroll and the + // outer page-level verticalScroll. Taps still clear + // the search-bar focus. + .pointerInput(Unit) { + detectTapGestures(onTap = { focusManager.clearFocus() }) + }, horizontalArrangement = Arrangement.spacedBy(10.dp) ) { filteredApps.forEach { app -> From c0a257bd526982851a7761f89a5ae04440f7402a Mon Sep 17 00:00:00 2001 From: Prateek <129204458+prateek-who@users.noreply.github.com> Date: Mon, 25 May 2026 15:03:20 +0530 Subject: [PATCH 3/3] all supported versions now have clickable links --- .../home/components/SupportedAppListRow.kt | 77 ++++++++++++++++--- 1 file changed, 65 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt index e1e9dab6..e3ee6afe 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt @@ -15,6 +15,7 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -34,6 +35,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -294,6 +296,7 @@ private fun ExpandedBody( // "Other stable" = supported versions other than the recommended latest. val otherStable = app.supportedVersions.filter { it != app.recommendedVersion } val maxPills = 16 + val uriHandler = LocalUriHandler.current Column( modifier = Modifier @@ -332,7 +335,16 @@ private fun ExpandedBody( verticalArrangement = Arrangement.spacedBy(4.dp), ) { otherStable.take(maxPills).forEach { v -> - Pill(text = v, color = accents.secondary, mono = mono, cornerSmall = cornerSmall) + // URL is a pure function of package + version — compute + // per pill rather than pre-storing all of them on the model. + val url = remember(v) { SupportedApp.getDownloadUrl(app.packageName, v) } + Pill( + text = v, + color = accents.secondary, + mono = mono, + cornerSmall = cornerSmall, + onClick = url?.let { { uriHandler.openUri(it) } }, + ) } if (otherStable.size > maxPills) { Text( @@ -353,7 +365,14 @@ private fun ExpandedBody( verticalArrangement = Arrangement.spacedBy(4.dp), ) { app.experimentalVersions.take(maxPills).forEach { v -> - Pill(text = v, color = accents.warning, mono = mono, cornerSmall = cornerSmall) + val url = remember(v) { SupportedApp.getDownloadUrl(app.packageName, v) } + Pill( + text = v, + color = accents.warning, + mono = mono, + cornerSmall = cornerSmall, + onClick = url?.let { { uriHandler.openUri(it) } }, + ) } if (app.experimentalVersions.size > maxPills) { Text( @@ -394,20 +413,54 @@ private fun Pill( textColor: Color = color, borderAlpha: Float = 0.3f, backgroundAlpha: Float = 0.06f, + // When non-null, the pill becomes a tappable download link: gets a hand + // cursor, an OpenInNew icon, a subtle hover lift, and fires onClick on tap. + // detectTapGestures (not .clickable) so scroll wheel / two-finger gestures + // pass through on Linux/Skiko — same reason as the apps-cards Row. + onClick: (() -> Unit)? = null, ) { + val hoverSource = remember { MutableInteractionSource() } + val isHovered by hoverSource.collectIsHoveredAsState() + val isInteractive = onClick != null + val hoveredLift = if (isInteractive && isHovered) 0.20f else 0f + val effectiveBorderAlpha = (borderAlpha + hoveredLift).coerceAtMost(0.85f) + val effectiveBackgroundAlpha = (backgroundAlpha + hoveredLift / 2f).coerceAtMost(0.30f) + Box( modifier = Modifier - .border(1.dp, color.copy(alpha = borderAlpha), RoundedCornerShape(cornerSmall)) - .background(color.copy(alpha = backgroundAlpha), RoundedCornerShape(cornerSmall)) + .hoverable(hoverSource) + .then( + if (isInteractive) Modifier + .pointerHoverIcon(PointerIcon.Hand) + .pointerInput(onClick) { + detectTapGestures(onTap = { onClick?.invoke() }) + } + else Modifier + ) + .border(1.dp, color.copy(alpha = effectiveBorderAlpha), RoundedCornerShape(cornerSmall)) + .background(color.copy(alpha = effectiveBackgroundAlpha), RoundedCornerShape(cornerSmall)) .padding(horizontal = 6.dp, vertical = 2.dp), ) { - Text( - text = text, - fontSize = 10.sp, - fontWeight = FontWeight.SemiBold, - fontFamily = mono, - color = textColor, - maxLines = 1, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = text, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = textColor, + maxLines = 1, + ) + if (isInteractive) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = "Open download page", + tint = textColor.copy(alpha = if (isHovered) 0.9f else 0.5f), + modifier = Modifier.size(9.dp), + ) + } + } } }