feat: add intro page, rotated TOC headers, progress indicator, fix TOC padding (Closes #33)

- Add intro/title page before TOC (configurable via intro.enabled in songbook.yaml)
- Rotate reference book column headers 90° in TOC to prevent text truncation
- Add progress callback to SongbookPipeline.build() with CLI and GUI integration
- Make GUI song loading async with spinner indicator
- Fix TOC page padding: use actual rendered page count instead of blindly adding tocPages-1 blank pages
- Pre-render TOC to measure actual pages needed (TocRenderer.measurePages)
- Account for intro pages in pagination offset and page numbering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shahondin1624
2026-03-18 09:45:32 +01:00
parent 9056dbd9cd
commit 3d346e899d
7 changed files with 238 additions and 58 deletions

View File

@@ -51,6 +51,7 @@ fun App() {
var isCustomOrder by remember { mutableStateOf(false) }
var statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) }
var isRunning by remember { mutableStateOf(false) }
var isLoadingSongs by remember { mutableStateOf(false) }
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
val previewState = remember { PdfPreviewState() }
@@ -65,44 +66,58 @@ fun App() {
isCustomOrder = false
if (!projectDir.isDirectory) return
val configFile = File(projectDir, "songbook.yaml")
var songsDir: File
songsOrderConfig = "alphabetical"
isLoadingSongs = true
statusMessages = listOf(StatusMessage("Lieder werden geladen...", MessageType.INFO))
if (configFile.exists()) {
try {
val config = ConfigParser.parse(configFile)
songsDir = File(projectDir, config.songs.directory)
songsOrderConfig = config.songs.order
} catch (_: Exception) {
songsDir = File(projectDir, "songs")
scope.launch {
val (loadedSongs, order) = withContext(Dispatchers.IO) {
val configFile = File(projectDir, "songbook.yaml")
var songsDir: File
var orderConfig = "alphabetical"
if (configFile.exists()) {
try {
val config = ConfigParser.parse(configFile)
songsDir = File(projectDir, config.songs.directory)
orderConfig = config.songs.order
} catch (_: Exception) {
songsDir = File(projectDir, "songs")
}
} else {
songsDir = File(projectDir, "songs")
}
if (!songsDir.isDirectory) return@withContext Pair(emptyList<SongEntry>(), orderConfig)
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
?.sortedBy { it.name }
?: emptyList()
val loaded = songFiles.mapNotNull { file ->
try {
val song = ChordProParser.parseFile(file)
SongEntry(fileName = file.name, title = song.title.ifBlank { file.nameWithoutExtension })
} catch (_: Exception) {
SongEntry(fileName = file.name, title = "${file.nameWithoutExtension} (Fehler beim Lesen)")
}
}
Pair(loaded, orderConfig)
}
} else {
songsDir = File(projectDir, "songs")
}
if (!songsDir.isDirectory) return
val songFiles = songsDir.listFiles { f: File -> f.extension in listOf("chopro", "cho", "crd") }
?.sortedBy { it.name }
?: emptyList()
val loadedSongs = songFiles.mapNotNull { file ->
try {
val song = ChordProParser.parseFile(file)
SongEntry(fileName = file.name, title = song.title.ifBlank { file.nameWithoutExtension })
} catch (_: Exception) {
SongEntry(fileName = file.name, title = "${file.nameWithoutExtension} (Fehler beim Lesen)")
songsOrderConfig = order
songs = if (order == "alphabetical") {
loadedSongs.sortedBy { it.title.lowercase() }
} else {
loadedSongs
}
originalSongs = songs.toList()
isLoadingSongs = false
statusMessages = if (loadedSongs.isNotEmpty()) {
listOf(StatusMessage("${loadedSongs.size} Lieder geladen.", MessageType.SUCCESS))
} else {
listOf(StatusMessage("Keine Lieder gefunden.", MessageType.INFO))
}
}
// Apply config-based sort for display
songs = if (songsOrderConfig == "alphabetical") {
loadedSongs.sortedBy { it.title.lowercase() }
} else {
loadedSongs
}
originalSongs = songs.toList()
}
MaterialTheme {
@@ -185,7 +200,18 @@ fun App() {
}
Spacer(modifier = Modifier.height(4.dp))
if (songs.isEmpty() && projectPath.isNotBlank()) {
if (isLoadingSongs) {
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(modifier = Modifier.size(32.dp))
Spacer(modifier = Modifier.height(8.dp))
Text("Lieder werden geladen...", color = Color.Gray)
}
}
} else if (songs.isEmpty() && projectPath.isNotBlank()) {
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
Text(
"Keine Lieder gefunden. Bitte Projektverzeichnis pruefen.",
@@ -233,7 +259,9 @@ fun App() {
}
val result = withContext(Dispatchers.IO) {
try {
SongbookPipeline(File(projectPath)).build(customOrder)
SongbookPipeline(File(projectPath)).build(customOrder) { msg ->
statusMessages = listOf(StatusMessage(msg, MessageType.INFO))
}
} catch (e: Exception) {
BuildResult(
success = false,