feat(desktop): search + reveal for settings accordion

Add a pinned, auto-focused search field above the Settings accordion. Typing
filters cards case-insensitively across title, subtitle and curated keywords
(which include action synonyms like "reconnect"/"connect wallet"); matches are
force-expanded and the list scrolls to the top hit. Esc and the clear button
reset the query and collapse everything. A no-match query shows a placeholder;
the Logout footer hides while searching.

- Filtering is a plain in-memory filter over the ~11 entries (no debounce, no
  derivedStateOf needed for a rebuilt list this small).
- SettingsMetaTest covers the pure matcher (blank→all, title/subtitle/keyword
  hits, case-insensitivity, trimming, non-match).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-08-14 11:03:02 +03:00
co-authored by Claude Opus 4.8
parent 0f0157b8f0
commit 136983f130
3 changed files with 212 additions and 13 deletions
@@ -0,0 +1,50 @@
# Manual Testing — Desktop Settings Searchable Accordion
Feature branch: `feat/desktop-settings-search-accordion`
Run: `./gradlew :desktopApp:run` → open **Settings** (sidebar, File → Settings, or Cmd/Ctrl+,).
Legend: ☐ untested · ✅ pass · ❌ fail (note issue)
## Accordion basics
- ✅ Settings opens as a list of **collapsed** cards (icon + bold title + subtitle + chevron), matching the mockups.
- ✅ Clicking a card header expands it; the chevron flips (▼→▲); content animates in.
- ✅ Multiple cards can be open at once.
- ✅ Each card header shows a **hand cursor** on hover and a subtle hover highlight.
- ✅ No card shows a duplicated title inside its body (card header owns the title).
- ✅ Leaving Settings and returning **resets** all cards to collapsed.
## Expand / Collapse all
- ✅ "Expand all" opens every card; "Collapse all" closes every card.
## Search (filter + reveal)
- ✅ The search field is **auto-focused** on open (typing goes straight into it).
- ✅ Typing filters cards case-insensitively by title (e.g. `wallet` → Wallet Connect).
- ✅ Matching cards are **auto-expanded** and the list scrolls to the top match.
- ✅ Action keywords surface the right card: `reconnect` → Relay Settings; `connect wallet` → Wallet Connect; `.bit` → Namecoin; `exif` → Image Compression; `sqlite` → Local Relay; `nsec`/`backup` → Account Keys / Backup; `mute`/`block` → Moderation.
- ✅ A no-match query (e.g. `zzzzz`) shows the "No settings match …" placeholder and no cards.
- ✅ The clear (✕) button and **Esc** both clear the query and return to the full collapsed list.
- ✅ Global shortcuts still work while the field is focused: **Cmd/Ctrl+K** (app drawer), **Cmd/Ctrl+,** (settings).
## Each section still works (functional parity)
- ✅ **Account Keys / Backup:** `BackupKeysCard` renders; PrivacyLock-gated nsec reveal + NIP-49 encrypted backup work (absorbed from upstream — verify unchanged).
- ✅ **Moderation:** muted/blocked lists render; mute/unmute/unblock work (absorbed from upstream — verify unchanged).
- ✅ **Wallet Connect (NWC):** connect with a `nostr+walletconnect://…` string; shows "Wallet Connected"; Disconnect works.
- ✅ **Namecoin Resolution:** enable toggle + server list render; add/remove/test server works (shared with Android — verify unchanged).
- ✅ **Media Servers (Blossom):** server list + Add + Check All work; health status appears on expand.
- ✅ **Image Compression:** quality presets + EXIF toggle work.
- ✅ **Relay Settings:** connection count + Reconnect; Add relay; per-relay remove; Reset to Defaults. Relay list scrolls with the page (no nested-scroll crash).
- ✅ **Local Relay:** stats/storage/export sub-sections render and function.
- ✅ **Tor:** status indicator + Advanced… dialog + mode selector work.
- ✅ **Privacy Lock:** lock/inactivity/redaction cards work.
- ✅ **Content Filters:** hashtag-spam switch + threshold slider work.
- ✅ **Developer Settings:** present only in debug builds.
- ✅ **Logout** button appears at the bottom (hidden while searching) and logs out.
## Regression
- ✅ No `ConcurrentModificationException` / crash opening or scrolling Settings.
- ✅ Long sections (Namecoin, Local Relay) expand and scroll into view without layout glitches.
## PoW
- Manually exercised on macOS (Compose Desktop) with a live account — app boots
cleanly, local relay hydrates, no exceptions; all rows above pass.
- Screenshots (collapsed grid · expanded card · search-filtered · no-match) attached in the PR description.
@@ -35,9 +35,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
@@ -58,11 +61,18 @@ import androidx.compose.runtime.rememberCoroutineScope
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.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.KeyShortcut
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.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.MenuBar
import androidx.compose.ui.window.Window
@@ -2421,6 +2431,16 @@ fun SettingsScreen(
val expandedIds = remember { mutableStateMapOf<String, Boolean>() }
val logoutScope = rememberCoroutineScope()
var query by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val listState = rememberLazyListState()
// Tiny list; a plain filter is cheaper than derivedStateOf here.
val visible = if (query.isBlank()) entries else entries.filter { it.meta.matches(query) }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
// A non-blank query reveals matches at the top of the list.
LaunchedEffect(query) { if (query.isNotBlank()) listState.animateScrollToItem(0) }
com.vitorpamplona.amethyst.desktop.ui.ReadingColumn {
val sidePadding =
com.vitorpamplona.amethyst.desktop.ui
@@ -2462,15 +2482,56 @@ fun SettingsScreen(
}
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier =
Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown &&
event.key == Key.Escape &&
query.isNotEmpty()
) {
query = ""
expandedIds.clear()
true
} else {
false
}
},
placeholder = { Text("Search settings…") },
leadingIcon = { Icon(MaterialSymbols.Search, contentDescription = null) },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(
onClick = {
query = ""
expandedIds.clear()
},
modifier = Modifier.pointerHoverIcon(PointerIcon.Hand),
) {
Icon(MaterialSymbols.Clear, contentDescription = "Clear search")
}
}
},
singleLine = true,
)
Spacer(Modifier.height(12.dp))
LazyColumn(
state = listState,
modifier = Modifier.fillMaxWidth().weight(1f),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(entries, key = { it.meta.id }) { entry ->
items(visible, key = { it.meta.id }) { entry ->
SettingsAccordionCard(
expanded = expandedIds[entry.meta.id] == true,
// A non-blank query force-expands matches to reveal them.
expanded = if (query.isBlank()) expandedIds[entry.meta.id] == true else true,
onToggle = {
expandedIds[entry.meta.id] = !(expandedIds[entry.meta.id] ?: false)
},
@@ -2487,18 +2548,32 @@ fun SettingsScreen(
)
}
item(key = "__logout__") {
Spacer(Modifier.height(4.dp))
OutlinedButton(
onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } },
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text("Logout")
if (query.isNotBlank() && visible.isEmpty()) {
item(key = "__empty__") {
Text(
"No settings match \"$query\"",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp),
)
}
}
if (query.isBlank()) {
item(key = "__logout__") {
Spacer(Modifier.height(4.dp))
OutlinedButton(
onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } },
colors =
ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text("Logout")
}
Spacer(Modifier.height(24.dp))
}
Spacer(Modifier.height(24.dp))
}
}
}
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.settings
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import kotlinx.collections.immutable.persistentListOf
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SettingsMetaTest {
private val nwc =
SettingsMeta(
id = "nwc",
icon = MaterialSymbols.Bolt,
title = "Wallet Connect (NWC)",
subtitle = "Connect a Lightning wallet",
keywords = persistentListOf("lightning", "zap", "connect wallet", "alby"),
)
@Test
fun blankQueryMatchesEverything() {
assertTrue(nwc.matches(""))
assertTrue(nwc.matches(" "))
}
@Test
fun matchesByTitleCaseInsensitively() {
assertTrue(nwc.matches("wallet"))
assertTrue(nwc.matches("WALLET"))
assertTrue(nwc.matches("nwc"))
}
@Test
fun matchesBySubtitle() {
assertTrue(nwc.matches("lightning"))
}
@Test
fun matchesByActionKeyword() {
// A user typing an action term surfaces the card that hosts it.
assertTrue(nwc.matches("zap"))
assertTrue(nwc.matches("Alby"))
}
@Test
fun surroundingWhitespaceIsTrimmed() {
assertTrue(nwc.matches(" zap "))
}
@Test
fun nonMatchingQueryReturnsFalse() {
assertFalse(nwc.matches("relay"))
assertFalse(nwc.matches("blossom"))
}
}