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:
@@ -56,11 +56,16 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
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.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
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.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -70,7 +75,9 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import de.jeanlucmakiola.calendula.R
|
import de.jeanlucmakiola.calendula.R
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.MatchSpan
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.SearchHit
|
||||||
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
import de.jeanlucmakiola.floret.identity.animateItemMotion
|
||||||
import de.jeanlucmakiola.floret.identity.fadeThrough
|
import de.jeanlucmakiola.floret.identity.fadeThrough
|
||||||
import de.jeanlucmakiola.floret.identity.predictiveBack
|
import de.jeanlucmakiola.floret.identity.predictiveBack
|
||||||
@@ -406,7 +413,7 @@ private fun SearchResults(
|
|||||||
val event = hit.event
|
val event = hit.event
|
||||||
val deletable = results.isDeletable(event)
|
val deletable = results.isDeletable(event)
|
||||||
SearchResultRow(
|
SearchResultRow(
|
||||||
event = event,
|
hit = hit,
|
||||||
position = positionOf(index, hits.size),
|
position = positionOf(index, hits.size),
|
||||||
modifier = animateItemMotion(),
|
modifier = animateItemMotion(),
|
||||||
selected = event.eventId in selection,
|
selected = event.eventId in selection,
|
||||||
@@ -427,7 +434,7 @@ private fun SearchResults(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SearchResultRow(
|
private fun SearchResultRow(
|
||||||
event: EventInstance,
|
hit: SearchHit,
|
||||||
position: Position,
|
position: Position,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
selected: Boolean = false,
|
selected: Boolean = false,
|
||||||
@@ -436,12 +443,19 @@ private fun SearchResultRow(
|
|||||||
onClick: (() -> Unit)?,
|
onClick: (() -> Unit)?,
|
||||||
onLongClick: (() -> Unit)? = null,
|
onLongClick: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
|
val event = hit.event
|
||||||
val dark = isSystemInDarkTheme()
|
val dark = isSystemInDarkTheme()
|
||||||
val soften = LocalSoftenColors.current
|
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(
|
GroupedRow(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
title = event.title,
|
title = marked(event.title, hit.titleSpans, highlight),
|
||||||
summary = searchSummary(event),
|
summary = searchSummary(hit, highlight),
|
||||||
position = position,
|
position = position,
|
||||||
minHeight = 64.dp,
|
minHeight = 64.dp,
|
||||||
selected = selected,
|
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
|
@Composable
|
||||||
private fun searchSummary(event: EventInstance): String {
|
private fun searchSummary(hit: SearchHit, highlight: SpanStyle): AnnotatedString {
|
||||||
|
val event = hit.event
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
val zone = remember { ZoneId.systemDefault() }
|
val zone = remember { ZoneId.systemDefault() }
|
||||||
val start = remember(event.start, zone) {
|
val start = remember(event.start, zone) {
|
||||||
@@ -538,8 +569,23 @@ private fun searchSummary(event: EventInstance): String {
|
|||||||
} else {
|
} else {
|
||||||
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
remember(locale, use24Hour) { timeOfDayFormatter(use24Hour, locale) }.format(start)
|
||||||
}
|
}
|
||||||
val base = "$dateText · $timeText"
|
return buildAnnotatedString {
|
||||||
return event.location?.takeIf { it.isNotBlank() }?.let { "$base · $it" } ?: base
|
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
|
@Composable
|
||||||
|
|||||||
Reference in New Issue
Block a user