diff --git a/gui/rpgmaker_image_manager.py b/gui/rpgmaker_image_manager.py index 199ab0c..a74e856 100644 --- a/gui/rpgmaker_image_manager.py +++ b/gui/rpgmaker_image_manager.py @@ -291,23 +291,23 @@ class RPGMakerImageManager(QWidget): action_row = QHBoxLayout() action_row.setSpacing(8) - self.open_workspace_button = QPushButton("Open folder") + self.open_workspace_button = QPushButton("Open") self.open_workspace_button.setToolTip( "Open the highlighted image's editable folder, the chosen folder filter, or the " "editable img root." ) self.open_workspace_button.clicked.connect(self._open_editable_folder) - self.decrypt_selected_button = QPushButton("Decrypt highlighted") + self.decrypt_selected_button = QPushButton("Decrypt") self.decrypt_selected_button.clicked.connect(self._decrypt_checked) self.decrypt_all_button = QPushButton("Decrypt all") self.decrypt_all_button.clicked.connect(self._decrypt_all) - self.remove_button = QPushButton("Remove highlighted") + self.remove_button = QPushButton("Remove") self.remove_button.setToolTip( "Delete highlighted PNG copies from the editable folder. Runtime images remain " "untouched and can be decrypted again. The Delete key does the same thing." ) self.remove_button.clicked.connect(self._remove_highlighted) - self.prepare_button = QPushButton("Encrypt all + patch") + self.prepare_button = QPushButton("Patch all") self.prepare_button.setStyleSheet( "QPushButton{border:1px solid #4ec9b0;color:#4ec9b0;font-weight:bold;padding:6px 14px;}" "QPushButton:hover{background:#18352f;}" @@ -494,9 +494,14 @@ class RPGMakerImageManager(QWidget): label = asset.relative_png.name item = QListWidgetItem(placeholder_icon, label) item.setData(_ASSET_ID_ROLE, asset.asset_id) - kind = "encrypted + editable" if asset.has_encrypted and asset.has_plain else ( - "encrypted" if asset.has_encrypted else "runtime PNG" - ) + if asset.has_encrypted and asset.has_plain: + kind = "encrypted + editable" + elif asset.has_encrypted: + kind = "encrypted" + elif asset.has_runtime_plain: + kind = "runtime PNG" + else: + kind = "editable PNG" item.setToolTip(f"{asset.asset_id}\n{kind}") self.image_list.addItem(item) item.setSelected(asset.asset_id in self.selected_ids) @@ -562,9 +567,9 @@ class RPGMakerImageManager(QWidget): def _update_prepare_scope(self) -> None: if self.selected_ids: - self.prepare_button.setText("Encrypt highlighted + patch") + self.prepare_button.setText("Patch selected") else: - self.prepare_button.setText("Encrypt all + patch") + self.prepare_button.setText("Patch all") def _show_preview(self, current: QListWidgetItem | None, _previous=None) -> None: if current is None: @@ -606,14 +611,11 @@ class RPGMakerImageManager(QWidget): return try: workspace = ensure_editable_workspace(self.game_root) - current = self.image_list.currentItem() - asset = ( - self.assets_by_id.get(current.data(_ASSET_ID_ROLE)) - if current is not None - else None - ) - if asset is not None: - target = asset.plain_path.parent + highlighted_parents = { + asset.plain_path.parent for asset in self._selected_assets() + } + if len(highlighted_parents) == 1: + target = highlighted_parents.pop() else: root = Path(self.game_root).expanduser().resolve() content_relative = resolve_content_root(root).relative_to(root) diff --git a/tests/test_rpgmaker_image_manager.py b/tests/test_rpgmaker_image_manager.py index fdbd61d..136728a 100644 --- a/tests/test_rpgmaker_image_manager.py +++ b/tests/test_rpgmaker_image_manager.py @@ -193,7 +193,7 @@ class RPGMakerImageManagerSelectionTests(unittest.TestCase): self.assertFalse(asset.plain_path.exists()) self.assertTrue(asset.runtime_plain_path.exists()) self.assertEqual(self.manager.image_list.count(), 0) - self.assertEqual(self.manager.remove_button.text(), "Remove highlighted") + self.assertEqual(self.manager.remove_button.text(), "Remove") def test_prepare_uses_only_highlighted_editable_images(self): highlighted = self.manager.assets[0] @@ -214,9 +214,7 @@ class RPGMakerImageManagerSelectionTests(unittest.TestCase): self.assertEqual(assets, [highlighted]) self.assertTrue(highlighted.plain_path.exists()) self.assertTrue(not_highlighted.plain_path.exists()) - self.assertEqual( - self.manager.prepare_button.text(), "Encrypt highlighted + patch" - ) + self.assertEqual(self.manager.prepare_button.text(), "Patch selected") def test_prepare_uses_all_editable_images_without_highlights(self): editable = self.manager.assets[:2] @@ -233,7 +231,7 @@ class RPGMakerImageManagerSelectionTests(unittest.TestCase): action, assets = start_action.call_args.args self.assertEqual(action, "prepare") self.assertEqual(assets, editable) - self.assertEqual(self.manager.prepare_button.text(), "Encrypt all + patch") + self.assertEqual(self.manager.prepare_button.text(), "Patch all") def test_bottom_controls_share_one_row_and_action_width(self): action_buttons = ( @@ -256,6 +254,22 @@ class RPGMakerImageManagerSelectionTests(unittest.TestCase): self._click(0) asset_id = self.manager.image_list.currentItem().data(Qt.UserRole + 1) expected = self.manager.assets_by_id[asset_id].plain_path.parent + self.manager.image_list.setCurrentItem(None) + + with patch( + "gui.rpgmaker_image_manager.QDesktopServices.openUrl", + return_value=True, + ) as open_url: + self.manager._open_editable_folder() + + opened_url = open_url.call_args.args[0] + self.assertEqual(Path(opened_url.toLocalFile()), expected) + self.assertTrue(expected.is_dir()) + + def test_open_folder_ignores_current_item_without_highlights(self): + self.manager.image_list.setCurrentRow(0) + self.manager.selected_ids.clear() + expected = self.game_root / ".dazedtl" / "images" / "img" with patch( "gui.rpgmaker_image_manager.QDesktopServices.openUrl", diff --git a/tests/test_rpgmaker_images.py b/tests/test_rpgmaker_images.py index ee4d564..728b361 100644 --- a/tests/test_rpgmaker_images.py +++ b/tests/test_rpgmaker_images.py @@ -88,6 +88,46 @@ class RPGMakerImageTests(unittest.TestCase): self.assertEqual(result.skipped, 1) self.assertEqual(plain.read_bytes(), translated) + def test_separate_decrypt_batches_accumulate_without_overwriting(self): + self._encrypted_asset("001.rpgmvp", "red") + self._encrypted_asset("002.rpgmvp", "green") + first, second = scan_image_assets(self.root) + + first_result = decrypt_assets([first], KEY, game_root=self.root) + edited = png_bytes("blue") + first.plain_path.write_bytes(edited) + refreshed = {asset.asset_id: asset for asset in scan_image_assets(self.root)} + second_result = decrypt_assets( + [refreshed[second.asset_id]], KEY, game_root=self.root + ) + + editable = { + asset.asset_id: asset + for asset in scan_image_assets(self.root) + if asset.has_plain + } + self.assertEqual(first_result.completed, 1) + self.assertEqual(second_result.completed, 1) + self.assertEqual(set(editable), {first.asset_id, second.asset_id}) + self.assertEqual(editable[first.asset_id].plain_path.read_bytes(), edited) + self.assertEqual( + editable[second.asset_id].plain_path.read_bytes(), png_bytes("green") + ) + + def test_workspace_image_remains_scannable_until_removed(self): + encrypted = self._encrypted_asset() + asset = scan_image_assets(self.root)[0] + decrypt_assets([asset], KEY, game_root=self.root) + encrypted.unlink() + + editable_only = scan_image_assets(self.root) + + self.assertEqual([item.asset_id for item in editable_only], [asset.asset_id]) + self.assertTrue(editable_only[0].has_plain) + self.assertFalse(editable_only[0].has_encrypted) + remove_editable_assets(self.root, editable_only) + self.assertEqual(scan_image_assets(self.root), []) + def test_remove_editable_image_deletes_only_workspace_copy(self): self._encrypted_asset() asset = scan_image_assets(self.root)[0] diff --git a/util/rpgmaker_images.py b/util/rpgmaker_images.py index a8b282b..2319016 100644 --- a/util/rpgmaker_images.py +++ b/util/rpgmaker_images.py @@ -122,11 +122,13 @@ def _logical_png(path: Path) -> Path: def scan_image_assets(game_root: str | Path) -> list[ImageAsset]: - """Scan ``img/`` and combine matching PNG/encrypted files into records.""" + """Scan runtime and editable image trees and combine matching files.""" root = Path(game_root).expanduser().resolve() content_root = resolve_content_root(root) image_root = content_root / "img" + workspace_root = editable_workspace_root(root) + workspace_content_root = workspace_root / content_root.relative_to(root) by_id: dict[str, dict[str, Path]] = {} for path in image_root.rglob("*"): if not path.is_file(): @@ -149,11 +151,29 @@ def scan_image_assets(game_root: str | Path) -> list[ImageAsset]: else: entry["runtime_plain"] = path + editable_image_root = workspace_content_root / "img" + if editable_image_root.is_dir(): + for path in editable_image_root.rglob("*"): + if not path.is_file() or path.suffix.casefold() != ".png": + continue + relative = path.relative_to(workspace_content_root) + asset_id = relative.as_posix() + entry = by_id.setdefault( + asset_id, + { + "relative": relative, + "workspace_relative": path.relative_to(workspace_root), + }, + ) + entry["plain"] = path + assets = [ ImageAsset( asset_id=asset_id, relative_png=entry["relative"], - plain_path=editable_workspace_root(root) / entry["workspace_relative"], + plain_path=entry.get( + "plain", workspace_root / entry["workspace_relative"] + ), encrypted_path=entry.get("encrypted"), runtime_plain_path=entry.get("runtime_plain"), )