Add musical markers popup in the editor

This commit is contained in:
Hasinjato 2026-07-10 15:14:00 +03:00
parent f32147f24d
commit 9550ab0e3c
2 changed files with 510 additions and 23 deletions

View file

@ -0,0 +1,415 @@
package mg.dot.feufaro.solfa
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddCircleOutline
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.RemoveCircleOutline
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
data class Marker(
val abbr: String,
val description: String,
val canEditValue: Boolean = false,
val defaultValue: String = "",
val options: List<String> = emptyList()
)
data class MarkerGroup(
val title: String,
val markers: List<Marker>
)
private val markerGroups = listOf(
MarkerGroup(
title = "Nuances",
markers = listOf(
Marker("ppp", "Très très doux"),
Marker("pp", "Pianissimo"),
Marker("p", "Piano"),
Marker("mp", "Mezzo piano"),
Marker("mf", "Mezzo forte"),
Marker("f", "Forte"),
Marker("ff", "Fortissimo"),
Marker("fff", "Très très fort"),
Marker("cres.", "Augmenter progressivement le volume"),
Marker("<", "Crescendo"),
Marker("decresc.", "Diminuer progressivement le volume"),
Marker("dim.", "Diminuendo"),
Marker(">", "Decrescendo"),
Marker("sf", "Accent soudain"),
Marker("sfz", "Sforzato"),
Marker("fz", "Forzando"),
Marker("rfz", "Rinforzando"),
Marker("fp", "Forte puis immédiatement piano"),
Marker("pf", "Piano puis forte"),
Marker("sub.p", "Subito piano"),
Marker("sub.f", "Subito forte")
)
),
MarkerGroup(
title = "Tempo",
markers = listOf(
Marker("𝄐", "Point d'orgue"),
Marker("Largh.", "Très très lent"),
Marker("Grave", "Très lent et solennel"),
Marker("Largo", "Large et lent"),
Marker("Lento", "Lent"),
Marker("Adagio", "Assez lent"),
Marker("And.", "Andante"),
Marker("Mod.", "Moderato"),
Marker("Alleg.", "Allegretto"),
Marker("All.", "Allegro"),
Marker("Viv.", "Vivace"),
Marker("Presto", "Très rapide"),
Marker("Prestiss.", "Extrêmement rapide"),
Marker("accel.", "Accélérer progressivement"),
Marker("rit.", "Ralentir progressivement"),
Marker("rall.", "Ralentir"),
Marker("riten.", "Retenir le tempo"),
Marker("string.", "Presser le tempo"),
Marker("♩=", "Changement tempo", canEditValue = true, defaultValue = "60"),
Marker("allarg.", "Élargir le tempo"),
Marker("a tempo", "Retour au tempo initial"),
Marker("Tempo I", "Retour au premier tempo"),
Marker("rubato", "Tempo libre"),
Marker("meno mosso", "Moins rapide"),
Marker("più mosso", "Plus rapide")
)
),
MarkerGroup(
title = "Reprises",
markers = listOf(
Marker("D.C.", "Da Capo"),
Marker("D.C. Fin", "Retour au début jusqu'à Fine"),
Marker("D.C. al Coda", "Retour au début puis aller à la Coda"),
Marker("D.S.", "Retour au Segno"),
Marker("End", "Fin"),
Marker("D.S. Fin", "Retour au Segno jusqu'à Fine"),
Marker("Farany", "Fin"),
Marker("$", "Segno"),
Marker("Fine", "Fin"),
Marker("𝄌", "Coda"),
Marker("To Coda", "Aller à la Coda"),
Marker("||:", "Début de reprise"),
Marker(":||", "Fin de reprise"),
Marker("Bis", "Rejouer")
)
),
MarkerGroup(
title = "Tonalité",
markers = listOf(
Marker("Dô dia", "Modulation", canEditValue = true, defaultValue = "C", options = Transpose.keyToNumber)
)
)
)
@Composable
fun MarkerPopup(
menuPosition: IntOffset,
onDismiss: () -> Unit,
onMarkerSelected: (String) -> Unit
) {
var expandedGroupIndex by remember {
mutableStateOf<Int?>(null)
}
Popup(
offset = menuPosition,
onDismissRequest = onDismiss,
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true
)
) {
Surface (
modifier = Modifier
.widthIn(max=250.dp)
.heightIn(max=450.dp),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.onSecondary.copy(0.75f),
) {
LazyColumn {
itemsIndexed(markerGroups) { index, group ->
ExpandableGroup(
group = group,
expanded = expandedGroupIndex == index,
onExpand = {
expandedGroupIndex = if(expandedGroupIndex == index) null else index
},
onMarkerSelected = {
onMarkerSelected(it)
onDismiss()
}
)
}
}
}
}
}
@OptIn(
ExperimentalFoundationApi::class,
ExperimentalMaterial3Api::class,
ExperimentalLayoutApi::class
)
@Composable
private fun ExpandableGroup(
group: MarkerGroup,
expanded: Boolean,
onExpand: () -> Unit,
onMarkerSelected: (String) -> Unit
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onExpand() }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = group.title,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.headlineSmall.copy(
color = MaterialTheme.colorScheme.background
)
)
Icon(
imageVector = if (expanded)
Icons.Default.RemoveCircleOutline
else
Icons.Default.AddCircleOutline,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
AnimatedVisibility(
visible = expanded,
enter = expandVertically(
animationSpec = tween(250)
) + fadeIn(
animationSpec = tween(250)
),
exit = shrinkVertically(
animationSpec = tween(200)
) + fadeOut(
animationSpec = tween(200)
)
) {
Column {
FlowRow(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 220.dp)
.verticalScroll(rememberScrollState())
.padding(horizontal = 2.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
group.markers.forEach { marker ->
if (marker.canEditValue) {
var value by remember { mutableStateOf(marker.defaultValue) }
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(Color.Gray)
.padding(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (marker.options.isNotEmpty()) {
Text(
marker.abbr,
style = MaterialTheme.typography.titleMedium
)
var optionsExpanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth(0.6f)) {
OutlinedButton(
onClick = { optionsExpanded = true },
modifier = Modifier
.fillMaxWidth()
.padding(2.dp)
) {
Text(
value,
style = MaterialTheme.typography.titleLarge.copy(
fontWeight = FontWeight.Bold
),
color = MaterialTheme.colorScheme.secondary
)
}
DropdownMenu(
expanded = optionsExpanded,
onDismissRequest = { optionsExpanded = false }
) {
marker.options.forEach { option ->
DropdownMenuItem(
onClick = {
value = option
optionsExpanded = false
},
text = {
Text(option)
}
)
}
}
}
} else {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = value,
onValueChange = { input ->
value = input.filter { it.isDigit() }
},
modifier = Modifier.fillMaxWidth(0.70f),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number
),
leadingIcon = {
Text(
text = marker.abbr,
fontSize = MaterialTheme.typography.titleMedium.fontSize,
color = MaterialTheme.colorScheme.onSecondary
)
},
trailingIcon = {
Text(
text = "bpm",
fontSize = MaterialTheme.typography.titleSmall.fontSize,
color = MaterialTheme.colorScheme.onSecondary
)
}
)
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
IconButton(
onClick = {
val current = value.toIntOrNull() ?: 0
value = (current + 1).toString()
},
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = Icons.Default.KeyboardArrowUp,
contentDescription = "Augmenter le tempo",
tint = Color.White
)
}
IconButton(
onClick = {
val current = value.toIntOrNull() ?: 0
value = (current - 1).coerceAtLeast(0).toString()
},
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = "Diminuer le tempo",
tint = Color.White
)
}
}
}
}
IconButton(
onClick = {
val result = marker.abbr + value
onMarkerSelected(result)
},
modifier = Modifier.size(40.dp)
) {
Text(text = "", color = Color.White)
}
}
} else {
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
PlainTooltip(
containerColor = Color.DarkGray,
contentColor = Color.White
) {
Text(
text = marker.description,
fontSize = MaterialTheme.typography.bodySmall.fontSize,
modifier = Modifier.padding(8.dp)
)
}
},
state = rememberTooltipState()
) {
Box(
modifier = Modifier
.weight(1f, fill = true)
.padding(4.dp)
.background(Color.Gray)
.clickable { onMarkerSelected(marker.abbr) }
.padding(horizontal = 7.dp, vertical = 2.dp),
contentAlignment = Alignment.Center
) {
Text(
text = marker.abbr,
fontSize = MaterialTheme.typography.titleSmall.fontSize,
color = MaterialTheme.colorScheme.surface
)
}
}
}
}
}
Spacer(Modifier.height(8.dp))
}
}
HorizontalDivider()
}
}

View file

@ -16,9 +16,13 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
@ -29,6 +33,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import mg.dot.feufaro.solfa.MarkerPopup
import mg.dot.feufaro.solfa.Solfa
import mg.dot.feufaro.solfa.TUOEditState
import mg.dot.feufaro.solfa.TimeUnitObject
@ -102,7 +107,11 @@ fun TUODetailDialog(
}
}
var canAddMark = mutableStateOf(false)
var showMarkerPopup by remember { mutableStateOf(false) }
var markerButtonPos by remember {
mutableStateOf(IntOffset.Zero)
}
Popup(
offset = menuPosition,
onDismissRequest = onDismiss,
@ -271,7 +280,18 @@ fun TUODetailDialog(
IconButton(
modifier = Modifier.size(20.dp),
onClick = {
newSep = ""
val state = TUOEditState(
tuoIndex = globalIndex,
notesByVoice = notes.toMap(),
originalNotes = originalNotes.toMap(),
originalLyricsByStanza = originalLyricsByStz.toMutableMap(),
lyricsByStanza = mutableMapOf(),
templateFragment = templateFragment,
marker = if(marker.isEmpty()) "_" else marker,
originalSep = initialSep,
sep = ""
)
onSave(state)
}) {
Text(
text = "/",
@ -285,7 +305,19 @@ fun TUODetailDialog(
IconButton(
modifier = Modifier.size(20.dp),
onClick = {
newSep = "/"
val state = TUOEditState(
tuoIndex = globalIndex,
notesByVoice = notes.toMap(),
originalNotes = originalNotes.toMap(),
originalLyricsByStanza = originalLyricsByStz.toMutableMap(),
lyricsByStanza = mutableMapOf(),
templateFragment = templateFragment,
marker = if(marker.isEmpty()) "_" else marker,
originalSep = initialSep,
sep = "/"
)
onSave(state)
}) {
Icon(
imageVector = Icons.Default.Add,
@ -312,23 +344,13 @@ fun TUODetailDialog(
)
}
if(!editState.marker.isNullOrEmpty() || canAddMark.value) {
Column(
modifier = Modifier.weight(1f)
) {
MyTextEditField(
value = marker,
customFontSize = 14.sp,
color = MaterialTheme.colorScheme.tertiary.copy(alpha = 1.5f),
customPadding = 8.dp,
customBrush = SolidColor(Color.White),
isEditable = isEditable,
isAddable = canAdd,
funTransform = ::transformMarkerInput,
onValueChng = { marker = it }
)
}
if(!templateFragment.isNullOrBlank() && templateFragment !="-") {
val hasMarker = marker.isNotEmpty()
val tooltipText = if (hasMarker) {
"Enlever la marqueur"
} else {
"Ajouter une marqueur"
}
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
@ -336,18 +358,41 @@ fun TUODetailDialog(
containerColor = Color.DarkGray,
contentColor = Color.White
) {
Text(text = "Ajouter une marqueur", fontSize = 12.sp)
Text(text = tooltipText, fontSize = MaterialTheme.typography.titleSmall.fontSize)
}
},
state = rememberTooltipState()
) {
IconButton(
modifier = Modifier.size(30.dp),
modifier = Modifier.size(30.dp)
.onGloballyPositioned { coordinates ->
markerButtonPos = coordinates.positionInParent()
.let {
IntOffset(
it.x.toInt(),
it.y.toInt()
)
}
},
onClick = {
canAddMark.value = true
if(hasMarker) {
val state = TUOEditState(
tuoIndex = globalIndex,
notesByVoice = notes.toMap(),
originalNotes = originalNotes.toMap(),
originalLyricsByStanza = originalLyricsByStz.toMutableMap(),
lyricsByStanza = mutableMapOf(),
templateFragment = templateFragment,
marker = "_",
originalSep = initialSep,
sep = newSep
)
onSave(state)
}
showMarkerPopup = true
}) {
Icon(
imageVector = Icons.Default.Add,
imageVector = if(hasMarker) Icons.Default.Remove else Icons.Default.Add,
tint = MaterialTheme.colorScheme.secondary.copy(alpha = 1.5f),
contentDescription = null
)
@ -541,6 +586,33 @@ fun TUODetailDialog(
}
}
}
if (showMarkerPopup) {
MarkerPopup(
menuPosition = IntOffset(
x = markerButtonPos.x + 180,
y = markerButtonPos.y + 100
),
onDismiss = {
showMarkerPopup = false
},
onMarkerSelected = { marker ->
showMarkerPopup = false
val state = TUOEditState(
tuoIndex = globalIndex,
notesByVoice = notes.toMap(),
originalNotes = originalNotes.toMap(),
originalLyricsByStanza = originalLyricsByStz.toMutableMap(),
lyricsByStanza = mutableMapOf(),
templateFragment = templateFragment,
marker = marker,
originalSep = initialSep,
sep = newSep
)
onSave(state)
}
)
}
}
private fun unpackLyrics(lyrics: String): String {