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:
+184
-28
@@ -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") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user