diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt index 3cf6377..4bbea70 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt @@ -8,6 +8,8 @@ import mg.dot.feufaro.getGlobalTemplate import mg.dot.feufaro.launchFilePicker import mg.dot.feufaro.midi.MidiPitch import mg.dot.feufaro.midi.MidiWriterKotlin +import mg.dot.feufaro.transformLyricsInput +import mg.dot.feufaro.viewmodel.PartitionMetadata import java.io.File import kotlin.math.min @@ -56,6 +58,8 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository val REGEX_PAREN_RECURSIVE = Regex("(\\([^\\(\\)]*)\\(([^\\)]*)\\)") val REGEX_COMMENT = Regex("\\$\\{[^\\}]*\\}") 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 @@ -465,7 +469,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository val finalString = updatedSource.joinToString("\n") - val tempDir = System.getProperty("java.io.tmpdir") + val tempDir = System.getProperty("java.io.tmpdir")?: "" val fileName = filePath.substringAfterLast('/') val currentTempFile = File(tempDir, fileName) currentTempFile?.deleteOnExit() @@ -665,6 +669,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository val resultTokens = mutableListOf() var currentLogicalIdx = 0 var i = 0 + val consumedTokenIndices = mutableSetOf() // println("\nSuivi pour la voix $linePrefix (Pointeur cible: $targetPointer)") // println( @@ -678,6 +683,14 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository // ) // ) 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 isStructural = token.contains("#") || token == "/" || token == "(" || token == ")" @@ -691,7 +704,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository if (currentLogicalIdx == targetPointer) { val originalToken = allTokens[i] - //println("Insertition Multiples : $newNot") +// println("Insertition Multiples : $newNot") val rawUserTokens = regex.findAll(revertMusicalInput(newNot)).map { it.value }.toList() /** Si y a '-' dans template inutile d' ajouter au notes */ val hasDashInTemplate = editState.templateFragment.contains("-") @@ -700,7 +713,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository } else { 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 firstNewNote = newUserTokens.firstOrNull { it.matches(Regex("[drmfsltDRFSTzw].*")) } ?: "" @@ -713,36 +726,71 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository val oldTokensForThisFragment = regex.findAll(revertMusicalInput(oldNot)) .map { it.value } - .filter { it.matches(Regex("[drmfsltDRFSTzw].*|[-―]")) } + .filter { it.matches(Regex("[drmfsltDRFSTzw].*")) } .toList() val dynamicDeleteCount = oldTokensForThisFragment.size newUserTokens.forEach { ut -> - val processedToken = if (ut.matches(Regex("[drmfsltDRFSTzw].*"))) { - val (fileBase, fileLvl) = parseNoteAndOctave(allTokens[i]) - val (uBase, _) = parseNoteAndOctave(ut) - uBase + formatOctave(fileLvl + octaveDiff) + 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 { - ut - } -// println("j'aoute $processedToken") + val processedToken = if (ut.matches(Regex("[drmfsltDRFSTzw].*"))) { + val (fileBase, fileLvl) = parseNoteAndOctave(allTokens[i]) + val (uBase, _) = parseNoteAndOctave(ut) + uBase + formatOctave(fileLvl + octaveDiff) + } else { + ut + } +// println("j'aoute $processedToken") - resultTokens.add(processedToken) + resultTokens.add(processedToken) - if (processedToken.matches(Regex("[drmfsltDRFSTzw].*|[-―]"))) { -// println(String.format("NEW | %-10s | INSERT | %-12d | REPLACE", processedToken, currentLogicalIdx)) - currentLogicalIdx++ + if (processedToken.matches(Regex("[drmfsltDRFSTzw].*|[-―]"))) { +// println( +// String.format( +// "NEW | %-10s | INSERT | %-12d | REPLACE", +// processedToken, +// currentLogicalIdx +// ) +// ) + currentLogicalIdx++ + } } } var skipped = 0 while (skipped < dynamicDeleteCount && i < allTokens.size) { + if (consumedTokenIndices.contains(i)) { + i++ + continue + } val nextT = allTokens[i] if (!nextT.contains("#") && nextT != "/" && nextT != "(" && nextT != ")") { skipped++ } else if (hasAnchor) { - resultTokens.add(nextT) // Garder les ancres si on est en mode ancre + resultTokens.add(nextT) } i++ } @@ -779,9 +827,6 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository initMarkerValue: String, editState: TUOEditState ): 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 originalNotes = editState.originalNotes val separat = editState.sep diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TUODetailDialog.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TUODetailDialog.kt index 773d7a3..e4ebfcb 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TUODetailDialog.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TUODetailDialog.kt @@ -19,14 +19,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight 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.unit.* import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import mg.dot.feufaro.solfa.Solfa import mg.dot.feufaro.solfa.TUOEditState import mg.dot.feufaro.solfa.TimeUnitObject @@ -349,6 +352,36 @@ fun TUODetailDialog( 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 --- Column( modifier = Modifier.fillMaxWidth(), @@ -356,46 +389,23 @@ fun TUODetailDialog( ) { (0..3).forEach { voice -> val currentNote = notes[voice] ?: "" - val isValid = validateMusicalInput(currentNote, templateFragment) - val tooltipState = rememberTooltipState(isPersistent = false) - Row( + val validation = validateMusicalInput(currentNote, templateFragment) + Box(modifier = Modifier.wrapContentSize() ) { - TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { - if (!isValid) { - PlainTooltip( - containerColor = Color(0xFFF59E0B), - contentColor = Color.Black - ) { - Text("Veuillez suivre ce format: $templateFragment") - } - } - }, - state = tooltipState - ) { - MyTextEditField( - value = currentNote ?: "", - customFontSize = 14.sp, - color = Color.White, - customPadding = 8.dp, - customBrush = SolidColor(Color.White), - isEditable = isEditable, - isAddable = canAdd, - isWarn = !isValid, - funTransform = ::transformMusicalInput, - onValueChng = { newValue -> - notes[voice] = newValue - } - ) - } - } - LaunchedEffect(isValid, currentNote) { - if (!isValid && currentNote.isNotEmpty()) { - tooltipState.show() - } else { - tooltipState.dismiss() - } + MyTextEditField( + value = currentNote ?: "", + customFontSize = 14.sp, + color = Color.White, + customPadding = 8.dp, + customBrush = SolidColor(Color.White), + isEditable = isEditable, + isAddable = canAdd, + isWarn = !validation.isValid, + funTransform = ::transformMusicalInput, + onValueChng = { newValue -> + notes[voice] = newValue + } + ) } } } @@ -495,13 +505,17 @@ private fun transformMusicalInput(input: String): String { .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 .replace("dc", "DC") .replace("ds", "DS") @@ -522,14 +536,38 @@ fun MyTextEditField( funTransform: ((String) -> String)? = null, 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( - value = textToShow, + value = textFieldValueState, onValueChange = { newValue -> if (isEditable || isAddable) { - val processedVal = funTransform?.invoke(newValue) ?: newValue - onValueChng(processedVal) + val transformedText = funTransform?.invoke(newValue.text) ?: newValue.text + 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( @@ -555,17 +593,60 @@ fun MyTextEditField( ) } -fun validateMusicalInput(input: String, template: String): Boolean { - if (template.isEmpty()) return true +data class ValidNoteResult( + 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 regexPattern = buildString { cleanTemplate.forEachIndexed { index, char -> when (char) { in 'A'..'Y', in 'a'..'y' -> { - append("(?:―|di|ri|fi|si|ta|[drmfsltz])[¹²³₁₂₃]*") + append("(?:―|di|ri|fi|si|ta|[drmfsltz])[₄₃₂₁¹²³⁴]*") } 'z' -> { append(" ?") @@ -602,21 +683,59 @@ fun validateMusicalInput(input: String, template: String): Boolean { └────────────────────────────────────────────────────────── """.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 { // println("\n--- DÉBOGAGE getGlobalTemplate ---") val voiceTemplates = (0..3).map { voiceIdx -> val notes = editState.notesByVoice[voiceIdx] ?: "" - val tpl = notesToTemplate(notes) -// println("Voix $voiceIdx | Notes: '$notes' -> Template: '$tpl'") + val filteredNotes = notes + .replace(Solfa.REGEX_NOTE_TOADD, "$3$4") + .replace("<", "") + val tpl = notesToTemplate(filteredNotes) +// println("Voix $voiceIdx | Notes: '$filteredNotes' -> Template: '$tpl'") tpl } val rythmicTemplates = voiceTemplates.map { tpl -> tpl.replace(Regex("[()]"), "") } 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 closeBefore = BooleanArray(maxLen) @@ -673,7 +792,6 @@ public fun getGlobalTemplate(editState: TUOEditState): String { previousChars.contains('.') && chars.contains(' ') -> 'z' else -> ' ' } - // Supprimer espace avant une virgule if (combinedChar == ',' && result.isNotEmpty() && result.last() == ' ') { result.deleteCharAt(result.length - 1) }