new: mark liaison characters

This commit is contained in:
2026-07-06 15:25:11 +02:00
parent 91f11ac800
commit 9dc5d27c19
2 changed files with 120 additions and 26 deletions
@@ -198,7 +198,10 @@ private fun TranscriptionLine(
TranscriptionText( TranscriptionText(
text = text, text = text,
color = contentColor, color = contentColor,
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp), style = MaterialTheme.typography.bodyLarge.copy(
fontSize = 20.sp,
lineHeight = 32.sp,
),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
} }
@@ -1,7 +1,12 @@
package fr.ajaury.gwenedeg.player.ui.components package fr.ajaury.gwenedeg.player.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize 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.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -11,6 +16,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign 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.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import fr.ajaury.gwenedeg.theme.ChomBevTheme import fr.ajaury.gwenedeg.theme.ChomBevTheme
/** /**
* Matches a liaison/optional letter written between parentheses in a transcription, e.g. the `n` * Matches a single letter marked in a transcription, either:
* in `ma(n)`. The captured group is the letter itself, without the surrounding parentheses. * - 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. */ /** Diacritic drawn above a parenthesised letter to mark that it is muted. */
private const val MUTED_LETTER_MARK = "ø" private const val MUTED_MARK = "ø"
/** /**
* Renders a transcription, giving special treatment to letters written between parentheses * Zero-width, no-break character placed on both sides of a marked letter so that line wrapping never
* (liaison consonants such as the `n` in `ma(n)`): the parentheses are dropped, the letter is * separates it from the word it is glued to — breaks still happen at spaces, exactly as for words.
* shown in a muted grey and topped with a small [MUTED_LETTER_MARK]. */
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 @Composable
fun TranscriptionText( fun TranscriptionText(
@@ -47,7 +66,7 @@ fun TranscriptionText(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
textAlign: TextAlign = TextAlign.Center, 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()) { if (matches.isEmpty()) {
Text( Text(
@@ -61,13 +80,16 @@ fun TranscriptionText(
} }
// Muted grey that still follows the surrounding line's dimming (current vs. inactive line). // 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 { val annotatedText = buildAnnotatedString {
var cursor = 0 var cursor = 0
matches.forEachIndexed { index, match -> matches.forEachIndexed { index, match ->
append(text.substring(cursor, match.range.first)) 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 cursor = match.range.last + 1
} }
append(text.substring(cursor)) append(text.substring(cursor))
@@ -78,10 +100,10 @@ fun TranscriptionText(
val inlineContent = matches val inlineContent = matches
.mapIndexed { index, match -> .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 // 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 // taller placeholder leaves room for the mark. Aligning to the text bottom keeps the
// letter's baseline in step with the rest of the line. // baseline in step with the rest of the line.
val letterWidthEm = remember(letter, style) { val letterWidthEm = remember(letter, style) {
val letterWidthPx = textMeasurer.measure(text = letter, style = style).size.width val letterWidthPx = textMeasurer.measure(text = letter, style = style).size.width
val fontSizePx = with(density) { style.fontSize.toPx() } val fontSizePx = with(density) { style.fontSize.toPx() }
@@ -95,7 +117,19 @@ fun TranscriptionText(
placeholderVerticalAlign = PlaceholderVerticalAlign.TextBottom, 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() }.toMap()
@@ -110,33 +144,78 @@ fun TranscriptionText(
} }
@Composable @Composable
private fun OptionalLetter( private fun MutedLetter(
letter: String, letter: String,
color: Color, color: Color,
style: TextStyle, style: TextStyle,
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Text( Text(
text = MUTED_LETTER_MARK, text = MUTED_MARK,
color = color, 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), modifier = Modifier.align(Alignment.TopCenter),
) )
Text( Text(
text = letter, text = letter,
color = color, color = color,
// Tight line height so the glyph's baseline lands where the surrounding line expects it. // Tight line height so a bottom glyph's baseline lands where the surrounding line expects it.
style = style.copy(lineHeight = style.fontSize), style = style.copy(fontSize = style.fontSize, lineHeight = style.fontSize),
modifier = Modifier.align(Alignment.BottomCenter), 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) @Preview(backgroundColor = 0xFFFFFFFF)
@Composable @Composable
private fun TranscriptionTextPreview() { private fun TranscriptionTextLiaisonPreview() {
ChomBevTheme { ChomBevTheme {
TranscriptionText( TranscriptionText(
text = "Tomm eo ma(n) divskouarn, penn ma fri", text = "Tomm eo ma(n) divskouarn, penn ma fri",
@@ -148,10 +227,22 @@ private fun TranscriptionTextPreview() {
@Preview(backgroundColor = 0xFFFFFFFF) @Preview(backgroundColor = 0xFFFFFFFF)
@Composable @Composable
private fun TranscriptionTextNoOptionalLetterPreview() { private fun TranscriptionTextBracketPreview() {
ChomBevTheme { ChomBevTheme {
TranscriptionText( 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, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp), style = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp),
) )