From 93797089d85776211076544c3ab92a9eb7054d6d Mon Sep 17 00:00:00 2001 From: Hasinjato Date: Thu, 4 Jun 2026 09:23:19 +0300 Subject: [PATCH] Implement visual note Transposition by Clicking on SongKey --- .../kotlin/mg/dot/feufaro/solfa/Solfa.kt | 4 +- .../mg/dot/feufaro/solfa/TimeUnitObject.kt | 19 ++- .../kotlin/mg/dot/feufaro/solfa/Transpose.kt | 61 ++++++++- .../kotlin/mg/dot/feufaro/ui/DrawerUI.kt | 124 +++++++++++++++++- .../feufaro/viewmodel/SharedScreenModel.kt | 8 +- .../dot/feufaro/viewmodel/SolfaScreenModel.kt | 5 +- 6 files changed, 194 insertions(+), 27 deletions(-) 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 0756d97..3cf6377 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Solfa.kt @@ -247,7 +247,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository sharedScreenModel.setStanza(1) } } - fun justeBuild(sourceFile: String) { + fun justBuild(sourceFile: String, sourceContent: String) { currentFile = sourceFile val parseScope = CoroutineScope(Dispatchers.Default) parseScope.launch { @@ -485,7 +485,7 @@ class Solfa(val sharedScreenModel: SharedScreenModel, private val fileRepository currentTempFile?.writeBytes(finalString.toByteArray()) withContext(Dispatchers.Main) { - justeBuild(currentTempFile.absolutePath) + justBuild(currentTempFile.absolutePath, finalString) } } diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt index 4e979c5..2f14a68 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/TimeUnitObject.kt @@ -3,12 +3,7 @@ package mg.dot.feufaro.solfa import SharedScreenModel import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.LocalIndication -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.focusable +import androidx.compose.foundation.* import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -270,7 +265,8 @@ fun TimeUnitComposable( gridColumnCount: Int, gridActive: Boolean, isCurrentBlock: Boolean = false, - parentFocusRequester: FocusRequester? = null + parentFocusRequester: FocusRequester? = null, + transpositionInterval: Int ) { val col = if (tuo.getNum() % 2 == 0) Color(0xff, 0xfa, 0xf7) else Color(0xfb, 0xf3, 0xff) val currentDensity = LocalDensity.current @@ -389,7 +385,8 @@ fun TimeUnitComposable( tuo.annotate() // Utile pour que Compose recalcule la cellule. La valeur sera toutefois toujours égale à "" val mutableNoteVersionX = if (tuo.mutableNoteVersion == -1) "7" else "" - val multiLineText = tuo.noteAsMultiString() + val multiLineNotes = tuo.noteAsMultiString() + val multiLineText = Transpose.transposeText(text = multiLineNotes, interval = transpositionInterval) var textLayoutResult: TextLayoutResult? by remember { mutableStateOf(null) } Box (modifier = Modifier.fillMaxWidth() .drawBehind { @@ -823,13 +820,15 @@ fun LazyVerticalGridTUO( ) } ) { + val currentInterval by sharedScreenModel.transpositionInterval.collectAsState() TimeUnitComposable( tuo = oneTUO, stanzaNumber = currentStanza, gridColumnCount = gridColumnCount, gridActive = isActive, isCurrentBlock = isSelectedByKeyboard && editMode, - parentFocusRequester = focusRequester + parentFocusRequester = focusRequester, + transpositionInterval = currentInterval ) if (showContextualMenu && selectedIndex == globalIndex) { @@ -1246,7 +1245,7 @@ fun EditSourceCompose( } withContext(Dispatchers.Main) { - solfaScreenModel.justeCompile(currentTempFile!!.absolutePath) + solfaScreenModel.justCompile(currentTempFile!!.absolutePath, expandedContent) println("Compilation : ${currentTempFile?.absolutePath}") currentTempFile?.deleteOnExit() diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Transpose.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Transpose.kt index e59856e..afd70d0 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Transpose.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/solfa/Transpose.kt @@ -25,6 +25,59 @@ class Transpose { val noteToNumber = listOf("d", "di", "r", "ri", "m", "f", "fi", "s", "si", "l", "ta", "t") val keyToNumber = listOf("C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B" ) val keyToArmature = listOf(0, -7, 2, -3, 4, -1, -6, 1, -4, 3, -2, 5 ) + fun transposeText(text: String, interval: Int): String { + if (text.isEmpty()) return text + + var currentModulationInterval = 0 + val MODULATION_REGEX = Regex(">([drmfslt][ia]?)([₄₃₂₁¹²³⁴',]*)>([drmfslt][ia]?)([₄₃₂₁¹²³⁴',]*)") + val NOTE_UNIT_REGEX = Regex("([drmfslt][ia]?)([₄₃₂₁¹²³⁴]*)") + val tokens = text.split(Regex("(?<=[:|•\\s])|(?=[:|•\\s])")) + + val finalResult = tokens.joinToString("") { token -> + val trimmed = token.trim() + + if (trimmed.isEmpty() || trimmed.matches(Regex("[:|•\\s]+"))) { + token + } else { + val modMatch = MODULATION_REGEX.matchEntire(trimmed) + if (modMatch != null) { + val oldNoteCore = modMatch.groupValues[1] + val oldOctave = modMatch.groupValues[2] + val newNoteCore = modMatch.groupValues[3] + val newOctave = modMatch.groupValues[4] + + val oldIdx = noteToNumber.indexOf(oldNoteCore) + val newIdx = noteToNumber.indexOf(newNoteCore) + + currentModulationInterval = newIdx - oldIdx + + val transposedOld = transpose(note = oldNoteCore + oldOctave, fromKey = "C", toKey = "C", alter = interval) + val transposedNew = transpose(note = newNoteCore + newOctave, fromKey = "C", toKey = "C", alter = interval) + + val modResult = ">$transposedOld>$transposedNew" + modResult + } else { + val separators = token.takeWhile { it == ',' || it == '.' } + val remaining = token.drop(separators.length) + + val totalAlter = interval + currentModulationInterval + + val transposedBody = NOTE_UNIT_REGEX.replace(remaining) { matchResult -> + val noteCore = matchResult.groupValues[1] + val octave = matchResult.groupValues[2] + + val transposedCore = transpose(note = noteCore, fromKey = "C", toKey = "C", alter = totalAlter) + transposedCore + octave + } + + val result = separators + transposedBody + result + } + } + } + return finalResult + } + fun compareNotes(note1: String, note2: String): Int { val found1 = REGEX_ONE_NOTE.find(note1) val found2 = REGEX_ONE_NOTE.find(note2) @@ -112,13 +165,13 @@ class Transpose { var newNoteNum = noteNum - newKey + oldKey + alter var suffix = "" val noteSize = noteToNumber.size - if (newNoteNum < 0) { + while (newNoteNum < 0) { newNoteNum += noteSize - suffix = "," + suffix += "," } - if (newNoteNum >= noteSize) { + while (newNoteNum >= noteSize) { newNoteNum -= noteSize - suffix = "'" + suffix += "'" } val newNote = noteToNumber[newNoteNum] + regexFound.groupValues[2] + suffix return simplifyNote(newNote) + regexFound.groupValues[3] diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt index 1f8b5b2..741444a 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/ui/DrawerUI.kt @@ -2,14 +2,17 @@ package mg.dot.feufaro.ui import SharedScreenModel import androidx.compose.animation.* +import androidx.compose.animation.core.tween import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn 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.InsertDriveFile +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.Undo import androidx.compose.material.icons.filled.* import androidx.compose.material3.* @@ -20,8 +23,8 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -31,9 +34,9 @@ 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.solfa.Transpose import mg.dot.feufaro.viewmodel.SolfaScreenModel import java.io.File @@ -226,15 +229,121 @@ fun MainScreenWithDrawer( Icon(Icons.Filled.Menu, contentDescription = "Ouvrir Menu") } }, actions = { - Box( + var tempInterval by remember(fileContent) { mutableStateOf(0) } + var isEyeVisible by remember { mutableStateOf(false) } + val keysOrder = Transpose.keyToNumber + val songKeyIndex = keysOrder.indexOf(songKey).takeIf { it != -1 } ?: 0 + val tempUiKey = keysOrder[(songKeyIndex + tempInterval + 24) % 12] + val appliedInterval by sharedScreenModel.transpositionInterval.collectAsState() + val isPendingChange = tempInterval != appliedInterval + val isCurrentlyTransposed = appliedInterval != 0 + + Row( modifier = Modifier.fillMaxHeight(), - contentAlignment = Alignment.Center + verticalAlignment = Alignment.CenterVertically ) { + AnimatedVisibility( + visible = isEyeVisible, + enter = expandHorizontally(expandFrom = Alignment.End) + fadeIn(tween(300)), + exit = shrinkHorizontally(shrinkTowards = Alignment.End) + fadeOut(tween(300)) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + .padding(end = 8.dp) + .background(Color.White.copy(alpha = 0.15f), RoundedCornerShape(50)) + .padding(horizontal = 4.dp) + ) { + /* <*/ + val currentIndex = keysOrder.indexOf(tempUiKey).takeIf { it != -1 } ?: 0 + val targetKeyLeft = keysOrder[(currentIndex - 1 + 12) % 12] + + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip(containerColor = Color.DarkGray, contentColor = Color.White) { Text(targetKeyLeft, fontSize = 12.sp) } }, + state = rememberTooltipState() + ) { + IconButton( + modifier = Modifier.size(36.dp), + onClick = { tempInterval-- } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = null, + tint = Color.White + ) + } + } + + val centralIcon = if (isPendingChange) Icons.Filled.Check else Icons.Filled.SwapHoriz + val centralTint = if (isPendingChange) Color(0xFFFFD700) else Color.White + val tooltipText = if (isPendingChange) "Transposer en $tempUiKey" else if (isCurrentlyTransposed) "Revenir au ($songKey)" else "Transposer" + + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + PlainTooltip(containerColor = Color.DarkGray, contentColor = Color.White) { Text(text = tooltipText, fontSize = 12.sp) } + }, + state = rememberTooltipState() + ) { + IconButton( + modifier = Modifier.size(36.dp), + onClick = { + if (!isPendingChange && isCurrentlyTransposed) { + tempInterval = 0 + sharedScreenModel.setTranspositionInterval(0) + } else { + sharedScreenModel.setTranspositionInterval(tempInterval) + } + solfaScreenModel.loadFromFile(sharedScreenModel.activeFilePath.value) + isEyeVisible = false + } + ) { + Icon( + imageVector = centralIcon, + contentDescription = null, + tint = centralTint + ) + } + } + + /* > */ + val targetKeyRight = keysOrder[(currentIndex + 1) % 12] + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { PlainTooltip(containerColor = Color.DarkGray, contentColor = Color.White) { Text(targetKeyRight, fontSize = 12.sp) } }, + state = rememberTooltipState() + ) { + IconButton( + modifier = Modifier.size(36.dp), + onClick = { tempInterval++ } + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = Color.White + ) + } + } + } + } + Text( - text = songKey, + text = tempUiKey, fontSize = 25.sp, fontWeight = FontWeight.Black, - modifier = Modifier.padding(end = 16.dp) + textAlign = TextAlign.Center, + color = if (isCurrentlyTransposed && !isPendingChange) Color(0xFFFFD700) else Color.White, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + isEyeVisible = !isEyeVisible + } + .padding(end = 16.dp) + .width(45.dp) ) } }, colors = TopAppBarColors( @@ -527,6 +636,7 @@ fun MainScreenWithDrawer( solfaScreenModel.loadFromFile(item.path) sharedScreenModel.stopMidi() sharedScreenModel.seekTo(0f) + sharedScreenModel.setTranspositionInterval(0) }) HorizontalDivider() } diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt index f447c9c..3074c7b 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SharedScreenModel.kt @@ -16,7 +16,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import mg.dot.feufaro.DeepLinkHandler import mg.dot.feufaro.FileRepository import mg.dot.feufaro.data.DrawerItem import mg.dot.feufaro.data.GridTUOData @@ -227,6 +226,11 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode fun toggleEditorMode(enabled: Boolean) { _modeEditor.value = enabled } + val transpositionInterval = MutableStateFlow(0) + + fun setTranspositionInterval(interval: Int) { + transpositionInterval.value = interval + } private val _synchronizedSYllables = MutableStateFlow>(emptyList()) val synchronizedSyllables: StateFlow> = _synchronizedSYllables.asStateFlow() @@ -547,6 +551,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode _dcDone.value = false _dsDone.value = false _editModeState.value = false + setTranspositionInterval(0) try { val midiFileName = fileRepository.getFileName(newMidiFile) println("Opening xx129 $midiFileName") @@ -647,6 +652,7 @@ class SharedScreenModel(private val fileRepository: FileRepository) : ScreenMode _expandedFAB.value = false _showSearchMenu.value = false _editModeState.value = false + setTranspositionInterval(0) _midiMarkersList.value = emptyList() _tuoTimestamps.value = emptyList() _searchTitle.value = "" diff --git a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt index 5df5c3c..90d86a2 100644 --- a/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt +++ b/composeApp/src/commonMain/kotlin/mg/dot/feufaro/viewmodel/SolfaScreenModel.kt @@ -8,7 +8,6 @@ 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, @@ -43,8 +42,8 @@ class SolfaScreenModel( solfa.parse(path) } - fun justeCompile(path:String) { - solfa.justeBuild(path) + fun justCompile(path: String, content: String) { + solfa.justBuild(path, content) } fun saveTUOEdit(editState: TUOEditState) { screenModelScope.launch(Dispatchers.IO) {