diff --git a/.plans/issue-19-drag-and-drop.md b/.plans/issue-19-drag-and-drop.md new file mode 100644 index 0000000..47e3998 --- /dev/null +++ b/.plans/issue-19-drag-and-drop.md @@ -0,0 +1,41 @@ +# Issue #19: Add drag-and-drop song reordering in the GUI + +## Summary + +Add drag-and-drop reordering of songs in the GUI song list. When `songs.order` is "manual", users can drag songs to rearrange them. The custom order is passed to the pipeline at build time. When order is "alphabetical", drag-and-drop is disabled with a hint. + +## AC Verification Checklist + +1. Songs can be reordered via drag-and-drop +2. Reordered list is used when building (overrides config order) +3. Visual indicator shows drop target (highlight) +4. Order can be reset via a button +5. Reordering only enabled when songs.order is "manual" +6. When alphabetical, list shows alphabetical order and drag is disabled (with hint) +7. GUI remains responsive during drag operations + +## Implementation Steps + +### Step 1: Add customSongOrder parameter to SongbookPipeline.build() + +Add an optional `customSongOrder: List? = null` parameter. When provided, use this ordered list of file names to sort the parsed songs instead of the config-based sort. + +### Step 2: Create ReorderableSongList composable + +Build a song list that supports drag-and-drop reordering: +- Use `detectDragGesturesAfterLongPress` on each item to detect drag start +- Track the dragged item index and current hover position +- Show a visual indicator (highlighted background) at the drop target +- On drop, reorder the list + +### Step 3: Integrate into App.kt + +- Track `songsOrder` config value ("alphabetical" or "manual") +- Track `originalSongs` list (from file loading) to support reset +- When manual: enable drag-and-drop, show reset button +- When alphabetical: disable drag-and-drop, show hint +- Pass custom order (file names) to pipeline on build + +### Step 4: Add reset button + +"Reihenfolge zurücksetzen" button restores `songs` to `originalSongs`. diff --git a/app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt b/app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt index fc1aae2..3a3ed95 100644 --- a/app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt +++ b/app/src/main/kotlin/de/pfadfinder/songbook/app/SongbookPipeline.kt @@ -21,7 +21,15 @@ data class BuildResult( class SongbookPipeline(private val projectDir: File) { - fun build(): BuildResult { + /** + * Build the songbook PDF. + * + * @param customSongOrder Optional list of song file names in the desired order. + * When provided, songs are sorted to match this order instead of using the + * config-based sort (alphabetical or manual). Files not in this list are + * appended at the end. + */ + fun build(customSongOrder: List? = null): BuildResult { // 1. Parse config val configFile = File(projectDir, "songbook.yaml") if (!configFile.exists()) { @@ -55,7 +63,7 @@ class SongbookPipeline(private val projectDir: File) { logger.info { "Found ${songFiles.size} song files" } - val songs = mutableListOf() + val songsByFileName = mutableMapOf() val allErrors = mutableListOf() for (file in songFiles) { @@ -65,7 +73,7 @@ class SongbookPipeline(private val projectDir: File) { if (songErrors.isNotEmpty()) { allErrors.addAll(songErrors) } else { - songs.add(song) + songsByFileName[file.name] = song } } catch (e: Exception) { allErrors.add(ValidationError(file.name, null, "Parse error: ${e.message}")) @@ -76,10 +84,20 @@ class SongbookPipeline(private val projectDir: File) { return BuildResult(false, errors = allErrors) } - // Sort songs - val sortedSongs = when (config.songs.order) { - "alphabetical" -> songs.sortedBy { it.title.lowercase() } - else -> songs // manual order = file order + val songs = songsByFileName.values.toList() + + // Sort songs: custom order takes priority, then config-based sort + val sortedSongs = if (customSongOrder != null) { + val orderMap = customSongOrder.withIndex().associate { (index, name) -> name to index } + songs.sortedBy { song -> + val fileName = songsByFileName.entries.find { it.value === song }?.key + orderMap[fileName] ?: Int.MAX_VALUE + } + } else { + when (config.songs.order) { + "alphabetical" -> songs.sortedBy { it.title.lowercase() } + else -> songs // manual order = file order + } } logger.info { "Parsed ${sortedSongs.size} songs" } diff --git a/gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt b/gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt index 1255277..d02b58a 100644 --- a/gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt +++ b/gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt @@ -1,8 +1,8 @@ package de.pfadfinder.songbook.gui import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.VerticalScrollbar import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -21,6 +21,7 @@ import androidx.compose.ui.window.application import de.pfadfinder.songbook.app.BuildResult import de.pfadfinder.songbook.app.SongbookPipeline import de.pfadfinder.songbook.parser.ChordProParser +import de.pfadfinder.songbook.parser.ConfigParser import de.pfadfinder.songbook.parser.ValidationError import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -45,6 +46,9 @@ data class SongEntry(val fileName: String, val title: String) fun App() { var projectPath by remember { mutableStateOf("") } var songs by remember { mutableStateOf>(emptyList()) } + var originalSongs by remember { mutableStateOf>(emptyList()) } + var songsOrderConfig by remember { mutableStateOf("alphabetical") } + var isCustomOrder by remember { mutableStateOf(false) } var statusMessages by remember { mutableStateOf>(emptyList()) } var isRunning by remember { mutableStateOf(false) } var lastBuildResult by remember { mutableStateOf(null) } @@ -52,21 +56,29 @@ fun App() { val scope = rememberCoroutineScope() + val reorderEnabled = songsOrderConfig != "alphabetical" + fun loadSongs(path: String) { val projectDir = File(path) songs = emptyList() + originalSongs = emptyList() + isCustomOrder = false if (!projectDir.isDirectory) return val configFile = File(projectDir, "songbook.yaml") - val songsDir = if (configFile.exists()) { + var songsDir: File + songsOrderConfig = "alphabetical" + + if (configFile.exists()) { try { - val config = de.pfadfinder.songbook.parser.ConfigParser.parse(configFile) - File(projectDir, config.songs.directory) + val config = ConfigParser.parse(configFile) + songsDir = File(projectDir, config.songs.directory) + songsOrderConfig = config.songs.order } catch (_: Exception) { - File(projectDir, "songs") + songsDir = File(projectDir, "songs") } } else { - File(projectDir, "songs") + songsDir = File(projectDir, "songs") } if (!songsDir.isDirectory) return @@ -75,7 +87,7 @@ fun App() { ?.sortedBy { it.name } ?: emptyList() - songs = songFiles.mapNotNull { file -> + val loadedSongs = songFiles.mapNotNull { file -> try { val song = ChordProParser.parseFile(file) SongEntry(fileName = file.name, title = song.title.ifBlank { file.nameWithoutExtension }) @@ -83,6 +95,14 @@ fun App() { SongEntry(fileName = file.name, title = "${file.nameWithoutExtension} (Fehler beim Lesen)") } } + + // Apply config-based sort for display + songs = if (songsOrderConfig == "alphabetical") { + loadedSongs.sortedBy { it.title.lowercase() } + } else { + loadedSongs + } + originalSongs = songs.toList() } MaterialTheme { @@ -141,47 +161,55 @@ fun App() { modifier = Modifier.weight(1f) ) } else { - // Song list - Text( - text = "Lieder (${songs.size}):", - fontWeight = FontWeight.Medium - ) - Spacer(modifier = Modifier.height(4.dp)) - - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { - val listState = rememberLazyListState() - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize().padding(end = 12.dp) - ) { - if (songs.isEmpty() && projectPath.isNotBlank()) { - item { - Text( - "Keine Lieder gefunden. Bitte Projektverzeichnis prüfen.", - color = Color.Gray, - modifier = Modifier.padding(8.dp) - ) - } - } else if (projectPath.isBlank()) { - item { - Text( - "Bitte ein Projektverzeichnis auswählen.", - color = Color.Gray, - modifier = Modifier.padding(8.dp) - ) - } - } - items(songs) { song -> - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp, horizontal = 8.dp)) { - Text(song.title, modifier = Modifier.weight(1f)) - Text(song.fileName, color = Color.Gray, fontSize = 12.sp) - } - Divider() + // Song list header with optional reset button + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Lieder (${songs.size}):", + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + if (reorderEnabled && isCustomOrder) { + Button( + onClick = { + songs = originalSongs.toList() + isCustomOrder = false + }, + enabled = !isRunning + ) { + Text("Reihenfolge zuruecksetzen") } } - VerticalScrollbar( - modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), - adapter = rememberScrollbarAdapter(listState) + } + Spacer(modifier = Modifier.height(4.dp)) + + if (songs.isEmpty() && projectPath.isNotBlank()) { + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + Text( + "Keine Lieder gefunden. Bitte Projektverzeichnis pruefen.", + color = Color.Gray, + modifier = Modifier.padding(8.dp) + ) + } + } else if (projectPath.isBlank()) { + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + Text( + "Bitte ein Projektverzeichnis auswaehlen.", + color = Color.Gray, + modifier = Modifier.padding(8.dp) + ) + } + } else { + ReorderableSongList( + songs = songs, + reorderEnabled = reorderEnabled, + onReorder = { newList -> + songs = newList + isCustomOrder = true + }, + modifier = Modifier.weight(1f) ) } } @@ -197,9 +225,15 @@ fun App() { lastBuildResult = null statusMessages = listOf(StatusMessage("Buch wird erstellt...", MessageType.INFO)) scope.launch { + // Build custom song order from the current GUI list + val customOrder = if (isCustomOrder) { + songs.map { it.fileName } + } else { + null + } val result = withContext(Dispatchers.IO) { try { - SongbookPipeline(File(projectPath)).build() + SongbookPipeline(File(projectPath)).build(customOrder) } catch (e: Exception) { BuildResult( success = false, @@ -249,7 +283,7 @@ fun App() { if (projectPath.isBlank()) return@Button isRunning = true lastBuildResult = null - statusMessages = listOf(StatusMessage("Validierung läuft...", MessageType.INFO)) + statusMessages = listOf(StatusMessage("Validierung laeuft...", MessageType.INFO)) scope.launch { val errors = withContext(Dispatchers.IO) { try { @@ -288,7 +322,7 @@ fun App() { Desktop.getDesktop().open(file) } catch (e: Exception) { statusMessages = statusMessages + StatusMessage( - "PDF konnte nicht geöffnet werden: ${e.message}", + "PDF konnte nicht geoeffnet werden: ${e.message}", MessageType.ERROR ) } @@ -296,7 +330,7 @@ fun App() { }, enabled = !isRunning ) { - Text("PDF öffnen") + Text("PDF oeffnen") } // Show/hide preview button diff --git a/gui/src/main/kotlin/de/pfadfinder/songbook/gui/ReorderableSongList.kt b/gui/src/main/kotlin/de/pfadfinder/songbook/gui/ReorderableSongList.kt new file mode 100644 index 0000000..7d1a187 --- /dev/null +++ b/gui/src/main/kotlin/de/pfadfinder/songbook/gui/ReorderableSongList.kt @@ -0,0 +1,142 @@ +package de.pfadfinder.songbook.gui + +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** + * A song list that supports drag-and-drop reordering when enabled. + * + * @param songs The current song list + * @param reorderEnabled Whether drag-and-drop is enabled + * @param onReorder Callback when songs are reordered, provides the new list + */ +@Composable +fun ReorderableSongList( + songs: List, + reorderEnabled: Boolean, + onReorder: (List) -> Unit, + modifier: Modifier = Modifier +) { + // Track drag state + var draggedIndex by remember { mutableStateOf(-1) } + var hoverIndex by remember { mutableStateOf(-1) } + var dragOffset by remember { mutableStateOf(0f) } + + // Approximate item height for calculating target index from drag offset + val itemHeightPx = 36f // approximate height of each row in pixels + + Box(modifier = modifier.fillMaxWidth()) { + val listState = rememberLazyListState() + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().padding(end = 12.dp) + ) { + itemsIndexed(songs, key = { _, song -> song.fileName }) { index, song -> + val isDragTarget = hoverIndex == index && draggedIndex != -1 && draggedIndex != index + val isBeingDragged = draggedIndex == index + + Row( + modifier = Modifier + .fillMaxWidth() + .then( + if (isDragTarget) { + Modifier.background(Color(0xFFBBDEFB)) // light blue drop indicator + } else if (isBeingDragged) { + Modifier.background(Color(0xFFE0E0E0)) // grey for dragged item + } else { + Modifier + } + ) + .padding(vertical = 2.dp, horizontal = 8.dp) + .then( + if (reorderEnabled) { + Modifier.pointerInput(songs) { + detectDragGesturesAfterLongPress( + onDragStart = { + draggedIndex = index + hoverIndex = index + dragOffset = 0f + }, + onDrag = { change, dragAmount -> + change.consume() + dragOffset += dragAmount.y + // Calculate target index based on cumulative drag offset + val indexDelta = (dragOffset / itemHeightPx).toInt() + val newHover = (draggedIndex + indexDelta).coerceIn(0, songs.size - 1) + hoverIndex = newHover + }, + onDragEnd = { + if (draggedIndex != -1 && hoverIndex != -1 && draggedIndex != hoverIndex) { + val mutable = songs.toMutableList() + val item = mutable.removeAt(draggedIndex) + mutable.add(hoverIndex, item) + onReorder(mutable) + } + draggedIndex = -1 + hoverIndex = -1 + dragOffset = 0f + }, + onDragCancel = { + draggedIndex = -1 + hoverIndex = -1 + dragOffset = 0f + } + ) + } + } else { + Modifier + } + ), + verticalAlignment = Alignment.CenterVertically + ) { + if (reorderEnabled) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = "Ziehen zum Verschieben", + modifier = Modifier.size(16.dp).padding(end = 4.dp), + tint = Color.Gray + ) + } + Text(song.title, modifier = Modifier.weight(1f)) + Text(song.fileName, color = Color.Gray, fontSize = 12.sp) + } + Divider() + } + + if (!reorderEnabled && songs.isNotEmpty()) { + item { + Text( + "Reihenfolge ist alphabetisch. Wechsle in songbook.yaml zu songs.order: \"manual\" um die Reihenfolge zu aendern.", + color = Color.Gray, + fontStyle = FontStyle.Italic, + fontSize = 11.sp, + modifier = Modifier.padding(8.dp) + ) + } + } + } + VerticalScrollbar( + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + adapter = rememberScrollbarAdapter(listState) + ) + } +}