new: mark muted characters

This commit is contained in:
2026-07-06 14:49:33 +02:00
parent ea7dfe6470
commit 91f11ac800
2 changed files with 164 additions and 4 deletions
@@ -36,6 +36,7 @@ import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import fr.ajaury.gwenedeg.core.model.Phrase
import fr.ajaury.gwenedeg.player.ui.components.TranscriptionText
import fr.ajaury.gwenedeg.preferences.model.SubtitlePreferences
import fr.ajaury.gwenedeg.subtitle.model.SubtitleLine
import fr.ajaury.gwenedeg.theme.ChomBevTheme
@@ -194,10 +195,10 @@ private fun TranscriptionLine(
text: String,
contentColor: Color,
) {
Text(
TranscriptionText(
text = text,
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp),
color = contentColor,
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp),
textAlign = TextAlign.Center,
)
}
@@ -234,8 +235,8 @@ private val previewSubtitleLines =
SubtitleLine(
startTime = 7350.milliseconds,
phrase = Phrase(
transcription = "Kenavo emberr !",
translation = "À ce soir !",
transcription = "Tomm eo ma(n) divskouarn",
translation = "J'ai chaud aux oreilles",
),
),
)
@@ -0,0 +1,159 @@
package fr.ajaury.gwenedeg.player.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import fr.ajaury.gwenedeg.theme.ChomBevTheme
/**
* Matches a liaison/optional letter written between parentheses in a transcription, e.g. the `n`
* in `ma(n)`. The captured group is the letter itself, without the surrounding parentheses.
*/
private val MUTED_LETTER_REGEX = Regex("""\(([A-Za-z])\)""")
/** Diacritic drawn above an optional letter to mark that it is only sounded on liaison. */
private const val MUTED_LETTER_MARK = "ø"
/**
* Renders a transcription, giving special treatment to letters written between parentheses
* (liaison consonants such as the `n` in `ma(n)`): the parentheses are dropped, the letter is
* shown in a muted grey and topped with a small [MUTED_LETTER_MARK].
*
* Optional letters are emitted as inline content so the line still wraps like normal text.
*/
@Composable
fun TranscriptionText(
text: String,
color: Color,
style: TextStyle,
modifier: Modifier = Modifier,
textAlign: TextAlign = TextAlign.Center,
) {
val matches = remember(text) { MUTED_LETTER_REGEX.findAll(text).toList() }
if (matches.isEmpty()) {
Text(
text = text,
color = color,
style = style,
textAlign = textAlign,
modifier = modifier,
)
return
}
// Muted grey that still follows the surrounding line's dimming (current vs. inactive line).
val markColor = color.copy(alpha = color.alpha * 0.6f)
val annotatedText = buildAnnotatedString {
var cursor = 0
matches.forEachIndexed { index, match ->
append(text.substring(cursor, match.range.first))
appendInlineContent(id = inlineId(index), alternateText = match.groupValues[1])
cursor = match.range.last + 1
}
append(text.substring(cursor))
}
val density = LocalDensity.current
val textMeasurer = rememberTextMeasurer()
val inlineContent = matches
.mapIndexed { index, match ->
val letter = match.groupValues[1]
// Reserve exactly the letter's advance width (in em, so it is font-scale independent); the
// taller placeholder leaves room for the mark above. Aligning to the text bottom keeps the
// letter's baseline in step with the rest of the line.
val letterWidthEm = remember(letter, style) {
val letterWidthPx = textMeasurer.measure(text = letter, style = style).size.width
val fontSizePx = with(density) { style.fontSize.toPx() }
letterWidthPx / fontSizePx
}
inlineId(index) to InlineTextContent(
placeholder = Placeholder(
width = letterWidthEm.em,
height = 1.7.em,
placeholderVerticalAlign = PlaceholderVerticalAlign.TextBottom,
),
) {
OptionalLetter(letter = letter, color = markColor, style = style)
}
}.toMap()
Text(
text = annotatedText,
color = color,
style = style,
textAlign = textAlign,
inlineContent = inlineContent,
modifier = modifier,
)
}
@Composable
private fun OptionalLetter(
letter: String,
color: Color,
style: TextStyle,
) {
Box(modifier = Modifier.fillMaxSize()) {
Text(
text = MUTED_LETTER_MARK,
color = color,
style = style.copy(fontSize = style.fontSize * 0.75f, lineHeight = 10.sp),
modifier = Modifier.align(Alignment.TopCenter),
)
Text(
text = letter,
color = color,
// Tight line height so the glyph's baseline lands where the surrounding line expects it.
style = style.copy(lineHeight = style.fontSize),
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
private fun inlineId(index: Int): String = "optional-letter-$index"
@Preview(backgroundColor = 0xFFFFFFFF)
@Composable
private fun TranscriptionTextPreview() {
ChomBevTheme {
TranscriptionText(
text = "Tomm eo ma(n) divskouarn, penn ma fri",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp),
)
}
}
@Preview(backgroundColor = 0xFFFFFFFF)
@Composable
private fun TranscriptionTextNoOptionalLetterPreview() {
ChomBevTheme {
TranscriptionText(
text = "Demat deoc'h !",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp),
)
}
}