Skip to content

Commit c833aea

Browse files
committed
Add Desktop multi-file drop batch save
1 parent 5ec8009 commit c833aea

3 files changed

Lines changed: 131 additions & 43 deletions

File tree

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

Lines changed: 74 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,11 @@ private val IMAGE_EXTENSIONS = setOf("png", "jpg", "jpeg", "webp", "bmp", "gif")
6666
private 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)
7576
private 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
}

shared/src/desktopMain/kotlin/me/rosuh/easywatermark/render/DesktopSaveDecision.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import java.io.File
1717
* S4d-222 adds [resolveUniqueOutputFile], a desktopMain filesystem helper that performs existence
1818
* checks only and does not create, write, or delete files.
1919
*
20+
* S4d-228 adds [supportedImageFiles], the pure multi-file selection used by the Desktop window's
21+
* multi-image drag/drop batch (the AWT file-list extraction stays in `:desktopApp`).
22+
*
2023
* The Image-mode blank-icon loud-fail message is moved here **verbatim** from the inline `require` it
2124
* replaces, so the flow's behavior is byte-for-byte the same (the missing-FILE check, which is IO,
2225
* stays in the flow and keeps its own message). See `DesktopSaveDecisionTest`.
@@ -86,4 +89,15 @@ object DesktopSaveDecision {
8689
* testable without IO. Pure.
8790
*/
8891
fun usesCallerInput(inputBytes: ByteArray?): Boolean = inputBytes != null
92+
93+
/**
94+
* The supported image files among [files] — those whose extension (lower-cased) is in [extensions],
95+
* with the original order preserved. Pure (no IO).
96+
*
97+
* S4d-228: the Desktop window's multi-file drag/drop batch selects every supported dropped image (was
98+
* a single-file `.firstOrNull`). This pure filter lives here so the selection is unit-testable in
99+
* `:shared:desktopTest`; the AWT `javaFileListFlavor` extraction stays in `:desktopApp`.
100+
*/
101+
fun supportedImageFiles(files: List<File>, extensions: Set<String>): List<File> =
102+
files.filter { it.extension.lowercase() in extensions }
89103
}

shared/src/desktopTest/kotlin/me/rosuh/easywatermark/render/DesktopSaveDecisionTest.kt

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,47 @@ class DesktopSaveDecisionTest {
109109
val pngResult = DesktopSaveDecision.resolveUniqueOutputFile(dir, ImageFormat.PNG)
110110
assertEquals(File(dir, "watermarked.png"), pngResult)
111111
}
112+
113+
// --- S4d-228: multi-file drag/drop batch selection + sequential-naming contract ---
114+
115+
@Test
116+
fun supported_image_files_keeps_supported_subset_in_order() {
117+
val exts = setOf("png", "jpg", "jpeg", "webp", "bmp", "gif")
118+
val files = listOf(
119+
File("/a/photo.png"),
120+
File("/a/notes.txt"),
121+
File("/a/PIC.JPG"), // mixed-case extension still matches (lower-cased), order preserved
122+
File("/a/clip.gif"),
123+
File("/a/archive.zip"),
124+
)
125+
val result = DesktopSaveDecision.supportedImageFiles(files, exts)
126+
assertEquals(
127+
listOf(File("/a/photo.png"), File("/a/PIC.JPG"), File("/a/clip.gif")),
128+
result,
129+
)
130+
}
131+
132+
@Test
133+
fun supported_image_files_empty_when_none_match() {
134+
val exts = setOf("png", "jpg")
135+
assertTrue(DesktopSaveDecision.supportedImageFiles(emptyList(), exts).isEmpty(), "empty input → empty")
136+
assertTrue(
137+
DesktopSaveDecision.supportedImageFiles(listOf(File("/a/x.txt"), File("/a/y.doc")), exts).isEmpty(),
138+
"no supported extensions → empty",
139+
)
140+
}
141+
142+
@Test
143+
fun sequential_resolve_then_create_yields_distinct_names() {
144+
val dir = createTempDirectory().toFile()
145+
// Model the batch loop contract: resolve, WRITE the returned file, then resolve the next. Because
146+
// resolveUniqueOutputFile is existence-check-only, this sequence (mirroring runSaveFlow writing its
147+
// output before the next resolve) must produce distinct, collision-free names.
148+
val names = (1..3).map {
149+
val f = DesktopSaveDecision.resolveUniqueOutputFile(dir, ImageFormat.JPEG)
150+
f.createNewFile()
151+
f.name
152+
}
153+
assertEquals(listOf("watermarked.jpg", "watermarked_1.jpg", "watermarked_2.jpg"), names)
154+
}
112155
}

0 commit comments

Comments
 (0)