Show what a search result matched (#80)

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.
This commit is contained in:
2026-08-08 16:27:16 +02:00
parent e8aaa7d333
commit a9c5bc1038

View File

@@ -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<MatchSpan>, 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