@@ -66,10 +66,11 @@ private val IMAGE_EXTENSIONS = setOf("png", "jpg", "jpeg", "webp", "bmp", "gif")
6666private fun describePref (p : UserPreferences ): String = " ${p.outputFormat} / ${p.compressLevel} "
6767
6868/* *
69- * S4d-158: drop-target file extraction. [hasFileList] is the cheap drag-over predicate (flavor only);
70- * [firstSupportedImageFile] does the real extraction on drop — reads the dropped file list and picks the
71- * first file whose extension is in [IMAGE_EXTENSIONS]. Both swallow AWT failures → false/null (soft-fail,
72- * never crash). The AWT file-list flavor is the desktop drag-drop interop; commonMain is untouched.
69+ * S4d-158 / S4d-228: drop-target file extraction. [hasFileList] is the cheap drag-over predicate (flavor
70+ * only); [supportedImageFiles] does the real extraction on drop — reads the dropped file list and returns
71+ * ALL files whose extension is in [IMAGE_EXTENSIONS] (order preserved), via the pure, unit-tested
72+ * [DesktopSaveDecision.supportedImageFiles]. Both swallow AWT failures → false/empty (soft-fail, never
73+ * crash). The AWT file-list flavor is the desktop drag-drop interop; commonMain is untouched.
7374 */
7475@OptIn(ExperimentalComposeUiApi ::class )
7576private fun hasFileList (event : DragAndDropEvent ): Boolean = try {
@@ -79,17 +80,17 @@ private fun hasFileList(event: DragAndDropEvent): Boolean = try {
7980}
8081
8182@OptIn(ExperimentalComposeUiApi ::class )
82- private fun firstSupportedImageFile (event : DragAndDropEvent ): File ? = try {
83+ private fun supportedImageFiles (event : DragAndDropEvent ): List < File > = try {
8384 val transferable = event.awtTransferable
8485 if (transferable.isDataFlavorSupported(DataFlavor .javaFileListFlavor)) {
8586 @Suppress(" UNCHECKED_CAST" )
86- (transferable.getTransferData(DataFlavor .javaFileListFlavor) as ? List <File >)
87- ?.firstOrNull { it.extension.lowercase() in IMAGE_EXTENSIONS }
87+ val files = (transferable.getTransferData(DataFlavor .javaFileListFlavor) as ? List <File >).orEmpty( )
88+ DesktopSaveDecision .supportedImageFiles(files, IMAGE_EXTENSIONS )
8889 } else {
89- null
90+ emptyList()
9091 }
9192} catch (t: Throwable ) {
92- null
93+ emptyList()
9394}
9495
9596/* * Stable UI label for a [TextTypeface] — explicit map (no reflection / object-name dependency). */
@@ -308,53 +309,83 @@ fun launchDesktopWindow() = application {
308309 return msg
309310 }
310311
311- // S4d-158: drop an image file onto the window to load it through the SAME save spine as "Open image…"
312- // (read off the UI thread → lastImage + lastSavedFile + status). onDrop runs on the Compose UI thread, so
313- // it reads/sets state directly and launches the heavy render on `scope`. A drop while busy or an
314- // unsupported/empty drop fails softly with a status (no crash). Remembered so the target identity is stable.
312+ // S4d-158: drop image file(s) onto the window to load them through the SAME save spine as "Open image…".
313+ // S4d-228: a multi-file drop now watermarks and saves EVERY supported dropped image (was first-only),
314+ // sequentially, to the user output dir with collision-free names. onDrop runs on the Compose UI thread,
315+ // so it reads/sets state directly and launches the heavy render loop on `scope`. A drop while busy or a
316+ // drop with no supported image fails softly with a status (no crash). Remembered so the target identity
317+ // is stable.
315318 val dropTarget = remember {
316319 object : DragAndDropTarget {
317320 override fun onDrop (event : DragAndDropEvent ): Boolean {
318321 if (busy) {
319322 status = " Busy — wait for the current render before dropping another image."
320323 return false
321324 }
322- val file = firstSupportedImageFile(event)
323- if (file == null ) {
324- status = " Unsupported drop — drop a single image file (${IMAGE_EXTENSIONS .joinToString(" , " )} )."
325+ // S4d-228: take ALL supported dropped images (pure DesktopSaveDecision.supportedImageFiles).
326+ val files = supportedImageFiles(event)
327+ if (files.isEmpty()) {
328+ status = " Unsupported drop — no supported image files in drop (${IMAGE_EXTENSIONS .joinToString(" , " )} )."
325329 return false
326330 }
327331 scope.launch {
328332 busy = true
329- status = " Rendering ${file.name} …"
330- var picked: LastImage ? = null
331- var savedFile: File ? = null
332- val next = withContext(Dispatchers .IO ) {
333- try {
334- val bytes = file.readBytes()
335- picked = LastImage (bytes, file.path)
336- // S4d-217: write the real save to the user output dir (not the build/ default).
337- val fmt = userConfigRepo.userPreferences.first().outputFormat
338- val out = DesktopSaveDecision .resolveUniqueOutputFile(outputDir, fmt)
339- val o = DesktopWatermarkFlow .runSaveFlow(
340- repo, editor, userConfigRepo, inputBytes = bytes, inputLabel = file.path,
341- outputFile = out ,
342- )
343- // S4d-157: remember the real saved output for the share-substitute buttons.
344- savedFile = File (o.outputPath)
345- " Saved: ${o.outputPath} \n ${o.format} , ${o.width} x${o.height} , ${o.outputByteCount} B\n " +
346- " input: ${o.inputLabel} (${o.inputByteCount} B)\n config: ${o.configAfterEdit} "
347- } catch (t: Throwable ) {
348- " Failed: ${t.message} "
333+ status = " Rendering ${files.size} image(s)…"
334+ // Remember the LAST successful image/output (for reuse + the share-substitute buttons).
335+ var lastPicked: LastImage ? = null
336+ var lastSaved: File ? = null
337+ // S4d-228-r1: the whole batch span is wrapped in try/finally so `busy` is ALWAYS reset —
338+ // even if setup (notably reading the output prefs) throws BEFORE the per-file loop. This
339+ // restores the old single-file drop's recovery: a setup failure must not leave the UI stuck.
340+ try {
341+ val next = withContext(Dispatchers .IO ) {
342+ // S4d-228-r1: a setup-level failure (e.g. userConfigRepo.userPreferences.first())
343+ // surfaces as a "Failed: …" status instead of an uncaught throw. The per-file loop
344+ // keeps its OWN try/catch so one bad image still does not abort the batch.
345+ try {
346+ // S4d-228 / S4d-217: read the output format ONCE per batch; saves go to the user dir.
347+ val fmt = userConfigRepo.userPreferences.first().outputFormat
348+ var successCount = 0
349+ var failCount = 0
350+ var firstFailure: String? = null
351+ // S4d-228: STRICTLY SEQUENTIAL. resolveUniqueOutputFile is existence-check-only and does
352+ // NOT create the file, and runSaveFlow writes its output synchronously before returning,
353+ // so resolving the NEXT path only after the previous save has written yields collision-
354+ // free names (watermarked.<ext>, watermarked_1.<ext>, …). Read one file's bytes per
355+ // iteration (never all up front). A per-file failure never aborts the batch.
356+ for (file in files) {
357+ try {
358+ val bytes = file.readBytes()
359+ val out = DesktopSaveDecision .resolveUniqueOutputFile(outputDir, fmt)
360+ val o = DesktopWatermarkFlow .runSaveFlow(
361+ repo, editor, userConfigRepo, inputBytes = bytes, inputLabel = file.path,
362+ outputFile = out ,
363+ )
364+ lastPicked = LastImage (bytes, file.path)
365+ lastSaved = File (o.outputPath)
366+ successCount++
367+ } catch (t: Throwable ) {
368+ failCount++
369+ if (firstFailure == null ) firstFailure = " ${file.name} : ${t.message} "
370+ }
371+ }
372+ buildString {
373+ append(" Saved $successCount /${files.size} images to ${outputDir.path} " )
374+ if (failCount > 0 ) append(" · $failCount failed: $firstFailure " )
375+ }
376+ } catch (t: Throwable ) {
377+ " Failed: ${t.message} "
378+ }
349379 }
380+ lastPicked?.let { lastImage = it }
381+ lastSaved?.let { lastSavedFile = it }
382+ // S4d-228: refresh the preview AT MOST ONCE after the batch, only when ≥1 save succeeded
383+ // (over the last successful image). refreshPreview writes ONLY the temp preview file
384+ // (never lastSavedFile, so the share-substitute buttons stay bound to real saves).
385+ status = if (lastSaved != null ) " $next · ${refreshPreview()} " else next
386+ } finally {
387+ busy = false
350388 }
351- picked?.let { lastImage = it }
352- savedFile?.let { lastSavedFile = it }
353- // S4d-198-r1: a successful drop is an explicit source change → auto-refresh the preview
354- // over the just-loaded image (lastImage is set above). savedFile != null ⟺ the real save
355- // succeeded; refreshPreview writes ONLY the temp preview file (never lastSavedFile).
356- status = if (savedFile != null ) " $next · ${refreshPreview()} " else next
357- busy = false
358389 }
359390 return true
360391 }
0 commit comments