feat(search): add keyboard shortcuts, search history UI, and operator hints

Phase 6: Integration and polish.
- Escape key closes advanced panel or clears search
- Search history shown in empty state (recent + saved searches)
- Auto-save to history when search completes
- Updated empty state with operator hint examples

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-10 12:15:14 +02:00
parent e1abb037be
commit b3952ce9db
@@ -36,22 +36,29 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.Tag
import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -62,15 +69,22 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
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.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
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 com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState import com.vitorpamplona.amethyst.commons.search.AdvancedSearchBarState
import com.vitorpamplona.amethyst.commons.search.QuerySerializer
import com.vitorpamplona.amethyst.commons.search.SearchResult import com.vitorpamplona.amethyst.commons.search.SearchResult
import com.vitorpamplona.amethyst.commons.search.SearchResultFilter import com.vitorpamplona.amethyst.commons.search.SearchResultFilter
import com.vitorpamplona.amethyst.commons.search.parseSearchInput import com.vitorpamplona.amethyst.commons.search.parseSearchInput
import com.vitorpamplona.amethyst.desktop.SearchHistoryStore
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
@@ -204,12 +218,44 @@ fun SearchScreen(
} }
} }
// Save to history when search completes
LaunchedEffect(isSearching, debouncedQuery) {
if (!isSearching && !debouncedQuery.isEmpty) {
SearchHistoryStore.addToHistory(debouncedQuery)
}
}
// History state
val historyItems by SearchHistoryStore.history.collectAsState()
val savedSearches by SearchHistoryStore.savedSearches.collectAsState()
// Auto-focus // Auto-focus
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.requestFocus() focusRequester.requestFocus()
} }
Column(modifier = modifier.fillMaxSize()) { Column(
modifier =
modifier
.fillMaxSize()
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (event.key) {
Key.Escape -> {
if (panelExpanded) {
state.togglePanel()
} else if (displayText.isNotEmpty()) {
state.clearSearch()
}
true
}
else -> {
false
}
}
},
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -347,40 +393,150 @@ fun SearchScreen(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
} else { } else {
// Empty state with operator hints // Empty state: show history + saved searches + operator hints
EmptySearchHints() SearchEmptyState(
historyItems = historyItems,
savedSearches = savedSearches,
onLoadQuery = { query -> state.updateFromText(QuerySerializer.serialize(query)) },
onDeleteSaved = { id -> SearchHistoryStore.deleteSavedSearch(id) },
onClearHistory = { SearchHistoryStore.clearHistory() },
)
} }
} }
} }
@Composable @Composable
private fun EmptySearchHints() { private fun SearchEmptyState(
Column( historyItems: List<com.vitorpamplona.amethyst.commons.search.SearchQuery>,
modifier = Modifier.fillMaxWidth().padding(top = 32.dp), savedSearches: List<com.vitorpamplona.amethyst.desktop.SavedSearch>,
horizontalAlignment = Alignment.CenterHorizontally, onLoadQuery: (com.vitorpamplona.amethyst.commons.search.SearchQuery) -> Unit,
onDeleteSaved: (String) -> Unit,
onClearHistory: () -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( // Saved searches
"Search for users, notes, or content", if (savedSearches.isNotEmpty()) {
style = MaterialTheme.typography.titleMedium, item {
color = MaterialTheme.colorScheme.onSurfaceVariant, Text(
) "Saved Searches",
Spacer(Modifier.height(8.dp)) style = MaterialTheme.typography.labelMedium,
Text( color = MaterialTheme.colorScheme.onSurfaceVariant,
"Use operators to refine your search:", modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant, )
style = MaterialTheme.typography.bodyMedium, }
) items(savedSearches, key = { "saved-${it.id}" }) { saved ->
Spacer(Modifier.height(16.dp)) Row(
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { modifier =
SearchHint("from:npub1...", "Filter by author") Modifier
SearchHint("kind:article", "Long-form content") .fillMaxWidth()
SearchHint("since:2025-01", "After January 2025") .clickable { onLoadQuery(saved.query) }
SearchHint("#bitcoin", "Hashtag search") .padding(vertical = 6.dp, horizontal = 4.dp),
SearchHint("\"exact phrase\"", "Exact match") verticalAlignment = Alignment.CenterVertically,
SearchHint("bitcoin OR nostr", "Either term") horizontalArrangement = Arrangement.spacedBy(8.dp),
SearchHint("-spam", "Exclude term") ) {
SearchHint("lang:en", "Language filter") Icon(
Icons.Default.Star,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Column(modifier = Modifier.weight(1f)) {
Text(
saved.label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
QuerySerializer.serialize(saved.query),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontFamily = FontFamily.Monospace,
)
}
IconButton(onClick = { onDeleteSaved(saved.id) }) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) }
} }
// Recent history
if (historyItems.isNotEmpty()) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Recent",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = onClearHistory) {
Text("Clear", style = MaterialTheme.typography.labelSmall)
}
}
}
items(historyItems.take(10), key = { "history-${QuerySerializer.serialize(it)}" }) { query ->
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { onLoadQuery(query) }
.padding(vertical = 6.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.Default.History,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
QuerySerializer.serialize(query),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f),
)
}
}
item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) }
}
// Operator hints
item {
Column(
modifier = Modifier.fillMaxWidth().padding(top = if (historyItems.isEmpty() && savedSearches.isEmpty()) 32.dp else 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Search operators",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
}
}
item { SearchHint("from:npub1...", "Filter by author") }
item { SearchHint("kind:article", "Long-form content") }
item { SearchHint("since:2025-01", "After January 2025") }
item { SearchHint("#bitcoin", "Hashtag search") }
item { SearchHint("\"exact phrase\"", "Exact match") }
item { SearchHint("bitcoin OR nostr", "Either term") }
item { SearchHint("-spam", "Exclude term") }
item { SearchHint("lang:en", "Language filter") }
} }
} }