From a9c5bc10380922bffd01e46f92fd06de3460a31f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 8 Aug 2026 16:27:16 +0200 Subject: [PATCH] Show what a search result matched (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Results were plain rows, so a hit that matched on its description looked unexplained. The matched runs are now emphasised in the title and location, and a description match adds a short excerpt of it as a second summary line — elided around the match rather than dumping the note. --- .../calendula/ui/search/SearchScreen.kt | 62 ++++++++++++++++--- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index abd4642..cbc4cd2 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -56,11 +56,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextAlign @@ -70,7 +75,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance +import de.jeanlucmakiola.calendula.domain.MatchSpan import de.jeanlucmakiola.calendula.domain.RecurringWriteScope +import de.jeanlucmakiola.calendula.domain.SearchHit import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.predictiveBack @@ -406,7 +413,7 @@ private fun SearchResults( val event = hit.event val deletable = results.isDeletable(event) SearchResultRow( - event = event, + hit = hit, position = positionOf(index, hits.size), modifier = animateItemMotion(), selected = event.eventId in selection, @@ -427,7 +434,7 @@ private fun SearchResults( @Composable private fun SearchResultRow( - event: EventInstance, + hit: SearchHit, position: Position, modifier: Modifier = Modifier, selected: Boolean = false, @@ -436,12 +443,19 @@ private fun SearchResultRow( onClick: (() -> Unit)?, onLongClick: (() -> Unit)? = null, ) { + val event = hit.event val dark = isSystemInDarkTheme() val soften = LocalSoftenColors.current + // On a picked row the headline is already recoloured for the secondary + // container, so the match is carried by weight alone there. + val highlight = SpanStyle( + fontWeight = FontWeight.Bold, + color = if (selected) Color.Unspecified else MaterialTheme.colorScheme.primary, + ) GroupedRow( modifier = modifier, - title = event.title, - summary = searchSummary(event), + title = marked(event.title, hit.titleSpans, highlight), + summary = searchSummary(hit, highlight), position = position, minHeight = 64.dp, selected = selected, @@ -517,9 +531,26 @@ private fun DeleteOutcomeChip( } } -/** "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then location. */ +/** [text] with every matched run emphasised. */ +private fun marked(text: String, spans: List, style: SpanStyle): AnnotatedString = + if (spans.isEmpty()) { + AnnotatedString(text) + } else { + buildAnnotatedString { + append(text) + spans.forEach { addStyle(style, it.start, it.end) } + } + } + +/** + * "Wed, 17 Jun 2026 · 09:00 · Office" — date, then time (or All day), then + * location, with a second line excerpting the description when that is where the + * query matched. Without it a description-only hit reads as though it arrived + * from nowhere. + */ @Composable -private fun searchSummary(event: EventInstance): String { +private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString { + val event = hit.event val locale = currentLocale() val zone = remember { ZoneId.systemDefault() } val start = remember(event.start, zone) { @@ -538,8 +569,23 @@ private fun searchSummary(event: EventInstance): String { } else { remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start) } - val base = "$dateText · $timeText" - return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base + return buildAnnotatedString { + append("$dateText · $timeText") + event.location?.takeIf { it.isNotBlank() }?.let { location -> + append(" · ") + val offset = length + append(location) + hit.locationSpans.forEach { + addStyle(highlight, offset + it.start, offset + it.end) + } + } + hit.descriptionSnippet?.let { snippet -> + append("\n") + val offset = length + append(snippet.text) + snippet.spans.forEach { addStyle(highlight, offset + it.start, offset + it.end) } + } + } } @Composable