diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 449cc79c2b..ea3c300d76 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -263,7 +263,6 @@ fun main() { var showAppDrawer by remember { mutableStateOf(false) } var showAddColumnDialog by remember { mutableStateOf(false) } var showImportFollowListDialog by remember { mutableStateOf(false) } - var showSearchSpotlight by remember { mutableStateOf(false) } // Tor state at Window level — survives key() app rebuild var torSettings by remember { @@ -452,7 +451,7 @@ fun main() { } else { KeyShortcut(Key.F, ctrl = true) }, - onClick = { showSearchSpotlight = true }, + onClick = { navigateToScreen?.invoke(DeckColumnType.Search) }, ) Item( "App Drawer", @@ -629,9 +628,6 @@ fun main() { accountManager = accountManager, showComposeDialog = showComposeDialog, showAppDrawer = showAppDrawer, - showSearchSpotlight = showSearchSpotlight, - onShowSearchSpotlight = { showSearchSpotlight = true }, - onDismissSearchSpotlight = { showSearchSpotlight = false }, onShowComposeDialog = { showComposeDialog = true }, onShowReplyDialog = { event -> replyToNote = event @@ -669,9 +665,6 @@ fun App( accountManager: AccountManager, showComposeDialog: Boolean, showAppDrawer: Boolean, - showSearchSpotlight: Boolean, - onShowSearchSpotlight: () -> Unit, - onDismissSearchSpotlight: () -> Unit, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onDismissComposeDialog: () -> Unit, @@ -1076,61 +1069,6 @@ fun App( ) } - // Search Spotlight overlay - if (showSearchSpotlight) { - com.vitorpamplona.amethyst.desktop.ui.search.SearchSpotlight( - onSelectProfile = { pubkey -> - onDismissSearchSpotlight() - when (layoutMode) { - LayoutMode.DECK -> { - deckState.addColumn(DeckColumnType.Profile(pubkey)) - } - LayoutMode.SINGLE_PANE -> { - singlePaneState.navigate(DeckColumnType.MyProfile) - } - } - }, - onSelectNote = { noteId -> - onDismissSearchSpotlight() - when (layoutMode) { - LayoutMode.DECK -> { - deckState.addColumn(DeckColumnType.Thread(noteId)) - } - LayoutMode.SINGLE_PANE -> { - singlePaneState.navigate(DeckColumnType.HomeFeed) - } - } - }, - onSelectHashtag = { tag -> - onDismissSearchSpotlight() - when (layoutMode) { - LayoutMode.DECK -> { - deckState.addColumn(DeckColumnType.Hashtag(tag)) - } - LayoutMode.SINGLE_PANE -> { - singlePaneState.navigate(DeckColumnType.Search) - } - } - }, - onOpenFullSearch = { queryText -> - onDismissSearchSpotlight() - when (layoutMode) { - LayoutMode.DECK -> { - if (deckState.hasColumnOfType(DeckColumnType.Search)) { - deckState.focusExistingColumn(DeckColumnType.Search) - } else { - deckState.addColumn(DeckColumnType.Search) - } - } - LayoutMode.SINGLE_PANE -> { - singlePaneState.navigate(DeckColumnType.Search) - } - } - }, - onDismiss = { onDismissSearchSpotlight() }, - ) - } - // App Drawer overlay if (showAppDrawer) { val openColumns by deckState.columns.collectAsState() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 4973b8b34b..f6463b4b06 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.TooltipArea import androidx.compose.foundation.layout.Arrangement @@ -33,7 +34,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog @@ -760,69 +760,60 @@ private fun FeedTabsHeader( val pinnedFeeds by feedRepo.pinnedFeeds.collectAsState() val sidePadding = LocalReadingSidePadding.current - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = sidePadding + 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding + 8.dp, vertical = 8.dp), ) { - // Pinned feed tabs - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + // Compact feed tabs pinnedFeeds.forEach { feed -> val isSelected = when (feed.source) { - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> { + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> feedMode == FeedMode.FOLLOWING - } - - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> { + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> feedMode == FeedMode.GLOBAL - } - - else -> { - activeFeedId == feed.id - } + else -> activeFeedId == feed.id } FilterChip( selected = isSelected, onClick = { when (feed.source) { - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> { + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Following -> onFeedModeChange(FeedMode.FOLLOWING) - } - - is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> { + is com.vitorpamplona.amethyst.commons.feeds.custom.FeedSource.Global -> onFeedModeChange(FeedMode.GLOBAL) - } - - else -> { - onNavigateToFeed(feed) - } + else -> onNavigateToFeed(feed) } }, - label = { Text("${feed.emoji} ${feed.name}") }, + label = { + Text( + "${feed.emoji} ${feed.name}", + maxLines = 1, + style = MaterialTheme.typography.labelMedium, + ) + }, ) } - // "Show More +" button - FilterChip( - selected = false, - onClick = onOpenFeedsDrawer, - label = { Text("+ More") }, - ) - } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + // Search pill — takes remaining center space com.vitorpamplona.amethyst.desktop.ui.search.SearchPill( onClick = onSearchClick, - modifier = Modifier.width(180.dp), + modifier = Modifier.weight(1f), ) - IconButton(onClick = onCompose) { + + // Compose button + IconButton(onClick = onCompose, modifier = Modifier.size(32.dp)) { Icon( MaterialSymbols.Edit, contentDescription = "Compose", - modifier = Modifier.size(20.dp), + modifier = Modifier.size(18.dp), ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt index 7c2a5ee463..fab04eb156 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt @@ -20,60 +20,283 @@ */ package com.vitorpamplona.amethyst.desktop.ui.search +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.HorizontalDivider 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.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.search.QuerySerializer +import com.vitorpamplona.amethyst.desktop.SearchHistoryStore import com.vitorpamplona.amethyst.desktop.platform.PlatformInfo import com.vitorpamplona.amethyst.desktop.ui.theme.hoverHighlight +/** + * Search pill that expands inline into a text input with history dropdown. + * Collapsed: clickable pill with search icon + shortcut hint. + * Expanded: BasicTextField in same pill shape + dropdown below with recent/saved searches. + */ @Composable fun SearchPill( onClick: () -> Unit, modifier: Modifier = Modifier, ) { val shortcutHint = if (PlatformInfo.isMacOS) "\u2318F" else "Ctrl+F" + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + var expanded by remember { mutableStateOf(false) } + var textFieldValue by remember { mutableStateOf(TextFieldValue("")) } + + val history by SearchHistoryStore.history.collectAsState() + val savedSearches by SearchHistoryStore.savedSearches.collectAsState() + Surface( - onClick = onClick, shape = RoundedCornerShape(999.dp), color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = modifier.height(36.dp).hoverHighlight(), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp), - ) { - Icon( - MaterialSymbols.Search, - contentDescription = "Search", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(8.dp)) - Text( - "Search...", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.weight(1f)) - Spacer(Modifier.width(8.dp)) - Text( - shortcutHint, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) + Box { + if (expanded) { + // Expanded: inline text input + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp), + ) { + Icon( + MaterialSymbols.Search, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + BasicTextField( + value = textFieldValue, + onValueChange = { textFieldValue = it }, + modifier = + Modifier + .weight(1f) + .focusRequester(focusRequester) + .onFocusChanged { state -> + if (!state.isFocused && expanded) { + expanded = false + textFieldValue = TextFieldValue("") + } + }.onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) { + expanded = false + textFieldValue = TextFieldValue("") + focusManager.clearFocus() + true + } else { + false + } + }, + textStyle = + MaterialTheme.typography.labelMedium.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + singleLine = true, + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (textFieldValue.text.isEmpty()) { + Text( + "Search notes, profiles...", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + innerTextField() + } + }, + ) + Spacer(Modifier.width(8.dp)) + Text( + shortcutHint, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + + // Dropdown with history + DropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + textFieldValue = TextFieldValue("") + }, + ) { + if (history.isNotEmpty()) { + Text( + "Recent", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + history.take(5).forEach { query -> + val text = QuerySerializer.serialize(query) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { + textFieldValue = TextFieldValue(text, TextRange(text.length)) + onClick() + }.hoverHighlight() + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Icon( + MaterialSymbols.History, + null, + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Text(text, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } + } + } + + if (savedSearches.isNotEmpty()) { + if (history.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + Text( + "Saved", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + savedSearches.take(5).forEach { saved -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { onClick() } + .hoverHighlight() + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Icon( + MaterialSymbols.Bookmark, + null, + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(8.dp)) + Text(saved.label, style = MaterialTheme.typography.bodySmall, maxLines = 1) + } + } + } + + if (history.isEmpty() && savedSearches.isEmpty()) { + Text( + "Type to search", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(12.dp), + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { onClick() } + .hoverHighlight() + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Icon( + MaterialSymbols.AutoMirrored.OpenInNew, + null, + Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Open full search", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } else { + // Collapsed: clickable pill + Surface( + onClick = { + expanded = true + }, + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier.matchParentSize(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 12.dp), + ) { + Icon( + MaterialSymbols.Search, + contentDescription = "Search", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Text( + "Search...", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.weight(1f)) + Spacer(Modifier.width(8.dp)) + Text( + shortcutHint, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + } + } + } + + if (expanded) { + LaunchedEffect(Unit) { + focusRequester.requestFocus() } } }