diff --git a/.vscode/launch.json b/.vscode/launch.json index 3b2a2a4..591239b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -35,12 +35,10 @@ "args": [ "app.py", "--icon=assets/icons/app_icon.ico", - "--onefile", "--windowed", "--name", "MosaicTool", "--noconfirm", - "--noconsole", "--clean", "--collect-data", "tkinterdnd2", diff --git a/.vscode/settings.json b/.vscode/settings.json index b6cfd62..7b01048 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,6 +5,7 @@ "columnspan", "confirmoverwrite", "exif", + "initialfile", "ipadx", "itxt", "opsz", diff --git a/MosaicTool.spec b/MosaicTool.spec index 196eb96..d952e69 100644 --- a/MosaicTool.spec +++ b/MosaicTool.spec @@ -23,16 +23,13 @@ pyz = PYZ(a.pure) exe = EXE( pyz, a.scripts, - a.binaries, - a.datas, [], + exclude_binaries=True, name='MosaicTool', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, - upx_exclude=[], - runtime_tmpdir=None, console=False, disable_windowed_traceback=False, argv_emulation=False, @@ -41,3 +38,12 @@ exe = EXE( entitlements_file=None, icon=['assets\\icons\\app_icon.ico'], ) +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='MosaicTool', +) diff --git a/dist/MosaicTool.zip b/dist/MosaicTool.zip index e52f8ab..f4f9b97 100644 Binary files a/dist/MosaicTool.zip and b/dist/MosaicTool.zip differ diff --git a/dist/ReadMe.txt b/dist/ReadMe.txt index aef1cdf..e8cbf99 100644 --- a/dist/ReadMe.txt +++ b/dist/ReadMe.txt @@ -1,4 +1,4 @@ -2024/06/16 ver 0.0.5 +2024/06/23 ver 0.0.6 # 🌟 MosaicTool — モザイク編集のお供に ![Python version](https://img.shields.io/badge/python-3.9+-important) @@ -47,7 +47,7 @@ ## ハッシュ値 -MosaicTool.exe Hash Value (SHA-256): 6527274CE267D508E34E030CA6071C75DA54B9BBC2B420948D71E1220040F203 +MosaicTool.exe Hash Value (SHA-256): 331B940D7F8712B7C0AAA8DD1CC526522BC9EDD215ECB1771C5A9EE882F87434 ## 補足説明 1. アプリがアンチウィルスソフトで検知された場合は、使用しているライブラリが影響している誤検知です。 diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 458e6e7..c30994a 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -21,10 +21,8 @@ - `./run.bat`: app.pyを実行するスクリプト。またはVSCodeの実行ボタンより。 - `./build.bat`: 実行ファイルを作成するスクリプト。PyInstallerライブラリを使用して生成します。 - `./release.bat`: 配布用zipファイルを作成するスクリプト。release.pyを使用します。 -- 🎨`/dist`: 実行ファイル生成フォルダ。 - 視覚的な要素を提供します。 -- 🎨`/dist`: 実行ファイル生成フォルダ。 - 視覚的な要素を提供します。 +- 🎨`/assets`: アプリで使用するプログラムアイコン +- 📦`/dist`: 実行ファイル生成フォルダ。配布物のディレクトリ - 🎨`/third_party`:サードパーティのリソースを含むディレクトリ。 - `/third_party/icons`:アプリで使用するアイコン。 - 📚`/docs`: ドキュメンテーション。 diff --git a/docs/initial_screen.png b/docs/initial_screen.png index ded5036..e4ca314 100644 Binary files a/docs/initial_screen.png and b/docs/initial_screen.png differ diff --git a/scripts/release.py b/scripts/release.py index 6e21a51..6cefcc5 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -16,6 +16,7 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) from src import PROGRAM_NAME, get_package_version +from src.utils import Stopwatch __version__ = get_package_version() @@ -63,29 +64,11 @@ def create_readMe(file1_path: Path, file2_path: Path, hash_value: str, output_pa output_path.write_text(readme_content, "utf-8") -def get_compressible_files(root_dir: Path, exclude_files: list[Path]) -> list[Path]: +def create_zip(folder_path: Path, output_zip: Path, additional_files_with_names: dict[Path, str]) -> int: """ - 圧縮対象のファイル一覧を取得します。 + 引数で指定されたフォルダとファイルをZIPファイルに格納します。 - :param root_dir: 圧縮したいディレクトリのパス。 - :param exclude_file: 除外するファイルのリスト。 - :return: 圧縮対象のファイルパスのリスト。 - """ - compressible_files: list[Path] = [] - - for file_path in glob.glob(str(root_dir) + "/*.*"): - file_path = Path(file_path) # 文字列からPathに変換 - if not any(file_path.samefile(exclude_file) for exclude_file in exclude_files if exclude_file.exists()): - compressible_files.append(file_path) - - return compressible_files - - -def create_zip(source_files: list[Path], output_zip: Path, additional_files_with_names: dict[Path, str]) -> int: - """ - 引数で指定されたファイルをZIPファイルに格納します。 - - :param source_files: ZIPファイルに含めるファイル。 + :param folder_path: ZIPファイルに含めるフォルダ。 :param output_zip: 出力するZIPファイルのパス。 :param additional_files_with_names: 追加ファイルとその保存名の辞書。 :return: ZIPファイルに追加されたファイルの数。 @@ -93,15 +76,19 @@ def create_zip(source_files: list[Path], output_zip: Path, additional_files_with count: int = 0 with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_DEFLATED) as zip_file: - for file_path in source_files: - print(file_path) - zip_file.write(file_path, arcname=file_path.name) - count += 1 + for root, dirs, files in os.walk(folder_path): + for file in files: + file_path = Path(root) / file + arcname = file_path.relative_to(folder_path) + zip_file.write(file_path, arcname) + print(arcname) + count += 1 for file_path, save_name in additional_files_with_names.items(): print(file_path) zip_file.write(file_path, arcname=save_name) count += 1 + return count @@ -112,37 +99,37 @@ class SetupConfigRation: """ source_dir: Path = Path("dist") output_zip: Path = source_dir / f"{PROGRAM_NAME}.zip" - app_file: Path = source_dir / f"{PROGRAM_NAME}.exe" + app_file: Path = source_dir / f"{PROGRAM_NAME}/{PROGRAM_NAME}.exe" handouts_file: Path = source_dir / "handouts.txt" exclude_files: list[Path] = field(default_factory=list) def main(): - start: float = time.perf_counter() + sw = Stopwatch.start_new() # Configuration config = SetupConfigRation() # 除外するファイル config.exclude_files.extend([config.output_zip, config.source_dir / ".gitignore", config.handouts_file]) # zipファイルに追加するファイル + readme = config.source_dir / "ReadMe.txt" additional_files_with_names = { + readme: readme.name, Path("docs/initial_screen.png"): "initial_screen.png", Path(f"{PROGRAM_NAME}.json"): f"sample/{PROGRAM_NAME}.json", } hash_value = hash_compute(config.app_file) - print(f"Compute hash, ({time.perf_counter() - start:.3f}s)") + print(f"Compute hash, ({sw.elapsed:.3f}s)") - create_readMe(Path("ReadMe.md"), config.handouts_file, hash_value, config.source_dir / "ReadMe.txt") - print(f"Created ReadMe.txt, ({time.perf_counter() - start:.3f}s)") + create_readMe(Path("ReadMe.md"), config.handouts_file, hash_value, readme) + print(f"Created ReadMe.txt, ({sw.elapsed:.3f}s)") - compressible_files = get_compressible_files(config.source_dir, config.exclude_files) - print(f"Get_compressible_files, ({time.perf_counter() - start:.3f}s)") - count: int = create_zip(compressible_files, config.output_zip, additional_files_with_names) + count: int = create_zip(config.source_dir / str(f"{PROGRAM_NAME}"), config.output_zip, additional_files_with_names) current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") print(f"Release zip file created at: {config.output_zip} on {current_time}") - print(f" Store total:{count}, ({time.perf_counter() - start:.3f}s)") + print(f" Store total:{count}, ({sw.elapsed:.3f}s)") if __name__ == '__main__': diff --git a/src/abstract_controllers.py b/src/abstract_controllers.py index 8401180..346bbfe 100644 --- a/src/abstract_controllers.py +++ b/src/abstract_controllers.py @@ -115,13 +115,21 @@ def get_view(self): def get_config(self) -> AppConfig: pass - @abstractmethod - def set_file_property_visible(self, visible: bool): - pass + @property + def file_property_visible(self): + """ + 画像ファイルのプロパティウィンドウの表示・非表示状態 + :return: true:表示, false: 非表示 + """ + raise NotImplementedError() - @abstractmethod - def is_file_property_visible(self) -> bool: - pass + @file_property_visible.setter + def file_property_visible(self, visible: bool): + """ + ファイルプロパティウィンドウの表示・非表示状態を設定します。 + :param visible: true:表示, false: 非表示 + """ + raise NotImplementedError() @property def current_effect(self) -> MosaicEffect: diff --git a/src/controllers.py b/src/controllers.py index 32b78b8..7a3246b 100644 --- a/src/controllers.py +++ b/src/controllers.py @@ -43,6 +43,8 @@ def add_file_path(self, file_path: Path) -> int: files: list[Path] = [] for f in file_path.glob("*.*"): # ディレクトリの場合 files.append(f) + if len(files) != 0: + self.model.save_directory = True return self.model.add_images(files) def get_current_image(self) -> Optional[Path]: @@ -99,7 +101,11 @@ def on_save_as(self, event=None): ファイルを選択して保存ボタンをクリック時 :param event: イベント """ - self.view.on_save_as(None) + current = self.model.get_current_image() + if current is None: + return + mosaic_filename = ImageFileService.mosaic_filename(current, self.model.save_directory) + self.view.on_save_as(None, mosaic_filename) def handle_back_image(self, event=None): """ @@ -130,20 +136,22 @@ def on_show_file_property(self, event=None): image_info = ImageFileService.get_image_info(status.file_path) self.view.on_show_file_property(status, str(image_info)) - def set_file_property_visible(self, visible: bool): - """ - ファイルプロパティウィンドウの表示・非表示状態を設定します。 - :param visible: true:表示, false: 非表示 - """ - self.model.file_property_visible = visible - - def is_file_property_visible(self): + @property + def file_property_visible(self): """ 画像ファイルのプロパティウィンドウの表示・非表示状態 :return: true:表示, false: 非表示 """ return self.model.file_property_visible + @file_property_visible.setter + def file_property_visible(self, visible: bool): + """ + ファイルプロパティウィンドウの表示・非表示状態を設定します。 + :param visible: true:表示, false: 非表示 + """ + self.model.file_property_visible = visible + def handle_back_effect(self, event=None): """ 前のエフェクトに切り替えます。 @@ -218,7 +226,7 @@ def get_mosaic_filename(self) -> Path: """ f = self.model.get_current_image() if f is not None: - return ImageFileService.mosaic_filename(f) + return ImageFileService.mosaic_filename(f, self.model.save_directory) raise ValueError("get_mosaic_filename") def set_window_title(self, text: Path): diff --git a/src/image_file_service.py b/src/image_file_service.py index a7c6055..96af71e 100644 --- a/src/image_file_service.py +++ b/src/image_file_service.py @@ -117,6 +117,9 @@ def save(out_image: Image.Image, output_path: Path, filename: Path): # Todo: PNGINFOの情報はテストパターンを増やす。 # ファイルの読み込み処理を改善する。 # DataModel側に保持する。 + if not output_path.parent.exists(): + output_path.parent.mkdir(parents=True) + if ImageFileService.is_png(filename): with Image.open(filename) as src_img: ImageFileService.save_png_metadata(src_img, out_image, output_path) @@ -174,11 +177,12 @@ def save_jpeg_metadata(src_image: Image.Image, out_image: Image.Image, output_pa out_image.save(output_path) @staticmethod - def mosaic_filename(file_path: Path) -> Path: + def mosaic_filename(file_path: Path, is_dir: bool = False) -> Path: """ モザイク適用済みのファイルパスを生成します。 同名ファイルが存在する場合は、画像の大きさを比較します。 :param file_path: 元画像のファイルのパス + :param is_dir: ディレクトリの場合はTrue :return: モザイク適用済みのファイルパス """ size = (0, 0) @@ -188,21 +192,40 @@ def mosaic_filename(file_path: Path) -> Path: except Exception as e: print(e) + new_file = file_path + if is_dir: # 新しいディレクトリパスを作成 + # パスの親ディレクトリとファイル名を取得 + parent_dir = file_path.parent + file_name = file_path.name + + # ディレクトリ名の末尾に_mosaicを付ける + new_parent_dir = parent_dir.with_name(parent_dir.name + '_mosaic') + new_file = new_parent_dir / file_name + return ImageFileService.generate_new_filename(new_file, size) + + @staticmethod + def generate_new_filename(base_path: Path, size: tuple[int, int]) -> Path: + """ + 新しいファイル名を生成します。同名ファイルが存在する場合は、画像の大きさを比較します。 + :param base_path: 元となるファイルパス + :param size: 元画像のサイズ + :return: 新しいファイルパス + """ # 元のファイル名から新しいファイル名を作成 for i in range(0, 1000): # rangeは1から1000まで動作します - newFileName = file_path.with_stem(file_path.stem + f"_mosaic_{i}") - if not newFileName.exists(): - return newFileName # ファイルが存在しなければ新しいファイル名を返す + new_filename = base_path.with_stem(base_path.stem + f"_mosaic_{i}") + if not new_filename.exists(): + return new_filename # ファイルが存在しなければ新しいファイル名を返します。 try: - new_size = ImageFileService.get_image_size(newFileName) + new_size = ImageFileService.get_image_size(new_filename) if size == new_size: - return newFileName # 画像の大きさが同じなら新しいファイル名を返す + return new_filename # 画像の大きさが同じなら新しいファイル名を返します。 if size == (0, 0): - return newFileName # 元ファイルが削除されている場合は新しいファイル名を返す + return new_filename # 元ファイルが削除されている場合、新しいファイル名を返します。 except Exception as e: print(e) time.sleep(3) if i == 1000: raise ValueError("Failed to generate a new file name after 1000 attempts.") - return newFileName + return new_filename diff --git a/src/models.py b/src/models.py index 15e5ad9..b8d1883 100644 --- a/src/models.py +++ b/src/models.py @@ -87,6 +87,8 @@ def __init__(self, settings: AppConfig): # プリセット self._current_preset_name = settings.effect_presets.default_preset self._current_effect = settings.effect_presets.get_preset(self._current_preset_name) + # ディレクトリをドロップ時 + self._is_save_directory: bool = False def add_images(self, image_list: list[Path]) -> int: """ @@ -111,6 +113,14 @@ def get(self, key: str, default=None) -> Any: """ return self.settings.get(key, default) + @property + def save_directory(self): + return self._is_save_directory + + @save_directory.setter + def save_directory(self, value: bool): + self._is_save_directory = value + @property def settings(self) -> AppConfig: """ @@ -153,6 +163,7 @@ def clear(self): """ self.image_list = [] self.current = 0 + self._is_save_directory = False def back_image(self): """ diff --git a/src/utils.py b/src/utils.py index a9e058b..d705b2b 100644 --- a/src/utils.py +++ b/src/utils.py @@ -9,7 +9,7 @@ def get_package_version() -> str: """ バージョン情報を取得します。 """ - return '0.0.5' + return '0.0.6' def round_up_decimal(value: Decimal, places: int) -> Decimal: diff --git a/src/widget_file_property_window.py b/src/widget_file_property_window.py index 680fbb5..0f0e3f9 100644 --- a/src/widget_file_property_window.py +++ b/src/widget_file_property_window.py @@ -173,14 +173,14 @@ def on_window_open(self): ファイル情報を開く """ self.win.deiconify() - self.controller.set_file_property_visible(True) + self.controller.file_property_visible = True def on_window_close(self): """ ファイル情報ウィンドウを閉じる """ self.win.withdraw() - self.controller.set_file_property_visible(False) + self.controller.file_property_visible = False def set_file_status(self, status: StatusBarInfo): """ diff --git a/src/widget_image_canvas.py b/src/widget_image_canvas.py new file mode 100644 index 0000000..cc7a9ac --- /dev/null +++ b/src/widget_image_canvas.py @@ -0,0 +1,266 @@ +# -*- coding: utf-8 -*- +""" + ImageCanvas + 画像を表示および編集するためのキャンバス +""" +import tkinter as tk +from tkinter import messagebox +from pathlib import Path + +from PIL import ImageTk + +from . import PROGRAM_NAME +from . abstract_controllers import AbstractAppController +from . utils import Stopwatch +from . image_file_service import ImageFileService +from . effects.image_effects import MosaicEffect + + +class ImageCanvas(tk.Frame): + """ + 画面のキャンバス領域 + """ + def __init__(self, master, controller: AbstractAppController, bg: str): + """ + コンストラクタ + :param master: 親Widget + :param controller: コントローラー + :param bg: 背景色 + """ + super().__init__(master, bg=bg) + + self.controller = controller + font_sizes = self.controller.font_sizes + # 水平スクロールバーを追加 + self.hscrollbar = tk.Scrollbar(self, orient=tk.HORIZONTAL) + self.hscrollbar.pack(side=tk.BOTTOM, fill=tk.X) + + # 垂直スクロールバーを追加 + self.vscrollbar = tk.Scrollbar(self) + self.vscrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + # キャンバスを作成し、スクロールバーを設定 + self.canvas = tk.Canvas(self, yscrollcommand=self.vscrollbar.set, xscrollcommand=self.hscrollbar.set) + self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + # アプリ起動時に、初期メッセージを表示します。 + # 文字位置は、リサイズイベントにて調整します。 + self.startup_message_id = None + self.startup_message_id = self.canvas.create_text( + (0, 0), + text="画面にフォルダまたはファイルをドラッグ&ドロップしてください。", + font=("", font_sizes.h4)) + + # スクロールバーのコマンドを設定 + self.vscrollbar.config(command=self.canvas.yview) + self.hscrollbar.config(command=self.canvas.xview) + + self.photo = None + # モザイク領域の選択開始位置 + self.start_x = 0 + self.start_y = 0 + self.rect_id = None # モザイクを指定した範囲の矩形 + self.size_label_id = None # サイズ表示用ラベル + + # ドラッグ開始時のイベントをバインド + self.canvas.bind("", self.handle_start_drag) + + # ドラッグ中のイベントをバインド + self.canvas.bind("", self.handle_dragging) + + # ドラッグ終了時のイベントをバインド + self.canvas.bind("", self.handle_end_drag) + # 右クリックのイベントをバインド + self.canvas.bind("", self.handle_right_click) + + # Shift+右クリックのイベントをバインド + self.canvas.bind("", self.handle_shift_right_click) + + # ウィンドウサイズ変更時にキャンバスをリサイズする + # リサイズイベントのunbind用にresize_handler_idにイベント関数を退避します。 + self.resize_handler_id = self.canvas.bind("", self.on_resize) + + def on_resize(self, event): + """ + リサイズイベント + :param event: イベント + """ + if self.startup_message_id is None: + return + # キャンバスの新しい幅と高さを取得 + canvas_width = event.width + canvas_height = event.height + + # テキストの位置をキャンバスの中央に更新 + self.canvas.coords(self.startup_message_id, canvas_width / 2, canvas_height / 2) + + def suppress_startup_text(self): + """ + スタートアップメッセージを表示を抑止します。 + """ + if self.resize_handler_id is None: + return # リサイズイベントが解除済み + # 登録したリサイズイベントの解除 + self.canvas.unbind("", self.resize_handler_id) + self.resize_handler_id = None + + if self.startup_message_id: + self.canvas.delete(self.startup_message_id) + self.startup_message_id = None + + def handle_right_click(self, event): + """右クリックの処理 + :param event: イベント + """ + self.controller.handle_next_effect() + + def handle_shift_right_click(self, event): + """Shift+右クリックの処理 + :param event: イベント + """ + self.controller.handle_back_effect() + + def update_view(self, file_path: Path): + """ + 画面を更新します。 + :param file_path: 画像ファイルパス + """ + if file_path is None: + return + self.suppress_startup_text() + self.update_image(file_path) + + def update_image(self, file_path: Path): + """ + 表示画像を更新します。 + :param file_path: 画像ファイルパス + """ + if not file_path.exists(): + return + self.original_image = ImageFileService.load(file_path) # 元の画像を開く + self.photo = ImageTk.PhotoImage(self.original_image) # 元の画像のコピーをキャンバスに表示 + # 画像を更新 + self.canvas.create_image(0, 0, image=self.photo, anchor=tk.NW) + # キャンバスのスクロール領域を設定 + self.canvas.config(scrollregion=(0, 0, self.original_image.width, self.original_image.height)) + + def handle_start_drag(self, event): + """ + ドラッグ開始 + :param event: イベント + """ + # ドラッグ開始位置を記録(キャンバス上の座標に変換) + self.start_x = int(self.canvas.canvasx(event.x)) + self.start_y = int(self.canvas.canvasy(event.y)) + + def handle_dragging(self, event): + """ + ドラッグ中 + :param event: イベント + """ + end_x = int(self.canvas.canvasx(event.x)) + end_y = int(self.canvas.canvasy(event.y)) + + # 矩形が既に存在する場合は削除します。 + if self.rect_id: + self.canvas.delete(self.rect_id) + if self.size_label_id: + self.canvas.delete(self.size_label_id) + + # 矩形を描画し、タグを付けます。 + self.rect_id = self.canvas.create_rectangle( + self.start_x, self.start_y, end_x, end_y, + outline=self.controller.theme_colors.bg_danger) + + # サイズを計算して表示します。 + width = abs(end_x - self.start_x) + height = abs(end_y - self.start_y) + + # サイズラベルの位置をマウスカーソルの近くに設定します。 + label_x = end_x + 10 + label_y = end_y + 10 + + self.size_label_id = self.canvas.create_text( + (label_x, label_y), + font=("", self.controller.font_sizes.h5), text=f"{width} x {height}", + anchor="nw") + + def handle_end_drag(self, event): + """ + ドラッグ終了時 + :param event: イベント + """ + try: + sw = Stopwatch.start_new() + # ドラッグ終了位置を取得します。(キャンバス上の座標に変換) + end_x = int(self.canvas.canvasx(event.x)) + end_y = int(self.canvas.canvasy(event.y)) + + # 選択領域にモザイクをかけます。 + is_apply = self.apply_mosaic(self.start_x, self.start_y, end_x, end_y) + if is_apply: + self.controller.display_process_time(f"{sw.elapsed:.3f}s") + except Exception as e: + print(f"Error applying mosaic: {e}") + raise e + finally: + # 矩形とサイズ表示用ラベルを削除 + if self.rect_id: + self.canvas.delete(self.rect_id) + if self.size_label_id: + self.canvas.delete(self.size_label_id) + + def apply_mosaic(self, start_x: int, start_y: int, end_x: int, end_y: int) -> bool: + """ + モザイクを適用します。 + :param start_x: モザイクをかける領域の左上X座標 + :param start_y: モザイクをかける領域の左上Y座標 + :param end_x: モザイクをかける領域の右下X座標 + :param end_y: モザイクをかける領域の右下Y座標 + :return: モザイクを掛けてたかどうか + """ + if self.photo is None: + return False # 画像ファイルを未選択状態にモザイク領域を指定した時 + + # 座標を正しい順序に並べ替える + left = min(start_x, end_x) + right = max(start_x, end_x) + top = min(start_y, end_y) + bottom = max(start_y, end_y) + + mosaic = self.controller.current_effect + # Todo:mosaic#apply側で判定します。 + if mosaic.cell_size == MosaicEffect.AUTO: # セルサイズの自動計算 + mosaic = MosaicEffect(MosaicEffect.calc_cell_size(self.original_image)) + is_apply = mosaic.apply(self.original_image, left, top, right, bottom) + if not is_apply: + return False + + self.photo = ImageTk.PhotoImage(self.original_image) # 元の画像のコピーをキャンバスに表示 + # キャンバスの画像も更新 + self.canvas.create_image(0, 0, image=self.photo, anchor=tk.NW) + # キャンバスのスクロール領域を設定 + #self.canvas.config(scrollregion=(0, 0, self.original_image.width, self.original_image.height)) + + # モザイク適用後のファイルを自動保存します。 + self.save(self.controller.get_mosaic_filename()) + return True + + def save(self, output_path: Path, override: bool = False): + """ + モザイク画像を保存します。 + :param output_path: 保存するファイルの名前 + :param override: 自動保存時に上書きするかの確認 + """ + current_file = self.controller.get_current_image() + + # 自動保存時に同一ファイル名の場合は、念のため確認メッセージを表示します。 + if not override: + if current_file == output_path: + retval = messagebox.askokcancel( + PROGRAM_NAME, + f"{output_path}は既に存在します。\n上書きしますか?") + if not retval: + return + + ImageFileService.save(self.original_image, output_path, current_file) diff --git a/src/widgets.py b/src/widgets.py index 4c0aa94..1acc7ee 100644 --- a/src/widgets.py +++ b/src/widgets.py @@ -8,9 +8,8 @@ from functools import partial from decimal import Decimal from pathlib import Path -from typing import Optional +from typing import Optional, Callable -from PIL import ImageTk from tkinterdnd2 import DND_FILES, TkinterDnD from . import PROGRAM_NAME @@ -19,7 +18,7 @@ from . utils import round_up_decimal, Stopwatch from . widgets_core import WidgetUtils, PhotoImageButton, Tooltip from . widget_file_property_window import FilePropertyWindow -from . image_file_service import ImageFileService +from . widget_image_canvas import ImageCanvas from . effects.image_effects import MosaicEffect @@ -117,7 +116,6 @@ def update_view(self, event): class MainFrame(tk.Frame): """ 画面のメイン部 - ToDo:ImageEditorクラスを新設する予定です。 """ def __init__(self, master, controller: AbstractAppController, bg: str): """ @@ -129,240 +127,14 @@ def __init__(self, master, controller: AbstractAppController, bg: str): super().__init__(master, bg=bg) self.controller = controller - font_sizes = self.controller.font_sizes - # 水平スクロールバーを追加 - self.hscrollbar = tk.Scrollbar(self, orient=tk.HORIZONTAL) - self.hscrollbar.pack(side=tk.BOTTOM, fill=tk.X) - - # 垂直スクロールバーを追加 - self.vscrollbar = tk.Scrollbar(self) - self.vscrollbar.pack(side=tk.RIGHT, fill=tk.Y) - - # キャンバスを作成し、スクロールバーを設定 - self.canvas = tk.Canvas(self, yscrollcommand=self.vscrollbar.set, xscrollcommand=self.hscrollbar.set) - self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - - # アプリ起動時に、初期メッセージを表示します。 - # 文字位置は、リサイズイベントにて調整します。 - self.startup_message_id = None - self.startup_message_id = self.canvas.create_text( - (0, 0), - text="画面にフォルダまたはファイルをドラッグ&ドロップしてください。", - font=("", font_sizes.h4)) - - # スクロールバーのコマンドを設定 - self.vscrollbar.config(command=self.canvas.yview) - self.hscrollbar.config(command=self.canvas.xview) - - self.photo = None - # モザイク領域の選択開始位置 - self.start_x = 0 - self.start_y = 0 - self.rect_id = None # モザイクを指定した範囲の矩形 - self.size_label_id = None # サイズ表示用ラベル - - # ドラッグ開始時のイベントをバインド - self.canvas.bind("", self.handle_start_drag) - - # ドラッグ中のイベントをバインド - self.canvas.bind("", self.handle_dragging) - - # ドラッグ終了時のイベントをバインド - self.canvas.bind("", self.handle_end_drag) - # 右クリックのイベントをバインド - self.canvas.bind("", self.handle_right_click) - - # Shift+右クリックのイベントをバインド - self.canvas.bind("", self.handle_shift_right_click) - - # ウィンドウサイズ変更時にキャンバスをリサイズする - # リサイズイベントのunbind用にresize_handler_idにイベント関数を退避します。 - self.resize_handler_id = self.canvas.bind("", self.on_resize) - - def on_resize(self, event): - """ - リサイズイベント - :param event: イベント - """ - if self.startup_message_id is None: - return - # キャンバスの新しい幅と高さを取得 - canvas_width = event.width - canvas_height = event.height - - # テキストの位置をキャンバスの中央に更新 - self.canvas.coords(self.startup_message_id, canvas_width / 2, canvas_height / 2) - - def suppress_startup_text(self): - """ - スタートアップメッセージを表示を抑止します。 - """ - if self.resize_handler_id is None: - return # リサイズイベントが解除済み - # 登録したリサイズイベントの解除 - self.canvas.unbind("", self.resize_handler_id) - self.resize_handler_id = None - - if self.startup_message_id: - self.canvas.delete(self.startup_message_id) - self.startup_message_id = None - - def handle_right_click(self, event): - """右クリックの処理 - :param event: イベント - """ - self.controller.handle_next_effect() - - def handle_shift_right_click(self, event): - """Shift+右クリックの処理 - :param event: イベント - """ - self.controller.handle_back_effect() - - def update_view(self, file_path: Path): - """ - 画面を更新します。 - :param file_path: 画像ファイルパス - """ - if file_path is None: - return - self.suppress_startup_text() - self.update_image(file_path) - - def update_image(self, file_path: Path): - """ - 表示画像を更新します。 - :param file_path: 画像ファイルパス - """ - if not file_path.exists(): - return - self.original_image = ImageFileService.load(file_path) # 元の画像を開く - self.photo = ImageTk.PhotoImage(self.original_image) # 元の画像のコピーをキャンバスに表示 - # 画像を更新 - self.canvas.create_image(0, 0, image=self.photo, anchor=tk.NW) - # キャンバスのスクロール領域を設定 - self.canvas.config(scrollregion=(0, 0, self.original_image.width, self.original_image.height)) - - def handle_start_drag(self, event): - """ - ドラッグ開始 - :param event: イベント - """ - # ドラッグ開始位置を記録(キャンバス上の座標に変換) - self.start_x = int(self.canvas.canvasx(event.x)) - self.start_y = int(self.canvas.canvasy(event.y)) - def handle_dragging(self, event): - """ - ドラッグ中 - :param event: イベント - """ - end_x = int(self.canvas.canvasx(event.x)) - end_y = int(self.canvas.canvasy(event.y)) - - # 矩形が既に存在する場合は削除します。 - if self.rect_id: - self.canvas.delete(self.rect_id) - if self.size_label_id: - self.canvas.delete(self.size_label_id) - - # 矩形を描画し、タグを付けます。 - self.rect_id = self.canvas.create_rectangle( - self.start_x, self.start_y, end_x, end_y, - outline=self.controller.theme_colors.bg_danger) - - # サイズを計算して表示します。 - width = abs(end_x - self.start_x) - height = abs(end_y - self.start_y) - - # サイズラベルの位置をマウスカーソルの近くに設定します。 - label_x = end_x + 10 - label_y = end_y + 10 - - self.size_label_id = self.canvas.create_text( - (label_x, label_y), - font=("", self.controller.font_sizes.h5), text=f"{width} x {height}", - anchor="nw") - - def handle_end_drag(self, event): - """ - ドラッグ終了時 - :param event: イベント - """ - try: - sw = Stopwatch.start_new() - # ドラッグ終了位置を取得します。(キャンバス上の座標に変換) - end_x = int(self.canvas.canvasx(event.x)) - end_y = int(self.canvas.canvasy(event.y)) - - # 選択領域にモザイクをかけます。 - is_apply = self.apply_mosaic(self.start_x, self.start_y, end_x, end_y) - if is_apply: - self.controller.display_process_time(f"{sw.elapsed:.3f}s") - except Exception as e: - print(f"Error applying mosaic: {e}") - raise e - finally: - # 矩形とサイズ表示用ラベルを削除 - if self.rect_id: - self.canvas.delete(self.rect_id) - if self.size_label_id: - self.canvas.delete(self.size_label_id) - - def apply_mosaic(self, start_x: int, start_y: int, end_x: int, end_y: int) -> bool: - """ - モザイクを適用します。 - :param start_x: モザイクをかける領域の左上X座標 - :param start_y: モザイクをかける領域の左上Y座標 - :param end_x: モザイクをかける領域の右下X座標 - :param end_y: モザイクをかける領域の右下Y座標 - :return: モザイクを掛けてたかどうか - """ - if self.photo is None: - return False # 画像ファイルを未選択状態にモザイク領域を指定した時 - - # 座標を正しい順序に並べ替える - left = min(start_x, end_x) - right = max(start_x, end_x) - top = min(start_y, end_y) - bottom = max(start_y, end_y) - - mosaic = self.controller.current_effect - # Todo:mosaic#apply側で判定します。 - if mosaic.cell_size == MosaicEffect.AUTO: # セルサイズの自動計算 - mosaic = MosaicEffect(MosaicEffect.calc_cell_size(self.original_image)) - is_apply = mosaic.apply(self.original_image, left, top, right, bottom) - if not is_apply: - return False - - self.photo = ImageTk.PhotoImage(self.original_image) # 元の画像のコピーをキャンバスに表示 - # キャンバスの画像も更新 - self.canvas.create_image(0, 0, image=self.photo, anchor=tk.NW) - # キャンバスのスクロール領域を設定 - #self.canvas.config(scrollregion=(0, 0, self.original_image.width, self.original_image.height)) - - # モザイク適用後のファイルを自動保存します。 - self.save(self.controller.get_mosaic_filename()) - return True - - def save(self, output_path: Path, override: bool = False): - """ - モザイク画像を保存します。 - :param output_path: 保存するファイルの名前 - :param override: 自動保存時に上書きするかの確認 - """ - current_file = self.controller.get_current_image() + # 画面のキャンバス部分 + self.image_canvas = ImageCanvas(self, controller, bg) + self.image_canvas.pack(fill=tk.BOTH, expand=True) - # 自動保存時に同一ファイル名の場合は、念のため確認メッセージを表示します。 - if not override: - if current_file == output_path: - retval = messagebox.askokcancel( - PROGRAM_NAME, - f"{output_path}は既に存在します。\n上書きしますか?") - if not retval: - return - - ImageFileService.save(self.original_image, output_path, current_file) + # イベントを登録します。 + self.update_view = self.image_canvas.update_view + self.save = self.image_canvas.save class FooterFrame(tk.Frame): @@ -524,14 +296,12 @@ def on_file_open(self, event): return self.controller.handle_select_files_complete(files) - def on_save_as(self, event): + def on_save_as(self, event, initial_file: Path): """ ファイルを選択して保存ボタン :param event: イベント + :param initial_file: ダイアログのファイル名(初期値) """ - if not hasattr(self.MainFrame, "original_image"): - return - IMAGE_FILE_TYPES = [ ('Image Files', ImageFormat['PNG'] + ImageFormat['JPEG'] + ImageFormat['WEBP'] + ImageFormat['BMP']), ('png (*.png)', ImageFormat['PNG']), @@ -541,7 +311,10 @@ def on_save_as(self, event): ('*', '*.*') ] - files = filedialog.asksaveasfilename(parent=self, confirmoverwrite=True, filetypes=IMAGE_FILE_TYPES) + files = filedialog.asksaveasfilename(parent=self, + initialfile=initial_file.name, + confirmoverwrite=True, + filetypes=IMAGE_FILE_TYPES) if len(files) == 0: return @@ -552,7 +325,7 @@ def on_save_as(self, event): if not retval: print(f"名前を付けて保存の処理を中断。:{save_file}") return - self.on_save_as(event) + self.on_save_as(event, initial_file) return sw = Stopwatch.start_new() self.MainFrame.save(save_file, True) diff --git a/test/test_effects_image_effects.py b/test/test_effects_image_effects.py index 8895003..e4872a7 100644 --- a/test/test_effects_image_effects.py +++ b/test/test_effects_image_effects.py @@ -1,5 +1,5 @@ """ -Modelの単体テスト +Image_Effectsの単体テスト """ import os import sys