#!/usr/bin/env python3 """ File browser utility functions for the Faceswap GUI. """ import logging import platform import tkinter as tk from tkinter import filedialog import typing as T logger = logging.getLogger(__name__) # pylint: disable=invalid-name _FILETYPE = T.Literal["default", "alignments", "config_project", "config_task", "config_all", "csv", "image", "ini", "state", "log", "video"] _HANDLETYPE = T.Literal["open", "save", "filename", "filename_multi", "save_filename", "context", "dir"] class FileHandler(): # pylint:disable=too-few-public-methods """ Handles all GUI File Dialog actions and tasks. Parameters ---------- handle_type: ['open', 'save', 'filename', 'filename_multi', 'save_filename', 'context', 'dir'] The type of file dialog to return. `open` and `save` will perform the open and save actions and return the file. `filename` returns the filename from an `open` dialog. `filename_multi` allows for multi-selection of files and returns a list of files selected. `save_filename` returns the filename from a `save as` dialog. `context` is a context sensitive parameter that returns a certain dialog based on the current options. `dir` asks for a folder location. file_type: ['default', 'alignments', 'config_project', 'config_task', 'config_all', 'csv', \ 'image', 'ini', 'state', 'log', 'video'] or ``None`` The type of file that this dialog is for. `default` allows selection of any files. Other options limit the file type selection title: str, optional The title to display on the file dialog. If `None` then the default title will be used. Default: ``None`` initial_folder: str, optional The folder to initially open with the file dialog. If `None` then tkinter will decide. Default: ``None`` initial_file: str, optional The filename to set with the file dialog. If `None` then tkinter no initial filename is. specified. Default: ``None`` command: str, optional Required for context handling file dialog, otherwise unused. Default: ``None`` action: str, optional Required for context handling file dialog, otherwise unused. Default: ``None`` variable: str, optional Required for context handling file dialog, otherwise unused. The variable to associate with this file dialog. Default: ``None`` parent: :class:`tkinter.Frame`, optional The parent that is launching the file dialog. ``None`` sets this to root. Default: ``None`` Attributes ---------- return_file: str or object The return value from the file dialog Example ------- >>> handler = FileHandler('filename', 'video', title='Select a video...') >>> video_file = handler.return_file >>> print(video_file) '/path/to/selected/video.mp4' """ def __init__(self, handle_type: _HANDLETYPE, file_type: _FILETYPE | None, title: str | None = None, initial_folder: str | None = None, initial_file: str | None = None, command: str | None = None, action: str | None = None, variable: str | None = None, parent: tk.Frame | None = None) -> None: logger.debug("Initializing %s: (handle_type: '%s', file_type: '%s', title: '%s', " "initial_folder: '%s', initial_file: '%s', command: '%s', action: '%s', " "variable: %s, parent: %s)", self.__class__.__name__, handle_type, file_type, title, initial_folder, initial_file, command, action, variable, parent) self._handletype = handle_type self._dummy_master = self._set_dummy_master() self._defaults = self._set_defaults() self._kwargs = self._set_kwargs(title, initial_folder, initial_file, file_type, command, action, variable, parent) self.return_file = getattr(self, f"_{self._handletype.lower()}")() self._remove_dummy_master() logger.debug("Initialized %s", self.__class__.__name__) @property def _filetypes(self) -> dict[str, list[tuple[str, str]]]: """ dict: The accepted extensions for each file type for opening/saving """ all_files = ("All files", "*.*") filetypes = { "default": [all_files], "alignments": [("Faceswap Alignments", "*.fsa"), all_files], "config_project": [("Faceswap Project files", "*.fsw"), all_files], "config_task": [("Faceswap Task files", "*.fst"), all_files], "config_all": [("Faceswap Project and Task files", "*.fst *.fsw"), all_files], "csv": [("Comma separated values", "*.csv"), all_files], "image": [("Bitmap", "*.bmp"), ("JPG", "*.jpeg *.jpg"), ("PNG", "*.png"), ("TIFF", "*.tif *.tiff"), all_files], "ini": [("Faceswap config files", "*.ini"), all_files], "json": [("JSON file", "*.json"), all_files], "model": [("Keras model files", "*.h5"), all_files], "state": [("State files", "*.json"), all_files], "log": [("Log files", "*.log"), all_files], "video": [("Audio Video Interleave", "*.avi"), ("Flash Video", "*.flv"), ("Matroska", "*.mkv"), ("MOV", "*.mov"), ("MP4", "*.mp4"), ("MPEG", "*.mpeg *.mpg *.ts *.vob"), ("WebM", "*.webm"), ("Windows Media Video", "*.wmv"), all_files]} # Add in multi-select options and upper case extensions for Linux for key in filetypes: if platform.system() == "Linux": filetypes[key] = [item if item[0] == "All files" else (item[0], f"{item[1]} {item[1].upper()}") for item in filetypes[key]] if len(filetypes[key]) > 2: multi = [f"{key.title()} Files"] multi.append(" ".join([ftype[1] for ftype in filetypes[key] if ftype[0] != "All files"])) filetypes[key].insert(0, T.cast(tuple[str, str], tuple(multi))) return filetypes @property def _contexts(self) -> dict[str, dict[str, str | dict[str, str]]]: """dict: Mapping of commands, actions and their corresponding file dialog for context handle types. """ return {"effmpeg": {"input": {"extract": "filename", "gen-vid": "dir", "get-fps": "filename", "get-info": "filename", "mux-audio": "filename", "rescale": "filename", "rotate": "filename", "slice": "filename"}, "output": {"extract": "dir", "gen-vid": "save_filename", "get-fps": "nothing", "get-info": "nothing", "mux-audio": "save_filename", "rescale": "save_filename", "rotate": "save_filename", "slice": "save_filename"}}} @classmethod def _set_dummy_master(cls) -> tk.Frame | None: """ Add an option to force black font on Linux file dialogs KDE issue that displays light font on white background). This is a pretty hacky solution, but tkinter does not allow direct editing of file dialogs, so we create a dummy frame and add the foreground option there, so that the file dialog can inherit the foreground. Returns ------- tkinter.Frame or ``None`` The dummy master frame for Linux systems, otherwise ``None`` """ if platform.system().lower() == "linux": frame = tk.Frame() frame.option_add("*foreground", "black") retval: tk.Frame | None = frame else: retval = None return retval def _remove_dummy_master(self) -> None: """ Destroy the dummy master widget on Linux systems. """ if platform.system().lower() != "linux" or self._dummy_master is None: return self._dummy_master.destroy() del self._dummy_master self._dummy_master = None def _set_defaults(self) -> dict[str, str | None]: """ Set the default file type for the file dialog. Generally the first found file type will be used, but this is overridden if it is not appropriate. Returns ------- dict: The default file extension for each file type """ defaults: dict[str, str | None] = { key: next(ext for ext in val[0][1].split(" ")).replace("*", "") for key, val in self._filetypes.items()} defaults["default"] = None defaults["video"] = ".mp4" defaults["image"] = ".png" logger.debug(defaults) return defaults def _set_kwargs(self, title: str | None, initial_folder: str | None, initial_file: str | None, file_type: _FILETYPE | None, command: str | None, action: str | None, variable: str | None, parent: tk.Frame | None ) -> dict[str, None | tk.Frame | str | list[tuple[str, str]]]: """ Generate the required kwargs for the requested file dialog browser. Parameters ---------- title: str The title to display on the file dialog. If `None` then the default title will be used. initial_folder: str The folder to initially open with the file dialog. If `None` then tkinter will decide. initial_file: str The filename to set with the file dialog. If `None` then tkinter no initial filename is. file_type: ['default', 'alignments', 'config_project', 'config_task', 'config_all', \ 'csv', 'image', 'ini', 'state', 'log', 'video'] or ``None`` The type of file that this dialog is for. `default` allows selection of any files. Other options limit the file type selection command: str Required for context handling file dialog, otherwise unused. action: str Required for context handling file dialog, otherwise unused. variable: str, optional Required for context handling file dialog, otherwise unused. The variable to associate with this file dialog. Default: ``None`` parent: :class:`tkinter.Frame` The parent that is launching the file dialog. ``None`` sets this to root Returns ------- dict: The key word arguments for the file dialog to be launched """ logger.debug("Setting Kwargs: (title: %s, initial_folder: %s, initial_file: '%s', " "file_type: '%s', command: '%s': action: '%s', variable: '%s', parent: %s)", title, initial_folder, initial_file, file_type, command, action, variable, parent) kwargs: dict[str, None | tk.Frame | str | list[tuple[str, str]]] = { "master": self._dummy_master} if self._handletype.lower() == "context": assert command is not None and action is not None and variable is not None self._set_context_handletype(command, action, variable) if title is not None: kwargs["title"] = title if initial_folder is not None: kwargs["initialdir"] = initial_folder if initial_file is not None: kwargs["initialfile"] = initial_file if parent is not None: kwargs["parent"] = parent if self._handletype.lower() in ( "open", "save", "filename", "filename_multi", "save_filename"): assert file_type is not None kwargs["filetypes"] = self._filetypes[file_type] if self._defaults.get(file_type): kwargs['defaultextension'] = self._defaults[file_type] if self._handletype.lower() == "save": kwargs["mode"] = "w" if self._handletype.lower() == "open": kwargs["mode"] = "r" logger.debug("Set Kwargs: %s", kwargs) return kwargs def _set_context_handletype(self, command: str, action: str, variable: str) -> None: """ Sets the correct handle type based on context. Parameters ---------- command: str The command that is being executed. Used to look up the context actions action: str The action that is being performed. Used to look up the correct file dialog variable: str The variable associated with this file dialog """ if self._contexts[command].get(variable, None) is not None: handletype = T.cast(dict[str, dict[str, dict[str, str]]], self._contexts)[command][variable][action] else: handletype = T.cast(dict[str, dict[str, str]], self._contexts)[command][action] logger.debug(handletype) self._handletype = T.cast(_HANDLETYPE, handletype) def _open(self) -> T.IO | None: """ Open a file. """ logger.debug("Popping Open browser") return filedialog.askopenfile(**self._kwargs) # type: ignore def _save(self) -> T.IO | None: """ Save a file. """ logger.debug("Popping Save browser") return filedialog.asksaveasfile(**self._kwargs) # type: ignore def _dir(self) -> str: """ Get a directory location. """ logger.debug("Popping Dir browser") return filedialog.askdirectory(**self._kwargs) # type: ignore def _savedir(self) -> str: """ Get a save directory location. """ logger.debug("Popping SaveDir browser") return filedialog.askdirectory(**self._kwargs) # type: ignore def _filename(self) -> str: """ Get an existing file location. """ logger.debug("Popping Filename browser") return filedialog.askopenfilename(**self._kwargs) # type: ignore def _filename_multi(self) -> tuple[str, ...]: """ Get multiple existing file locations. """ logger.debug("Popping Filename browser") return filedialog.askopenfilenames(**self._kwargs) # type: ignore def _save_filename(self) -> str: """ Get a save file location. """ logger.debug("Popping Save Filename browser") return filedialog.asksaveasfilename(**self._kwargs) # type: ignore @staticmethod def _nothing() -> None: # pylint: disable=useless-return """ Method that does nothing, used for disabling open/save pop up. """ logger.debug("Popping Nothing browser") return