Files
torzdf 4b2a3cdc89 bugfixes:
- Installer: Force conda-forge for git on linux
  - GUI project: Fix issue with autosaving current settings
2026-05-01 10:34:52 +01:00

1113 lines
44 KiB
Python

#!/usr/bin/env python3
"""Handling of Faceswap GUI Projects, Tasks and Last Session"""
from __future__ import annotations
import logging
import os
import tkinter as tk
from tkinter import messagebox
import typing as T
from lib.serializer import get_serializer
from lib.gui import gui_config as cfg
from lib.logger import parse_class_init
from lib.utils import get_module_objects
if T.TYPE_CHECKING:
from .utils.config import Config
from .utils import FileHandler
logger = logging.getLogger(__name__)
class _GuiSession(): # pylint:disable=too-few-public-methods
"""Parent class for GUI Session Handlers.
Parameters
----------
config
The master GUI config
file_handler
A file handler object
"""
def __init__(self, config: Config, file_handler: type[FileHandler] | None = None) -> None:
# NB file_handler has to be passed in to avoid circular imports
logger.debug(parse_class_init(locals()))
self._serializer = get_serializer("json")
self._config = config
self._options: dict[str, str | dict[str, bool | int | float | str]] | None = None
self._file_handler = file_handler
self._filename: str | None = None
self._saved_tasks = None
self._modified = False
@property
def _active_tab(self) -> str:
"""The name of the currently selected :class:`lib.gui.command.CommandNotebook` tab"""
notebook = self._config.command_notebook
assert notebook is not None
tools_book = self._config.tools_notebook
command = notebook.tab(notebook.select(), "text").lower()
if command == "tools":
command = tools_book.tab(tools_book.select(), "text").lower()
logger.debug("Active tab: %s", command)
return command
@property
def _modified_vars(self) -> dict[str, tk.BooleanVar]:
"""The tkinter Boolean vars indicating the modified state for each tab."""
return self._config.modified_vars
@property
def _file_exists(self) -> bool:
"""``True`` if :attr:`_filename` exists otherwise ``False``."""
return self._filename is not None and os.path.isfile(self._filename)
@property
def _cli_options(self) -> dict[str, dict[str, bool | int | float | str]]:
"""The raw cli options from :attr:`_options` with project fields removed. """
assert self._options is not None
return {key: val for key, val in self._options.items() if isinstance(val, dict)}
@property
def _default_options(self) -> dict[str, T.Any]:
"""The default options for all tabs"""
return self._config.default_options
@property
def _dirname(self) -> str | None:
"""The folder name that :attr:`_filename` resides in. Returns ``None`` if filename is
``None``."""
return os.path.dirname(self._filename) if self._filename is not None else None
@property
def _basename(self) -> str | None:
"""The base name of :attr:`_filename`. Returns ``None`` if filename is ``None``."""
return os.path.basename(self._filename) if self._filename is not None else None
@property
def _stored_tab_name(self) -> str | None:
"""The tab_name stored in :attr:`_options` or ``None`` if it does not exist"""
if self._options is None:
return None
retval = self._options.get("tab_name", None)
assert retval is None or isinstance(retval, str)
return retval
@property
def _selected_to_choices(self) -> dict[str, dict[str, dict[str, T.Any]]]:
"""The selected value and valid choices for multi-option, radio or combo options."""
# TODO do instance check on CliOption. Not done for now due to circular import
# pylint:disable=line-too-long
valid_choices = {
cmd: {
opt: {
"choices": val.panel_option.choices, # pyright:ignore[reportAttributeAccessIssue] # noqa[E501]
"is_multi": val.panel_option.is_multi_option # pyright:ignore[reportAttributeAccessIssue] # noqa[E501]
}
for opt, val in data.items()
if hasattr(val, "panel_option") # Filter out helptext
and val.panel_option.choices is not None # pyright:ignore[reportAttributeAccessIssue] # noqa[E501]
}
for cmd, data in self._config.cli_opts.opts.items()
}
logger.trace("valid_choices: %s", valid_choices) # type:ignore[attr-defined]
assert self._options is not None
retval = {command: {option: {"value": value,
"is_multi": valid_choices[command][option]["is_multi"],
"choices": valid_choices[command][option]["choices"]}
for option, value in options.items()
if value and command in valid_choices
and option in valid_choices[command]}
for command, options in self._options.items()
if isinstance(options, dict)}
logger.trace("returning: %s", retval) # type:ignore[attr-defined]
return retval
def _current_gui_state(self, command: str | None = None
) -> dict[str, dict[str, bool | int | float | str]]:
"""The current state of the GUI.
Parameters
----------
command
If provided, returns the state of just the given tab command. If ``None`` returns
options for all tabs. Default ``None``
Returns
-------
The options currently set in the GUI
"""
return self._config.cli_opts.get_option_values(command)
def _set_filename(self,
filename: str | None = None,
session_type: T.Literal["all", "project", "task"] = "project") -> bool:
"""Set the :attr:`_filename` attribute.
:attr:`_filename` is set either from a given filename or the result from
a :attr:`_file_handler`.
Parameters
----------
filename
An optional filename. If given then this filename will be used otherwise it will be
collected by a :attr:`_file_handler`
session_type
The session type that the filename is being set for. Dictates the type of file handler
that is opened. Default: `"Project"`
Returns
-------
``True`` if filename has been successfully set otherwise ``False``
"""
logger.debug("filename: '%s', session_type: '%s'", filename, session_type)
handler = T.cast(T.Literal["config_all", "config_project", "config_task"],
f"config_{session_type}")
if filename is None:
logger.debug("Popping file handler")
assert self._file_handler is not None
cfg_file = self._file_handler("open", handler).return_file
if not cfg_file:
logger.debug("No filename given")
return False
filename = cfg_file.name
cfg_file.close()
assert filename is not None
if not os.path.isfile(filename):
msg = f"File does not exist: '{filename}'"
logger.error(msg)
return False
ext = os.path.splitext(filename)[1]
if (session_type == "project" and ext != ".fsw") or (session_type == "task"
and ext != ".fst"):
logger.debug("Invalid file extension for session type: (session_type: '%s', "
"extension: '%s')", session_type, ext)
return False
logger.debug("Setting filename: '%s'", filename)
self._filename = filename
return True
# GUI STATE SETTING
def _set_options(self, command: str | None = None) -> None:
"""Set the GUI options based on the currently stored properties of :attr:`_options`
and sets the active tab.
Parameters
----------
command
The tab to set the options for. If None then sets options for all tabs.
Default: ``None``
"""
opts = self._get_options_for_command(command) if command else self._cli_options
logger.debug("command: %s, opts: %s", command, opts)
if opts is None:
logger.debug("No options found. Returning")
return
for cmd, opt in opts.items():
self._set_gui_state_for_command(cmd, opt)
assert self._options is not None
tab_name = self._options.get("tab_name", None) if command is None else command
tab_name = tab_name if tab_name is not None else "extract"
logger.debug("tab_name: %s", tab_name)
assert isinstance(tab_name, str)
self._config.set_active_tab_by_name(tab_name)
def _get_options_for_command(self, command: str
) -> dict[str, dict[str, bool | int | float | str]] | None:
"""Return a single command's options from :attr:`_options` formatted consistently with
an all options dict.
Parameters
----------
command
The command to return the options for
Returns
-------
dict: The options for a single command in the format {command: options}. If the command
is not found then returns ``None``
"""
logger.debug(command)
assert self._options is not None
opts = T.cast(dict[str, int | float | bool | str] | None, self._options.get(command, None))
if opts is None:
self._config.tk_vars.console_clear.set(True)
logger.info("No %s section found in file", command)
retval = None
else:
retval = {command: opts}
logger.debug(retval)
return retval
def _set_gui_state_for_command(self,
command: str,
options: dict[str, bool | int | float | str]
) -> None:
"""Set the GUI state for the given command.
Parameters
----------
command
The tab to set the options for
options
The option values to set the GUI to
"""
logger.debug("command: %s: options: %s", command, options)
if not options:
logger.debug("No options provided, not updating GUI")
return
for src_opt, src_val in options.items():
opt_var = self._config.cli_opts.get_one_option_variable(command, src_opt)
if not opt_var:
continue
logger.trace( # type:ignore[attr-defined]
"setting option: (src_opt: %s, opt_var: %s, src_val: %s)",
src_opt, opt_var, src_val)
opt_var.set(src_val)
def _reset_modified_var(self, command: str | None = None) -> None:
"""Reset :attr:`_modified_vars` variables back to unmodified (`False`) for all
commands or for the given command.
Parameters
----------
command
The command to reset the modified tkinter variable for. If ``None`` then all tkinter
modified variables are reset to `False`. Default: ``None``
"""
for key, tk_var in self._modified_vars.items():
if (command is None or command == key) and tk_var.get():
logger.debug("Reset modified state for: (command: %s key: %s)", command, key)
tk_var.set(False)
# RECENT FILE HANDLING
def _add_to_recent(self, command: str | None = None) -> None:
"""Add the file for this session to the recent files list.
Parameters
----------
command
The command that this session relates to. If `None` then the whole project is added.
Default: ``None``
"""
logger.debug(command)
if self._filename is None:
logger.debug("No filename for selected file. Not adding to recent.")
return
recent_filename = os.path.join(self._config.path_cache, ".recent.json")
logger.debug("Adding to recent files '%s': (%s, %s)",
recent_filename, self._filename, command)
if not os.path.exists(recent_filename) or os.path.getsize(recent_filename) == 0:
logger.debug("Starting with empty recent_files list")
recent_files: list[tuple[str, str]] | None = []
else:
logger.debug("loading recent_files list: %s", recent_filename)
assert self._serializer is not None
recent_files = self._serializer.load( # pyright:ignore[reportCallIssue]
recent_filename)
logger.debug("Initial recent files: %s", recent_files)
recent_files = self._del_from_recent(self._filename, recent_files)
assert recent_files is not None
f_type = "project" if command is None else command
recent_files.insert(0, (self._filename, f_type))
recent_files = recent_files[:20]
logger.debug("Final recent files: %s", recent_files)
assert self._serializer is not None
self._serializer.save(recent_filename, recent_files) # pyright:ignore[reportCallIssue]
def _del_from_recent(self,
filename: str,
recent_files: list[tuple[str, str]] | None = None,
save: bool = False) -> list[tuple[str, str]] | None:
"""Remove an item from the recent files list.
Parameters
----------
filename
The filename to be removed from the recent files list
recent_files
If the recent files list has already been loaded, it can be passed in to avoid
loading again. If ``None`` then load the recent files list from disk. Default: ``None``
save
Whether the recent files list should be saved after removing the file. ``True`` saves
the file, ``False`` does not. Default: ``False``
Returns
-------
List of recent files and their filetypes
"""
recent_filename = os.path.join(self._config.path_cache, ".recent.json")
if recent_files is None:
logger.debug("Loading file list from disk: %s", recent_filename)
if not os.path.exists(recent_filename) or os.path.getsize(recent_filename) == 0:
logger.debug("No recent file list")
return None
assert self._serializer is not None
recent_files = self._serializer.load( # pyright:ignore[reportCallIssue]
recent_filename)
assert recent_files is not None
filenames = [recent[0] for recent in recent_files]
if filename in filenames:
idx = filenames.index(filename)
logger.debug("Removing from recent file list: %s", filename)
del recent_files[idx]
if save:
logger.debug("Saving recent files list: %s", recent_filename)
assert self._serializer is not None
self._serializer.save(recent_filename, # pyright:ignore[reportCallIssue]
recent_files)
else:
logger.debug("Filename '%s' does not appear in recent file list", filename)
return recent_files
def _get_lone_task(self) -> str | None:
"""Get the sole command name from :attr:`_options`.
Returns
-------
The only existing command name in the current :attr:`_options` dict or ``None`` if there
are multiple commands stored.
"""
command = None
if len(self._cli_options) == 1:
command = list(self._cli_options.keys())[0]
logger.debug(command)
return command
# DISK IO
def _load(self) -> bool:
"""Load GUI options from :attr:`_filename` location and set to :attr:`_options`.
Returns
-------
``True`` if successfully loaded otherwise ``False``
"""
if self._file_exists:
logger.debug("Loading config")
assert self._serializer is not None
self._options = self._serializer.load( # pyright:ignore[reportCallIssue]
self._filename)
self._check_valid_choices()
retval = True
else:
logger.debug("File doesn't exist. Aborting")
retval = False
return retval
def _check_valid_choices(self) -> None:
"""Check whether the loaded file has any selected combo/radio/multi-option values that are
no longer valid and remove them so that they are not passed into faceswap."""
assert self._options is not None
for command, options in self._selected_to_choices.items():
opts = T.cast(dict[str, bool | int | float | str], self._options[command])
for option, data in options.items():
if not data["is_multi"] and data["value"] in data["choices"]:
continue
if (data["is_multi"] and
isinstance(data["value"], str) and
all(v in data["choices"] for v in data["value"].split())):
continue
if data["is_multi"] and isinstance(data["value"], str):
val = " ".join([v for v in data["value"].split() if v in data["choices"]])
else:
val = ""
val = self._default_options[command][option] if not val else val
logger.debug("Updating invalid value to default: (command: '%s', option: '%s', "
"original value: '%s', new value: '%s')", command, option,
opts[option], val)
opts[option] = val
def _save_as_to_filename(self, session_type: T.Literal["all", "task", "project"]) -> bool:
"""Set :attr:`_filename` from a save as dialog.
Parameters
----------
session_type: ['all', 'task', 'project']
The type of session to pop the save as dialog for. Limits the allowed filetypes
Returns
-------
True if :attr:`filename` successfully set otherwise ``False``
"""
logger.debug("Popping save as file handler. session_type: '%s'", session_type)
title = f"Save {f'{session_type.title()} ' if session_type != 'all' else ''}As..."
assert self._file_handler is not None
cfg_file = self._file_handler(
"save",
T.cast(T.Literal["config_all", "config_project", "config_task"],
f"config_{session_type}"),
title=title,
initial_folder=self._dirname).return_file
if not cfg_file:
logger.debug("No filename provided. session_type: '%s'", session_type)
return False
self._filename = cfg_file.name
logger.debug("Set filename: (session_type: '%s', filename: '%s'",
session_type, self._filename)
cfg_file.close()
return True
def _save(self, command: str | None = None) -> None:
"""Collect the options in the current GUI state and save.
Obtains the current options set in the GUI with the selected tab and applies them to
:attr:`_options`. Saves :attr:`_options` to :attr:`_filename`. Resets :attr:_modified_vars
for either the given command or all commands,
Parameters
----------
command
The tab to collect the current state for. If ``None`` then collects the current
state for all tabs. Default: ``None``
"""
self._options = T.cast(dict[str, str | dict[str, bool | int | float | str]],
self._current_gui_state(command))
self._options["tab_name"] = self._active_tab
logger.debug("Saving options: (filename: %s, options: %s", self._filename, self._options)
assert self._serializer is not None
self._serializer.save(self._filename, self._options) # pyright:ignore[reportCallIssue]
self._reset_modified_var(command)
self._add_to_recent(command)
class Tasks(_GuiSession):
"""Faceswap ``.fst`` Task File handling.
Faceswap tasks handle the management of each individual task tab in the GUI. Unlike
:class:`Projects`, Tasks contains all the active tasks currently running, rather than an
individual task.
Parameters
----------
config
The master GUI config
file_handler
A file handler object
"""
def __init__(self, config: Config, file_handler: type[FileHandler]):
super().__init__(config, file_handler)
self._tasks: dict[
str, dict[T.Literal["filename", "options", "is_project"],
str | bool | dict[str, str | dict[str,
bool | int | float | str]] | None]] = {}
@property
def _is_project(self) -> bool:
"""``True`` if all tasks are from an overarching session project else ``False``."""
retval = False if not self._tasks else all(v.get("is_project", False)
for v in self._tasks.values())
return retval
@property
def _project_filename(self) -> str | None:
"""The overarching session project filename."""
fname = None
if not self._is_project:
return fname
for val in self._tasks.values():
fname = val["filename"]
break
assert fname is None or isinstance(fname, str)
return fname
def load(self, # pylint:disable=unused-argument
*args,
filename: str | None = None,
current_tab: bool = True) -> None:
"""Load a task into this :class:`Tasks` class.
Tasks can be loaded from project ``.fsw`` files or task ``.fst`` files, depending on where
this function is being called from.
Parameters
----------
*args
Unused, but needs to be present for arguments passed by tkinter event handling
filename
If a filename is passed in, This will be used, otherwise a file handler will be
launched to select the relevant file.
current_tab
``True`` if the task to be loaded must be for the currently selected tab. ``False``
if loading a task into any tab. If current_tab is `True` then tasks can be loaded from
``.fsw`` and ``.fst`` files, otherwise they can only be loaded from ``.fst`` files.
Default: ``True``
"""
logger.debug("Loading task config: (filename: '%s', current_tab: '%s')",
filename, current_tab)
# Option to load specific task from project files:
session_type: T.Literal["all", "task"] = "all" if current_tab else "task"
is_legacy = (not self._is_project and
filename is not None and session_type == "task" and
os.path.splitext(filename)[1] == ".fsw")
if is_legacy:
logger.debug("Legacy task found: '%s'", filename)
assert filename is not None
filename = self._update_legacy_task(filename)
filename_set = self._set_filename(filename, session_type=session_type)
if not filename_set:
return
loaded = self._load()
if not loaded:
return
command = self._active_tab if current_tab else self._stored_tab_name
command = self._get_lone_task() if command is None else command
if command is None:
logger.error("Unable to determine task from the given file: '%s'", filename)
return
assert self._options is not None
if command not in self._options:
logger.error("No '%s' task in '%s'", command, self._filename)
return
self._set_options(command)
self._add_to_recent(command)
if self._is_project:
self._filename = self._project_filename
elif self._filename is not None and self._filename.endswith(".fsw"):
self._filename = None
self._add_task(command)
if is_legacy:
self.save()
logger.debug("Loaded task config: (command: '%s', filename: '%s')", command, filename)
def _update_legacy_task(self, filename: str) -> str:
"""Update legacy ``.fsw`` tasks to ``.fst`` tasks.
Tasks loaded from the recent files menu may be passed in with a ``.fsw`` extension.
This renames the file and removes it from the recent file list.
Parameters
----------
filename
The filename of the `.fsw` file that needs converting
Returns
-------
The new filename of the updated tasks file
"""
# TODO remove this code after a period of time. Implemented November 2019
logger.debug("original filename: '%s'", filename)
fname, ext = os.path.splitext(filename)
if ext != ".fsw":
logger.debug("Not a .fsw file: '%s'", filename)
return filename
new_filename = f"{fname}.fst"
logger.debug("Renaming '%s' to '%s'", filename, new_filename)
os.rename(filename, new_filename)
self._del_from_recent(filename, save=True)
logger.debug("new filename: '%s'", new_filename)
return new_filename
def save(self, save_as: bool = False) -> None:
"""Save the current GUI state for the active tab to a ``.fst`` faceswap task file.
Parameters
----------
save_as
Whether to save to the stored filename, or pop open a file handler to ask for a
location. If there is no stored filename, then a file handler will automatically be
popped. Default: ``False``
"""
logger.debug("Saving config...")
self._set_active_task()
save_as = save_as or self._is_project or self._filename is None
if save_as and not self._save_as_to_filename("task"):
return
command = self._active_tab
self._save(command=command)
self._add_task(command)
if not save_as:
logger.info("Saved project to: '%s'", self._filename)
else:
logger.debug("Saved project to: '%s'", self._filename)
def clear(self) -> None:
"""Reset all GUI options to their default values for the active tab."""
self._config.cli_opts.reset(self._active_tab)
def reload(self) -> None:
"""Reset currently selected tab GUI options to their last saved state."""
self._set_active_task()
if self._options is None:
logger.info("No active task to reload")
return
logger.debug("Reloading task")
self.load(filename=self._filename, current_tab=True)
if self._is_project:
self._reset_modified_var(self._active_tab)
def _add_task(self, command: str) -> None:
"""Add the currently active task to the internal :attr:`_tasks` dict.
If the currently stored task is from an overarching session project, then
only the options are updated. When resetting a tab to saved a project will always
be preferred to a task loaded into the project, so the original reference file name
stays with the project.
Parameters
----------
command
The tab that pertains to the currently active task
"""
self._tasks[command] = {"filename": self._filename,
"options": self._options,
"is_project": self._is_project}
def clear_tasks(self) -> None:
"""Clears all of the stored tasks.
This is required when loading a task stored in a legacy project file, and is only to be
called by :class:`Project` when a project has been loaded which is in fact a task.
"""
logger.debug("Clearing stored tasks")
self._tasks = {}
def add_project_task(self,
filename: str,
command: str,
options: dict[str, str | dict[str, bool | int | float | str]]) -> None:
"""Add an individual task from a loaded :class:`Project` to the internal :attr:`_tasks`
dict.
Project tasks take priority over any other tasks, so the individual tasks from a new
project must be placed in the _tasks dict.
Parameters
----------
filename
The filename of the session project file
command
The tab that this task's options belong to
options
The options for this task loaded from the project
"""
self._tasks[command] = {"filename": filename, "options": options, "is_project": True}
def _set_active_task(self, command: str | None = None) -> None:
"""Set the active :attr:`_filename` and :attr:`_options` to currently selected tab's
options.
Parameters
----------
command
If a command is passed in then set the given tab to active, If this is none set the tab
which currently has focus to active. Default: ``None``
"""
logger.debug(command)
command = self._active_tab if command is None else command
task = self._tasks.get(command, None)
if task is None:
self._filename, self._options = (None, None)
else:
filename = task.get("filename", None)
opts = task.get("options", None)
assert filename is None or isinstance(filename, str)
assert opts is None or isinstance(opts, dict)
self._filename = filename
self._options = opts
logger.debug("tab: %s, filename: %s, options: %s",
self._active_tab, self._filename, self._options)
class Project(_GuiSession):
"""Faceswap ``.fsw`` Project File handling.
Faceswap projects handle the management of all task tabs in the GUI and updates
the main Faceswap title bar with the project name and modified state.
Parameters
----------
config
The master GUI config
file_handler
A file handler object
"""
def __init__(self, config: Config, file_handler: type[FileHandler]) -> None:
super().__init__(config, file_handler)
self._update_root_title()
@property
def filename(self) -> str | None:
"""The currently active project filename."""
return self._filename
@property
def cli_options(self) -> dict[str, dict[str, bool | int | float | str]]:
"""The raw cli options from :attr:`_options` with project fields removed."""
return self._cli_options
@property
def _project_modified(self) -> bool:
"""``True`` if the project has been modified otherwise ``False``. """
return any(var.get() for var in self._modified_vars.values())
@property
def _tasks(self) -> Tasks:
"""The current session's :class:``Tasks``."""
return self._config.tasks
def set_default_options(self) -> None:
"""Set the default options. The Default GUI options are stored on Faceswap startup.
Exposed as the :attr:`_default_options` for a project cannot be set until after the main
Command Tabs have been loaded.
"""
logger.debug("Setting options to default")
self._options = self._default_options
# MODIFIED STATE CALLBACK
def set_modified_callback(self) -> None:
"""Adds a callback to each of the :attr:`_modified_vars` tkinter variables
When one of these variables is changed, triggers :func:`_modified_callback`
with the command that was changed.
This is exposed as the callback can only be added after the main Command Tabs have
been drawn, and their options' initial values have been set."""
for key, tk_var in self._modified_vars.items():
logger.debug("Adding callback for tab: %s", key)
tk_var.trace("w", self._modified_callback)
def _modified_callback(self, *args) -> None: # pylint:disable=unused-argument
"""Update the project modified state on a GUI modification change and
update the Faceswap title bar. """
if self._project_modified and self._current_gui_state() == self._cli_options:
logger.debug("Project is same as stored. Setting modified to False")
self._reset_modified_var()
if self._modified != self._project_modified:
logger.debug("Updating project state from variable: (modified: %s)",
self._project_modified)
self._modified = self._project_modified
self._update_root_title()
def load(self, # pylint:disable=unused-argument
*args,
filename: str | None = None,
last_session: bool = False) -> None:
"""Load a project from a saved ``.fsw`` project file.
Parameters
----------
*args
Unused, but needs to be present for arguments passed by tkinter event handling
filename
If a filename is passed in, This will be used, otherwise a file handler will be
launched to select the relevant file.
last_session
``True`` if the project is being loaded from the last opened session ``False`` if the
project is being loaded directly from disk. Default: ``False``
"""
logger.debug("Loading project config: (filename: '%s', last_session: %s)",
filename, last_session)
filename_set = self._set_filename(filename, session_type="project")
if not filename_set:
logger.debug("No filename set")
return
loaded = self._load()
if not loaded:
logger.debug("Options not loaded")
return
# Legacy .fsw files could store projects or tasks. Check if this is a legacy file
# and hand off file to Tasks if necessary
command = self._get_lone_task()
legacy = command is not None
if legacy:
self._handoff_legacy_task()
return
if not last_session:
self._set_options() # Options will be set by last session. Don't set now
self._update_tasks()
self._add_to_recent()
self._reset_modified_var()
self._update_root_title()
logger.debug("Loaded project config: (command: '%s', filename: '%s')", command, filename)
def _handoff_legacy_task(self) -> None:
"""Update legacy tasks saved with the old file extension ``.fsw`` to tasks ``.fst``.
Hands off file handling to :class:`Tasks` and resets project to default."""
logger.debug("Updating legacy task '%s", self._filename)
filename = self._filename
self._filename = None
self.set_default_options()
self._tasks.clear_tasks()
self._tasks.load(filename=filename, current_tab=False)
logger.debug("Updated legacy task and reset project")
def _update_tasks(self) -> None:
"""Add the tasks from the loaded project to the :class:`Tasks` class."""
assert self._filename is not None
for key, val in self._cli_options.items():
opts: dict[str, str | dict[str, bool | int | float | str]] = {key: val}
opts["tab_name"] = key
self._tasks.add_project_task(self._filename, key, opts)
def reload(self, *args) -> None: # pylint:disable=unused-argument
"""Reset all GUI's option tabs to their last saved state.
Parameters
----------
*args
Unused, but needs to be present for arguments passed by tkinter event handling
"""
if self._options is None:
logger.info("No active project to reload")
return
logger.debug("Reloading project")
self._set_options()
self._update_tasks()
self._reset_modified_var()
self._update_root_title()
def _update_root_title(self) -> None:
"""Update the root Window title with the project name. Add a asterisk if the file is
modified."""
text = "<untitled project>" if self._basename is None else self._basename
text += "*" if self._modified else ""
self._config.set_root_title(text=text)
def save(self, *args, save_as: bool = False) -> None: # pylint:disable=unused-argument
"""Save the current GUI state to a ``.fsw`` project file.
Parameters
----------
*args: tuple
Unused, but needs to be present for arguments passed by tkinter event handling
save_as: bool, optional
Whether to save to the stored filename, or pop open a file handler to ask for a
location. If there is no stored filename, then a file handler will automatically be
popped.
"""
logger.debug("Saving config as...")
save_as = save_as or self._filename is None
if save_as and not self._save_as_to_filename("project"):
return
self._save()
self._update_tasks()
self._update_root_title()
if not save_as:
logger.info("Saved project to: '%s'", self._filename)
else:
logger.debug("Saved project to: '%s'", self._filename)
def new(self, *args) -> None: # pylint:disable=unused-argument
"""Create a new project with default options.
Pops a file handler to select location.
Parameters
----------
*args
Unused, but needs to be present for arguments passed by tkinter event handling
"""
logger.debug("Creating new project")
if not self.confirm_close():
logger.debug("Creating new project cancelled")
return
assert self._file_handler is not None
cfg_file = self._file_handler("save",
"config_project",
title="New Project...",
initial_folder=self._basename).return_file
if not cfg_file:
logger.debug("No filename selected")
return
self._filename = cfg_file.name
cfg_file.close()
self.set_default_options()
self._config.cli_opts.reset()
self._save()
self._update_root_title()
def close(self, *args) -> None: # pylint:disable=unused-argument
"""Clear the current project and set all options to default.
Parameters
----------
*args
Unused, but needs to be present for arguments passed by tkinter event handling
"""
logger.debug("Close requested")
if not self.confirm_close():
logger.debug("Close cancelled")
return
self._config.cli_opts.reset()
self._filename = None
self.set_default_options()
self._reset_modified_var()
self._update_root_title()
self._config.set_active_tab_by_name(cfg.tab())
def confirm_close(self) -> bool:
"""Pop a message box to get confirmation that an unsaved project should be closed
Returns
-------
``True`` if user confirms close, ``False`` if user cancels close
"""
if not self._modified:
logger.debug("Project is not modified")
return True
confirm_txt = "You have unsaved changes.\n\nAre you sure you want to close the project?"
if messagebox.askokcancel("Close", confirm_txt, default="cancel", icon="warning"):
logger.debug("Close Cancelled")
return True
logger.debug("Close confirmed")
return False
class LastSession(_GuiSession):
"""Faceswap Last Session handling.
Faceswap :class:`LastSession` handles saving the state of the Faceswap GUI at close and
reloading the state at launch.
Last Session behavior can be configured in :file:`config.gui.ini`.
Parameters
----------
config
The master GUI config
"""
def __init__(self, config: Config) -> None:
super().__init__(config)
self._filename = os.path.join(self._config.path_cache, ".last_session.json")
if not self._enabled:
return
if cfg.autosave_last_session() == "prompt":
self.ask_load()
elif cfg.autosave_last_session() == "always":
self.load()
@property
def _enabled(self) -> bool:
"""``True`` if autosave is enabled otherwise ``False``."""
return cfg.autosave_last_session() != "never"
def from_dict(self, options: dict[str, str | dict[str, bool | int | float | str]]) -> None:
"""Set the :attr:`_options` property based on the given options dictionary
and update the GUI to use these values.
This function is required for reloading the GUI state when the GUI has been force
refreshed on a config change.
Parameters
----------
options
The options to set. Should be the output of :func:`to_dict`
"""
logger.debug("Setting options from dict: %s", options)
self._options = options
self._set_options()
def to_dict(self) -> dict[str, str | dict[str, bool | int | float | str] | None] | None:
"""Collect the current GUI options and place them in a dict for retrieval or storage.
This function is required for reloading the GUI state when the GUI has been force
refreshed on a config change.
Returns
-------
The current cli options ready for saving or retrieval by :func:`from_dict`
"""
opts = T.cast(dict[str, str | dict[str, bool | int | float | str] | None],
self._current_gui_state())
logger.debug("Collected opts: %s", opts)
if not opts or opts == self._default_options:
logger.debug("Default session, or no opts found. Not saving last session.")
return None
opts["tab_name"] = self._active_tab
fname = self._config.project.filename
opts["project"] = fname
logger.debug("Added project items: %s", {k: v for k, v in opts.items()
if k in ("tab_name", "project")})
return opts
def ask_load(self) -> None:
"""Pop a message box to ask the user if they wish to load their last session."""
if not self._file_exists:
logger.debug("No last session file found")
elif messagebox.askyesno("Last Session", "Load last session?"):
logger.debug("Loading last session at user request")
self.load()
else:
logger.debug("Not loading last session at user request")
logger.debug("Deleting LastSession file")
assert self._filename is not None
os.remove(self._filename)
def load(self) -> None:
"""Load the last session.
Loads the last saved session options. Checks if a previous project was loaded
and whether there have been changes since the last saved version of the project.
Sets the display and :class:`Project` and :class:`Task` objects accordingly."""
loaded = self._load()
if not loaded:
return
self._set_project()
self._set_options()
def _set_project(self) -> None:
"""Set the :class:`Project` if session is resuming from one. """
assert self._options is not None
if self._options.get("project", None) is None:
logger.debug("No project stored")
else:
logger.debug("Loading stored project")
fname = self._options["project"]
assert isinstance(fname, str)
self._config.project.load(filename=fname, last_session=True)
def save(self) -> None:
"""Save a snapshot of currently set GUI config options.
Called on Faceswap shutdown.
"""
assert self._filename is not None
if not self._enabled:
logger.debug("LastSession not enabled")
if os.path.exists(self._filename):
logger.debug("Deleting existing LastSession file")
os.remove(self._filename)
return
opts = self.to_dict()
if opts is None and os.path.exists(self._filename):
logger.debug("Last session default or blank. Clearing saved last session.")
os.remove(self._filename)
if opts is not None:
assert self._serializer is not None
self._serializer.save(self._filename, opts) # pyright:ignore[reportCallIssue]
logger.debug("Saved last session. (filename: '%s', opts: %s", self._filename, opts)
__all__ = get_module_objects(__name__)