new: mark liaison characters
This commit is contained in:
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-25
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user