diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt index 56da381..8b861a0 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/SubtitleList.kt @@ -198,7 +198,10 @@ private fun TranscriptionLine( TranscriptionText( text = text, color = contentColor, - style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp), + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = 20.sp, + lineHeight = 32.sp, + ), textAlign = TextAlign.Center, ) } diff --git a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/TranscriptionText.kt b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/TranscriptionText.kt index 37e7683..5154a84 100644 --- a/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/TranscriptionText.kt +++ b/shared/src/commonMain/kotlin/fr/ajaury/gwenedeg/player/ui/components/TranscriptionText.kt @@ -1,7 +1,12 @@ package fr.ajaury.gwenedeg.player.ui.components +import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material3.MaterialTheme @@ -11,6 +16,7 @@ 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.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign @@ -19,25 +25,38 @@ 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.dp 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. + * Matches a single letter marked in a transcription, either: + * - between parentheses, e.g. the `n` in `ma(n)` (a muted consonant), captured in group 1, or + * - between square brackets, e.g. the `t` in `[t]din` (a linking consonant), captured in + * group 2. + * + * The captured group is the bare letter, without its surrounding delimiters. */ -private val MUTED_LETTER_REGEX = Regex("""\(([A-Za-z])\)""") +private val MARKED_LETTER_REGEX = Regex("""\(([A-Za-z])\)|\[([A-Za-z])\]""") -/** Diacritic drawn above an optional letter to mark that it is only sounded on liaison. */ -private const val MUTED_LETTER_MARK = "ø" +/** Diacritic drawn above a parenthesised letter to mark that it is muted. */ +private const val MUTED_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]. + * Zero-width, no-break character placed on both sides of a marked letter so that line wrapping never + * separates it from the word it is glued to — breaks still happen at spaces, exactly as for words. + */ +private const val WORD_JOINER = "\u2060" + +/** + * Renders a transcription, giving special treatment to single letters written between delimiters: + * - `(x)` — a liaison consonant: the parentheses are dropped and the letter stays on the line, in + * muted grey, topped with a small [MUTED_MARK]. + * - `[x]` — a linking consonant: the brackets are dropped and the letter is raised above the line, + * in muted grey, with a small [LIAISON_MARK] underneath it. * - * Optional letters are emitted as inline content so the line still wraps like normal text. + * Marked letters are emitted as inline content so the line still wraps like normal text. */ @Composable fun TranscriptionText( @@ -47,7 +66,7 @@ fun TranscriptionText( modifier: Modifier = Modifier, textAlign: TextAlign = TextAlign.Center, ) { - val matches = remember(text) { MUTED_LETTER_REGEX.findAll(text).toList() } + val matches = remember(text) { MARKED_LETTER_REGEX.findAll(text).toList() } if (matches.isEmpty()) { Text( @@ -61,13 +80,16 @@ fun TranscriptionText( } // Muted grey that still follows the surrounding line's dimming (current vs. inactive line). - val markColor = color.copy(alpha = color.alpha * 0.6f) + val markColor = color.copy(alpha = color.alpha * 0.7f) 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]) + // Glue the marked letter to its neighbouring characters so a line never breaks onto it. + append(WORD_JOINER) + appendInlineContent(id = inlineId(index), alternateText = match.letter) + append(WORD_JOINER) cursor = match.range.last + 1 } append(text.substring(cursor)) @@ -78,10 +100,10 @@ fun TranscriptionText( val inlineContent = matches .mapIndexed { index, match -> - val letter = match.groupValues[1] + val letter = match.letter // 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. + // taller placeholder leaves room for the mark. Aligning to the text bottom keeps the + // 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() } @@ -95,7 +117,19 @@ fun TranscriptionText( placeholderVerticalAlign = PlaceholderVerticalAlign.TextBottom, ), ) { - OptionalLetter(letter = letter, color = markColor, style = style) + if (match.isBracketed) { + LiaisonLetter( + letter = letter, + color = markColor, + style = style, + ) + } else { + MutedLetter( + letter = letter, + color = markColor, + style = style, + ) + } } }.toMap() @@ -110,33 +144,78 @@ fun TranscriptionText( } @Composable -private fun OptionalLetter( +private fun MutedLetter( letter: String, color: Color, style: TextStyle, ) { Box(modifier = Modifier.fillMaxSize()) { Text( - text = MUTED_LETTER_MARK, + text = MUTED_MARK, color = color, - style = style.copy(fontSize = style.fontSize * 0.75f, lineHeight = 10.sp), + style = style.copy( + fontSize = style.fontSize * 0.75f, + lineHeight = style.fontSize * 0.75f, + ), 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), + // Tight line height so a bottom glyph's baseline lands where the surrounding line expects it. + style = style.copy(fontSize = style.fontSize, lineHeight = style.fontSize), modifier = Modifier.align(Alignment.BottomCenter), ) } } -private fun inlineId(index: Int): String = "optional-letter-$index" +@Composable +private fun LiaisonLetter( + letter: String, + color: Color, + style: TextStyle, +) { + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 1.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = letter, + color = color, + letterSpacing = 1.sp, + // Tight line height so a bottom glyph's baseline lands where the surrounding line expects it. + style = style.copy( + fontSize = style.fontSize * 0.75f, + lineHeight = style.fontSize * 0.75f, + ), + ) + Canvas( + modifier = Modifier + .width(100.dp) + .height(3.dp), + ) { + drawArc( + color = color, + startAngle = 180f, + sweepAngle = 180f, + useCenter = false, + style = Stroke(width = 2f), + ) + } + } +} + +private val MatchResult.letter: String + get() = groupValues[1].ifEmpty { groupValues[2] } + +private val MatchResult.isBracketed: Boolean + get() = groupValues[1].isEmpty() + +private fun inlineId(index: Int): String = "marked-letter-$index" @Preview(backgroundColor = 0xFFFFFFFF) @Composable -private fun TranscriptionTextPreview() { +private fun TranscriptionTextLiaisonPreview() { ChomBevTheme { TranscriptionText( text = "Tomm eo ma(n) divskouarn, penn ma fri", @@ -148,10 +227,22 @@ private fun TranscriptionTextPreview() { @Preview(backgroundColor = 0xFFFFFFFF) @Composable -private fun TranscriptionTextNoOptionalLetterPreview() { +private fun TranscriptionTextBracketPreview() { ChomBevTheme { TranscriptionText( - text = "Demat deoc'h !", + text = "Lârit[t] din, mar plij[k] geneoc'h.", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp), + ) + } +} + +@Preview(backgroundColor = 0xFFFFFFFF) +@Composable +private fun LiaisonLetterPreview() { + ChomBevTheme { + LiaisonLetter( + letter = "D", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp), )