Add Edit Mode With source line & on UI(Only notes)

This commit is contained in:
hasinarak3@gmail.com 2026-05-08 16:30:41 +03:00
parent 49186a76c8
commit 410046a3cc
15 changed files with 2370 additions and 321 deletions

View file

@ -30,6 +30,10 @@ class MainActivity : ComponentActivity() {
1
)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
super.onCreate(savedInstanceState)
setFilePickerActivity(this)
WindowCompat.setDecorFitsSystemWindows(window, false)

View file

@ -399,7 +399,7 @@ actual fun rememberPdfExportAction(
nbStanza, songKey, songAut, songComp, songRythm
)
val chosenPath = withContext(Dispatchers.Main) {
fileRepository.pickSavePath(computedFileName)
fileRepository.pickSavePath(computedFileName, "Exporter en PDF")
}
if (chosenPath != null) {
fileRepository.saveLocalFile(chosenPath, pdfBytes)

View file

@ -2,8 +2,8 @@
"themeMode": "DARK",
"fontSize": 18.5,
"playlist": [
"assets://a.txt",
"assets://ffpm-617.txt",
"assets://a.txt",
"assets://ews-127.txt",
"assets://ffpm-521.txt",
"assets://ews-126.txt",

View file

@ -1,23 +1,38 @@
package mg.dot.feufaro
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.List
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.material3.IconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun ContextualMenu (onMenuItemClick: (String) -> Unit){
Column(
@ -32,7 +47,7 @@ fun ContextualMenu (onMenuItemClick: (String) -> Unit){
}
@Composable
fun MenuItem(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String, onClick: () -> Unit) {
fun MenuItem(icon: ImageVector, text: String, onClick: () -> Unit) {
IconButton(onClick = onClick) {
Column(horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally) {
Icon(icon, contentDescription = text, tint = Color.White)

View file

@ -13,13 +13,21 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Icon
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@ -50,6 +58,8 @@ import cafe.adriel.voyager.koin.koinScreenModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import mg.dot.feufaro.data.GridTUOData
import mg.dot.feufaro.solfa.ColorPrefixB
import mg.dot.feufaro.solfa.EditSourceCompose
import mg.dot.feufaro.ui.MyVerticalScrollbar
import mg.dot.feufaro.viewmodel.SolfaScreenModel
import java.io.ObjectStreamException
@ -64,7 +74,7 @@ object ScreenSolfa : Screen {
val tuoList by sharedScreenModel.tuoList.collectAsState()
val measure by sharedScreenModel.measure.collectAsState()
val stanza by sharedScreenModel.stanza.collectAsState()
val gridTUOData = GridTUOData(measure, tuoList, stanza)
val gridTUOData = remember(tuoList, measure, stanza) { GridTUOData(measure, tuoList, stanza) }
val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState()
var showContextualMenu = false
@ -81,6 +91,16 @@ object ScreenSolfa : Screen {
// DeepLinkHandler.consumePending("")
}
val tuoEditState by sharedScreenModel.tuoEditState.collectAsState()
LaunchedEffect(tuoEditState) {
tuoEditState?.let { state ->
coroutineScope.launch(Dispatchers.IO) {
solfaScreenModel.saveTUOEdit(state)
}
sharedScreenModel.closeTUOEditor()
}
}
DisposableEffect(Unit) {
onDispose {
DeepLinkHandler.onSongReceived = null
@ -97,7 +117,10 @@ object ScreenSolfa : Screen {
solfaScrollState = scrollState
) { paddingValues ->
Box(
Modifier.fillMaxSize().padding(paddingValues)
Modifier.fillMaxSize()
.padding(paddingValues)
.windowInsetsPadding(WindowInsets.safeDrawing)
.windowInsetsPadding(WindowInsets.ime)
.padding(horizontal = 16.dp)
) {
Column(
@ -139,7 +162,7 @@ object ScreenSolfa : Screen {
) {
FlowRow(
modifier = Modifier.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing)
//.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(start = 8.dp, end = 8.dp, top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
@ -163,10 +186,23 @@ object ScreenSolfa : Screen {
sharedScreenModel = sharedScreenModel,
onGridWidthMeasured = { width -> gridWidthPx = width }
)
EditSourceCompose(sharedScreenModel, solfaScreenModel)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
val isEditMode = sharedScreenModel.editModeState.value
IconToggleButton(
checked = isEditMode,
onCheckedChange = { sharedScreenModel.toggleEditMode() },
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = if (isEditMode) Icons.Filled.Check else Icons.Filled.Edit,
contentDescription = "Mode édition",
tint = if (isEditMode) ColorPrefixB else Color.Gray
)
}
MGButton(onClick = {
//showContent = !showContent
@ -184,7 +220,7 @@ object ScreenSolfa : Screen {
}
FlowRow(
modifier = Modifier.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing)
//.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(start = 8.dp, end = 8.dp, top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)

View file

@ -9,10 +9,11 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mg.dot.feufaro.FileRepository
import mg.dot.feufaro.SaveSettings
import mg.dot.feufaro.getConfigDirectoryPath
import mg.dot.feufaro.data.GridTUOData
import mg.dot.feufaro.launchFilePicker
import mg.dot.feufaro.midi.MidiPitch
import mg.dot.feufaro.midi.MidiWriterKotlin
import java.io.File
import kotlin.math.min
//@todo: split voices (ffpm19/ews22) ${S:mfs} in N4:, idem ffpm-212
@ -71,16 +72,23 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
private val meta: MutableMap<String, String> = mutableMapOf()
private val lyricsComment: MutableList<String> = mutableListOf()
suspend fun nextTimeUnitObject() {
val lastTUO = sharedScreenModel.lastTUO()
suspend fun nextTimeUnitObject(accumulator: MutableList<TimeUnitObject>) {
val lastTUO = accumulator.lastOrNull()
nextTIndex++
if (nextTIndex == 5) {
if (T.getOrNull(nextTIndex) == null) {
withContext(Dispatchers.Main) {
sharedScreenModel.updateFullTUOList(accumulator)
//sharedScreenModel.doneTUOList()
}
return
}
/*if (nextTIndex == 5) {
sharedScreenModel.doneTUOList()
}
if (T.getOrNull(nextTIndex) == null) {
sharedScreenModel.doneTUOList()
return
}
}*/
val pTemplate = T[nextTIndex]
val unitObject = TimeUnitObject(pTemplate, lastTUO, N.size)
if (lastTUO == null) {
@ -132,9 +140,10 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
}
}
withContext(Dispatchers.Main) {
sharedScreenModel.addTUO(unitObject)
//sharedScreenModel.addTUO(unitObject)
accumulator.add(unitObject)
}
nextTimeUnitObject()
nextTimeUnitObject(accumulator)
}
fun loadSolfa() {
@ -157,7 +166,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
screenModelScope.launch {
val initialPath = stateSettings.loadLastUsedDir()
val homedir = getConfigDirectoryPath();
val homedir = fileRepository.getAppPublicFolder().absolutePath
launchFilePicker(
mimeTypes = arrayOf("text/plain"),
@ -186,7 +195,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
sharedScreenModel.reset()
}
fun parse(sourceFile: String) {
println("37: lFL bien reçu $sourceFile")
//println("37: lFL bien reçu $sourceFile")
currentFile = sourceFile
val parseScope = CoroutineScope(Dispatchers.Default)
parseScope.launch {
@ -229,7 +238,11 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
preloadN(it)
}
try {
nextTimeUnitObject()
val localAccumulator = mutableListOf<TimeUnitObject>()
withContext(Dispatchers.Main) {
sharedScreenModel.updateFullTUOList(emptyList())
}
nextTimeUnitObject(localAccumulator)
} catch (e: Exception) {
println("Erreur parseScope Solfa:150 : ${e.message} iter: ${TimeUnitObject.nbBlock}")
}
@ -240,6 +253,707 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
sharedScreenModel.setStanza(1)
}
}
fun justeBuild(sourceFile: String) {
currentFile = sourceFile
val parseScope = CoroutineScope(Dispatchers.Default)
parseScope.launch {
val lines = try {
fileRepository.readFileLines(sourceFile)
} catch (e: Exception) {
println("Opening $sourceFile raised exception {${e.message}")
emptyList()
}
val contentString = fileRepository.readFileLines(sourceFile).joinToString("\n")
sharedScreenModel.setFileContent(contentString, sourceFile)
O.clear()
T.clear()
N.clear()
L.clear()
if (pitches.isNotEmpty()) {
pitches[0].reset()
}
pitches.clear()
refrainBeginsAt = -1
unparsedNote.clear()
templateString = ""
nextTIndex = -1
nextNIndex = -1
nextLIndex = -1
lyricsComment.clear()
TimeUnitObject.hasMarker(false)
lines.forEach { line ->
run {
parseOneLine(line)
}
}
unparsedNote.forEach {
preloadN(it)
}
try {
val localAccumulator = mutableListOf<TimeUnitObject>()
withContext(Dispatchers.Main) {
sharedScreenModel.updateFullTUOList(emptyList())
}
nextTimeUnitObject(localAccumulator)
} catch (e: Exception) {
println("Erreur parseScope Solfa:150 : ${e.message} iter: ${TimeUnitObject.nbBlock}")
}
val pitches = pitches.sortedWith(compareBy({ it.tick }, { it.voiceNumber }))
val midiWriter = MidiWriterKotlin(fileRepository)
midiWriter.process(pitches)
midiWriter.save("whawyd3.mid")
}
}
suspend fun saveTUOEdit(editState: TUOEditState) {
println(editState)
/* N.mapIndexed { index, note ->
note.oneVoiceNote.mapIndexed { index, notes ->
println(notes.note)
}
}*/
val filePath = currentFile
if (filePath.isBlank()) return
// inclusion -----
val includedContent = expandInclusions(filePath)
val lines = includedContent.split("\n").toMutableList()
val targetIdx = editState.tuoIndex
val t0Idx = lines.indexOfFirst { it.startsWith("T0:") }
val u0Idx = lines.indexOfFirst { it.startsWith("U0:") }
val noteS = lines[lines.indexOfFirst { it.startsWith("N1") }]
val noteA = lines[lines.indexOfFirst { it.startsWith("N2") }]
val noteT = lines[lines.indexOfFirst { it.startsWith("N3") }]
val noteB = lines[lines.indexOfFirst { it.startsWith("N4") }]
val currentStanza = sharedScreenModel.stanza.value
val lyrIdx = lines.indexOfFirst { line ->
line.contains(Regex("^[EY]$currentStanza:"))
}
val lyrics = lines[lyrIdx]
println("Parole sur $currentStanza: $lyrics")
val content = smartYLyrics(lyrics).substringAfter(":")
val indexedLyrics = content.split('_', ' ', '/')
.filter { it.isNotBlank() }
.toMutableList()
/*indexedLyrics.forEachIndexed { index, syllable ->
println("Index $index : [$syllable]")
}*/
val measureString = sharedScreenModel.measure.value
val templateString = when {
t0Idx != -1 -> {
lines[t0Idx]
}
u0Idx != -1 -> {
lines[u0Idx]
}
else -> ""
}
val templateIndices = when {
t0Idx != -1 -> {
t0Idx
}
u0Idx != -1 -> {
u0Idx
}
else -> 0
}
when {
t0Idx != -1 -> {
val line = lines[t0Idx]
val prefix = line.substringBefore('{') + "{"
val suffix = "}"
val body = line.substringAfter('{').substringBeforeLast('}')
val regex = Regex("([^:|/!]*[:|/!])|([^:|/!]+)")
val fragments = regex.findAll(body).map { it.value }.toMutableList()
// println("Nombre de blocs détectés : ${fragments.size}")
val markerRegex = Regex("""\$\{.*?\}|\$.""")
if (targetIdx in fragments.indices) {
val oldFrag = fragments[targetIdx]
val delimiter = if (oldFrag.isNotEmpty() && oldFrag.last() in ":|/!") {
oldFrag.last().toString()
} else ""
val cleanBody = fragments.joinToString("") { it.replace(markerRegex, "") }
lines[t0Idx] = prefix + cleanBody + suffix
println("AVANT: $line")
println("APRÈS: ${lines[t0Idx]}")
} else {
println("Index $targetIdx hors limites pour T0 (taille=${fragments.size})")
return
}
}
u0Idx != -1 -> {
val fullLine = lines[u0Idx]
// println("=== DEBUG U0 ===")
// println("fullLine = '$fullLine'")
val afterU0 = fullLine.substringAfter("U0:")
val blankPrefixRegex = Regex("^(z[048CEKO]:)")
val match = blankPrefixRegex.find(afterU0)
val blankPrefix = match?.value ?: ""
val body = if (match != null) {
afterU0.substring(blankPrefix.length)
} else {
afterU0
}
val measure = measureString.split("/")[0].toIntOrNull() ?: 4
val parser = ParseULine(body, measure)
val symboles = parser.parsed()
val newLigneT = "U0:"+blankPrefix+symboles
println("Ligne de U0 ==> $newLigneT")
lines[u0Idx] = newLigneT
}
else -> {
println("Aucune ligne T0 ou U0 trouvée dans le fichier")
return
}
}
val templatArray = templateToArray(if(u0Idx != -1) lines[u0Idx] else lines[t0Idx])
/* println("index | contenuTUO")
templatArray.forEachIndexed { index, block ->
println("$index $block")
}*/
/*
MODIF NOTES N1 N2 N3 N4
*/
// println("taille tA: ${templatArray.size} et le trgt $targetIdx")
val originalBlock = templatArray[targetIdx]
val markerRegex = Regex("""\$\{.*?\}|\$\w+""")
val deleteCount = originalBlock.replace(markerRegex, "").count { it.isLetter() }
val updatedNotes = updateSourceLines(lines, templatArray, targetIdx, deleteCount, editState)
// RESTAURATION DES TEMPLATES
if (templateString != "") {
updatedNotes[templateIndices] = templateString
}
val finalString = updatedNotes.joinToString("\n")
val tempDir = System.getProperty("java.io.tmpdir")
val fileName = filePath.substringAfterLast('/')
val currentTempFile = File(tempDir, fileName)
currentTempFile?.deleteOnExit()
val normalizedTempDir = File(tempDir).absolutePath
val originalFilePath = if (filePath.startsWith(normalizedTempDir)) {
filePath
} else {
""
}
// println("Fichier Modifier:\n" +
// "$finalString")
//fileRepository.saveFile(filePath, finalString.toByteArray())
currentTempFile?.writeBytes(finalString.toByteArray())
withContext(Dispatchers.Main) {
justeBuild(currentTempFile.absolutePath)
}
}
private suspend fun expandInclusions(filePath: String):String {
val result = StringBuilder()
val lastSlash = filePath.lastIndexOf('/')
val directory = if (lastSlash != -1) filePath.substring(0, lastSlash + 1) else ""
val myLines = fileRepository.readFileLines(filePath)
myLines.forEach { line ->
if (line.startsWith("I0:")) {
try {
val parts = line.substring(3).split(":")
val fileName = parts[0]
val ignorePattern = if (parts.size > 1) parts[1] else ""
val fullPath = directory + fileName
val includedLines = fileRepository.readFileLines(fullPath)
val regexIgnore = if (ignorePattern.isNotEmpty()) Regex(ignorePattern) else null
includedLines.forEach { incLine ->
if (regexIgnore == null || !regexIgnore.containsMatchIn(incLine)) {
result.append(incLine).append("\n")
}
}
} catch (e: Exception) {
result.append("// Erreur inclusion: ${e.message}\n")
}
} else {
result.append(line).append("\n")
}
}
return result.toString().trimEnd()
}
private fun updateSourceLines(
lines: MutableList<String>,
fragments: List<String>,
targetIdx: Int,
deleteCount: Int,
editState: TUOEditState
): MutableList<String> {
/* Notes N1 N2 N3 N4 */
updateNotesInLines(lines, fragments, targetIdx, deleteCount, editState)
/* MODIF LYRICS */
//updateLyricsInLines(lines, fragments, targetIdx, deleteCount, editState)
return lines
}
private fun updateLyricsInLines(
lines: MutableList<String>,
fragments: List<String>,
targetIdx: Int,
deleteCount: Int,
editState: TUOEditState
) {
editState.lyricsByStanza.forEach { (stanzaNum, newSyllable) ->
val lineYIdx = lines.indexOfFirst { it.matches(Regex("^[Y]$stanzaNum:.*")) }
val lineEIdx = lines.indexOfFirst { it.matches(Regex("^[E]$stanzaNum:.*")) }
val lineIdx = if(lineYIdx != -1) lineYIdx else lineEIdx
if (lineIdx != -1) {
val currentLine = lines[lineIdx]
val prefix = currentLine.substringBefore(":") + ":"
val lyricsBody = currentLine.substringAfter(":")
// On traite les paroles pour avoir la liste brute
val lyricsContent = if(lineYIdx != -1) smartYLyrics(lyricsBody) else smartELyrics(lyricsBody)
// Découpage strict pour correspondre au template
println("Lyrics BODY $lyricsBody")
println("LyricsC $lyricsContent")
val lyricsInLines = sharedScreenModel.synchronizedSyllables.value
lyricsInLines.mapIndexed { index, string ->
//println("SYNRO $index => {$string}")
}
val allTokens = lyricsInLines.toMutableList()
// println("--- TABLEAU DE CORRESPONDANCE (Strophe $stanzaNum) ---")
// println(String.format("%-5s | %-10s | %-15s", "Idx", "Template", "Syllabe"))
// println("-------------------------------------------")
var actualTokenPointer = 0
for (i in fragments.indices) {
val fragment = fragments[i]
val isProlongation = fragment.trim() == "-"
val associatedSyllable = allTokens.getOrNull(actualTokenPointer) ?: ""
actualTokenPointer++
val marker = if (i == targetIdx) " <== [CIBLE]" else ""
// println(String.format("%-5d | %-10s | %-15s %s",
// i,
// "[$fragment]",
// "[$associatedSyllable]",
// marker))
}
println("\nAction finale : Remplacer l'index $targetIdx (valeur: ${allTokens.getOrNull(targetIdx)})")
if (targetIdx < allTokens.size) {
allTokens[targetIdx] = newSyllable
if(deleteCount > 1){
var deleted = 0
var j = targetIdx + 1
while (deleted < deleteCount - 1 && j < allTokens.size) {
if (allTokens[j].isNotEmpty()) {
allTokens[j] = ""
deleted++
}
j++
}
}
}
val rawResult = allTokens.filter { it.isNotEmpty() }.joinToString(" ")
println("allToken ${allTokens.joinToString("")}")
// println("RAWRes ${rawResult}")
val formattedResult = rawResult
.replace(Regex("-\\s+"), "")
.replace(Regex("-"), "")
println("FORMATTER $formattedResult")
val lyrIdx = lines.indexOfFirst { line ->
line.contains(Regex("^[EY]$stanzaNum:"))
}
val lyrics = lines[lyrIdx]
println("Originl $lyrics")
lines[lineIdx] = prefix + formattedResult
//lines[lineIdx] = prefix + reconstructLyrics(finalTokens)
}
}
// println("--- FIN DEBUG ---")
}
private fun updateNotesInLines(
lines: MutableList<String>,
fragments: List<String>,
targetIdx: Int,
deleteCount: Int,
editState: TUOEditState
) {
val notesByVoice = editState.notesByVoice
val originalNotes = editState.originalNotes
for (voiceNum in 0..3) {
val linePrefix = "N${voiceNum + 1}:"
val lineIdx = lines.indexOfFirst { it.startsWith(linePrefix) }
if (lineIdx != -1) {
val currentLine = lines[lineIdx]
val prefix = currentLine.substringBefore(":") + ":"
val noteBody = currentLine.substringAfter(":")
val hasAnchor = noteBody.contains("#")
val noteExpanded = expandNotes(noteBody)
// println("Notes===>$noteExpanded")
val newNot = notesByVoice[voiceNum] ?: ""
val oldNot = originalNotes[voiceNum] ?: ""
val isNoteModified = newNot != oldNot
// println("est-ce? $isNoteModified = $newNot != $oldNot")
if (!isNoteModified) {
// println("Voix $linePrefix : Aucune modification détectée (Note: $oldNot ).")
continue
}
val newNote = notesByVoice[voiceNum] ?: ""
if (newNote.isEmpty()) continue
val cleanNewNote = revertMusicalInput(newNote)
val regex = Regex("#\\S[',]*|\\s#\\S[',]*|/|\\(|\\)|[drmfsltDRFSTzw][0-9'¹²³⁴⁵₁₂₃₄₅,]*|[-.]")
val allTokens = regex.findAll(noteExpanded).map { it.value }.toList()
val targetPointer = getNotePointer(fragments, targetIdx, currentLine)
val resultTokens = mutableListOf<String>()
var currentLogicalIdx = 0
var i = 0
// println("\nSuivi pour la voix $linePrefix (Pointeur cible: $targetPointer)")
// println(
// String.format(
// "%-5s | %-10s | %-8s | %-12s | %-10s",
// "Idx P",
// "Token",
// "Type",
// "LogiqueIdx",
// "Action"
// )
// )
while (i < allTokens.size) {
val token = allTokens[i]
val isStructural = token.contains("#") || token == "/" || token == "(" || token == ")"
if (isStructural) {
// println(String.format("%-5d | %-10s | %-8s | %-12s | %-10s", i, token, "STRUCT", "-", "Keep"))
resultTokens.add(token)
i++
continue
}
if (currentLogicalIdx == targetPointer) {
val originalToken = allTokens[i]
var replacement = if (!hasAnchor) {
val (newBase, newLevel) = parseNoteAndOctave(revertMusicalInput(newNot))
val (oldBase, oldLevel) = parseNoteAndOctave(revertMusicalInput(oldNot))
val octaveDiff = newLevel - oldLevel
val (tokenBase, tokenLevel) = parseNoteAndOctave(originalToken)
newBase + formatOctave(tokenLevel + octaveDiff)
} else {
// SI y a '#'
val anchorMatch = Regex("#([drmfsltDRFST])[',]*").find(noteBody)
val anchorLevel = if (anchorMatch != null) {
val anchorStr = anchorMatch.value
anchorStr.count { it == '\'' } - anchorStr.count { it == ',' }
} else 0
val newNotCleaned = revertMusicalInput(newNot)
val oldNotCleaned = revertMusicalInput(oldNot)
val (newBase, newLevel) = parseNoteAndOctave(newNotCleaned)
val (oldBase, oldLevel) = parseNoteAndOctave(oldNotCleaned)
val sourceLevel = newLevel - anchorLevel
val formattedOctave = formatOctave(sourceLevel)
val finalPureNew = newBase + formattedOctave
val pureOldBase = oldBase + formatOctave(oldLevel - anchorLevel)
// println("Ancre niveau: $anchorLevel | UI Level: $newLevel -> Source Level: $sourceLevel")
//
if (finalPureNew.startsWith(pureOldBase) && pureOldBase.isNotEmpty()) {
val addedPart = finalPureNew.substring(pureOldBase.length)
originalToken + addedPart
} else {
finalPureNew
}
}
// println(
// String.format(
// "%-5s | %-10s | %-8s | %-12s | %-10s",
// "NEW", replacement, "INSERT", currentLogicalIdx, "REPLACE ($originalToken)"
// )
// )
resultTokens.add(replacement)
var notesSkipped = 0
while (notesSkipped < deleteCount && i < allTokens.size) {
val nextToken = allTokens[i]
val isNextStructural =
nextToken.contains("#") || nextToken == "/" || nextToken == "(" || nextToken == ")"
if (!isNextStructural) {
notesSkipped++
} else if (hasAnchor) {
resultTokens.add(nextToken)
}
i++
}
currentLogicalIdx += deleteCount
} else {
// AFFICHAGE NOTE NORMALE
// println(
// String.format(
// "%-5d | %-10s | %-8s | %-12d | %-10s",
// i,
// token,
// "NOTE",
// currentLogicalIdx,
// "Keep"
// )
// )
resultTokens.add(token)
currentLogicalIdx++
i++
}
}
lines[lineIdx] = prefix + resultTokens.joinToString("")
// println("-".repeat(55))
// println("RÉSULTAT : ${lines[lineIdx]}")
}
}
}
private fun parseNoteAndOctave(s: String): Pair<String, Int> {
val base = s.replace(Regex("[^drmfsltDRFSTzw]"), "")
var level = 0
level += s.count { it == '\'' || it == '¹' || it == '²' || it == '³' }
level -= s.count { it == ',' || it == '₁' || it == '₂' || it == '₃' }
return Pair(base, level)
}
private fun formatOctave(level: Int): String {
if (level == 0) return ""
return if (level > 0) "'".repeat(level) else ",".repeat(Math.abs(level))
}
private fun revertMusicalInput(input: String): String {
return input
.replace("• ,", "")
.replace("―•", "")
.replace("", "-")
.replace("", "")
.replace("³", "'''")
.replace("", ",,,")
.replace("²", "''")
.replace("", ",,")
.replace("¹", "'")
.replace("", ",")
.replace("di", "D")
.replace("ri", "R")
.replace("fi", "F")
.replace("si", "S")
.replace("ta", "T")
.replace("(","")
.replace(")","")
}
private fun parseNoteLineToArray(noteLine: String): List<String> {
val content = noteLine.substringAfter(":").replace(" ", "")
val result = mutableListOf<String>()
var i = 0
while (i < content.length) {
val char = content[i]
if (char == '/' || char == ' '/* || char == 'z'*/) {
i++
continue
}
if (char == '#') {
i += 2
continue
}
if (char.isLetter() || char == '-') {
val noteBuilder = StringBuilder(char.toString())
var nextIdx = i + 1
while (nextIdx < content.length && (content[nextIdx] == ',' || content[nextIdx] == '\'')) {
noteBuilder.append(content[nextIdx])
nextIdx++
}
val fullNote = noteBuilder.toString()
if (nextIdx < content.length && content[nextIdx].isDigit()) {
val repeatCount = content[nextIdx].toString().toInt()
repeat(repeatCount) {
result.add(fullNote)
}
nextIdx++
} else {
result.add(fullNote)
}
i = nextIdx
continue
}
i++
}
return result
}
private fun templateToArray(templateLine: String): List<String> {
var templateArray: List<String> = emptyList()
val templateType = if(templateLine.startsWith("T0")) "T" else "U"
if(templateType == "T") {
var content = templateLine.substringAfter("{").substringBeforeLast("}")
// println("AVANT T: $content")
content = content.replace(Regex("\\\$\\{.*?\\}"), "")
val rawBlocks = content.split(Regex("[:|/!]"))
templateArray = rawBlocks.filter { it.isNotBlank() }
// println("\n\nLine = $templateLine \n," +
// "Array = ${templateArray.joinToString("|")}")
// println("APRèS T: ${templateArray.joinToString("|")}")
} else {
val body = templateLine.replaceFirst(Regex("^U0:z.:"), "")
//println("Mon contenu est: $body.")
val result = mutableListOf<String>()
var currentBlock = StringBuilder()
var i = 0
while (i < body.length) {
val char = body[i]
// 1. GESTION DES MARQUEURS ${...} -> ON IGNORE TOUT LE BLOC
if (char == '$' && i + 1 < body.length && body[i + 1] == '{') {
val closingBrace = body.indexOf('}', i)
if (closingBrace != -1) {
i = closingBrace + 1 // On saute jusqu'après le '}'
continue
}
}
if (char == '$') {
i += 2 // On saute le '$' et la lettre qui suit
continue
}
// 3. SÉPARATEURS -> ON COUPE LE BLOC
if (char in listOf(':', '|', '!', '/')) {
if (currentBlock.isNotEmpty()) {
result.add(currentBlock.toString())
currentBlock.clear()
}
}
// 4. IGNORER LES ESPACES
else if (char == ' ') {
// ne rien faire
}
// 5. TOUT LE RESTE (D, z, -, ., ,, (, ) )
else {
currentBlock.append(char)
}
i++
}
// Ajouter le dernier morceau s'il n'y a pas de séparateur à la fin
if (currentBlock.isNotEmpty()) result.add(currentBlock.toString())
templateArray = result
}
// println("\n\nLine = $templateLine \n," +
// "Array = ${templateArray.joinToString("|")}")
return templateArray
}
private fun expandNotes(text: String): String {
val regex = Regex("([drmfsltDRFSTzw])([,']*)(\\d+)")
return regex.replace(text) { matchResult ->
val note = matchResult.groupValues[1]
val octaves = matchResult.groupValues[2]
val count = matchResult.groupValues[3].toInt()
(note + octaves).repeat(count)
}
}
private fun rearrangeNote(noteString: String, infiniteIter: Int = 0): String {
var result: String = noteString
@ -968,4 +1682,96 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository
this.add(element)
}
}
fun getNotePointerWithDebug(templatArray: List<String>, targetIdx: Int, sourceLine: String): Int {
println("\n=== DÉBOGAGE ALIGNEMENT RYTHME / NOTES (CORRIGÉ) ===")
val bodyOnly = sourceLine.substringAfter(":")
val expandedBody = expandNotes(bodyOnly)
// On suppose que parseNoteLineToArray découpe chaque "événement" (note ou prolongement)
val notesList = parseNoteLineToArray("N:$expandedBody")
println("\n=== ALIGNEMENT STRICT : TEMPLATE -> SOURCE ===")
println("Ligne Source : ${sourceLine.substringBefore(":")}")
println("----------------------------------------------------------------------")
println("%-5s | %-12s | %-15s | %-10s".format("IDX", "BLOC TEMP.", "NOTES SRC", "POINTEUR"))
println("----------------------------------------------------------------------")
var currentPointer = 0
var notesInTargetBlock = mutableListOf<String>()
var countInTarget = 0
for (i in templatArray.indices) {
val block = templatArray[i]
// NETTOYAGE : On ignore les marqueurs $Q, ${DC} pour le comptage
val markerRegex = Regex("""\$\{.*?\}|\$\w+""")
val cleanBlock = block.replace(markerRegex, "")
// RÈGLE : On compte uniquement les LETTRES (S, F, D, R, M, L, T, d, r...)
val nbLettres = cleanBlock.count { it.isLetter() && it.lowercaseChar() != 'z' }
// On récupère les notes correspondantes dans le fichier source
val blocNotes = (0 until nbLettres).map { offset ->
notesList.getOrNull(currentPointer + offset) ?: "[VIDE]"
}
if (i == targetIdx) {
countInTarget = nbLettres
notesInTargetBlock.addAll(blocNotes)
}
val marker = if (i == targetIdx) " [ CIBLE ]" else ""
println("[%3d] | %-12s | %-15s | Index: %d %s"
.format(i, block, blocNotes.joinToString(","), currentPointer, marker))
// On incrémente le pointeur pour le prochain bloc
// SEULEMENT si nous n'avons pas encore dépassé la cible
// if (i < targetIdx) {
currentPointer += nbLettres
// }
}
println("----------------------------------------------------------------------")
println("RÉSULTAT : Le bloc $targetIdx contient $countInTarget lettre(s).")
println("Notes pointées dans le fichier source : ${notesInTargetBlock.joinToString(" ET ")}")
println("Pointeur de départ (offset) : $currentPointer")
println("======================================================================\n")
return currentPointer
}
fun getNotePointer(templatArray: List<String>, targetIdx: Int, sourceLine: String): Int {
val bodyOnly = sourceLine.substringAfter(":")
val expandedBody = expandNotes(bodyOnly)
val notesList = parseNoteLineToArray("N:$expandedBody")
var currentPointer = 0
var notesInTargetBlock = mutableListOf<String>()
var countInTarget = 0
for (i in templatArray.indices) {
val block = templatArray[i]
val markerRegex = Regex("""\$\{.*?\}|\$\w+""")
val cleanBlock = block.replace(markerRegex, "")
val nbLettres = cleanBlock.count { it.isLetter() && it.lowercaseChar() != 'z' }
val blocNotes = (0 until nbLettres).map { offset ->
notesList.getOrNull(currentPointer + offset) ?: "[VIDE]"
}
if (i == targetIdx) {
countInTarget = nbLettres
notesInTargetBlock.addAll(blocNotes)
}
val marker = if (i == targetIdx) " [ CIBLE ]" else ""
if (i < targetIdx) {
currentPointer += nbLettres
}
}
return currentPointer
}
}

View file

@ -0,0 +1,210 @@
package mg.dot.feufaro.solfa
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
class SolfaVisualTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
val mapping = IntArray(text.text.length + 1)
var currentOriginalIndex = 0
val transformed = buildAnnotatedString {
val lines = text.text.split("\n")
val hasU0 = lines.any { it.startsWith("U0:") }
val hasT0 = lines.any { it.startsWith("T0:") }
lines.forEachIndexed { index, line ->
val lineStartIndex = currentOriginalIndex
val startLen = this.length // Longueur actuelle du AnnotatedString
when {
line.startsWith("M0:") -> parseMetadataLine(line)
line.startsWith("U0:") && hasU0 -> parseControlLine(line)
line.startsWith("T0:") && !hasU0 && hasT0 -> parseControlLine(line)
line.startsWith("N") && line.getOrNull(2) == ':' -> parseNoteLine(line)
(line.startsWith("Y") || line.startsWith("E")) && (line.getOrNull(2) == ':' || line.getOrNull(3) == ':') -> parseLyricLine(line)
else -> append(line)
}
val lineTextLength = line.length
val transformedLineLength = this.length - startLen
for (i in 0 until lineTextLength) {
val transformedOffset = if (transformedLineLength == 0) startLen
else startLen + (i * transformedLineLength / lineTextLength)
mapping[lineStartIndex + i] = transformedOffset
}
currentOriginalIndex += lineTextLength
if (index < lines.size - 1) {
mapping[currentOriginalIndex] = this.length
append("\n")
currentOriginalIndex++
}
}
mapping[text.text.length] = this.length
}
val solfaOffsetMapping = object : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
return mapping.getOrElse(offset) { transformed.length }.coerceIn(0, transformed.length)
}
override fun transformedToOriginal(offset: Int): Int {
var bestOriginal = 0
for (i in mapping.indices) {
if (mapping[i] <= offset) bestOriginal = i
else break
}
return bestOriginal.coerceIn(0, text.text.length)
}
}
return TransformedText(transformed, solfaOffsetMapping)
}
}
// COULEURS DU THÈME
val ColorPrefixA = Color(0xFF6200EE) // Violet
val ColorPrefixB = Color(0xFF03DAC5) // Teal
val ColorSeparator = Color(0xFFB0A400) // Jaune
val ColorNoteOdd = Color(0xFF2196F3) // Bleu
val ColorNoteEven = Color(0xFFE91E63) // Rose
val ColorDigit = Color(0xFF4CAF50) // Vert
val ColorSpecial = Color(0xFFFF9800) // Orange (#)
val ColorValue = Color(0xFF757575) // Gris
val ColorMarker = Color(0xFF795548) // Marron
val MarkerRegex = Regex("\\$\\{.*?\\}|\\$[^ \n]")
private fun AnnotatedString.Builder.parseMetadataLine(line: String) {
val parts = line.split(":")
// M0:
pushStyle(SpanStyle(color = ColorPrefixA, fontWeight = FontWeight.Bold))
append(parts[0] + ":")
pop()
if (parts.size > 1) {
val remaining = line.substring(parts[0].length + 1)
val segments = remaining.split("|")
segments.forEachIndexed { i, seg ->
if (seg.contains(":")) {
val kv = seg.split(":")
pushStyle(SpanStyle(color = ColorPrefixB)) // m:, r:, t:
append(kv[0] + ":")
pop()
pushStyle(SpanStyle(color = ColorValue))
append(kv[1])
pop()
} else {
append(seg)
}
if (i < segments.size - 1) {
pushStyle(SpanStyle(color = ColorSeparator))
append("|")
pop()
}
}
}
}
private fun AnnotatedString.Builder.appendWithMarkers(text: String) {
var lastIndex = 0
MarkerRegex.findAll(text).forEach { match ->
append(text.substring(lastIndex, match.range.first))
pushStyle(SpanStyle(color = ColorMarker, fontWeight = FontWeight.Bold))
append(match.value)
pop()
lastIndex = match.range.last + 1
}
append(text.substring(lastIndex))
}
private fun AnnotatedString.Builder.parseControlLine(line: String) {
val regex = Regex("^(U0:)(z[0-9A-Z]:)?(.*)$")
val match = regex.find(line)
match?.let {
pushStyle(SpanStyle(color = ColorPrefixA))
append(it.groupValues[1])
pop()
pushStyle(SpanStyle(color = ColorPrefixB))
append(it.groupValues[2])
pop()
parseGenericContent(it.groupValues[3])
}
}
private fun AnnotatedString.Builder.parseNoteLine(line: String) {
val prefix = line.take(3) // N1:
pushStyle(SpanStyle(color = ColorPrefixA, fontWeight = FontWeight.Bold))
append(prefix)
pop()
val content = line.drop(3)
var noteCount = 0
var i = 0
while (i < content.length) {
val char = content[i]
when {
char == '#' -> {
pushStyle(SpanStyle(color = ColorSpecial))
append(char)
pop()
}
char == '/' -> {
pushStyle(SpanStyle(color = ColorSeparator, fontWeight = FontWeight.Bold))
append(char)
pop()
}
char.isDigit() -> {
pushStyle(SpanStyle(color = ColorDigit))
append(char)
pop()
}
char.lowercaseChar() in "drmfsltr" -> {
// Détecter la note complète avec ses octaves (',')
val start = i
while (i + 1 < content.length && (content[i+1] == '\'' || content[i+1] == ',')) {
i++
}
val fullNote = content.substring(start, i + 1)
val color = if (noteCount % 2 == 0) ColorNoteOdd else ColorNoteEven
pushStyle(SpanStyle(color = color))
append(fullNote)
pop()
noteCount++
}
else -> append(char)
}
i++
}
}
private fun AnnotatedString.Builder.parseLyricLine(line: String) {
pushStyle(SpanStyle(color = ColorPrefixA))
append(line.take(3))
pop()
parseGenericContent(line.drop(3))
}
private fun AnnotatedString.Builder.parseGenericContent(content: String) {
val parts = content.split("/")
parts.forEachIndexed { i, part ->
appendWithMarkers(part)
if (i < parts.size - 1) {
pushStyle(SpanStyle(color = ColorSeparator))
append("/")
pop()
}
}
}

View file

@ -0,0 +1,334 @@
package mg.dot.feufaro
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Savings
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
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.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.toUpperCase
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import mg.dot.feufaro.data.GridTUOData
import mg.dot.feufaro.solfa.TUOEditState
import mg.dot.feufaro.solfa.TimeUnitObject
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TUODetailDialog(
editState: TUOEditState,
tuo: TimeUnitObject,
currentStanza: Int,
menuPosition: IntOffset,
globalIndex: Int,
isEditable: Boolean,
canAdd: Boolean,
onDismiss: () -> Unit,
onSave: (TUOEditState) -> Unit
) {
val notes = remember {
mutableStateMapOf<Int, String>().apply {
putAll(editState.notesByVoice)
}
}
val originalNotes = mutableStateMapOf<Int, String>().apply {
putAll(editState.notesByVoice)
}
val originalLyricsByStz = mutableStateMapOf<Int, String>().apply {
putAll(editState.lyricsByStanza)
}
var templateFragment by remember { mutableStateOf(editState.templateFragment) }
var marker by remember { mutableStateOf(editState.marker) }
var hairPin by remember { mutableStateOf(editState.hairPin) }
val lyricsLines = remember {
mutableStateListOf<String>().apply {
val existing = editState.lyricsByStanza[currentStanza] ?: ""
if (existing.isEmpty() && canAdd) add("_") else add(existing)
}
}
Popup(
offset = menuPosition,
onDismissRequest = onDismiss,
properties = PopupProperties(
focusable = true,
dismissOnBackPress = true,
dismissOnClickOutside = true
),
) {
Surface(
modifier = Modifier
.widthIn(max=125.dp)
.heightIn(max = 400.dp),
shape = MaterialTheme.shapes.small,
color = Color(0xFF2D2D2D).copy(0.75f),
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp).fillMaxWidth()) {
Text(
text = "$globalIndex",
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.height(2.dp))
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
) {
// --- SECTION TEMPLATE ---
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Column(
modifier = Modifier.weight(1f)
) {
MyTextEditField(
value = templateFragment,
customFontSize = 14.sp,
color = Color.Yellow,
customPadding = 8.dp,
customBrush = SolidColor(Color.White),
isEditable = isEditable,
isAddable = canAdd,
onValueChng = { templateFragment = it }
)
}
if(!editState.marker.isNullOrEmpty()) {
Column(
modifier = Modifier.weight(1f)
) {
MyTextEditField(
value = marker,
customFontSize = 14.sp,
color = Color.Yellow,
customPadding = 8.dp,
customBrush = SolidColor(Color.White),
isEditable = isEditable,
isAddable = canAdd,
funTransform = ::transformMarkerInput,
onValueChng = { marker = it }
)
}
}
if(!editState.hairPin.isNullOrEmpty()) {
Column(
modifier = Modifier.weight(1f)
) {
MyTextEditField(
value = hairPin,
customFontSize = 14.sp,
color = Color.Yellow,
customPadding = 8.dp,
customBrush = SolidColor(Color.White),
isEditable = isEditable,
isAddable = canAdd,
onValueChng = { hairPin = it }
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
// --- SECTION NOTES ---
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(5.dp)
) {
(0..3).forEach { voice ->
Row(
) {
MyTextEditField(
value = notes[voice] ?: "",
customFontSize = 14.sp,
color = Color.White,
customPadding = 8.dp,
customBrush = SolidColor(Color.White),
isEditable = isEditable,
isAddable = canAdd,
funTransform = ::transformMusicalInput,
onValueChng = { newValue ->
notes[voice] = newValue
}
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
// --- SECTION LYRICS ---
lyricsLines.forEachIndexed { index, line ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 2.dp)
) {
Column(
modifier = Modifier.fillMaxWidth(0.8f)
) {
MyTextEditField(
value = line,
customFontSize = 13.sp,
color = Color.White,
customPadding = 8.dp,
customBrush = SolidColor(Color.White),
isEditable = isEditable,
isAddable = canAdd,
onValueChng = { newValue ->
lyricsLines[index] = newValue
}
)
}
if (isEditable || canAdd) {
IconButton(
onClick = {
if (index == 0) lyricsLines.add("_") else lyricsLines.removeAt(index)
}) {
Icon(
imageVector = if (index == 0) Icons.Default.Add else Icons.Default.Clear,
tint = if (index == 0) Color.Green else Color.Red,
contentDescription = null
)
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
IconButton(onClick = onDismiss, modifier = Modifier.size(20.dp)) {
Icon(
Icons.Default.Clear,
contentDescription = null,
tint = Color.LightGray
)
}
if (isEditable || canAdd) {
IconButton(
onClick = {
val state = TUOEditState(
tuoIndex = globalIndex,
notesByVoice = notes.toMap(),
originalNotes = originalNotes.toMap(),
originalLyricsByStanza = originalLyricsByStz.toMutableMap(),
lyricsByStanza = mutableMapOf(currentStanza to lyricsLines.joinToString(" ")),
templateFragment = templateFragment
)
onSave(state)
},
modifier = Modifier.size(22.dp)
) {
Icon(
Icons.Default.Build,
contentDescription = null,
tint = Color.Green
)
}
}
}
}
}
}
}
fun transformMusicalInput(input: String): String {
return input.lowercase()
.replace(";", "• ,")
.replace(".", "")
.replace("-", "")
.replace("(?<=[a-z])[']".toRegex(), "¹")
.replace("(?<=[a-z])[,]".toRegex(), "")
.replace("¹¹", "²")
.replace("¹'", "²")
.replace("²¹", "³")
.replace("²'", "³")
.replace("₁,", "")
.replace("₁₁", "")
.replace("₂,", "")
.replace("₂₁", "")
.replace("'", "¹")
}
fun transformMarkerInput(input: String): String {
return input
.replace("dc", "DC")
.replace("ds", "DS")
.replace(".)", "\uD834\uDD10")
}
@Composable
fun MyTextEditField(
value: String,
customFontSize: TextUnit,
font: FontFamily? = FontFamily.SansSerif,
color: Color,
customPadding: Dp,
customBrush: Brush,
isEditable: Boolean,
isAddable: Boolean,
funTransform: ((String) -> String)? = null,
onValueChng: (String) -> Unit
) {
val textToShow = if (isAddable && value == "_") "" else value
BasicTextField(
value = textToShow,
onValueChange = { newValue ->
if (isEditable || isAddable) {
val processedVal = funTransform?.invoke(newValue) ?: newValue
onValueChng(processedVal)
}
},
keyboardOptions = KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password
),
textStyle = TextStyle(
color = color,
fontSize = customFontSize,
fontFamily = font
),
readOnly = !(isEditable || isAddable),
modifier = Modifier
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.3f), RoundedCornerShape(4.dp))
.padding(customPadding),
cursorBrush = customBrush
)
}

View file

@ -0,0 +1,12 @@
package mg.dot.feufaro.solfa
data class TUOEditState(
val tuoIndex: Int,
val notesByVoice: Map<Int, String> = emptyMap(),
val originalNotes: Map<Int, String> = emptyMap(),
val lyricsByStanza: MutableMap<Int, String> = mutableMapOf(),
val originalLyricsByStanza: MutableMap<Int, String> = mutableMapOf(),
val templateFragment: String = "",
val marker: String = "",
val hairPin: String = ""
)

View file

@ -1,55 +1,66 @@
package mg.dot.feufaro.solfa
import androidx.compose.foundation.background
import androidx.compose.runtime.Composable
import androidx.compose.foundation.layout.*
import androidx.compose.ui.Modifier
import androidx.compose.material3.Text
import androidx.compose.material3.LocalTextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import mg.dot.feufaro.data.GridTUOData
import kotlin.math.min
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.withStyle
import SharedScreenModel
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.runtime.collectAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBackIos
import androidx.compose.material.icons.automirrored.filled.Undo
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import androidx.compose.ui.window.Popup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mg.dot.feufaro.ContextualMenu
import mg.dot.feufaro.FileRepository
import mg.dot.feufaro.TUODetailDialog
import mg.dot.feufaro.data.GridTUOData
import mg.dot.feufaro.ui.rememberFileSaveLauncher
import mg.dot.feufaro.viewmodel.MidiMarkers
import mg.dot.feufaro.viewmodel.SolfaScreenModel
import java.io.File
import kotlin.math.min
import kotlin.math.roundToInt
val FEUFAROO_TRIOLET_COLOR = Color.DarkGray
val FEUFAROO_KEY_CHANGE_COLOR = Color.Blue
@Stable
class TimeUnitObject (val pTemplate: PTemplate, val prevTUO: TimeUnitObject?, countOfN: Int) {
var mutableNoteVersion: Int by mutableStateOf(0)
private var lyrics: MutableList<POneStanzaLyrics> = mutableListOf()
@ -60,6 +71,7 @@ class TimeUnitObject (val pTemplate: PTemplate, val prevTUO: TimeUnitObject?, co
private var annotated : Boolean
var transposeNewKey: String = ""
var transposeOldKey: String = ""
var firstTuoIndex: Int = -1
companion object {
var nbBlock: Int = 0
var sep1: String = ""
@ -543,8 +555,29 @@ fun LazyVerticalGridTUO(
onGridWidthMeasured: (Int) -> Unit,
modifier: Modifier = Modifier
) {
val regexMeasure = Regex("(\\d)/\\d").find(viewModel.measure)
val tuoList = viewModel.tuoList
key(tuoList) {
var menuPosition by remember { mutableStateOf(Offset.Zero) }
var showAddDialog by remember { mutableStateOf(false) }
var showDetailDialog by remember { mutableStateOf(false) }
var showEditDialog by remember { mutableStateOf(false) }
var showContextualMenu by remember { mutableStateOf(false) }
var selectedTUO by remember { mutableStateOf<TimeUnitObject?>(null) }
var selectedIndex by remember { mutableStateOf(-1) }
val regexMeasure = Regex("(\\d)/\\d").find(viewModel.measure)
LaunchedEffect(tuoList) {
showContextualMenu = false
showEditDialog = false
showDetailDialog = false
showAddDialog = false
selectedIndex = -1
selectedTUO = null
}
var showFullChord by remember { mutableStateOf(false) }
val editMode by sharedScreenModel.modeEditor.collectAsState()
Column(
modifier = Modifier
@ -618,11 +651,21 @@ fun LazyVerticalGridTUO(
println("Mise à jour MidiData avec ${metadataList.size}")
sharedScreenModel.updateAndFinalizeMidiData(metadataList)
}
}
LaunchedEffect(measures, sharedScreenModel.stanza.value) {
sharedScreenModel.updateSyllablesFromList(
measures = measures,
stanzaNumber = sharedScreenModel.stanza.value
)
val totalSyllables = sharedScreenModel.synchronizedSyllables.value.size
//println("Sync complète effectuée SUR stz: ${sharedScreenModel.stanza.value}! Total colonnes : $totalSyllables")
}
Column(
modifier = Modifier.fillMaxWidth()
){
measures.forEachIndexed { measureIndex, measureTUOs ->
key(measureIndex) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Row(
@ -717,33 +760,204 @@ fun LazyVerticalGridTUO(
}
}
}
/*Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
measureTUOs.forEachIndexed { indexInMeasure, tuo ->
val chordMap = (1..4).mapNotNull { voice ->
val noteStr = tuo.tuNotes.getOrNull(voice)?.toString()
if (noteStr != null) voice to noteStr else null
}.toMap()
val degreeName = remember(chordMap) {
if (chordMap.isNotEmpty()) HarmonicAnalyzer.analyzeDegree(chordMap) else null
}
val chordName = remember(chordMap) {
if (chordMap.isNotEmpty()) HarmonicAnalyzer.analyzeChordName(chordMap, sharedScreenModel.songKey.value) else null
}
Column(
modifier = Modifier
.width(gridWidthDp / gridColumnCount)
) {
// 1. Définir une plage de tailles de police
val maxFontSize = 13.sp
val minFontSize = 8.sp
var currentFontSize by remember { mutableStateOf(maxFontSize) }
TextButton(
onClick = { showFullChord = !showFullChord },
contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(24.dp).fillMaxWidth() // fillMaxWidth pour occuper toute la colonne
) {
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val textToDisplay = if (showFullChord) (chordName ?: "") else (degreeName ?: "")
Text(
text = textToDisplay,
onTextLayout = { textLayoutResult ->
if (textLayoutResult.hasVisualOverflow && currentFontSize > minFontSize) {
currentFontSize = (currentFontSize.value * 0.9f).sp
}
},
softWrap = false, // Empêche le retour à la ligne
maxLines = 1,
overflow = TextOverflow.Visible,
style = TextStyle(
color = if (showFullChord) Color(0xFF1B5E20) else Color(0xFF0D47A1),
fontWeight = FontWeight.Bold,
fontSize = currentFontSize // Utilisation de la taille dynamique[cite: 1]
)
)
}
}
}
}
}*/
Row(modifier = Modifier.fillMaxWidth()) {
measureTUOs.forEachIndexed { indexInMeasure, oneTUO ->
val globalIndex = (measureIndex * gridColumnCount) + indexInMeasure
val isActive = (globalIndex == activeRowIndex)
val myTimestamp = sharedScreenModel.tuoTimestamps.value.getOrElse(globalIndex) { 0L }
var menuOffset by remember { mutableStateOf(Offset.Zero) }
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
Box(modifier = Modifier.width(gridWidthDp / gridColumnCount)
key(oneTUO.numBlock) {
Box(modifier = Modifier
.width(gridWidthDp / gridColumnCount)
.background(
if (isHovered) Color.Black.copy(alpha = 0.05f) else Color.Transparent,
RoundedCornerShape(4.dp)
)
.combinedClickable(
interactionSource = interactionSource,
indication = LocalIndication.current,
onClick = {
// println("je suis sur grille no: $globalIndex")
if(!editMode) {
sharedScreenModel.seekToGrid(globalIndex)
} ,
onDoubleClick = {
val m = sharedScreenModel.getFullMarkers()
m.forEach { (timestamp, gridIndex, template, lastCallerMarker, marker, noteBefore, separat, note) ->
println("Allmarker $marker in $gridIndex on $timestamp ms")
// & note= $note & nBefore =$noteBefore & separateur = $separat
}
}
)) {
)
.pointerInput(globalIndex) {
detectTapGestures(
onTap = { offset ->
if(editMode) {
menuOffset = offset
selectedTUO = oneTUO
selectedIndex = globalIndex
showContextualMenu = true
}
}
)
}
) {
TimeUnitComposable(
tuo = oneTUO,
stanzaNumber = currentStanza,
gridColumnCount = gridColumnCount,
gridActive = isActive
)
if (showContextualMenu && selectedIndex == globalIndex) {
Popup(
alignment = Alignment.TopStart,
offset = IntOffset(
menuOffset.x.roundToInt(),
menuOffset.y.roundToInt()
),
onDismissRequest = { showContextualMenu = false }
) {
ContextualMenu(onMenuItemClick = { item ->
println("Clicked in TUO $globalIndex: $item")
showContextualMenu = false
when(item) {
"Modifier" -> {
showEditDialog = true
}
"Ajouter" -> {
showAddDialog = true
}
"Liste" -> {
showDetailDialog = true
}
else -> {
showDetailDialog = false
}
}
})
}
}
val currentSelected = selectedTUO
if ((showEditDialog || showAddDialog || showDetailDialog) && currentSelected != null && selectedIndex == globalIndex) {
val canAdd = showAddDialog
val canEdit = showEditDialog || canAdd
val template = oneTUO.pTemplate.template
val expectedTemplate = template.count { it.isLetter() } == 2 && template.contains(".")
val editState = TUOEditState(
tuoIndex = oneTUO.firstTuoIndex,
/*notesByVoice = (0..3).associate { i ->
i to (oneTUO.tuNotes.getOrNull(i + 1)?.toString() ?: "")
},*/
notesByVoice = (0..3).associate { i ->
val rawNote = oneTUO.tuNotes.getOrNull(i + 1)?.toString() ?: ""
val fixedNote =
if (!expectedTemplate) {
rawNote
} else {
val count = rawNote.count()
if (/*count < 2 && */!rawNote.contains("")) {
rawNote + "•―"
} else {
rawNote
}
}
i to fixedNote
},
lyricsByStanza = (1..currentStanza).associate { s ->
s to oneTUO.getSingleSyllable(s).firstOrNull().orEmpty()
}.toMutableMap(),
templateFragment = oneTUO.pTemplate.template,
marker = oneTUO.pTemplate.markerToString(),
hairPin = oneTUO.hasHairPin()?.toString() ?: ""
)
// oneTUO.tuNotes.mapIndexed { index, note ->
//
// println("i$index => ${note.toString()}")
// }
TUODetailDialog(
editState = editState,
tuo = currentSelected,
currentStanza = currentStanza,
menuPosition = IntOffset(
menuOffset.x.roundToInt(),
menuOffset.y.roundToInt()
),
globalIndex = globalIndex,
isEditable = canEdit,
canAdd = canAdd,
onDismiss = {
showAddDialog = false
showDetailDialog = false
showEditDialog = false
},
onSave = { newState ->
sharedScreenModel.openTUOEditor(newState)
showAddDialog = false
showEditDialog = false
}
)
}
}
}
}
}
@ -765,6 +979,8 @@ fun LazyVerticalGridTUO(
}
}
allTemps.forEachIndexed { syls_i, syllables ->
/*val fragmentU0 = measureTUOs[syls_i].pTemplate.template
println("Fragment=[$fragmentU0] -> Syllabes=$syllables")*/
Column(
modifier = Modifier
.width(columnWidthDp)
@ -821,6 +1037,8 @@ fun LazyVerticalGridTUO(
}
}
}
}
}
@Composable
fun makeSpaceBetweenSyllables(
@ -882,3 +1100,203 @@ fun makeSpaceBetweenSyllables(
}
}
}
private suspend fun expandInclusions(
content: String,
currentFilePath: String,
fileRepository: FileRepository
): String {
val lines = content.split("\n")
val result = StringBuilder()
val lastSlash = currentFilePath.lastIndexOf('/')
val directory = if (lastSlash != -1) currentFilePath.substring(0, lastSlash + 1) else ""
lines.forEach { line ->
if (line.startsWith("I0:")) {
try {
val parts = line.substring(3).split(":")
val fileName = parts[0]
val ignorePattern = if (parts.size > 1) parts[1] else ""
val fullPath = directory + fileName
val includedLines = fileRepository.readFileLines(fullPath)
val regexIgnore = if (ignorePattern.isNotEmpty()) Regex(ignorePattern) else null
includedLines.forEach { incLine ->
if (regexIgnore == null || !regexIgnore.containsMatchIn(incLine)) {
result.append(incLine).append("\n")
}
}
} catch (e: Exception) {
result.append("// Erreur inclusion: ${e.message}\n")
}
} else {
result.append(line).append("\n")
}
}
return result.toString().trimEnd()
}
@Composable
fun EditSourceCompose(
sharedScreenModel: SharedScreenModel,
solfaScreenModel: SolfaScreenModel
) {
var isEditorVisible = sharedScreenModel.editModeState.value
var sourcePath = sharedScreenModel.activeFilePath.value
var sourceContent = sharedScreenModel.fileContent.value ?: ""
var sourceTitle = sharedScreenModel.songTitle.value
// println("path $sourcePath\n" +
// "contenu $sourceContent")
val originalPath = remember(sourceTitle) {
if (!sourcePath.contains("_tmp") && !sourcePath.contains("/tmp/")) {
sourcePath
} else {
sourcePath
}
}
val fileName = originalPath.substringAfterLast('/').substringAfterLast(':')
val scope = rememberCoroutineScope()
var currentTempFile by remember { mutableStateOf<File?>(null) }
var codeContent by remember { mutableStateOf(sourceContent?: "") }
LaunchedEffect(sourceContent) {
codeContent = sourceContent
}
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
val saveLauncher = rememberFileSaveLauncher { chosenPath ->
if (chosenPath != null) {
val contentToSave = codeContent
scope.launch(Dispatchers.IO) {
try {
val data = codeContent.toByteArray(Charsets.UTF_8)
solfaScreenModel.fileRepository.saveLocalFile(chosenPath, data)
withContext(Dispatchers.Main) {
println("Fichier sauvegardé avec succès")
solfaScreenModel.loadExternalFile(chosenPath)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
} else {
println("Sauvegarde annulée par l'utilisateur")
}
}
if(sourcePath.isNotEmpty() && sourceContent.isNotEmpty()) {
Column(modifier = Modifier.fillMaxSize()) {
if (isEditorVisible) {
Card(
modifier = Modifier.fillMaxSize().padding(4.dp),
shape = RoundedCornerShape(12.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column {
Row(
Modifier.fillMaxWidth().background(Color(0xFFF5F5F5)).padding(2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Description, contentDescription = null, modifier = Modifier.size(16.dp))
Text(
text = sourceTitle.ifEmpty { "nouveau_feufaro.txt" },
modifier = Modifier.padding(start = 8.dp).widthIn(max = 160.dp),
style = TextStyle(fontSize = 12.sp, fontWeight = FontWeight.Medium)
)
Spacer(modifier = Modifier.weight(1f))
EditorActionButtons(
onUndo = {
scope.launch {
codeContent = sourceContent
withContext(Dispatchers.Main) {
solfaScreenModel.loadExternalFile(originalPath)
}
currentTempFile?.delete()
currentTempFile = null
}
},
onBuild = {
scope.launch {
try {
val expandedContent = expandInclusions(
codeContent,
sourcePath,
solfaScreenModel.fileRepository
)
if (currentTempFile == null || !currentTempFile!!.exists()) {
val tempDir = System.getProperty("java.io.tmpdir")
currentTempFile = File(tempDir, fileName)
currentTempFile?.deleteOnExit()
}
currentTempFile?.writeText(expandedContent)
currentTempFile?.let { file ->
withContext(Dispatchers.Main) {
solfaScreenModel.justeCompile(file.absolutePath)
}
}
println("Compilation sur le fichier : ${currentTempFile?.absolutePath}")
} catch (e: Exception) {
e.printStackTrace()
}
}
},
onSave = {
scope.launch {
try {
saveLauncher.launch(fileName)
} catch (e: Exception) {
e.printStackTrace()
}
}
},
onClose = { sharedScreenModel.toggleEditMode() }
)
}
Row(modifier = Modifier.fillMaxSize().background(Color.White)) {
BasicTextField(
value = codeContent,
onValueChange = { codeContent = it },
keyboardOptions = KeyboardOptions(
autoCorrectEnabled = false,
keyboardType = KeyboardType.Password
),
modifier = Modifier.fillMaxSize().padding(start = 8.dp, top = 8.dp),
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
lineHeight = 20.sp
),
visualTransformation = SolfaVisualTransformation(),
cursorBrush = SolidColor(Color.Black)
)
}
}
}
}
}
}
}
@Composable
fun EditorActionButtons(onUndo: () -> Unit, onBuild: () -> Unit, onSave: () -> Unit, onClose: () -> Unit) {
Row(horizontalArrangement = Arrangement.SpaceBetween) {
IconButton(onClick = onUndo) { Icon(Icons.AutoMirrored.Filled.Undo, null, tint = Color.Gray) }
IconButton(onClick = onBuild) { Icon(Icons.Default.Build, null, tint = Color(0xFF2196F3)) }
IconButton(onClick = onSave) { Icon(Icons.Default.Save, null, tint = Color(0xFF4CAF50)) }
IconButton(onClick = onClose) { Icon(Icons.Default.Close, null, tint = Color.Red) }
}
}

View file

@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Undo
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
@ -23,14 +24,17 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mg.dot.feufaro.pdf.PrintSettings
import mg.dot.feufaro.pdf.defaultPrintSettings
import mg.dot.feufaro.ui.PrintSettingsDialog
import mg.dot.feufaro.pdf.rememberPdfExportAction
import mg.dot.feufaro.solfa.Solfa
import mg.dot.feufaro.viewmodel.SolfaScreenModel
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -98,6 +102,43 @@ LaunchedEffect(isPlay, isPos) {
sharedScreenModel.loadNewSong("$midiFile")
}
}
val isEditMode by sharedScreenModel.modeEditor.collectAsState()
val currentActiveFilePath = sharedScreenModel.activeFilePath.value
val fileName = currentActiveFilePath.substringAfterLast('/')
val saveLauncher = rememberFileSaveLauncher { chosenPath ->
if (chosenPath != null) {
scope.launch(Dispatchers.IO) {
try {
delay(500)
val tempDir = System.getProperty("java.io.tmpdir")
val sourceFileName = File(currentActiveFilePath).name
val sourceFile = File(tempDir, sourceFileName)
if (sourceFile.exists() && sourceFile.isFile) {
val data = sourceFile.readBytes()
if (data.isNotEmpty()) {
solfaScreenModel.fileRepository.saveLocalFile(chosenPath, data)
withContext(Dispatchers.Main) {
println("Succès : ${data.size} octets copiés vers $chosenPath")
solfaScreenModel.loadExternalFile(chosenPath)
}
} else {
println("Erreur : Le fichier source dans /tmp est vide.")
}
} else {
println("Erreur : Fichier source introuvable à l'adresse ${sourceFile.path} ${sourceFile.absolutePath}")
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
ModalNavigationDrawer(drawerState = drawerState, drawerContent = {
SimpleDrawerContent(
items,
@ -197,6 +238,61 @@ LaunchedEffect(isPlay, isPos) {
modifier = Modifier.fillMaxWidth().padding(5.dp), horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(7.dp)
) {
if (isEditMode) {
AnimatedVisibility(
visible = true,
enter = fadeIn() + scaleIn() + slideInVertically { it / 2 },
exit = fadeOut() + scaleOut() + slideOutVertically { it / 2 }
) {
FloatingActionButton(
onClick = {
//sharedScreenModel.
}, modifier = Modifier.alpha(0.45f)
) {
Icon(
imageVector = Icons.AutoMirrored.Default.Undo,
contentDescription = null,
tint = Color.Blue
)
}
}
AnimatedVisibility(
visible = true,
enter = fadeIn() + scaleIn() + slideInVertically { it / 2 },
exit = fadeOut() + scaleOut() + slideOutVertically { it / 2 }
) {
FloatingActionButton(
onClick = {
saveLauncher.launch(fileName)
}, modifier = Modifier.alpha(0.45f)
) {
Icon(
imageVector = Icons.Filled.SaveAs,
contentDescription = null,
tint = Color.Blue
)
}
}
AnimatedVisibility(
visible = true,
enter = fadeIn() + scaleIn() + slideInVertically { it / 2 },
exit = fadeOut() + scaleOut() + slideOutVertically { it / 2 }
) {
FloatingActionButton(
onClick = {
sharedScreenModel.toggleEditorMode(false)
}, modifier = Modifier.alpha(0.45f)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = null,
tint = Color.Blue
)
}
}
} else {
AnimatedVisibility(
visible = isExpanded and !showMidiCtrl,
enter = fadeIn() + scaleIn() + slideInVertically { it / 2 },
@ -230,27 +326,7 @@ LaunchedEffect(isPlay, isPos) {
}
}
}
}
/*AnimatedVisibility(
visible = isExpanded and !showMidiCtrl,
enter = fadeIn() + scaleIn() + slideInVertically { it / 2 },
exit = fadeOut() + scaleOut() + slideOutVertically { it / 2 }
) {
FloatingActionButton(
onClick = {
sharedScreenModel.toggleEditMode()
sharedScreenModel.setExpandedFAB(false)
}, modifier = Modifier.alpha(0.45f)
) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = "null",
tint = Color.Blue
)
}
}*/
AnimatedVisibility(
visible = isExpanded and !showMidiCtrl,
enter = fadeIn() + scaleIn() + slideInVertically { it / 2 },
@ -364,11 +440,11 @@ LaunchedEffect(isPlay, isPos) {
}
}
}
}
}) { paddingValues ->
Box(
modifier = Modifier.fillMaxSize().padding(paddingValues).consumeWindowInsets(paddingValues).windowInsetsPadding(WindowInsets.ime)
modifier = Modifier.fillMaxSize().padding(paddingValues).consumeWindowInsets(paddingValues).windowInsetsPadding(WindowInsets.safeDrawing.union(WindowInsets.ime))
) {
content(PaddingValues(0.dp))
if (sharedScreenModel.isQRCodeVisible.value) {
@ -424,7 +500,7 @@ LaunchedEffect(isPlay, isPos) {
Box(
modifier = Modifier.fillMaxSize().fillMaxWidth(0.75f).align(Alignment.Center)
.padding(paddingValues)
.padding(paddingValues).windowInsetsPadding(WindowInsets.safeDrawing)
) {
Row(
modifier = Modifier.align(Alignment.TopEnd).padding(16.dp),
@ -464,9 +540,8 @@ LaunchedEffect(isPlay, isPos) {
}
}
}
Column(
) {
if(!isEditMode) {
Column {
IconButton(
onClick = {
sharedScreenModel.showSearchMenu(!isSearchActive)
@ -483,7 +558,7 @@ LaunchedEffect(isPlay, isPos) {
)
}
}
}
}
}

View file

@ -21,6 +21,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
@ -60,11 +61,49 @@ fun SimpleDrawerContent(
var externalExpanded by remember { mutableStateOf(false) }
var playListExpanded by remember { mutableStateOf(false) }
val editMode by sharedScreenModel.modeEditor.collectAsState()
var state1 by remember { mutableStateOf(false) }
var state2 by remember { mutableStateOf(false) }
ModalDrawerSheet(
modifier = Modifier.width(300.dp)
) {
Column(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.weight(1f).padding(0.dp, 20.dp)) {
// SWITCHES
Column(
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
CustomSwitchItem(
checked = editMode,
onCheckedChange = { newState ->
sharedScreenModel.toggleEditorMode(newState)
},
label = "Mode Edit",
color = MaterialTheme.colorScheme.primary
)
CustomSwitchItem(
checked = state1,
onCheckedChange = { state1 = it },
label = "Analyse chords",
color = Color(0xFFE57373)
)
CustomSwitchItem(
checked = state2,
onCheckedChange = { state2 = it },
label = "Titre 3",
color = Color(0xFFBFBF11)
)
}
}
Box(modifier = Modifier.weight(1f).padding(0.dp, 10.dp)) {
MyVerticalScrollbar(
lazyListState = listState,
@ -472,3 +511,30 @@ fun DrawerFavorisItemLabel(item: DrawerItem, index: Int, isSelected: Boolean, sh
}
}
}
@Composable
fun CustomSwitchItem(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
label: String,
color: Color
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = color,
uncheckedTrackColor = color.copy(alpha = 0.3f)
),
modifier = Modifier.scale(0.7f)
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}

View file

@ -23,6 +23,7 @@ import mg.dot.feufaro.data.GridTUOData
import mg.dot.feufaro.data.getCombinedList
import mg.dot.feufaro.solfa.TimeUnitObject
import mg.dot.feufaro.midi.FMediaPlayer
import mg.dot.feufaro.solfa.TUOEditState
import mg.dot.feufaro.viewmodel.MidiMarkers
import java.io.File
@ -217,6 +218,41 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
private val _isQRCodeVisible = mutableStateOf(false)
val isQRCodeVisible: State<Boolean> = _isQRCodeVisible
private val _editModeState = mutableStateOf(false)
val editModeState: State<Boolean> = _editModeState
private val _modeEditor = MutableStateFlow(false)
val modeEditor: StateFlow<Boolean> = _modeEditor
fun toggleEditorMode(enabled: Boolean) {
_modeEditor.value = enabled
}
private val _synchronizedSYllables = MutableStateFlow<List<String>>(emptyList())
val synchronizedSyllables: StateFlow<List<String>> = _synchronizedSYllables.asStateFlow()
fun updateSyllablesFromList(measures: List<List<TimeUnitObject>>, stanzaNumber: Int) {
val REGEX_CLEAN_PREFIX = Regex("(\\d+\\.)+")
// println("\n--- DEBUG updateSyllablesFromList (Strophe $stanzaNumber) ---")
var absoluteIndex = 0
_synchronizedSYllables.value = measures.flatten().map { tuo ->
val rawSyllablesList = tuo.getSingleSyllable(stanzaNumber)
val processedSyllable = rawSyllablesList.joinToString(" ") { syllable ->
val cleaned = syllable.replace(REGEX_CLEAN_PREFIX, "").trim()
cleaned
}
// println("TUO #$absoluteIndex | Template: ${tuo.pTemplate.template} | Raw: $rawSyllablesList | Final: [$processedSyllable]")
absoluteIndex++
processedSyllable
}
// println("--- FIN DEBUG (Total TUOs: $absoluteIndex) ---\n")
}
private val _activeFilePath = mutableStateOf("")
val activeFilePath: State<String> = _activeFilePath
@ -227,6 +263,9 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
fun toggleQRCodeVisibility() {
_isQRCodeVisible.value = !_isQRCodeVisible.value
}
fun toggleEditMode() {
_editModeState.value = !_editModeState.value
}
val qrCodeContent: State<String?>
get() = mutableStateOf(
_activeFilePath.value.takeIf { it.isNotBlank() }?.let { path ->
@ -479,6 +518,25 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
println("Markers finalisés et mis à jour : ${finalizedList.size}")
}
private val _tuoEditState = MutableStateFlow<TUOEditState?>(null)
val tuoEditState: StateFlow<TUOEditState?> = _tuoEditState.asStateFlow()
fun openTUOEditor(editState: TUOEditState) {
_tuoEditState.value = editState
println("nMrk = [${editState.marker}] template [${editState.templateFragment}] hp [${editState.hairPin}]")
}
fun closeTUOEditor() {
_tuoEditState.value = null
}
fun updateTUOEdit(new: TUOEditState) {
_tuoEditState.value = new
}
fun updateFullTUOList(newList: List<TimeUnitObject>) {
_tuoList.value = newList
}
fun loadNewSong(newMidiFile: String) {
_mediaPlayer?.stop()
_mediaPlayer?.release()
@ -488,6 +546,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
_currentPos.value = 0f
_dcDone.value = false
_dsDone.value = false
_editModeState.value = false
try {
val midiFileName = fileRepository.getFileName(newMidiFile)
println("Opening xx129 $midiFileName")
@ -587,6 +646,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode
_showMidiCtrl.value = false
_expandedFAB.value = false
_showSearchMenu.value = false
_editModeState.value = false
_midiMarkersList.value = emptyList()
_tuoTimestamps.value = emptyList()
updateSearchTxt("")

View file

@ -2,9 +2,13 @@ package mg.dot.feufaro.viewmodel
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import mg.dot.feufaro.DeepLinkHandler
import mg.dot.feufaro.FileRepository
import mg.dot.feufaro.solfa.Solfa
import mg.dot.feufaro.solfa.TUOEditState
import java.io.File
class SolfaScreenModel(
val fileRepository: FileRepository,
@ -38,4 +42,13 @@ class SolfaScreenModel(
fun loadFromFile(path: String) {
solfa.parse(path)
}
fun justeCompile(path:String) {
solfa.justeBuild(path)
}
fun saveTUOEdit(editState: TUOEditState) {
screenModelScope.launch(Dispatchers.IO) {
solfa.saveTUOEdit(editState)
}
}
}

View file

@ -598,7 +598,7 @@ actual fun rememberPdfExportAction(
settings
)
val chosenPath = withContext(Dispatchers.Main) {
fileRepository.pickSavePath(computedFileName)
fileRepository.pickSavePath(computedFileName, "Exporter en PDF")
}
if (chosenPath != null) {
fileRepository.saveLocalFile(chosenPath, pdfBytes)