fix(desktop): feed header card design, inline search, compact tabs

- FeedTabsHeader wrapped in Surface card (border + rounded corners)
- Feed tabs compact with spacedBy(4dp), removed "+ More" (in sidebar)
- SearchPill centered with weight(1f), expands inline to BasicTextField
  with DropdownMenu showing recent + saved searches
- Removed Dialog-based SearchSpotlight (wrong UX — created separate AWT window)
- Cmd+F now navigates to Search column via navigateToScreen callback
- All feed tab types (Following/Global/Custom) properly handled in onClick

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-05-29 07:02:49 +03:00
co-authored by Claude Opus 4.6
parent c21b85c83e
commit 55e7fcb903
3 changed files with 279 additions and 127 deletions
@@ -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()
@@ -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),
)
}
}
@@ -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()
}
}
}