feat: add drag-and-drop song reordering in the GUI (Closes #19) (#22)

This commit was merged in pull request #22.
This commit is contained in:
2026-03-17 14:30:07 +01:00
parent 0f038a68d8
commit d733e83cb1
4 changed files with 292 additions and 57 deletions

View File

@@ -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<String>? = 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`.

View File

@@ -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<String>? = 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<Song>()
val songsByFileName = mutableMapOf<String, Song>()
val allErrors = mutableListOf<ValidationError>()
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" }

View File

@@ -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<List<SongEntry>>(emptyList()) }
var originalSongs by remember { mutableStateOf<List<SongEntry>>(emptyList()) }
var songsOrderConfig by remember { mutableStateOf("alphabetical") }
var isCustomOrder by remember { mutableStateOf(false) }
var statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) }
var isRunning by remember { mutableStateOf(false) }
var lastBuildResult by remember { mutableStateOf<BuildResult?>(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

View File

@@ -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<SongEntry>,
reorderEnabled: Boolean,
onReorder: (List<SongEntry>) -> 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)
)
}
}