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(compose.desktop.currentOs)
|
||||||
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
|
implementation("io.github.microutils:kotlin-logging-jvm:3.0.5")
|
||||||
implementation("ch.qos.logback:logback-classic:1.5.16")
|
implementation("ch.qos.logback:logback-classic:1.5.16")
|
||||||
|
implementation("org.apache.pdfbox:pdfbox:3.0.4")
|
||||||
}
|
}
|
||||||
|
|
||||||
compose.desktop {
|
compose.desktop {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ fun App() {
|
|||||||
var statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) }
|
var statusMessages by remember { mutableStateOf<List<StatusMessage>>(emptyList()) }
|
||||||
var isRunning by remember { mutableStateOf(false) }
|
var isRunning by remember { mutableStateOf(false) }
|
||||||
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
|
var lastBuildResult by remember { mutableStateOf<BuildResult?>(null) }
|
||||||
|
val previewState = remember { PdfPreviewState() }
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -132,48 +133,57 @@ fun App() {
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Song list
|
// Central content area: song list or preview panel
|
||||||
Text(
|
if (previewState.isVisible) {
|
||||||
text = "Lieder (${songs.size}):",
|
// Show preview panel
|
||||||
fontWeight = FontWeight.Medium
|
PdfPreviewPanel(
|
||||||
)
|
state = previewState,
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
modifier = Modifier.weight(1f)
|
||||||
|
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
|
} 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))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@@ -222,6 +232,11 @@ fun App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
isRunning = false
|
isRunning = false
|
||||||
|
|
||||||
|
// Automatically load preview after successful build
|
||||||
|
if (result.success && result.outputFile != null) {
|
||||||
|
previewState.loadPdf(result.outputFile!!)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = !isRunning && projectPath.isNotBlank()
|
enabled = !isRunning && projectPath.isNotBlank()
|
||||||
@@ -283,6 +298,26 @@ fun App() {
|
|||||||
) {
|
) {
|
||||||
Text("PDF öffnen")
|
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) {
|
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