This commit was merged in pull request #22.
This commit is contained in:
@@ -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