Add '<' to drop note, shift left notes & '>' to add note, shift right notes on EditingDialog

This commit is contained in:
Hasinjato 2026-06-17 13:29:27 +03:00
parent 335b9b9edc
commit 13154d6acd
2 changed files with 235 additions and 72 deletions

View file

@ -8,6 +8,8 @@ import mg.dot.feufaro.getGlobalTemplate
import mg.dot.feufaro.launchFilePicker import mg.dot.feufaro.launchFilePicker
import mg.dot.feufaro.midi.MidiPitch import mg.dot.feufaro.midi.MidiPitch
import mg.dot.feufaro.midi.MidiWriterKotlin import mg.dot.feufaro.midi.MidiWriterKotlin
import mg.dot.feufaro.transformLyricsInput
import mg.dot.feufaro.viewmodel.PartitionMetadata
import java.io.File import java.io.File
import kotlin.math.min import kotlin.math.min
@ -56,6 +58,8 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
val REGEX_PAREN_RECURSIVE = Regex("(\\([^\\(\\)]*)\\(([^\\)]*)\\)") val REGEX_PAREN_RECURSIVE = Regex("(\\([^\\(\\)]*)\\(([^\\)]*)\\)")
val REGEX_COMMENT = Regex("\\$\\{[^\\}]*\\}") val REGEX_COMMENT = Regex("\\$\\{[^\\}]*\\}")
val REGEX_STRIP_DC = Regex("\\$\\{D[^:]*:[^\\}]*\\}") val REGEX_STRIP_DC = Regex("\\$\\{D[^:]*:[^\\}]*\\}")
val REGEX_NOTE_TOADD = Regex("([drmfsltz][ia]?|[―])([₄₃₂₁¹²³⁴',]*)>([drmfsltz][ia]?|[―])([₄₃₂₁¹²³⁴',]*)")
val REGEX_NOTE_TODROP = Regex("([drmfsltz][ia]?|[―])([₄₃₂₁¹²³⁴',]*)<")
} }
var nextTIndex: Int = -1 var nextTIndex: Int = -1
@ -465,7 +469,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
val finalString = updatedSource.joinToString("\n") val finalString = updatedSource.joinToString("\n")
val tempDir = System.getProperty("java.io.tmpdir") val tempDir = System.getProperty("java.io.tmpdir")?: ""
val fileName = filePath.substringAfterLast('/') val fileName = filePath.substringAfterLast('/')
val currentTempFile = File(tempDir, fileName) val currentTempFile = File(tempDir, fileName)
currentTempFile?.deleteOnExit() currentTempFile?.deleteOnExit()
@ -665,6 +669,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
val resultTokens = mutableListOf<String>() val resultTokens = mutableListOf<String>()
var currentLogicalIdx = 0 var currentLogicalIdx = 0
var i = 0 var i = 0
val consumedTokenIndices = mutableSetOf<Int>()
// println("\nSuivi pour la voix $linePrefix (Pointeur cible: $targetPointer)") // println("\nSuivi pour la voix $linePrefix (Pointeur cible: $targetPointer)")
// println( // println(
@ -678,6 +683,14 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
// ) // )
// ) // )
while (i < allTokens.size) { while (i < allTokens.size) {
if (consumedTokenIndices.contains(i)) {
val token = allTokens[i]
if (token.matches(Regex("[drmfsltDRFSTzw].*|[-―]"))) {
currentLogicalIdx++
}
i++
continue
}
val token = allTokens[i] val token = allTokens[i]
val isStructural = token.contains("#") || token == "/" || token == "(" || token == ")" val isStructural = token.contains("#") || token == "/" || token == "(" || token == ")"
@ -691,7 +704,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
if (currentLogicalIdx == targetPointer) { if (currentLogicalIdx == targetPointer) {
val originalToken = allTokens[i] val originalToken = allTokens[i]
//println("Insertition Multiples : $newNot") // println("Insertition Multiples : $newNot")
val rawUserTokens = regex.findAll(revertMusicalInput(newNot)).map { it.value }.toList() val rawUserTokens = regex.findAll(revertMusicalInput(newNot)).map { it.value }.toList()
/** Si y a '-' dans template inutile d' ajouter au notes */ /** Si y a '-' dans template inutile d' ajouter au notes */
val hasDashInTemplate = editState.templateFragment.contains("-") val hasDashInTemplate = editState.templateFragment.contains("-")
@ -700,7 +713,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
} else { } else {
rawUserTokens rawUserTokens
} }
//val newUserTokens = regex.findAll(revertMusicalInput(newNot)).map { it.value }.toList() // val newUserTokens = regex.findAll(revertMusicalInput(newNot)).map { it.value }.toList()
val oldUserTokens = regex.findAll(revertMusicalInput(oldNot)).map { it.value }.toList() val oldUserTokens = regex.findAll(revertMusicalInput(oldNot)).map { it.value }.toList()
val firstNewNote = newUserTokens.firstOrNull { it.matches(Regex("[drmfsltDRFSTzw].*")) } ?: "" val firstNewNote = newUserTokens.firstOrNull { it.matches(Regex("[drmfsltDRFSTzw].*")) } ?: ""
@ -713,12 +726,36 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
val oldTokensForThisFragment = regex.findAll(revertMusicalInput(oldNot)) val oldTokensForThisFragment = regex.findAll(revertMusicalInput(oldNot))
.map { it.value } .map { it.value }
.filter { it.matches(Regex("[drmfsltDRFSTzw].*|[-―]")) } .filter { it.matches(Regex("[drmfsltDRFSTzw].*")) }
.toList() .toList()
val dynamicDeleteCount = oldTokensForThisFragment.size val dynamicDeleteCount = oldTokensForThisFragment.size
newUserTokens.forEach { ut -> newUserTokens.forEach { ut ->
val dropRegex = Regex("${Regex.escape(ut)}<")
val isThisNoteDropped = dropRegex.containsMatchIn(revertMusicalInput(newNot))
if (isThisNoteDropped) {
if (ut == "z" || ut == "-" || ut == "") {
// println("Suppression de résonance/silence ($ut)...")
var lookAheadIdx = i
var foundDashIdx = -1
while (lookAheadIdx < allTokens.size) {
val lat = allTokens[lookAheadIdx]
if ((lat.matches(Regex("[-―]")) || lat.startsWith("z")) && !consumedTokenIndices.contains(lookAheadIdx)) {
foundDashIdx = lookAheadIdx
break
}
lookAheadIdx++
}
if (foundDashIdx != -1) {
consumedTokenIndices.add(foundDashIdx)
}
} else {
// println("Suppression de la note $ut (gérée par le saut du fragment d'origine)")
}
} else {
val processedToken = if (ut.matches(Regex("[drmfsltDRFSTzw].*"))) { val processedToken = if (ut.matches(Regex("[drmfsltDRFSTzw].*"))) {
val (fileBase, fileLvl) = parseNoteAndOctave(allTokens[i]) val (fileBase, fileLvl) = parseNoteAndOctave(allTokens[i])
val (uBase, _) = parseNoteAndOctave(ut) val (uBase, _) = parseNoteAndOctave(ut)
@ -731,18 +768,29 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
resultTokens.add(processedToken) resultTokens.add(processedToken)
if (processedToken.matches(Regex("[drmfsltDRFSTzw].*|[-―]"))) { if (processedToken.matches(Regex("[drmfsltDRFSTzw].*|[-―]"))) {
// println(String.format("NEW | %-10s | INSERT | %-12d | REPLACE", processedToken, currentLogicalIdx)) // println(
// String.format(
// "NEW | %-10s | INSERT | %-12d | REPLACE",
// processedToken,
// currentLogicalIdx
// )
// )
currentLogicalIdx++ currentLogicalIdx++
} }
} }
}
var skipped = 0 var skipped = 0
while (skipped < dynamicDeleteCount && i < allTokens.size) { while (skipped < dynamicDeleteCount && i < allTokens.size) {
if (consumedTokenIndices.contains(i)) {
i++
continue
}
val nextT = allTokens[i] val nextT = allTokens[i]
if (!nextT.contains("#") && nextT != "/" && nextT != "(" && nextT != ")") { if (!nextT.contains("#") && nextT != "/" && nextT != "(" && nextT != ")") {
skipped++ skipped++
} else if (hasAnchor) { } else if (hasAnchor) {
resultTokens.add(nextT) // Garder les ancres si on est en mode ancre resultTokens.add(nextT)
} }
i++ i++
} }
@ -779,9 +827,6 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
initMarkerValue: String, initMarkerValue: String,
editState: TUOEditState editState: TUOEditState
): String { ): String {
/* On ne modifie pas le template tant que les notes ne sont pas modifiées
* Et tant qu'un marker est ajouter
* Ou une séparateur de mesure est ajouter */
val notesByVoice = editState.notesByVoice val notesByVoice = editState.notesByVoice
val originalNotes = editState.originalNotes val originalNotes = editState.originalNotes
val separat = editState.sep val separat = editState.sep

View file

@ -19,14 +19,17 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.* import androidx.compose.ui.unit.*
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import mg.dot.feufaro.solfa.Solfa
import mg.dot.feufaro.solfa.TUOEditState import mg.dot.feufaro.solfa.TUOEditState
import mg.dot.feufaro.solfa.TimeUnitObject import mg.dot.feufaro.solfa.TimeUnitObject
@ -349,6 +352,36 @@ fun TUODetailDialog(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
val activeMessages = (0..3).mapNotNull { voice ->
val currentNote = notes[voice] ?: ""
val validation = validateMusicalInput(currentNote, templateFragment)
if (validation.message.isNotEmpty() && currentNote.isNotEmpty()) validation else null
}
if (activeMessages.isNotEmpty()) {
val firstValidation = activeMessages.first()
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
shape = RoundedCornerShape(8.dp),
color = if (firstValidation.isValid) Color(0xFF10B981) else Color(0xFFF59E0B),
shadowElevation = 2.dp
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = firstValidation.message,
color = if (firstValidation.isValid) Color.White else Color.Black,
fontSize = 13.sp,
fontWeight = FontWeight.Medium
)
}
}
}
// --- SECTION NOTES --- // --- SECTION NOTES ---
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -356,23 +389,8 @@ fun TUODetailDialog(
) { ) {
(0..3).forEach { voice -> (0..3).forEach { voice ->
val currentNote = notes[voice] ?: "" val currentNote = notes[voice] ?: ""
val isValid = validateMusicalInput(currentNote, templateFragment) val validation = validateMusicalInput(currentNote, templateFragment)
val tooltipState = rememberTooltipState(isPersistent = false) Box(modifier = Modifier.wrapContentSize()
Row(
) {
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
tooltip = {
if (!isValid) {
PlainTooltip(
containerColor = Color(0xFFF59E0B),
contentColor = Color.Black
) {
Text("Veuillez suivre ce format: $templateFragment")
}
}
},
state = tooltipState
) { ) {
MyTextEditField( MyTextEditField(
value = currentNote ?: "", value = currentNote ?: "",
@ -382,7 +400,7 @@ fun TUODetailDialog(
customBrush = SolidColor(Color.White), customBrush = SolidColor(Color.White),
isEditable = isEditable, isEditable = isEditable,
isAddable = canAdd, isAddable = canAdd,
isWarn = !isValid, isWarn = !validation.isValid,
funTransform = ::transformMusicalInput, funTransform = ::transformMusicalInput,
onValueChng = { newValue -> onValueChng = { newValue ->
notes[voice] = newValue notes[voice] = newValue
@ -390,14 +408,6 @@ fun TUODetailDialog(
) )
} }
} }
LaunchedEffect(isValid, currentNote) {
if (!isValid && currentNote.isNotEmpty()) {
tooltipState.show()
} else {
tooltipState.dismiss()
}
}
}
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -495,13 +505,17 @@ private fun transformMusicalInput(input: String): String {
.replace("¹'", "²") .replace("¹'", "²")
.replace("²¹", "³") .replace("²¹", "³")
.replace("²'", "³") .replace("²'", "³")
.replace("³'", "")
.replace("³¹", "")
.replace("₁,", "") .replace("₁,", "")
.replace("₁₁", "") .replace("₁₁", "")
.replace("₂,", "") .replace("₂,", "")
.replace("₂₁", "") .replace("₂₁", "")
.replace("₃,", "")
.replace("₃₁", "")
.replace("'", "¹") .replace("'", "¹")
} }
fun transformMarkerInput(input: String): String { private fun transformMarkerInput(input: String): String {
return input return input
.replace("dc", "DC") .replace("dc", "DC")
.replace("ds", "DS") .replace("ds", "DS")
@ -522,14 +536,38 @@ fun MyTextEditField(
funTransform: ((String) -> String)? = null, funTransform: ((String) -> String)? = null,
onValueChng: (String) -> Unit onValueChng: (String) -> Unit
) { ) {
val textToShow = if (isAddable && value == "_") "" else value var textFieldValueState by remember {
mutableStateOf(TextFieldValue(text = value, selection = TextRange(value.length)))
}
LaunchedEffect(value) {
if (textFieldValueState.text != value) {
val newSelection = if (textFieldValueState.selection.start <= value.length) {
textFieldValueState.selection
} else {
TextRange(value.length)
}
textFieldValueState = TextFieldValue(text = value, selection = newSelection)
}
}
val textToShow = if (isAddable && value == "_") "" else textFieldValueState.text
BasicTextField( BasicTextField(
value = textToShow, value = textFieldValueState,
onValueChange = { newValue -> onValueChange = { newValue ->
if (isEditable || isAddable) { if (isEditable || isAddable) {
val processedVal = funTransform?.invoke(newValue) ?: newValue val transformedText = funTransform?.invoke(newValue.text) ?: newValue.text
onValueChng(processedVal) val finalSelection = if (transformedText.length != newValue.text.length) {
if (newValue.selection.start <= transformedText.length) newValue.selection else TextRange(transformedText.length)
} else {
newValue.selection
}
textFieldValueState = TextFieldValue(
text = transformedText,
selection = finalSelection
)
onValueChng(transformedText)
} }
}, },
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
@ -555,17 +593,60 @@ fun MyTextEditField(
) )
} }
fun validateMusicalInput(input: String, template: String): Boolean { data class ValidNoteResult(
if (template.isEmpty()) return true val isValid: Boolean,
val message: String
)
val cleanInput = input.replace("(", "").replace(")", "").replace(" ", "") private fun validateMusicalInput(input: String, template: String): ValidNoteResult {
if (template.isEmpty()) return ValidNoteResult(false, "")
/* Ajout > */
val matchAddNote = Solfa.REGEX_NOTE_TOADD.find(input)
if (matchAddNote != null) {
val addedNote = matchAddNote.value.replace(">", "")
val inserted = matchAddNote.groupValues[1] + matchAddNote.groupValues[2]
val shifted = matchAddNote.groupValues[3] + matchAddNote.groupValues[4]
return ValidNoteResult(
isValid = true,
message = "+ $inserted$shifted"
)
}
/* Suppress < */
if (input.contains("<")) {
val matchDropNote = Solfa.REGEX_NOTE_TODROP.find(input)
val isValidDrop = matchDropNote != null/* && input.endsWith("<")*/
if (isValidDrop) {
val noteBase: String = matchDropNote.groupValues[1]
val octave: String = matchDropNote.groupValues[2]
val droppedNote = noteBase + octave
return ValidNoteResult(
isValid = true,
message = "$droppedNote ⌫ ?"
)
} else {
return ValidNoteResult(
isValid = false,
message = "< incorrect"
)
}
}
val cleanInput = input
.replace("(", "")
.replace(")", "")
.replace(" ", "")
.replace(Solfa.REGEX_NOTE_TOADD, "$3$4")
.replace(Solfa.REGEX_NOTE_TODROP, "$1$2")
val cleanTemplate = template.replace("(", "").replace(")", "") val cleanTemplate = template.replace("(", "").replace(")", "")
val regexPattern = buildString { val regexPattern = buildString {
cleanTemplate.forEachIndexed { index, char -> cleanTemplate.forEachIndexed { index, char ->
when (char) { when (char) {
in 'A'..'Y', in 'a'..'y' -> { in 'A'..'Y', in 'a'..'y' -> {
append("(?:―|di|ri|fi|si|ta|[drmfsltz])[¹²³₁₂₃]*") append("(?:―|di|ri|fi|si|ta|[drmfsltz])[₄₃₂₁¹²³⁴]*")
} }
'z' -> { 'z' -> {
append(" ?") append(" ?")
@ -602,21 +683,59 @@ fun validateMusicalInput(input: String, template: String): Boolean {
""".trimIndent())*/ """.trimIndent())*/
return isMatched return if (isMatched) {
ValidNoteResult(isValid = true, message = "")
} else {
ValidNoteResult(isValid = false, message = "Erreur")
}
} }
private fun validateLyricsInput(cleanInput: String, template: String): Boolean {
val vowels = "aeiouyòàéìỳAEIOUY"
val expectedSyllableCount = template.count { it.lowercaseChar() != 'z' && it.isLetter() }
val syllableRegex = Regex("[^aeiouyòàéìỳAEIOUY]*[aeiouyòàéìỳAEIOUY]+[^aeiouyòàéìỳAEIOUY]*", RegexOption.IGNORE_CASE)
val foundSyllables = syllableRegex.findAll(cleanInput).toList()
val rawSyllableCount = foundSyllables.size
val backslashCount = cleanInput.count { it == '\\' }
val actualSyllableCount = if (backslashCount > 0) {
(rawSyllableCount - backslashCount).coerceAtLeast(1)
} else {
rawSyllableCount
}
/*println("""
VALIDATION LYRICS
Template : "$template"
Input : "$cleanInput"
Attendu : $expectedSyllableCount syllabes
Trouvé : $actualSyllableCount syllabes
""".trimIndent())*/
return actualSyllableCount == expectedSyllableCount
}
public fun getGlobalTemplate(editState: TUOEditState): String { public fun getGlobalTemplate(editState: TUOEditState): String {
// println("\n--- DÉBOGAGE getGlobalTemplate ---") // println("\n--- DÉBOGAGE getGlobalTemplate ---")
val voiceTemplates = (0..3).map { voiceIdx -> val voiceTemplates = (0..3).map { voiceIdx ->
val notes = editState.notesByVoice[voiceIdx] ?: "" val notes = editState.notesByVoice[voiceIdx] ?: ""
val tpl = notesToTemplate(notes) val filteredNotes = notes
// println("Voix $voiceIdx | Notes: '$notes' -> Template: '$tpl'") .replace(Solfa.REGEX_NOTE_TOADD, "$3$4")
.replace("<", "")
val tpl = notesToTemplate(filteredNotes)
// println("Voix $voiceIdx | Notes: '$filteredNotes' -> Template: '$tpl'")
tpl tpl
} }
val rythmicTemplates = voiceTemplates.map { tpl -> tpl.replace(Regex("[()]"), "") } val rythmicTemplates = voiceTemplates.map { tpl -> tpl.replace(Regex("[()]"), "") }
val maxLen = rythmicTemplates.maxOfOrNull { it.length } ?: 1 val maxLen = rythmicTemplates.maxOfOrNull { it.length } ?: 1
// println("Longueur rythmique max détectée: $maxLen") // println("Longueur rythmique max détectée: $maxLen")
val openBefore = BooleanArray(maxLen) val openBefore = BooleanArray(maxLen)
val closeBefore = BooleanArray(maxLen) val closeBefore = BooleanArray(maxLen)
@ -673,7 +792,6 @@ public fun getGlobalTemplate(editState: TUOEditState): String {
previousChars.contains('.') && chars.contains(' ') -> 'z' previousChars.contains('.') && chars.contains(' ') -> 'z'
else -> ' ' else -> ' '
} }
// Supprimer espace avant une virgule
if (combinedChar == ',' && result.isNotEmpty() && result.last() == ' ') { if (combinedChar == ',' && result.isNotEmpty() && result.last() == ' ') {
result.deleteCharAt(result.length - 1) result.deleteCharAt(result.length - 1)
} }