This commit was merged in pull request #21.
This commit is contained in:
44
.plans/issue-18-page-preview.md
Normal file
44
.plans/issue-18-page-preview.md
Normal 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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(">")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user