This commit was merged in pull request #22.
This commit is contained in:
41
.plans/issue-19-drag-and-drop.md
Normal file
41
.plans/issue-19-drag-and-drop.md
Normal 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`.
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user