Beispiel #1
0
    def manager_ready(self):
        """To be called by the app entry point after the modmanager
        has been initialized and registered globally. This starts
        the data-loading part of the program"""

        # make sure that whoever called this isn't lying
        if not Manager():
            self.LOGGER.error("Manager NOT ready!")
            return

        self.LOGGER << "Notified Manager ready"

        # keep local ref
        self.Manager = Manager()

        # connect to signals
        self.Manager.alertsChanged.connect(self.update_alerts)

        # do an initial check of the Manager directories
        self.Manager.check_dirs()

        # perform some setup that requires the manager
        self._setup_data_interface()

        # instantiate installation helper
        self.install_helper = InstallerUI(self)
        self.install_helper.modAdded.connect(self.on_mod_install)
        # when an install manager has been prepared and a dialog is
        # about to shown, stop the activity indicator in the statusbar
        self.install_helper.installerReady.connect(
            self.hide_statusbar_progress)

        # load the initial profile (or not, depending on profile load policy)
        self.profile_helper.load_initial_profile(
            app_settings.Get("ManagerWindow", KeyStr_UI.PROFILE_LOAD_POLICY))
Beispiel #2
0
    def is_active(self):
        """Return True if this profile is the currently active profile"""
        m = Manager()

        ## this should work...now, anyway. Could just compare names,
        ## but if the names are equal and this isn't true then there's
        ## a problem...
        return m and m.profile is self
Beispiel #3
0
 def filelist(self):
     """Return the list of files contained by this mod."""
     # recently-queried mods are cached by modmanager
     try:
         return Manager().get_mod_file_list(self.key)
     except AttributeError:
         # no manager, somehow
         return []
Beispiel #4
0
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.tmpdir = None
        self.numfiles = 0
        # this should be instantiated from the main window after the
        # manager has been initialized
        self.Manager = Manager()

        # the current InstallManager instance
        self.installer = None
Beispiel #5
0
    def __init__(self, parent, **kwargs):
        """

        :param parent: parent widget (specifically, the file-viewer QTreeView)
        """
        # noinspection PyArgumentList
        super().__init__(parent=parent, **kwargs)
        self._parent = parent
        self.manager = Manager()  # should be initialized by now
        self.modname = None  #type: str
        self.rootitem = None  #type: QFSItem

        self.mod = None
        """:type: skymodman.types.ModEntry"""

        # maintain a flattened list of the files for the current mod
        self._files = []  # type: list [QFSItem]

        # set of hidden files for the current 'clean state' of the tree
        # (should correspond to entries in "hiddenfiles" db table)
        self._saved_state = set()

        self.command_queue = deque()
Beispiel #6
0
    def load_active_only_state(self):
        """When something major changes (likely the active profile)
        query the profile for the value of the 'Only show active mods'
        setting for the files-tab modlist."""

        active_only = Manager().get_profile_setting(INI.ACTIVE_ONLY,
                                                    Section.FILEVIEWER)

        if active_only is None:
            # if no profile loaded, set it unchecked and disable it
            self._filter.onlyShowActive = False
            self._cbox.setEnabled(False)
        else:
            self._filter.onlyShowActive = active_only
            self._cbox.setEnabled(True)

        self._cbox.setChecked(self.only_show_active)
Beispiel #7
0
    def on_active_only_toggled(self, only_show_active):
        """

        :param bool only_show_active:
        """

        # ignore if somehow it's the same as before
        if only_show_active == self.only_show_active: return

        # save to Profile
        Manager().set_profile_setting(INI.ACTIVE_ONLY, Section.FILEVIEWER,
                                      only_show_active)

        # update filter setting...
        self.only_show_active = only_show_active

        # ... and label text
        self.update_label()
Beispiel #8
0
    def reset_view(self):
        """Reset the view to a clean state"""

        # clear filter box
        self._filterbox.clear()

        # disable list if main mods folder is inaccessible
        ###...TODO: hmm...maybe we shouldn't do this; unless Skyrim dir is also invalid...
        if not Manager().Folders['mods']:
            self.setEnabled(False)
            self.setToolTip("Mods directory is currently invalid")
        else:
            self.setEnabled(True)
            self.setToolTip(None)

        # update checkbox and label
        self.load_active_only_state()
        self.update_label()
Beispiel #9
0
    def set_override_path(self, dirkey, path, enable=None):
        """
        Set a path override. Note that no path verification is performed
        here.

        :param dirkey: From constants.keystrings.Dirs
        :param path: should refer to a real path on the filesystem
        :param enable: if set to True or False, the "enabled" switch
            for this override will be updated to that value. If omitted
            or None, no change will be made to the enabled status.
        """
        # self.LOGGER << "Setting override: ({}, {}, {})".format(dirkey, path, enable)

        if dirkey in self._overrides:
            enabled = self._overrides[dirkey].enabled \
                if enable is None \
                else bool(enable)

            self._overrides[dirkey] = dir_override(path, enabled)

            # update config dict
            self._config[kstr_section.OVERRIDES][dirkey] = path
            self._config[kstr_section.OVR_ENABLED][dirkey] = enabled

            # save updated config to disk
            self._save_profile_settings()

            # if this is the active profile, update the AppFolder with
            # this override if it has been enabled
            # print(self.is_active, self._overrides[dirkey])
            if self.is_active and self._overrides[dirkey].enabled:
                Manager().Folders[dirkey].set_override(
                    self._overrides[dirkey].path)

        else:
            self.LOGGER.error(
                f"Attempted to set override for unrecognized path key '{dirkey}'"
            )
Beispiel #10
0
    def on_filter_changed(self, text):
        """
        Query the modfiles table in the db for files matching the filter
        string given by `text`. The resulting matches are fed to the
        proxy filter on the file viewer which uses them to make sure
        that matching files are shown in the tree regardless of whether
        their parent directories match the filter or not.

        :param text:
        """

        f = self._filter

        # don't bother querying db for empty string,
        # the filter will ignore the matched files anyway
        if not text:
            f.setFilterWildcard(text)

        else:
            # turn wildcard filter into SQL-compatible pattern
            sqlexpr = r'%' + text.replace('?', '_').replace('*', r'%') + r'%'

            # get list of matching files from db

            matches = list(Manager().DB.find_matching_files(
                self._srcmodel.modname, sqlexpr))

            # set the matches on the filter
            f.setMatchingFiles(matches)

            # set the wildcard; for this implementation, the filter only
            # needs to know the text so it can short-circuit empty strings
            # (and to do the normal filter-invalidation, etc.)
            f.setFilterWildcard(text)

            # expand full tree by default
            self.expandAll()
Beispiel #11
0
from skymodman import Manager, constants, exceptions
from skymodman.constants.keystrings import UI, Dirs as D
from skymodman.log import withlogger

from skymodman.interface import app_settings, ui_utils
from skymodman.interface.dialogs import checkbox_message, message
from skymodman.interface.designer.uic.preferences_dialog_ui import \
    Ui_Preferences_Dialog
from skymodman.utils.fsutils import check_path

# because I'm lazy
PLP = constants.ProfileLoadPolicy

# main manager instance
MManager = Manager()

## text and style sheets for indicator labels
_label_spec = namedtuple("_label_spec", "text style")

_path_error_labels = {
    'invalid':
    _label_spec(text="Path not found",
                style="QLabel {color: red; font-size: 10pt;}"),
    'missing':
    _label_spec(text="Path is required",
                style="QLabel { "
                "color: orange; "
                "font-size: 10pt; "
                "font-style: italic; "
                "}"),
Beispiel #12
0
 def _remove_appfolder_override(self, dirkey):
     Manager().Folders[dirkey].remove_override()
Beispiel #13
0
 def _set_appfolder_override(self, dirkey):
     Manager().Folders[dirkey].set_override(self._overrides[dirkey].path)
Beispiel #14
0
 def is_default(self):
     """Return true if this profile is the currently configured
     default profile"""
     m = Manager()
     return m and m.default_profile == self.name
Beispiel #15
0
 def filetree(self):
     """Return the files contained by this mod as a tree"""
     try:
         return Manager().get_mod_file_tree(self.key)
     except AttributeError:
         return None
Beispiel #16
0
class ModManagerWindow(QtWidgets.QMainWindow, Ui_MainWindow):
    # region signals

    # noinspection PyArgumentList
    modListModified = QtCore.pyqtSignal()
    # noinspection PyArgumentList
    modListSaved = QtCore.pyqtSignal()

    # noinspection PyArgumentList
    moveMods = QtCore.pyqtSignal(int)
    # noinspection PyArgumentList
    moveModsToTop = QtCore.pyqtSignal()
    # noinspection PyArgumentList
    moveModsToBottom = QtCore.pyqtSignal()

    # endregion

    def __init__(self, **kwargs):
        """
        :param kwargs: anything to pass on the the base class constructors
        """
        super().__init__(**kwargs)

        # this field will contain the reference to the main ModManager
        # backend (encapsulated in a QObject wrapper)
        self.Manager = None
        """:type: skymodman.interface.qmodmanager.QModManager"""

        self.LOGGER.info("Initializing ModManager Window")

        # setup the base ui
        self.setupUi(self)

        self.setWindowTitle(constants.APPTITLE)

        # for cancelling asyncio actions
        self.task: asyncio.Task = None
        self.install_helper: InstallerUI = None

        # setup trackers for all of our models and proxies
        self.models: Dict[M, QtCore.QAbstractItemModel] = {}
        # self.filters : Dict [F,QtCore.QSortFilterProxyModel] = {}

        self._currtab = TAB.MODTABLE

        # get a helping hand...ler
        self.profile_helper = profile_handler.ProfileHandler(self)

        # make sure the correct initial pages are showing
        self.manager_tabs.setCurrentIndex(self._currtab.value)

        ## The undo framework.
        ## Each tab will have its own UndoStack; when the user changes
        ## tabs, the stack for that tab will become the active stack
        ## in the main undoGroup, and the undo/redo actions will thus
        ## only affect that tab.
        self.undoManager = QtWidgets.QUndoGroup(self)
        # initialize a map of the undo stacks
        self.undo_stacks: Dict[TAB, QtWidgets.QUndoStack] = {
            TAB.MODTABLE: None,
            TAB.FILETREE: None
        }

        # ---------------------------------------------------
        ## Create an action for clearing mods that cannot be found
        ## from the mods list. Will be shown in the right-click menu
        ## of the mods table when the errors column is visible.
        # noinspection PyArgumentList
        self.action_clear_missing = QtWidgets.QAction(
            "Remove Missing Mods",
            self,
            objectName="action_clear_missing",
            icon=QtGui.QIcon().fromTheme("edit-clear"),
            triggered=self.remove_missing)

        ## Action that will cancel any active asyncio task
        # noinspection PyArgumentList
        self.action_cancel_task = QtWidgets.QAction(
            "Cancel Operation",
            self,
            objectName="action_cancel_task",
            icon=QtGui.QIcon().fromTheme("dialog-cancel"),
            triggered=self.cancel_task)

        # Call the setup methods which do not rely on the data backend
        self._setup_ui_interface()

        # this map is used during 'update_enabled_actions()'
        self._action_components = {
            "mmg": self.mod_movement_group,  # 0
            "atm": self.action_toggle_mod,  # 1
            "asc": self.action_save_changes,  # 2
            "arc": self.action_revert_changes,  # 3
            "afn": self.action_find_next,  # 4
            "afp": self.action_find_previous,  # 5
            "aum": self.action_uninstall_mod,  # 6
            "acm": self.action_clear_missing,
            "asa": self.action_select_all,
            "asn": self.action_select_none,

            # can't use this on the mod table; might be able to use it
            # on the fileview?
            # "asi": self.action_select_inverse,
        }

        # define and read the application settings
        self.init_settings()

        # finally, make sure the right stuff is showing
        self._update_visible_components()

        # self.update_UI()

    @property
    def current_tab(self):
        return self._currtab

    @current_tab.setter
    def current_tab(self, tabnum):
        self._currtab = TAB(tabnum)

    ##=============================================
    ## Application-wide settings management
    ##=============================================

    def init_settings(self):
        """
        Add the necessary properties and callbacks to the AppSettings instance
        """

        groups = (g1, g2) = ["ManagerWindow", "RecentFiles"]

        app_settings.init(*groups)

        ## define the boolean/toggle preferences ##
        app_settings.add(g1, KeyStr_UI.RESTORE_WINSIZE, True)

        app_settings.add(g1, KeyStr_UI.RESTORE_WINPOS, True)

        ## setup window-state prefs ##

        # define some functions to pass as on_read callbacks
        def _resize(size):
            # noinspection PyArgumentList
            self.resize(size if size and app_settings.
                        Get("ManagerWindow", KeyStr_UI.RESTORE_WINSIZE) else
                        QtGui.QGuiApplication.primaryScreen().availableSize() *
                        5 / 7)

        def _move(pos):
            if pos and app_settings.Get("ManagerWindow",
                                        KeyStr_UI.RESTORE_WINPOS):
                self.move(pos)

        # add the properties w/ callbacks
        app_settings.add(g1, "size", self.size, apply=_resize)
        app_settings.add(g1, "pos", self.pos, apply=_move)

        # TODO: handle and prioritize the SMM_PROFILE env var
        app_settings.add(g1, KeyStr_UI.PROFILE_LOAD_POLICY,
                         constants.ProfileLoadPolicy.last.value, int)

        #=================================
        # Keep track of recently-visited
        # folders for file-dialogs
        #---------------------------------

        # keep caches for the "install mod archive" dialog,
        # the "select app folder" dialog(s), and ... what else?
        for dialog in ["installer", "appdirs"]:
            app_settings.add_mru("RecentFiles", dialog, [], str)
        # note from pyqt5 docs: "If the value of the setting is a
        # container (corresponding to either QVariantList,
        # QVariantMap or QVariantHash) then the type is applied to
        # the contents of the container."
        # --which is why we're using `str` as the type

        ## ----------------------------------------------------- ##
        ## Now that we've defined them all, time to read them in ##

        # this will possibly:
        #  a) move the window
        #  b) resize the window
        app_settings.read_and_apply()

    ##===============================================
    ## Setup UI Functionality (called once on first load)
    ##===============================================

    def manager_ready(self):
        """To be called by the app entry point after the modmanager
        has been initialized and registered globally. This starts
        the data-loading part of the program"""

        # make sure that whoever called this isn't lying
        if not Manager():
            self.LOGGER.error("Manager NOT ready!")
            return

        self.LOGGER << "Notified Manager ready"

        # keep local ref
        self.Manager = Manager()

        # connect to signals
        self.Manager.alertsChanged.connect(self.update_alerts)

        # do an initial check of the Manager directories
        self.Manager.check_dirs()

        # perform some setup that requires the manager
        self._setup_data_interface()

        # instantiate installation helper
        self.install_helper = InstallerUI(self)
        self.install_helper.modAdded.connect(self.on_mod_install)
        # when an install manager has been prepared and a dialog is
        # about to shown, stop the activity indicator in the statusbar
        self.install_helper.installerReady.connect(
            self.hide_statusbar_progress)

        # load the initial profile (or not, depending on profile load policy)
        self.profile_helper.load_initial_profile(
            app_settings.Get("ManagerWindow", KeyStr_UI.PROFILE_LOAD_POLICY))

    def _setup_ui_interface(self):
        """
        Calls setup methods which don't rely on any data being loaded.
        For convenience, these methods have been prefixed with '_setupui'
        to indicate their data-independence
        """
        self._setupui_alerts_button()
        self._setupui_toolbar()
        self._setupui_statusbar()
        self._setupui_table()  # must wait for manager for model
        self._setupui_file_tree()

        self._setupui_actions()
        self._setupui_button_connections()
        self._setupui_local_signals_connections()

    def _setup_data_interface(self):
        """
        Called after the mod manager has been assigned; invokes
        setup methods that require the data backend.
        """

        self._setup_profile_selector()
        self._setup_table_model()
        self._setup_files_tab_views()

        # undo manager relies (for now) on the undo stack of both
        # the mod table and file viewer tabs being setup. The fileviewer
        # undostack relies (for now) on a reference to the Mod Manager.
        # Thus, _setup_undo_manager() must be called down here.
        # For now.
        self._setup_undo_manager()

    ##=============================================
    ## Data-independent setup
    ##=============================================
    # region interface setup

    def _setupui_alerts_button(self):
        """
        Adding a drop-down textbox to the toolbar isn't exactly straightforward.
        We need to create a toolbutton that pops up a menu. That menu
        then requires a QWidgetAction added to it that itself has the
        textbox set as its default widget. The toolbutton can then
        be added to the toolbar.
        """

        self.alerts_button = alerts_button.AlertsButton(self.file_toolBar)
        self.alerts_button.setObjectName("alerts_button")

        ## OK...so, since QWidget.setVisible() does not work for items
        ## added to a toolbar with addWidget(?!), we need to save the
        ## action returned by the addWidget() method (who knew?!) and
        ## use its setVisible() &c. methods.
        ## is this returned action the same as "show_popup_action"
        ## of the button?? I have no idea!!!
        self.action_show_alerts = self.file_toolBar.addWidget(
            self.alerts_button)
        self.action_show_alerts.setObjectName("action_show_alerts")

        # initially hide the alerts indicator since there is no manager yet
        self.action_show_alerts.setVisible(False)

    def _setupui_toolbar(self):
        """We've got a few things to add to the toolbar:

        * Profile Selector
        * Add/remove profile buttons
        * change mod-order buttons (up/down/top/bottom)
        """
        self.LOGGER << "_setup_toolbar"

        # Profile selector and add/remove buttons

        # since qtoolbars don't allow spacer widgets, we'll "fake" one
        # with a plain old qwidget.
        # noinspection PyArgumentList
        spacer = QtWidgets.QWidget()
        spacer.setSizePolicy(QtWidgets.QSizePolicy.Expanding,
                             QtWidgets.QSizePolicy.Expanding)

        # add it to the toolbar
        self.file_toolBar.addWidget(spacer)

        # now when we add the profile_group box, it will be right-aligned
        self.file_toolBar.addWidget(self.profile_group)
        self.file_toolBar.addActions(
            [self.action_new_profile, self.action_delete_profile])

        # Action Group for the mod-movement buttons.
        # this just makes it easier to enable/disable them all at once
        # mmag => "Mod Movement Action Group"
        mmag = self.mod_movement_group = QtWidgets.QActionGroup(self)

        # mact => "Movement ACTion"
        macts = [
            self.action_move_mod_to_top,
            self.action_move_mod_up,
            self.action_move_mod_down,
            self.action_move_mod_to_bottom,
        ]

        mmag.setExclusive(False)
        for a in macts:
            mmag.addAction(a)

        # let's actually make a new, vertical toolbar
        # for these and add it to the side of the mods table.
        movement_toolbar = QtWidgets.QToolBar(self.installed_mods_tab)
        movement_toolbar.setOrientation(Qt.Vertical)
        ## Note to : adding it to the left of the table never worked,
        ## for some reason...it always overlapped the table. Managed
        ## to get it placed on the right, though.
        self.installed_mods_layout.addWidget(
            movement_toolbar, 1, self.installed_mods_layout.columnCount(), -1,
            1)
        movement_toolbar.addActions(macts)

        self.movement_toolbar = movement_toolbar

        ## This is for testing the progress indicator::
        # show_busybar_action = QAction("busy",self)
        # show_busybar_action.triggered.connect(self.show_statusbar_progress)
        # self.file_toolBar.addAction(show_busybar_action)

    def _setupui_statusbar(self):
        """
        Add a progress bar to the status bar. Will be used for showing
        progress or activity of long-running processes.
        """
        self.LOGGER << "_setup_statusbar"

        # putting the bar and label together into a container
        # widget caused the 'busy' animation not to play...
        # I never did figure out why, but adding them separately
        # bypasses the issue.
        self.sb_progress_label = QtWidgets.QLabel("Working:", self)
        self.sb_progress_bar = QtWidgets.QProgressBar(self)
        self.sb_progress_bar.setMaximumWidth(100)

        # cancel button
        self.sb_progress_cancel_btn = QtWidgets.QToolButton(self)
        self.sb_progress_cancel_btn.setIcon(
            QtGui.QIcon().fromTheme('dialog-cancel'))
        self.sb_progress_cancel_btn.setToolTip("Cancel Operation")
        # connect click to cancel_task action
        self.sb_progress_cancel_btn.clicked.connect(
            self.action_cancel_task.trigger)

        # button first, then label, then bar
        self.status_bar.addPermanentWidget(self.sb_progress_cancel_btn)
        self.status_bar.addPermanentWidget(self.sb_progress_label)
        self.status_bar.addPermanentWidget(self.sb_progress_bar)
        self.sb_progress_cancel_btn.setVisible(False)
        self.sb_progress_label.setVisible(False)
        self.sb_progress_bar.setVisible(False)

    def _setupui_table(self):
        """
        Prepare the mods-display table and related functionality
        """
        self.LOGGER << "_setup_table_UI"

        self.mod_table.setupui(self.modtable_search_box)

        # handler for [dis|en]abling the search actions
        @pyqtSlot()
        def on_enable_searchactions(enable):
            self.action_find_next.setEnabled(enable)
            self.action_find_previous.setEnabled(enable)

        # connect signals from table
        self.mod_table.enableSearchActions.connect(on_enable_searchactions)
        self.mod_table.setStatusMessage.connect(
            self.on_status_text_change_request)

        # we don't actually use this yet...
        self.filters_dropdown.setVisible(False)

    def _setupui_file_tree(self):
        """
        Setup the sizes of the file-viewer mod selector and tree view.
        Models will be set up later.
        """

        self.LOGGER.debug("_setup_file_tree")

        self._filetreesplitter.setSizes(
            [1, 500])  # just make the left one smaller ok?

        # todo: remember user column resizes

    def _setupui_actions(self):
        """
        Connect all the actions to their appropriate slots/whatevers;
        also set some shortcut sequences
        """

        self.LOGGER << "_setup_actions"

        # create containers to enable easily setting up connections/
        # shortcuts via simple loops

        ## tuple(action, call_on_trigger)
        connections = [
            (self.action_new_profile,
             self.profile_helper.on_new_profile_action),
            (self.action_delete_profile,
             self.profile_helper.on_remove_profile_action),
            (self.action_rename_profile,
             self.profile_helper.on_rename_profile_action),
            (self.action_preferences, self.edit_preferences),
            (self.action_quit, self.close),
            (self.action_install_mod, self.install_mod_archive),
            (self.action_manual_install, partial(self.install_mod_archive,
                                                 True)),
            (self.action_reinstall_mod, self.reinstall_mod),
            (self.action_uninstall_mod, self.uninstall_mod),
            # (self.action_choose_mod_folder  , self.choose_mod_folder),
            (self.action_toggle_mod, self.mod_table.toggle_selection_checkstate
             ),
            (self.action_save_changes, self.on_save_command),
            (self.action_revert_changes, self.on_revert_command),
            (self.action_move_mod_up, partial(self.moveMods.emit, -1)),
            (self.action_move_mod_down, partial(self.moveMods.emit, 1)),
            (self.action_move_mod_to_top, self.moveModsToTop.emit),
            (self.action_move_mod_to_bottom, self.moveModsToBottom.emit),
            (self.action_find_next, partial(self.mod_table.on_table_search,
                                            1)),
            (self.action_find_previous,
             partial(self.mod_table.on_table_search, -1)),
            (self.action_select_all, self.on_select_all),
            (self.action_select_none, self.on_select_none),
            (self.action_show_in_file_manager, self.open_dir_in_fm)
        ]

        qks = QtGui.QKeySequence
        # tuple(action, shortcut-sequence)
        shortcuts = [
            (self.action_quit, qks.Quit),
            (self.action_save_changes, qks.Save),
            (self.action_show_search, qks.Find),
            (self.action_find_next, qks.FindNext),
            (self.action_find_previous, qks.FindPrevious),
            (self.action_select_all, qks.SelectAll),
            (self.action_select_none, qks.Deselect),
        ]

        ################################################
        # connect all action triggers
        for action, slot in connections:
            action.triggered.connect(slot)

        # setup shortcuts
        for action, shortcut in shortcuts:
            action.setShortcut(shortcut)

        ################################################

        # self.action_choose_mod_folder.setIcon(icons.get(
        # 'folder', color_disabled=QPalette().color(QPalette.Midlight)))

        ## clear missing-from-disk mods
        # add to movement toolbar
        # self.movement_toolbar.addSeparator()
        # self.movement_toolbar.addAction(self.action_clear_missing)

        # should only show when the errors column is visible, but lets
        # make sure its only active when at least one of the
        # mods has a DIR_NOT_FOUND error
        self.mod_table.errorsChanged.connect(
            lambda e: self.action_clear_missing.setEnabled(
                bool(e & constants.ModError.DIR_NOT_FOUND)))

    def _setupui_button_connections(self):
        """ Make the buttons do stuff
        """
        self.LOGGER << "_setup_buttons"

        # use a dialog-button-box for save/cancel;
        # have to specify by standard button type
        btn_apply: QtWidgets.QPushButton = self.save_cancel_btnbox.button(
            QtWidgets.QDialogButtonBox.Apply)
        btn_reset: QtWidgets.QPushButton = self.save_cancel_btnbox.button(
            QtWidgets.QDialogButtonBox.Reset)

        # connect the apply button to the 'save-changes' action
        btn_apply.clicked.connect(self.action_save_changes.trigger)

        # enabled/disable the save/cancel buttons based
        # on the status of the save-changes action
        self.action_save_changes.changed.connect(
            lambda: self.save_cancel_btnbox.setEnabled(self.action_save_changes
                                                       .isEnabled()))

        # connect reset button to the revert action
        btn_reset.clicked.connect(self.action_revert_changes.trigger)

        # connect search button to expanding/collapsing the search box
        self.modtable_search_button.clicked.connect(
            self.mod_table.toggle_search_box)

    # inspector complains about alleged lack of "connect" function
    # noinspection PyUnresolvedReferences
    def _setupui_local_signals_connections(self):
        """
        Connect signals to slots, whether they're local (an attribute
        of the main window) or non-local (part of a sub-component)

        """
        self.LOGGER << "_setup_signals_and_slots"

        tbl = self.mod_table
        prof = self.profile_helper

        connections = [

            ## local signals -> local slots ##
            ##-----

            ## local signals -> non-local (on other components) slots ##
            ## -----
            # connect the move up/down signal to the appropriate slot on
            # view; same for the move-to-top/-bottom signals
            (self.moveMods, tbl.move_selection),
            (self.moveModsToBottom, tbl.move_selection_to_bottom),
            (self.moveModsToTop, tbl.move_selection_to_top),

            ## non-local signals -> local slots ##
            ## -----

            # listen to profile helper for new profile
            (prof.newProfileLoaded, self.on_profile_load),
            # enable/disable rename/remove-profile actions as needed
            (prof.enableProfileActions, self.update_profile_actions),

            # ensure the UI is properly updated when the tab changes
            (self.manager_tabs.currentChanged, self.on_tab_changed),

            # depending on selection in table, the movement actions will
            # be enabled or disabled
            (tbl.enableModActions, self.on_make_or_clear_mod_selection),
            (tbl.canMoveItems, self._enable_mod_move_actions),
        ]

        for signal, slot in connections:
            signal.connect(slot)

    # endregion

    ##=============================================
    ## Data-dependent setup
    ##=============================================

    # region data provider setup

    def _setup_profile_selector(self):
        """
        Initialize the dropdown list for selecting profiles with the
        names of the profiles found on disk
        """
        self.LOGGER.debug("_setup_profile_selector")

        self.profile_helper.setup(self.Manager, self.profile_selector)

    def _setup_table_model(self):
        """
        Initialize the model for the mods table
        """
        tbl_model = models.ModTable_TreeModel(parent=self.mod_table,
                                              manager=self.Manager)

        self.mod_table.setModel(tbl_model)

        self.models[M.mod_table] = tbl_model

    def _setup_files_tab_views(self):
        """
        Initialize the models and filters used on the file tree tab
        """
        ## Mods List
        self.filetree_modlist.setup(self.models[M.mod_table],
                                    self.filetree_listlabel,
                                    self.filetree_activeonlytoggle,
                                    self.filetree_modfilter)

        ## File Viewer
        self.filetree_fileviewer.setup(self.filetree_modlist,
                                       self.filetree_filefilter)

    def _setup_undo_manager(self):
        """
        Setup the main QUndoGroup that we will use to manage all the
        undo stacks in the app (all TWO of them so far).

        """
        # create and configure undo action
        self.action_undo = self.undoManager.createUndoAction(self, "Undo")
        self.action_undo.pyqtConfigure(
            shortcut=QtGui.QKeySequence.Undo,
            # icon=icons.get(
            #     "undo", scale_factor=0.85,
            #     offset=(0, 0.1)),
            icon=QtGui.QIcon().fromTheme("edit-undo")
            # , triggered=self.on_undo
        )

        # it seems it calls undoStack.undo() automatically...no need
        # to connect to something that calls it manually...unless you
        # want every undo/redo action to do that action twice...like
        # it was.

        # create and configure redo action
        self.action_redo = self.undoManager.createRedoAction(self, "Redo")
        self.action_redo.pyqtConfigure(
            shortcut=QtGui.QKeySequence.Redo,
            # icon=icons.get(
            #     "redo", scale_factor=0.85,
            #     offset=(0, 0.1)),
            icon=QtGui.QIcon().fromTheme("edit-redo")
            # , triggered=self.on_redo
        )

        # insert into the "Edit" menu before the save-changes entry
        self.menu_edit.insertActions(self.action_save_changes,
                                     [self.action_undo, self.action_redo])

        # insert into the toolbar before the preferences entry
        self.file_toolBar.insertActions(self.action_preferences,
                                        [self.action_undo, self.action_redo])
        #now add separator between these and the preferences btn
        self.file_toolBar.insertSeparator(self.action_preferences)

        # add stacks
        self.undoManager.addStack(self.mod_table.undo_stack)
        self.undo_stacks[TAB.MODTABLE] = self.mod_table.undo_stack

        self.undo_stacks[TAB.FILETREE] = self.filetree_fileviewer.undo_stack
        self.undoManager.addStack(self.undo_stacks[TAB.FILETREE])

        # noinspection PyUnresolvedReferences
        self.undoManager.cleanChanged.connect(self.on_table_clean_changed)

        # self.undoView = QtWidgets.QUndoView(self.undoManager)
        # self.undoView.show()
        # self.undoView.setAttribute(Qt.WA_QuitOnClose, False)

    # endregion

    ##=============================================
    ## Event Handlers/Slots
    ##=============================================

    # region EventHandlers

    @pyqtSlot(int)
    def on_tab_changed(self, newindex):
        """
        When the user switches tabs, make sure the proper GUI components
        are visible and active

        :param int newindex:
        """
        self.current_tab = TAB(newindex)

        # ensure proper UI state for current tab
        self.update_UI()

    @pyqtSlot('QString')
    def on_profile_load(self, profile_name):
        """
        Call with the name of the selected profile from the profile-
        selector combobox. Update the proper parts of the UI for the
        new information.

        :param str profile_name:
        """
        ## Reset the views
        self.mod_table.reset_view()  # this also loads the new data
        self.filetree_modlist.reset_view()
        self.filetree_fileviewer.reset_view()

        # update the UI components for the current tab/profile/data
        self.update_UI()

        # also recheck alerts when loading new profile
        # self.update_alerts()

    @pyqtSlot(bool)
    def on_make_or_clear_mod_selection(self, has_selection):
        """
        Enable or disable buttons and actions that rely on having a
        selection in the mod table.
        """
        for a in (self.mod_movement_group, self.action_uninstall_mod,
                  self.action_reinstall_mod, self.action_toggle_mod,
                  self.action_select_none):
            a.setEnabled(has_selection)

    @pyqtSlot(bool)
    def on_table_clean_changed(self, clean):
        """
        When a change is made to the table __that takes it from a
        clean-save-state to a state w/ unsaved changes__, or vice versa,
        enable or disable certain actions depending on it's clean-vs.-
        unsaved status.

        :param bool clean: whether there are unsaved changes
        """

        for widgy in [
                self.save_cancel_btnbox, self.action_save_changes,
                self.action_revert_changes
        ]:
            widgy.setEnabled(not clean)

    @pyqtSlot()
    def on_save_command(self):
        """
        Save command does different things depending on which
        tab is active.
        """
        if self.current_tab == TAB.MODTABLE:
            self.mod_table.save_changes()
        elif self.current_tab == TAB.FILETREE:
            self.filetree_fileviewer.save()

    @pyqtSlot()
    def on_revert_command(self):
        """
        Undo all changes made to the table since the last savepoint
        """

        if self.current_tab == TAB.MODTABLE:
            self.mod_table.revert_changes()

        elif self.current_tab == TAB.FILETREE:
            self.filetree_fileviewer.revert()

    @pyqtSlot()
    def on_select_all(self):
        if self.current_tab == TAB.MODTABLE:
            self.mod_table.selectAll()

    @pyqtSlot()
    def on_select_none(self):
        """Deselect"""
        if self.current_tab == TAB.MODTABLE:
            self.mod_table.clearSelection()

    @pyqtSlot(str)
    def on_mod_install(self, mod_key):
        """Add a newly-installed mod to the mod table. The mod must be
        a managed mod (i.e., it must be in the main Mod-repo"""

        # get the mod entry from the manager (which also adds the mod
        # and its files to the appropriate tables) add it to the mod-
        # table model. When the model is done inserting, the table will
        # save the current collection and discard the undo-history
        # for the mod table.
        self.mod_table.model().add_mod(
            self.Manager.load_newly_installed_mod(mod_key))

    # endregion

    ##=============================================
    ## Statusbar operations
    ##=============================================
    # region statusbar

    @pyqtSlot(str)
    def on_status_text_change_request(self, text):
        """Allow other components to request updating the status text"""
        if text:
            self.status_bar.showMessage(text)
        else:
            self.status_bar.clearMessage()

    def show_statusbar_progress(self,
                                text="Working:",
                                minimum=0,
                                maximum=0,
                                show_bar_text=False):
        """
        Set up and display the small progress bar on the bottom right
        of the window (in the status bar). If `minimum` == `maximum`
        == 0, the bar will be in indeterminate ('busy') mode: this is
        useful for indicating to the user that *something* is going on
        in the background during activities that may take a moment or
        two to complete, so the user need not worry that their last
        command had no effect.

        :param text: Text that will be shown to the left of the
            progress bar
        :param minimum: Minumum value for the bar
        :param maximum: Maximum value for the bar
        :param show_bar_text: Whether to show the bar's text
            (% done by default)
        """
        self.sb_progress_label.setText(text)
        self.sb_progress_bar.reset()
        self.sb_progress_bar.setRange(minimum, maximum)
        self.sb_progress_bar.setTextVisible(show_bar_text)

        self.sb_progress_cancel_btn.setVisible(True)
        self.sb_progress_label.setVisible(True)
        self.sb_progress_bar.setVisible(True)

    def update_statusbar_progress(self, value, labeltext=None):
        """
        Set the status-progress-bar's value to `value`. If provided,
        also change the label to `labeltext`; otherwise leave the
        label as is. This method can be used as a callback.

        :param value:
        :param labeltext:
        :return:
        """
        self.sb_progress_bar.setValue(value)
        if labeltext is not None:
            self.sb_progress_label.setText(labeltext)

    def hide_statusbar_progress(self):
        """
        Make the statusbar-progress go away.
        """
        self.sb_progress_cancel_btn.setVisible(False)
        self.sb_progress_bar.setVisible(False)
        self.sb_progress_label.setVisible(False)

    @pyqtSlot()
    def cancel_task(self):
        """Cancels the active task."""
        self.LOGGER << "Cancelling task..."

        self.task.cancel()
        # hide the activity indicator if it's visible
        self.hide_statusbar_progress()

    # endregion

    ##===============================================
    ## UI Helper Functions
    ##===============================================

    # region update UI

    def update_alerts(self):
        """
        Just populates the alerts-dropdown with the contents of the
        main Manager's alerts collection. Does not request any checks.
        """

        # self.LOGGER << "update_alerts"

        # clear the list
        self.alerts_button.clear_widget()

        if self.Manager.has_alerts:

            self.alerts_button.update_widget(self.Manager.alerts)

            self.LOGGER << "Show alerts indicator"
            self.action_show_alerts.setVisible(True)

            # readjust drop-down menu size to fit items
            self.alerts_button.adjust_display_size()

        else:
            self.LOGGER << "Hide alerts indicator"
            # have to hide using action, not button
            # (See docs for qtoolbar.addWidget...)
            self.action_show_alerts.setVisible(False)

    # def update_UI(self, *args):
    def update_UI(self):
        """Ensure the UI has the appropriate parts active/visible for
        the current tab and data state."""
        self._update_visible_components()
        self._update_enabled_actions()

        # also change the current undo stack
        self._update_active_undostack()

    def _update_visible_components(self):
        """
        Some manager components should be hidden on certain tabs
        """
        all_components = [
            self.save_cancel_btnbox,  # 0
            self.next_button,  # 1
            self.modtable_search_button,  # 2
            self.modtable_search_box,  # 3
        ]

        # selector defining the visible components for each tab
        visible = {
            TAB.MODTABLE: [1, 0, 1, 1],
            TAB.FILETREE: [1, 0, 0, 0],
        }

        for comp, isvis in zip(all_components, visible[self.current_tab]):
            comp.setVisible(isvis)

    def _update_enabled_actions(self):
        """
        Some manager actions should be disabled on certain tabs
        """
        # use pre-constructed mapping of actions we want to consider
        all_components = self._action_components

        # this is a selector that, depending on how it is
        # modified below, will allow us to set every
        # component to its appropriate enabled state
        s = {c: False for c in all_components}

        if self.current_tab == TAB.MODTABLE:
            s["asa"] = self.mod_table.item_count > 0
            s["mmg"] = s["atm"] = s["aum"] = s[
                "asn"] = self.mod_table.has_selection
            s["asc"] = s["arc"] = not self.undoManager.isClean()
            s["afn"] = s["afp"] = bool(self.mod_table.search_text)
            s["acm"] = bool(self.mod_table.errors_present
                            & constants.ModError.DIR_NOT_FOUND)
        elif self.current_tab == TAB.FILETREE:
            s["asc"] = s["arc"] = self.filetree_fileviewer.has_unsaved_changes

        for comp, select in s.items():
            all_components[comp].setEnabled(select)

    def _update_active_undostack(self):
        """Set the active undo stack to the stack associated with
        the current tab"""
        self.undoManager.setActiveStack(self.undo_stacks[self.current_tab])

    def _enable_mod_move_actions(self, enable_moveup, enable_movedown):
        """
        Enable or disable the mod-movement actions

        :param bool enable_moveup: whether to enable the
            move-up/move-to-top actions
        :param bool enable_movedown: whether to enable the
            move-down/move-to-bottom actions
        """
        for action in [
                self.action_move_mod_to_bottom, self.action_move_mod_down
        ]:
            action.setEnabled(enable_movedown)

        for action in [self.action_move_mod_to_top, self.action_move_mod_up]:
            action.setEnabled(enable_moveup)

    # todo: change window title (or something) to reflect current folder
    # def on_filetree_fileviewer_rootpathchanged(self, newpath):
    #

    @pyqtSlot(bool, str, bool)
    def update_profile_actions(self, enable_remove, remove_tooltip,
                               enable_rename):
        """

        :param bool enable_remove: whether to enable the 'delete profile' button
        :param str remove_tooltip: tooltip for the delete profile button
        :param bool enable_rename: whether to enable the 'rename profile' button
        """

        self.action_delete_profile.setEnabled(enable_remove)
        self.action_delete_profile.setToolTip(remove_tooltip)
        self.action_rename_profile.setEnabled(enable_rename)

    # endregion

    def table_prompt_if_unsaved(self):
        """
        Check for unsaved changes to the mods list and show a prompt if
        any are found. Clicking yes will save the changes and mark the
        table clean, while clicking no will simply disable the apply/
        revert buttons as IF the table were clean. This is because
        this is intended to be used right before an action like loading
        a new profile (thus forcing a full table reset) or quitting the
        app.

        :return: the value of the button the user clicked
            (QMessageBox.[Yes/No/Cancel]), or None if the message box
            was not shown
        """
        # check for unsaved changes to the mod-list
        if self.Manager.profile is not None \
                and not self.mod_table.undo_stack.isClean():
            ok = QMessageBox(
                QMessageBox.Warning, 'Unsaved Changes',
                'Your mod install-order has unsaved '
                'changes. Would you like to save them '
                'before continuing?',
                QMessageBox.No | QMessageBox.Yes | QMessageBox.Cancel).exec_()

            if ok == QMessageBox.Yes:
                self.mod_table.save_changes()
            return ok
        # if clean, return None to indicate that the calling operation
        # may contine as normal
        return None

    ###=============================================
    ## Actions
    ## ---------------------------------------------
    ## stuff the user can do; available as slots for
    ## signals to connect to
    ###=============================================

    # region action slots

    @pyqtSlot()
    def edit_preferences(self):
        """
        Show a dialog allowing the user to change some application-wide
        preferences
        """
        from skymodman.interface.dialogs.preferences_dialog \
            import PreferencesDialog

        pdialog = PreferencesDialog(self.profile_helper.model,
                                    self.profile_helper.current_index)

        # connect some of the dialog's signals to the data managers

        pdialog.beginModifyPaths.connect(self.Manager.begin_queue_signals)
        pdialog.endModifyPaths.connect(self.Manager.end_queue_signals)

        pdialog.exec_()

        del PreferencesDialog

        # now have the Manager check to see if all the directories
        # are valid, and update the alerts indicator if needed
        ## XXX: this should happen automatically now...hopefully
        # self.Manager.check_dirs()
        # self.update_alerts()

    # @pyqtSlot()
    # def choose_mod_folder(self):
    #     """
    #     Show dialog allowing user to choose a mod folder.
    #
    #     This updates the default mod folder. If a profile override is
    #     active, it will be disabled. Use the preferences dialog
    #     to set up and enable a profile-specific override.
    #
    #     """
    #
    #     # noinspection PyTypeChecker, PyArgumentList
    #     moddir = QtWidgets.QFileDialog.getExistingDirectory(
    #         self,
    #         "Choose Directory Containing Installed Mods",
    #         self.Manager.Folders['mods'].spath
    #     )
    #
    #     # update config with new path
    #     if check_path(moddir):
    #         mfolder = self.Manager.Folders[KeyStr_Dirs.MODS]
    #
    #         mfolder.set_path(moddir)
    #
    #         if mfolder.is_overriden:
    #             mfolder.remove_override()
    #             self.Manager.profile.disable_override(KeyStr_Dirs.MODS)
    #
    #         # reverify and reload the mods.
    #         if not self.Manager.validate_mod_installs():
    #             self.mod_table.model().reload_errors_only()

    # noinspection PyTypeChecker,PyArgumentList
    def install_mod_archive(self, manual=False):
        """
        Install a mod from an archive.

        :param bool manual: If false, attempt to use
        the guided FOMOD installer if a fomod config is found, otherwise
        simply unpack the archive. If true, show the file-system view of
        the archive and allow the user to choose which parts to install.

        """
        from PyQt5.QtWidgets import QFileDialog
        from PyQt5.QtCore import QDir

        filepath = QFileDialog.getOpenFileName(
            self,
            "Select Mod Archive",
            # start in use most-recently accessed directory
            # (or home dir if none have been recorded)
            app_settings.Get("RecentFiles", "installer") or QDir.homePath(),
            # QDir.currentPath() + "/res",
            filter="Archives [zip, 7z, rar] (*.zip *.7z *.rar);;All Files(*)"
        )[0]

        # short-circuit for testing
        # filename='res/7ztest.7z'
        if filepath:
            directory = os.path.dirname(filepath)
            # archive_name = os.path.splitext(os.path.basename(filename))[0]

            # Add the containing directory to the MRU-list
            app_settings.Set("RecentFiles", "installer", directory)

            # check that the given mod does not already exist
            # if archive_name.lower() not in self.Manager.managed_mod_folders:
            # installui = InstallerUI(self.Manager) # helper class

            ##-> we can't check for existence here because the mod name
            # is not always the same/similar to the archive name

            if manual:
                # show busy indicator while archive is examined
                self.show_statusbar_progress("Loading archive:")
            else:
                # show busy indicator while installer loads
                self.show_statusbar_progress("Preparing installer:")

            self.task = asyncio.get_event_loop().create_task(
                self.install_helper.do_install(filepath, manual))
            # else:
            # tODO: inform the user and offer reinstall/cancel options
            # self.LOGGER.warning("Mod {0!r} already installed!".format(archive_name))

        del QFileDialog
        del QDir

    def reinstall_mod(self):
        """
        Repeat the installation process for the given mod
        """
        # todo: implement re-running the installer
        row = self.mod_table.currentIndex().row()
        if row > -1:
            # mod = self.models[M.mod_table][row]
            self.LOGGER << "Here's where we'd reinstall this mod."

    def uninstall_mod(self):
        """
        Remove the selected mod from the virtual installation directory
        """
        # todo: implement removing the mod
        row = self.mod_table.currentIndex().row()
        if row > -1:
            # mod = self.models[M.mod_table][row]
            self.LOGGER << "Here's where we'd uninstall this mod."

    def open_dir_in_fm(self):
        """
        Open the selected mod's directory in the default file manager
        """

        # XXX: should this use the selected index? (And thus not work w.o a selection?)
        mod = self.mod_table.model().get_mod_for_index(
            self.mod_table.currentIndex())

        if mod.managed:
            # launch and forget
            # note:: obviously, this only works on linux (maybe bsd?).
            # I probably don't care.
            try:
                subprocess.Popen([
                    "xdg-open",
                    os.path.join(self.Manager.Folders['mods'].spath,
                                 mod.directory)
                ])
            except FileNotFoundError:
                # thrown when the command (xdg-open) cannot be found
                self.LOGGER.error("Cannot find 'xdg-open' executable")
        else:
            self.LOGGER.warning("Cannot open directory for unmanaged mod")
            # TODO: open directory for unmanaged mod

    @pyqtSlot()
    def remove_missing(self):
        """
        Remove all mod entries that were not found on disk from the
        current profile's mod list
        """
        ## FIXME: make sure the mod-count on the file tree mods-list is updated correctly to show the correct new value for the number of known mods
        self.LOGGER << "Clear missing mods"

        self.mod_table.clear_missing_mods()

    # endregion

    ##=============================================
    ## Qt Overrides
    ##=============================================

    def closeEvent(self, event):
        """
        Override close event to check for unsaved changes and to save
        settings to disk

        :param event:
        """

        # only ignore the close event if the user clicks cancel
        # on the confirm window
        if self.table_prompt_if_unsaved() == QMessageBox.Cancel:
            event.ignore()
        else:
            # self.write_settings()
            # TODO: save profile-specific settings here as well (such
            # as the active-only checkbox) instead of on each change.
            app_settings.write()
            event.accept()
Beispiel #17
0
class ModFileTreeModel(QAbstractItemModel):
    """
    A custom model that presents a view into the actual files saved
    within a mod's folder. It is vastly simplified compared to the
    QFileSystemModel, and only supports editing the state of the
    checkbox on each file or folder (though there is some neat trickery
    that propagates a check-action on a directory to all of its
    descendants)
    """

    #TODO: calculate and inform the user of any file-conflicts that will occur in their mod-setup to help them decide what needs to be hidden.

    # rootPathChanged = pyqtSignal(str)

    def __init__(self, parent, **kwargs):
        """

        :param parent: parent widget (specifically, the file-viewer QTreeView)
        """
        # noinspection PyArgumentList
        super().__init__(parent=parent, **kwargs)
        self._parent = parent
        self.manager = Manager()  # should be initialized by now
        self.modname = None  #type: str
        self.rootitem = None  #type: QFSItem

        self.mod = None
        """:type: skymodman.types.ModEntry"""

        # maintain a flattened list of the files for the current mod
        self._files = []  # type: list [QFSItem]

        # set of hidden files for the current 'clean state' of the tree
        # (should correspond to entries in "hiddenfiles" db table)
        self._saved_state = set()

        self.command_queue = deque()

    def setMod(self, mod_entry):
        """Set the mod that this model is focusing on to `mod_entry`.
        Pass ``None`` to reset the model to empty"""

        # clear the index-cache
        self._locate.cache_clear()

        # tells the view to get ready to redisplay its contents
        self.beginResetModel()
        self.mod = mod_entry

        if mod_entry is None:  # reset Model to show nothing
            self.rootitem = None
            self.modname = None

        else:
            # the mod's _unique_ name
            self.modname = self.mod.directory

            self._setup_or_reload_tree()

        # tells the view it should get new
        # data from model & reset itself
        self.endResetModel()

    @property
    def root_item(self):
        return self.rootitem

    @property
    def current_hidden_file_indices(self):
        """Rather than querying the database, this examines the current
        state of the FSItems in the internal _files list"""
        return [i for i, item in enumerate(self._files) if item.hidden]

    def _setup_or_reload_tree(self):
        """
        Loads thde data from the db and disk
        """
        self._load_tree()

        # now mark hidden files
        self._mark_hidden_files(self._saved_state)

        # this used to call resetModel() stuff, too, but I decided
        # this wasn't the place for that. It's a little barren now...

    def _load_tree(self):
        """
        Build the tree from the rootitem
        """
        # name for this item is never actually seen
        self.rootitem = QFSItem(path="", name="data", parent=None)

        # build yonder tree
        QFSItem.build_filetree(self.rootitem,
                               self.mod.filetree,
                               name_filter=lambda n: n.lower() == "meta.ini")

        # create flattened list of just the files
        self._files = [
            f for f in self.rootitem.iterchildren(recursive=True)
            if not f.isdir
        ]

        # reset the "saved state" (indices of hidden files on load)
        self._saved_state = self._get_hidden_file_indices()

    @lru_cache()
    def _locate(self, file):
        """Given an FSItem or a file path (str), return the index of
        that item (or the item with that path) in the flattened file
        list"""

        # perform a binary search for the file/path
        i = bisect_left(self._files, file)

        # make sure the index returned is of the exact item searched for
        try:
            if self._files[i] == file:
                return i
        except IndexError:
            # this will only happen if 'file' doesn't exist in list
            # and would have come after the final item, so `i` will
            # be equal to len(self._files). Point is, file wasn't there
            pass

        raise ValueError

    def _get_hidden_file_indices(self):
        """Get the set of currently hidden files from the database
        and return a list of the indices corresponding to those files
        in self._files"""

        hidden = set()

        # the alternative to this (searching for each file by path to
        # get index) would be to do some sort of...math...or something
        # with the start/end index of the mod's entries in the database,
        # referencing position of each file returned...
        # but ehhhhhhhh, math.

        for hf in self.manager.hidden_files_for_mod(self.mod.directory):
            # NTS: As the number of hidden files approaches the total
            # number of files in the mod, this bin search algo becomes
            # less and less efficient compared to just going through
            # the loop linearly once. Might be a moot point, though,
            # as hiding the vast majority of files in a mod seems a
            # very unlikely thing to want to do.
            try:
                hidden.add(self._locate(hf))
            except ValueError:
                self.LOGGER.error(f"Hidden file {hf!r} was not found")

        return hidden

    def _mark_hidden_files(self, hidden_file_indices):

        # note:: if there is an IndexError here, THE WORLD WILL BURN
        for idx in hidden_file_indices:
            # use the internal _set_checkstate to avoid the
            # parent.child_state invalidation step
            self._files[idx]._set_checkstate(Qt_Unchecked, False)

    def getitem(self, index) -> QFSItem:
        """Extracts actual item from given index

        :param QModelIndex index:
        """
        if index.isValid():
            item = index.internalPointer()
            if item: return item
        return self.rootitem

    def columnCount(self, *args, **kwargs) -> int:
        """Dir/File Name(+checkbox), path to file, file conflicts """
        # return 2
        return len(COLUMNS)

    def rowCount(self, index=QModelIndex(), *args, **kwargs) -> int:
        """Number of children contained by the item referenced by `index`

        :param QModelIndex index:
        """
        # return 0 until we actually have something to show
        return self.getitem(index).child_count if self.rootitem else 0

    def headerData(self, section, orient, role=None):
        """super() call should take care of the
        size hints &c.

        :param int section:
        :param orient:
        :param role:
        """
        if orient == Qt.Horizontal and role == Qt_DisplayRole:
            return ColHeaders[section]
        return super().headerData(section, orient, role)

    def index(self, row, col, parent=QModelIndex(), *args, **kwargs):
        """

        :param int row:
        :param int col:
        :param QModelIndex parent:
        :return: the QModelIndex that represents the item at (row, col)
            with respect to the given  parent index. (or the root index
            if parent is invalid)
        """

        if parent.isValid():
            parent_item = parent.internalPointer()
        else:
            parent_item = self.rootitem

        child = parent_item[row]
        if child:
            return self.createIndex(row, col, child)

        return QModelIndex()

    def getIndexFromItem(self, item) -> QModelIndex:
        return self.createIndex(item.row, 0, item)

    # handle the 'parent' overload w/ the next two slots
    @pyqtSlot('QModelIndex', name="parent", result='QModelIndex')
    def parent(self, child_index=QModelIndex()):

        if not child_index.isValid():
            return QModelIndex()

        # get the parent FSItem from the reference stored in each FSItem
        parent = child_index.internalPointer().parent

        if not parent or parent is self.rootitem:
            return QModelIndex()

        # Every FSItem has a row attribute
        # which we use to create the index
        return self.createIndex(parent.row, 0, parent)

    @pyqtSlot(name='parent', result='QObject')
    def parent_of_self(self):
        return self._parent

    def flags(self, index):
        """
        Flags are held at the item level; lookup and return them from
        the item referred to by the index

        :param QModelIndex index:
        """
        return self.getitem(index).itemflags

    def data(self, index, role=Qt.DisplayRole):
        """
        We handle DisplayRole to return the filename, CheckStateRole to
        indicate whether the file has been hidden, and Decoration Role
        to return different icons for folders and files.

        :param QModelIndex index:
        :param role:
        """

        item = self.getitem(index)
        col = index.column()

        if role == Qt_DisplayRole:
            if col == COL_PATH:
                return item.parent.path + "/"
            elif col == COL_NAME:
                return item.name
            else:  # column must be "Conflicts"
                try:
                    # TODO: provide a way (perhaps a drop-down list on the Conflicts column) to easily identify and navigate to the other mods containing a conflicting file
                    if item.lpath in self.manager.file_conflicts.by_mod[
                            self.modname]:
                        return "Yes"
                # if the mod was not in the conflict map,
                # then return none
                except KeyError:
                    return None

        # if it's not the display role, we only care about the name column
        elif col == COL_NAME:
            if role == Qt_CheckStateRole:
                # hides the complexity of the tristate workings
                return item.checkState
            elif role == Qt_DecorationRole:
                return item.icon

    def setData(self, index, value, role=Qt_CheckStateRole):
        """Only the checkStateRole can be edited in this model.
        Most of the machinery for that is in the QFSItem class

        :param QModelIndex index:
        :param value:
        :param role:
        """
        if not index.isValid():
            return False

        item = self.getitem(index)

        if role == Qt_CheckStateRole:

            if item.isdir:
                cmd = HideDirectoryCommand(item, self, value == Qt_Unchecked)
            else:
                cmd = HideFileCommand(item, self)

            self.queue_command(cmd)

            return True
        return super().setData(index, value, role)

    # noinspection PyUnresolvedReferences
    def _send_data_through_proxy(self, index1, index2, *args):
        proxy = self._parent.model()  #QSortFilterProxyModel

        # if the two QModelIndexes are the same
        if index1 is index2:
            pindex = proxy.mapFromSource(index1)
            proxy.dataChanged.emit(pindex, pindex, *args)
        else:
            proxy.dataChanged.emit(proxy.mapFromSource(index1),
                                   proxy.mapFromSource(index2), *args)

    def emit_dataChanged(self, index1, index2):
        self._send_data_through_proxy(index1, index2)

    def emit_itemDataChanged(self, item_topleft, item_botright):

        if item_topleft is item_botright:
            iindex = self.getIndexFromItem(item_topleft)
            self._send_data_through_proxy(iindex, iindex)
        else:
            self._send_data_through_proxy(self.getIndexFromItem(item_topleft),
                                          self.getIndexFromItem(item_botright))

    def queue_command(self, command):
        """
        After creating a QUndoCommand, put it our command queue for
        the stack handler to grab when it's ready
        """
        self.command_queue.append(command)

    def dequeue_command(self):
        """
        Remove and return the oldest command in the command queue
        """
        return self.command_queue.popleft()

    def item_from_row_path(self, row_path):
        """
        Given the row path (a list of ints) of an item, retrieve that
        item from the file hierarchy

        :param row_path:
        :return:
        """

        item = self.rootitem

        for r in row_path:
            # each item of row_path is a row number;
            # just follow the trail down the tree
            item = item[r]

        return item

    def item_path_from_row_path(self, row_path):
        """
        Given the row-path for an item, return a list of the nodes that
        will be traversed to get to the item

        :param list[int] row_path:
        :rtype: list[QFSItem]
        """

        item = self.rootitem

        p = []

        for r in row_path:
            item = item[r]
            p.append(item)

        return p

    ##=============================================
    ## Saving/undoing
    ##=============================================

    def save(self):
        """
        Commit any unsaved changes (currenlty just to hidden files) to
        the db and save the updated db state to disk
        """

        # hidden files right now
        current_state = set(self.current_hidden_file_indices)

        # hidden files when last saved
        clean_state = self._saved_state

        # deltas
        to_hide = [
            self._files[i].path for i in sorted(current_state - clean_state)
        ]
        to_unhide = [
            self._files[i].path for i in sorted(clean_state - current_state)
        ]

        # update database, write to disk
        self.manager.save_hidden_files(self.mod.directory, to_unhide, to_hide)

        # make the current state the saved state
        self._saved_state = current_state