Skip to content

Commit 4c895aa

Browse files
committed
Add Desktop Open image multi-select
1 parent a54e1b9 commit 4c895aa

1 file changed

Lines changed: 60 additions & 35 deletions

File tree

desktopApp/src/main/kotlin/me/rosuh/easywatermark/desktop/DesktopWindow.kt

Lines changed: 60 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -900,54 +900,79 @@ fun launchDesktopWindow() = application {
900900
Button(
901901
enabled = !busy,
902902
onClick = {
903-
// Native AWT Open dialog on the EDT (the Compose Desktop UI thread); it is modal, so
904-
// it returns the selection synchronously. `window` is the FrameWindowScope's AWT frame.
903+
// S4d-229: native AWT Open dialog with MULTI-SELECT on the EDT (modal → returns the
904+
// selection synchronously). `window` is the FrameWindowScope's AWT frame.
905905
val dialog = FileDialog(window, "Open image", FileDialog.LOAD).apply {
906+
isMultipleMode = true
906907
setFilenameFilter { _, fileName ->
907908
fileName.substringAfterLast('.', "").lowercase() in IMAGE_EXTENSIONS
908909
}
909910
isVisible = true
910911
}
911-
val dir = dialog.directory
912-
val name = dialog.file
913-
if (dir != null && name != null) {
914-
val selected = File(dir, name)
912+
// S4d-229: take ALL selected supported images via the existing pure filter (cancel →
913+
// empty `files`). A single selection is just a one-element list, so single-select still works.
914+
val files = DesktopSaveDecision.supportedImageFiles(dialog.files.toList(), IMAGE_EXTENSIONS)
915+
if (files.isNotEmpty()) {
915916
// Read + render off the UI thread, then write Compose state back on the UI dispatcher.
916917
scope.launch {
917918
busy = true
918-
status = "Rendering ${selected.name}"
919-
var picked: LastImage? = null
920-
var savedFile: File? = null
921-
val next = withContext(Dispatchers.IO) {
922-
try {
923-
val bytes = selected.readBytes()
924-
picked = LastImage(bytes, selected.path)
925-
// S4d-217: write the real save to the user output dir (not the build/ default).
926-
val fmt = userConfigRepo.userPreferences.first().outputFormat
927-
val out = DesktopSaveDecision.resolveUniqueOutputFile(outputDir, fmt)
928-
val o = DesktopWatermarkFlow.runSaveFlow(
929-
repo, editor, userConfigRepo, inputBytes = bytes, inputLabel = selected.path,
930-
outputFile = out,
931-
)
932-
// S4d-157: remember the real saved output for the share-substitute buttons.
933-
savedFile = File(o.outputPath)
934-
"Saved: ${o.outputPath}\n ${o.format}, ${o.width}x${o.height}, ${o.outputByteCount} B\n" +
935-
" input: ${o.inputLabel} (${o.inputByteCount} B)\n config: ${o.configAfterEdit}"
936-
} catch (t: Throwable) {
937-
"Failed: ${t.message}"
919+
status = "Rendering ${files.size} image(s)…"
920+
// Remember the LAST successful image/output (for reuse + the share-substitute buttons).
921+
var lastPicked: LastImage? = null
922+
var lastSaved: File? = null
923+
// S4d-229: mirror the S4d-228 drop batch exactly — the whole batch span is in
924+
// try/finally so `busy` ALWAYS resets, and the withContext body is in try/catch so a
925+
// setup-level failure (e.g. userConfigRepo.userPreferences.first()) surfaces as a
926+
// "Failed: …" status instead of leaving the UI stuck busy.
927+
try {
928+
val next = withContext(Dispatchers.IO) {
929+
try {
930+
// S4d-217: read the output format ONCE per batch; saves go to the user dir.
931+
val fmt = userConfigRepo.userPreferences.first().outputFormat
932+
var successCount = 0
933+
var failCount = 0
934+
var firstFailure: String? = null
935+
// STRICTLY SEQUENTIAL: resolveUniqueOutputFile is existence-check-only and
936+
// does NOT create the file, and runSaveFlow writes synchronously before
937+
// returning, so resolving the NEXT path only after the previous save has
938+
// written yields collision-free names. Read one file's bytes per iteration
939+
// (never all up front). A per-file failure never aborts the batch.
940+
for (file in files) {
941+
try {
942+
val bytes = file.readBytes()
943+
val out = DesktopSaveDecision.resolveUniqueOutputFile(outputDir, fmt)
944+
val o = DesktopWatermarkFlow.runSaveFlow(
945+
repo, editor, userConfigRepo, inputBytes = bytes, inputLabel = file.path,
946+
outputFile = out,
947+
)
948+
lastPicked = LastImage(bytes, file.path)
949+
lastSaved = File(o.outputPath)
950+
successCount++
951+
} catch (t: Throwable) {
952+
failCount++
953+
if (firstFailure == null) firstFailure = "${file.name}: ${t.message}"
954+
}
955+
}
956+
buildString {
957+
append("Saved $successCount/${files.size} images to ${outputDir.path}")
958+
if (failCount > 0) append(" · $failCount failed: $firstFailure")
959+
}
960+
} catch (t: Throwable) {
961+
"Failed: ${t.message}"
962+
}
938963
}
964+
lastPicked?.let { lastImage = it }
965+
lastSaved?.let { lastSavedFile = it }
966+
// Auto-refresh the preview AT MOST ONCE after the batch, only when ≥1 save
967+
// succeeded (over the last successful image). refreshPreview writes ONLY the temp
968+
// preview file (never lastSavedFile, so share-substitute stays real-save-bound).
969+
status = if (lastSaved != null) "$next · ${refreshPreview()}" else next
970+
} finally {
971+
busy = false
939972
}
940-
picked?.let { lastImage = it }
941-
savedFile?.let { lastSavedFile = it }
942-
// S4d-198-r1: a successful "Open image…" is an explicit source change →
943-
// auto-refresh the preview over the just-loaded image (lastImage is set above).
944-
// savedFile != null ⟺ the real save succeeded; refreshPreview writes ONLY the
945-
// temp preview file (never lastSavedFile, so share-substitute stays real-save-bound).
946-
status = if (savedFile != null) "$next · ${refreshPreview()}" else next
947-
busy = false
948973
}
949974
}
950-
// Cancelled (null file/directory) → leave the status unchanged and do no work.
975+
// Cancelled / no supported selection (empty `files`) → no work, status unchanged (no-op).
951976
},
952977
) {
953978
Text("Open image…")

0 commit comments

Comments
 (0)