diff --git a/.plans/issue-18-page-preview.md b/.plans/issue-18-page-preview.md new file mode 100644 index 0000000..56fbded --- /dev/null +++ b/.plans/issue-18-page-preview.md @@ -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. diff --git a/gui/build.gradle.kts b/gui/build.gradle.kts index 6bd309b..98fea10 100644 --- a/gui/build.gradle.kts +++ b/gui/build.gradle.kts @@ -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 { diff --git a/gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt b/gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt index 5a1db64..1255277 100644 --- a/gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt +++ b/gui/src/main/kotlin/de/pfadfinder/songbook/gui/App.kt @@ -48,6 +48,7 @@ fun App() { var statusMessages by remember { mutableStateOf>(emptyList()) } var isRunning by remember { mutableStateOf(false) } var lastBuildResult by remember { mutableStateOf(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) { diff --git a/gui/src/main/kotlin/de/pfadfinder/songbook/gui/PdfPreviewPanel.kt b/gui/src/main/kotlin/de/pfadfinder/songbook/gui/PdfPreviewPanel.kt new file mode 100644 index 0000000..2934222 --- /dev/null +++ b/gui/src/main/kotlin/de/pfadfinder/songbook/gui/PdfPreviewPanel.kt @@ -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() + + /** + * 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(">") + } + } + } +}