feat: add page-by-page preview in the GUI after building (Closes #18) (#21)

This commit was merged in pull request #21.
This commit is contained in:
2026-03-17 14:25:13 +01:00
parent a69d14033d
commit 0f038a68d8
4 changed files with 342 additions and 41 deletions

View File

@@ -0,0 +1,44 @@
# Issue #18: Add page-by-page preview in the GUI after building
## Summary
Add a PDF preview panel to the GUI that appears after a successful build. It renders PDF pages as images using Apache PDFBox and displays them with previous/next navigation and a page counter. The preview loads pages lazily for performance and updates automatically on new builds.
## AC Verification Checklist
1. After a successful build, a preview panel appears showing generated pages
2. Users can navigate between pages (previous/next buttons)
3. Current page number and total count displayed (e.g., "Seite 3 / 42")
4. Preview renders actual PDF pages as images (PDF-to-image via PDFBox)
5. Preview panel can be closed/hidden to return to normal view
6. Preview updates automatically when a new build completes
7. GUI remains responsive while preview is loading (async rendering)
## Implementation Steps
### Step 1: Add PDFBox dependency to gui module
Add `org.apache.pdfbox:pdfbox:3.0.4` to gui/build.gradle.kts.
### Step 2: Create PdfPreviewState class
A state holder for the preview: current page index, total pages, rendered page images (cached), loading state. Pages are rendered lazily — only the current page is rendered at a time.
### Step 3: Create PdfPreviewPanel composable
A Compose panel with:
- An Image composable showing the current page
- Navigation row: "< Prev" button | "Seite X / Y" label | "Next >" button
- A close/hide button
- Loading indicator while page is rendering
### Step 4: Integrate preview into App composable
After a successful build:
- Show a "Vorschau" button in the action buttons row
- When clicked, show the preview panel (replacing or overlaying the song list area)
- When a new build succeeds, update the preview automatically
### Step 5: Lazy page rendering
Render pages on demand using coroutines on Dispatchers.IO to keep the UI responsive.

View File

@@ -11,6 +11,7 @@ dependencies {
implementation(compose.desktop.currentOs)
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
implementation("ch.qos.logback:logback-classic:1.5.16")
implementation("org.apache.pdfbox:pdfbox:3.0.4")
}
compose.desktop {

View File

@@ -48,6 +48,7 @@ fun App() {
var statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) }
var isRunning by remember { mutableStateOf(false) }
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
val previewState = remember { PdfPreviewState() }
val scope = rememberCoroutineScope()
@@ -132,48 +133,57 @@ fun App() {
Spacer(modifier = Modifier.height(16.dp))
// 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()
}
}
VerticalScrollbar(
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
adapter = rememberScrollbarAdapter(listState)
// Central content area: song list or preview panel
if (previewState.isVisible) {
// Show preview panel
PdfPreviewPanel(
state = previewState,
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()
}
}
VerticalScrollbar(
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
adapter = rememberScrollbarAdapter(listState)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
@@ -222,6 +232,11 @@ fun App() {
}
}
isRunning = false
// Automatically load preview after successful build
if (result.success && result.outputFile != null) {
previewState.loadPdf(result.outputFile!!)
}
}
},
enabled = !isRunning && projectPath.isNotBlank()
@@ -283,6 +298,26 @@ fun App() {
) {
Text("PDF öffnen")
}
// Show/hide preview button
Button(
onClick = {
if (previewState.isVisible) {
previewState.isVisible = false
} else {
scope.launch {
if (previewState.totalPages == 0) {
lastBuildResult?.outputFile?.let { previewState.loadPdf(it) }
} else {
previewState.isVisible = true
}
}
}
},
enabled = !isRunning
) {
Text(if (previewState.isVisible) "Vorschau ausblenden" else "Vorschau")
}
}
if (isRunning) {

View File

@@ -0,0 +1,221 @@
package de.pfadfinder.songbook.gui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.apache.pdfbox.Loader
import org.apache.pdfbox.pdmodel.PDDocument
import org.apache.pdfbox.rendering.PDFRenderer
import java.awt.image.BufferedImage
import java.io.File
/**
* State holder for the PDF preview. Manages the current page, total page count,
* and a cache of rendered page images.
*/
class PdfPreviewState {
var pdfFile: File? by mutableStateOf(null)
private set
var totalPages: Int by mutableStateOf(0)
private set
var currentPage: Int by mutableStateOf(0)
private set
var isLoading: Boolean by mutableStateOf(false)
private set
var currentImage: ImageBitmap? by mutableStateOf(null)
private set
var isVisible: Boolean by mutableStateOf(false)
private var document: PDDocument? = null
private var renderer: PDFRenderer? = null
private val pageCache = mutableMapOf<Int, ImageBitmap>()
/**
* Load a new PDF file for preview. Resets state and renders the first page.
*/
suspend fun loadPdf(file: File) {
close()
pdfFile = file
currentPage = 0
pageCache.clear()
currentImage = null
withContext(Dispatchers.IO) {
try {
val doc = Loader.loadPDF(file)
document = doc
renderer = PDFRenderer(doc)
totalPages = doc.numberOfPages
} catch (_: Exception) {
totalPages = 0
}
}
if (totalPages > 0) {
isVisible = true
renderCurrentPage()
}
}
suspend fun goToPage(page: Int) {
if (page < 0 || page >= totalPages) return
currentPage = page
renderCurrentPage()
}
suspend fun nextPage() {
goToPage(currentPage + 1)
}
suspend fun previousPage() {
goToPage(currentPage - 1)
}
private suspend fun renderCurrentPage() {
val cached = pageCache[currentPage]
if (cached != null) {
currentImage = cached
return
}
isLoading = true
try {
val image = withContext(Dispatchers.IO) {
try {
val pdfRenderer = renderer ?: return@withContext null
// Render at 150 DPI for a good balance of quality and speed
val bufferedImage: BufferedImage = pdfRenderer.renderImageWithDPI(currentPage, 150f)
bufferedImage.toComposeImageBitmap()
} catch (_: Exception) {
null
}
}
if (image != null) {
pageCache[currentPage] = image
currentImage = image
}
} finally {
isLoading = false
}
}
fun close() {
try {
document?.close()
} catch (_: Exception) {
// ignore
}
document = null
renderer = null
pageCache.clear()
currentImage = null
totalPages = 0
currentPage = 0
isVisible = false
}
}
@Composable
fun PdfPreviewPanel(
state: PdfPreviewState,
modifier: Modifier = Modifier
) {
val scope = rememberCoroutineScope()
Column(modifier = modifier.fillMaxWidth()) {
// Header with title and close button
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Vorschau",
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
Button(
onClick = { state.isVisible = false },
colors = ButtonDefaults.buttonColors(backgroundColor = Color.LightGray)
) {
Text("Schliessen")
}
}
// Page image area
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(Color(0xFFE0E0E0)),
contentAlignment = Alignment.Center
) {
if (state.isLoading) {
CircularProgressIndicator()
} else if (state.currentImage != null) {
Image(
bitmap = state.currentImage!!,
contentDescription = "Seite ${state.currentPage + 1}",
modifier = Modifier.fillMaxSize().padding(4.dp),
contentScale = ContentScale.Fit
)
} else {
Text(
"Keine Vorschau verfuegbar",
color = Color.Gray
)
}
}
// Navigation row
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = {
scope.launch { state.previousPage() }
},
enabled = state.currentPage > 0 && !state.isLoading
) {
Text("<")
}
Spacer(modifier = Modifier.width(16.dp))
Text(
text = "Seite ${state.currentPage + 1} / ${state.totalPages}",
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
modifier = Modifier.widthIn(min = 120.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Button(
onClick = {
scope.launch { state.nextPage() }
},
enabled = state.currentPage < state.totalPages - 1 && !state.isLoading
) {
Text(">")
}
}
}
}