diff --git a/modloader/__init__.py b/modloader/__init__.py index 05110c1..2385d94 100644 --- a/modloader/__init__.py +++ b/modloader/__init__.py @@ -141,6 +141,41 @@ def report_mod_errors(errors): # NoReturn raise +def report_modlist_errors(errors): # NoReturn + """ + Reports a modlist error to the user by displaying the modlist + error screen. + + As the only actions available on the modlist error screen + are to "Reload" and "Quit", this function is guaranteed + not to return to the caller (except through Ren'Py's + control exceptions' unwinding.) + + The structure of this function is taken from + report_mod_errors + """ + if not renpy.exports.has_screen("_modlist_errors"): + return True + + renpy.display.error.init_display() + + reload_action = renpy.exports.utter_restart + + try: + renpy.game.invoke_in_new_context( + renpy.display.error.call_exception_screen, + "_modlist_errors", + reload_action=reload_action, + errors=errors, + ) + except renpy.game.CONTROL_EXCEPTIONS: + raise + except: + renpy.display.log.write("While handling exception:") + renpy.display.log.exception() + raise + + def resolve_dependencies(): """Resolve mod dependencies and create mod load order""" from modloader import modinfo @@ -238,9 +273,9 @@ def main(reload_mods=False): report_duplicate_labels() - if has_steam(): - steammgr = get_instance() - steammgr.CachePersonas() + # if has_steam(): + # steammgr = get_instance() + # steammgr.CachePersonas() # By appending the mod folder to the import path we can do something like # `import test` to import the mod named test in the mod folder. diff --git a/modloader/modconfig.py b/modloader/modconfig.py index 8607c41..0be1936 100644 --- a/modloader/modconfig.py +++ b/modloader/modconfig.py @@ -9,6 +9,7 @@ from cStringIO import StringIO import zipfile from collections import namedtuple +import threading import renpy from renpy.audio.music import stop as _stop_music @@ -21,6 +22,7 @@ if workshop_enabled: from steam_workshop.steam_config import has_valid_signature import steam_workshop.steamhandler as steamhandler + import steamhandler_extensions BRANCHES_API = "https://api.github.com/repos/AWSW-Modding/AWSW-Modtools/branches" @@ -98,21 +100,128 @@ def github_downloadable_mods(): return sorted(data, key=lambda mod: mod[1].lower()) -@cache -def steam_downloadable_mods(): - # A different format, - # (id, mod_name, author, desc, image_url) - mods = [] - for mod in sorted(steamhandler.get_instance().GetAllItems(), key=lambda mod: mod[1]): - file_id = mod[0] - create_time, modify_time, signature = mod[5:8] - is_valid, verified = has_valid_signature(file_id, create_time, modify_time, signature) - if is_valid: - mods.append(list(mod[:5])) - mods[-1][3] += "\n\nVerified by {}".format(verified.username.replace("", "")) + +class SteamModlist: + """Manages the steam modlist, as is gotten by the steam_downloadable_mods method. + It supports loading the modlist in a separate thread via the load method, + And caching such results. + This is needed as loading the modlist takes quite a while, + And is an operation we would much rather do at startup, without delaying anything else. + Any exceptions raised in the loading process will be available through the get_exception() method. + Once the load finishes, Only one of the get() and get_exception() methods will return a value, while the other will return None. + If the load is successful, then get() will return a value. if the load raised an exception, then get_exception() will return a value. + """ + + def __init__(self): + self._loading_thread = None + self._loading_thread_lock = threading.Lock() + self._loaded_data = None + self._exception = None + self._is_done = threading.Event() + return + + def _loading_function(self): + """Loads and verifies the steam modlist data. + It must take no arguments, and return a single value: the loaded data. + It may raise an exception, in which case it'll be stored in self._exception. + """ + + # A different format, + # (id, mod_name, author, desc, image_url) + + # This uses GetAllItems(), Which is affected by the QueryApi crash. + # therefore, steamhandler_extensions are preferred + mods = [] + for mod in sorted(steamhandler_extensions.get_instance().GetAllItems(), key=lambda mod: mod[1]): + if mod[1] == "Modtools": + continue # The modtools themselves need not be here (as they're already present and can't be removed using themselves), nor should the signing system complain about them... + file_id = mod[0] + create_time, modify_time, signature = mod[5:8] + is_valid, verified = has_valid_signature(file_id, create_time, modify_time, signature) + if is_valid: + mods.append(list(mod[:5])) + mods[-1][3] += "\n\nVerified by {}".format(verified.username.replace("", "")) + else: + print "NOT VALID SIG", mod[1] # Note: printing only the mod name, instead of the whole thing SIGNIFICANTLY speeds up this call + return mods + + def _load_and_set(self): + """Calls the _loading_function and sets the internal values based on it's results.""" + try: + mods = self._loading_function() + self._loaded_data = mods + print "Finished steam modlist load without errors" + except Exception as e: + print "Finished steam modlist load with errors" + self._exception = e + self._exception.traceback = sys.exc_info()[2] + + self._is_done.set() + print "Done loading steam modlist" + return + + def load(self): + """Starts loading the steam modlist data if it is not already being loaded. + This method starts a thread which loads the modlist data. + It guarantees that for any number of repeated calls to it from any number of threads, only one loading thread will be started. + Once the data is loaded, it is available through the get method. + """ + with self._loading_thread_lock: + if self._loading_thread is None: + print "Steam modlist thread not present, Starting..." + self._loading_thread = threading.Thread(target=self._load_and_set, name=u"Thread-load-{}".format(self._load_and_set.__name__)) + self._loading_thread.start() + else: + print "Steam modlist thread already present" + return self._loading_thread + + def get(self): + """Get the steam modlist data. + If the data has already loaded, this method returns with it immediately, + Otherwise, load() is called, and this method blocks until the completion of the data loading thread. + """ + if self._is_done.is_set(): + # Note: while the value of is_set can change between checking it here and referring to _loaded_data, + # It can only change from False to True. + # In that case the load() method has been called before and is currently finishing, + # And it'll be called again here, ignored, and _is_done will be waited upon, which will finish only once _loaded_data is available. + + print "Steam modlist data already available" + else: + print "Steam modlist data not available, calling load" + self.load() + self._is_done.wait() + print "Loading done, fetching data" + return self._loaded_data + + def get_exception(self): + """Get the raised exception, If the load raised an exception. + If the data has already loaded, this method returns with it immediately, + Otherwise, load() is called, and this method blocks until the completion of the data loading thread. + """ + if self._is_done.is_set(): + # Identical logic to self.get() + + print "Steam modlist exception already available" else: - print "NOT VALID SIG", mod - return mods + print "Steam modlist data not available, calling load" + self.load() + self._is_done.wait() + print "Loading done, fetching exception" + return self._exception + + def is_done(self): + return self._is_done.is_set() + + def wait(self, timeout=None): + return self._is_done.wait(timeout) + + +steam_mod_list = SteamModlist() + + +def steam_downloadable_mods(): + return steam_mod_list.get() def download_github_mod(download_link, name, show_download=True, reload_script=True): diff --git a/modloader/patch_errorhandling_screens.rpym b/modloader/patch_errorhandling_screens.rpym index b028afa..e9e0cf8 100644 --- a/modloader/patch_errorhandling_screens.rpym +++ b/modloader/patch_errorhandling_screens.rpym @@ -476,3 +476,52 @@ screen _modloader_errors(errors,reload_action): # Tooltip. text tt.value + + +# Screen for displaying steam modlist loading errors +# Note: this is copied over from _modloader_errors +screen _modlist_errors(errors,reload_action): + modal True + zorder 1090 + + default tt = __Tooltip("") + + frame: + style_group "" + + has side "t c b": + spacing gui._scale(10) + + label _("Could not load the steam modlist.") + + viewport: + id "viewport" + child_size (4000, None) + mousewheel True + scrollbars "both" + xfill True + yfill True + + has vbox + + text errors substitute False + + vbox: + + hbox: + spacing gui._scale(25) + + textbutton _("Reload"): + action reload_action + hovered tt.action(_("Reloads the game from disk. (The same as quitting and restarting.)")) + + vbox: + xfill True + + textbutton _("Quit"): + action __ErrorQuit() + hovered tt.action(_("Quits the game.")) + xalign 1.0 + + # Tooltip. + text tt.value diff --git a/modloader/steamhandler_extensions.py b/modloader/steamhandler_extensions.py new file mode 100644 index 0000000..2a5313e --- /dev/null +++ b/modloader/steamhandler_extensions.py @@ -0,0 +1,345 @@ +import sys +import os +import shutil +import time +import errno +import json +import threading +import copy + +import renpy.config + +from steam_workshop.steamhandler import SteamMgr, PyCallback, WorkshopData, cache +from steam_workshop import steamhandler + + +class AttributeDict(dict, object): + """This class allows dict members to be accessed via __getattr__, which mimics the usage of array entries in Query callbacks""" + def __getattr__(self, item): + try: + return super(AttributeDict, self).__getattr__(item) + except AttributeError: + pass # Checking super's __getattr__ is mostly a just-in-case thing, as it generally wouldn't return anything. of course, if it fails, we want to actually add the __get_item__ call, so errors are ignored. + try: + return self[item] + except KeyError: + raise AttributeError("'{}' object has no attribute '{}'".format(self.__class__, item)) + + +class ManagedThread(threading.Thread, object): + """A threading.Thread which has a holding list. + When this thread starts, it adds itself to the list, + And when it finishes it removes itself from that list. + Coherence is ensured by lock, which is a threading.Lock. it is used on all accesses to holder.""" + + def __init__(self, holder, lock, group=None, target=None, name=None, args=(), kwargs={}): + super(ManagedThread, self).__init__(group=group, target=target, name=name, args=args, kwargs=kwargs) + self.holder = holder + self.lock = lock + return + + def run(self): + with self.lock: + self.holder.append(self) + + try: + return super(ManagedThread, self).run() + finally: + with self.lock: + self.holder.remove(self) + + +class CacheWriteError(RuntimeError, object): + """Represents an exception that was raised in the cache write callback.""" + + def __init__(self, cause, cause_traceback = None, *args): + super(CacheWriteError, self).__init__(*args) + self.cause = cause + self.cause_traceback = cause_traceback + + @classmethod + def from_exc(cls, *args): + """Construct this exception using sys.exc_info() for the cause and traceback.""" + _, cause, cause_traceback = sys.exc_info() + return cls(cause, cause_traceback, *args) + + def __str__(self): + if not self.message.strip(): + return "{}Caused by: {}: {}".format(self.message, type(self.cause).__name__, str(self.cause)) + return "{}\n Caused by: {}: {}".format(self.message, type(self.cause).__name__, str(self.cause)) + + +class CachedSteamMgr: + """Holds a SteamMgr instance, and caches results of problematic actions (QueryApi, which gets workshop data as a whole, not parts), + So that calls to them will not fail when done repeatedly. + In QueryApi's case, This is done to bypass an existing cache which causes failures. + It is highly recommended to use this whenever one needs lists of steam mods (for example, the mod browser). + """ + + __PAGE_CACHE_DIR = os.path.join(renpy.config.gamedir, "page_cache") + + def __init__(self, steam_manager): + if not isinstance(steam_manager, SteamMgr): + raise TypeError("steam_manager must be a steam_workshop.steamhandler.SteamMgr instance!") + self._steam_manager = steam_manager + + # When the cache is used, We need to create a thread in order to behave like QueryApi. + # As these threads are internal, we should at least keep track of which ones are active at any time. + # This is done using ManagedThread instances, which use self._active_threads as their holder. + self._active_threads = [] + self._active_threads_lock = threading.Lock() + return + + + def register_callback(self, type, func): + return self._steam_manager.register_callback(type, func) + + def unregister_callback(self, type, func): + return self._steam_manager.unregister_callback(type, func) + + + def is_file_stale(self, file_path): + """True if cache file given by file_path is stale.""" + # Testing if file has been updated in the last 15 minutes. If not, stale. + # Empirically, the problematic cache always becomes stale by this point. + curr_time = time.time() + cache_time = os.path.getmtime(file_path) + return (curr_time - cache_time) >= 15 * 60 + + def get_cache_filename(self, page): + """Returns the cache filename matching this page number.""" + if not isinstance(page, int): + raise TypeError("Page number must an integer!") + if page <= 0: + raise ValueError("Page number must be positive!") + return os.path.join(self.__PAGE_CACHE_DIR, "page_{:02}.json".format(page)) + + + def _CallQueryApi(self, page): + """Gets super's QueryApi(page), strictly by calling QueryApi. + Fills the cache as well, and keeps python thread trapped until cache has been filled. + :raises CacheWriteError If the cache callback raises an exception, containing the exception raised and it's traceback. + """ + print "Cache callback: Current query callbacks:", "\n".join(str(func) for func in self._steam_manager.Callbacks[PyCallback.Query]) + print "Cache callback: Current persona callbacks:", "\n".join(str(func) for func in self._steam_manager.Callbacks[PyCallback.Persona]) + + + def fill_cache_query_cb(array, arr_len): + try: + print "Cache callback called with: (len={0}), array={1}".format(arr_len, array) + + # Prepare data to write: convert it to json compatible dicts + field_names = [name for name, _ in WorkshopData._fields_] + array_data = [{name: getattr(array[i], name) for name in field_names} for i in range(arr_len)] + to_write = {"len": arr_len, "data": array_data} + + # Get cache file name + cache_file_name = self.get_cache_filename(page) + print "Cache file target: \"{}\"\n".format(cache_file_name) + + # Ensure file can be created (ensure directories) + to_ensure = os.path.dirname(cache_file_name) + if not os.path.exists(to_ensure): # If not exists: create + os.makedirs(os.path.dirname(cache_file_name)) + elif not os.path.isdir(to_ensure): # If exists and not dir: problem + raise OSError(errno.ENOTDIR, "The attempted directory \"{}\" exists and is not a directory.".format(to_ensure)) + # else: exists and is dir: no need to do anything + + # Write cache file + with open(cache_file_name, "w") as cache_file: + json.dump(to_write, cache_file, encoding="utf-8") # While not strictly necessary, I'd rather be explicit with the encoding. + + except Exception as e: + fill_cache_query_cb.error = CacheWriteError.from_exc("Error in cache file write.") + raise e + finally: + print "Cache file write callback done." + fill_cache_query_cb.done = True + return + + fill_cache_query_cb.error = None + self.register_callback(PyCallback.Query, fill_cache_query_cb) + try: + fill_cache_query_cb.done = False + + print "Calling QueryApi({})".format(page) + self._steam_manager.QueryApi(page) + + reps = 0 + while not fill_cache_query_cb.done: + print "Waiting for cache callback, rep {}".format(int(reps)) + reps += 1 + time.sleep(1) + print "Done cache callback" + + if fill_cache_query_cb.error is not None: + raise fill_cache_query_cb.error + + return + + finally: + self.unregister_callback(PyCallback.Query, fill_cache_query_cb) + + def QueryApi(self, page): + """Gets steam_manager's QueryApi(page), using the cache if available and not stale. + Cache becomes stale after 15 minutes from being written (see self.is_file_stale()), + After which the problematic one should also be stale and not fail the program. + :raises CacheWriteError If the cache write callback raises an exception, containing the exception raised and it's traceback. + Note that this is the only difference in interface between this and steam_manager's version, + As errors in the cache callback prevent the much-needed caching, and are therefore severe. + """ + + print "Called cached queryAPI with page={}".format(page) + + # Get most recent cache file if exists + cache_file_name = self.get_cache_filename(page) + + print "cache file is: \"{}\"".format(cache_file_name) + + # Check if cache file is available for use, and try to reclaim it if it is detected as a non-file entity. + is_cache_availbable = False + if os.path.exists(cache_file_name): + print "cache file Exists" + if os.path.isfile(cache_file_name): + print "cache file is a file" + is_cache_availbable = not self.is_file_stale(cache_file_name) + elif os.path.islink(cache_file_name): + print "cache file is a link to dir" + os.unlink(cache_file_name) # Clear symlink to directory in the position of the cache file... + else: + print "cache file is a dir" + shutil.rmtree(cache_file_name) # Clear directory in the position of the cache file... + + + if is_cache_availbable: + print "Using cache file" + + # There are 2 things that need to be ensured so that the json load will work like it should: + # 1. Fields of the steamhandler.WorkshopData should be accessible via attribute name. + # 2. Strings need to be utf-8 encoded string objects, and not unicode objects like json wishes. + # Both of these things are ensured by workshop_data_hook: + # 1. Values are put into an AttributeDict, which makes __getattr__ call __getitem__. + # This is preferred over, say, the WorkshopData, as it allows us to easily store and retrieve data using key: value pairs, + # Which avoids any and all issues with data getting mixed up in order. + # 2. Both keys and values are encoded into utf-8 str's, which makes them behave appropriately. + # This is needed as json (as is logical) creates unicode objects, while Ren'py expects str's. + + def workshop_data_hook(obj): + return AttributeDict({k.encode('utf-8') if isinstance(k, unicode) else k: + v.encode('utf-8') if isinstance(v, unicode) else v + for k, v in obj}) + + # Read cache file + print "Reading cache file \"{}\"".format(cache_file_name) + with open(cache_file_name, "r") as cache_file: + file_data = json.load(cache_file, encoding="utf-8", object_pairs_hook=workshop_data_hook) + arr_len = file_data["len"] + array = file_data["data"] + print arr_len + + # Thread is used to match the behaviour of QueryApi, where the function returns quickly and before the callbacks are called + qapi_thread = ManagedThread(holder=self._active_threads, lock=self._active_threads_lock, target=self._steam_manager.query_callback, kwargs={"array": array, "arr_len": arr_len}) + qapi_thread.start() + else: + print "Not using cache file" + self._CallQueryApi(page) + return + + + + def GetSubscribedItems(self): + return self._steam_manager.GetSubscribedItems() + + def GetAllItems(self, get_all=False): + + # Implemented here with a performance boost (see commented out print of item), + # And with fix to overzealous repeat calls + # Implementation is also needed as this uses QueryApi, so we need to ensure that the fixed one is used. + + # It seems the only way the callback can access these variables is through global variables + # Be careful! + results = [] + + def cb(array, arr_len): + print "Recieve items..." + cb.complete = False + # Querying a page is 50 results maximum + if arr_len == 51: + cb.should_run_next = False + cb.complete = True + return + + for x in range(arr_len): + item = array[x] + if get_all: + all_data = copy.deepcopy((item.m_nPublishedFileId, item.m_eResult, item.m_eFileType, + item.m_nCreatorAppID, item.m_nConsumerAppID, item.m_rgchTitle, + item.m_rgchDescription, item.m_ulSteamIDOwner, item.m_rtimeCreated, + item.m_rtimeUpdated, item.m_rtimeAddedToUserList, item.m_eVisibility, + item.m_bBanned, item.m_bAcceptedForUse, item.m_bTagsTruncated, + item.m_rgchTags, item.m_hFile, item.m_hPreviewFile, item.m_pchFileName, + item.m_nFileSize, item.m_nPreviewFileSize, item.m_rgchURL, item.m_unVotesUp, + item.m_unVotesDown, item.m_flScore, item.m_unNumChildren, + item.m_pchPreviewLink, item.m_metadata)) + results.append(all_data) + else: + not_all_data = copy.deepcopy((item.m_nPublishedFileId, item.m_rgchTitle, item.m_ulSteamIDOwner, + item.m_rgchDescription, item.m_pchPreviewLink, item.m_rtimeCreated, + item.m_rtimeUpdated, item.m_metadata)) + results.append(not_all_data) + + cb.should_run_next = (arr_len == 50) + cb.i += 1 + cb.complete = True + return + + cb.should_run_next = True + cb.i = 1 + cb.complete = False + + self.register_callback(PyCallback.Query, cb) + try: + while cb.should_run_next: + cb.complete = False # Important! make sure that consecutive runs don't claim that the function is already finished! + self.QueryApi(cb.i) + + # Block + while not cb.complete: + pass + finally: # Ensure that the callback will be unregistered + self.unregister_callback(PyCallback.Query, cb) + + if not get_all: + adj_results = [] + for i, item in enumerate(results): + print "Getting persona", i, item[1] #, item # Printing full items made this ~100x slower... + item = list(item) + item[2] = self.GetPersona(item[2]) + adj_results.append(tuple(item)) + results = adj_results + + return results + + + def GetItemFromID(self, id): + return self._steam_manager.GetItemFromID(id) + + @cache + def GetPersona(self, id): + return self._steam_manager.GetPersona(id) + + @cache + def GetItemDownloadInfo(self, id): + return self._steam_manager.GetItemDownloadInfo(id) + + + + + +def get_instance(): + global _cached_instance + + if "_cached_instance" not in globals(): + _cached_instance = CachedSteamMgr(steamhandler.get_instance()) + + return _cached_instance \ No newline at end of file diff --git a/mods/core/__init__.py b/mods/core/__init__.py index 03a45d9..d8eb960 100644 --- a/mods/core/__init__.py +++ b/mods/core/__init__.py @@ -2,7 +2,7 @@ import renpy.parser as parser import renpy.ast as ast -from modloader import modinfo, modast +from modloader import modinfo, modast, has_steam from modloader.modgame import base as ml from modloader.modclass import Mod, loadable_mod from modloader.modinfo import get_mod_folders @@ -45,12 +45,23 @@ def mod_load(): screen dummy: imagebutton auto "ui/mods_%s.png" action [Show("preferencesbg"), Show('modmenu'), Play("audio", "se/sounds/open.wav")] hovered Play("audio", "se/sounds/select.ogg") xalign 0.03 yalign 0.955 """ - + + # Figure out at which indentation level do we want to add new stuff + tocompile = tocompile.rstrip(" ") # Ensure that tocompile ends with a new line, so that anyone adding additional things to it can align properly using the bas_indent + indented_line = tocompile.rstrip().rsplit("\n", 1)[-1] # Get a properly indented line (A line with text which is contained in the dummy screen) + base_indent = " " * (len(indented_line) - len(indented_line.lstrip(" "))) + steam_only = all(folder.isdigit() or folder == "core" for folder in get_mod_folders()) if not steam_only: - tocompile += """text "Non-Steam mods detected. The safety or appropriateness of these mods cannot be guaranteed." xalign 0.16 yalign -0.005""" - + tocompile += base_indent + """text "Non-Steam mods detected. The safety or appropriateness of these mods cannot be guaranteed." xalign 0.16 yalign -0.005\n""" + + + # Added timer to check if preload failed once it's finished. a timer is used so the main thread doesn't wait on the preload... + if has_steam(): + tocompile += base_indent + 'default timer_active = True\n' + tocompile += base_indent + 'timer 1.0 repeat timer_active action If(timer_active and _is_modlist_done(), true=[SetScreenVariable("timer_active", False), Function(_ensure_modlist_okay)], false=[])\n' # If requires both the true and false arguments to be not None (or else Ren'py complains that the timer doesn't have an action?!), so I put an empty list to convince it that it's fine... + compiled = parser.parse("FNDummy", tocompile) for node in compiled: if isinstance(node, ast.Init): diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index bd70844..1dbb218 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -132,24 +132,82 @@ init python: init -1 python: + import math + import traceback + + import modloader + from modloader import modconfig, steamhandler_extensions + + # Preload steam modlist, so we don't wait for it when we try to open the mod browser + if modloader.has_steam(): + modconfig.steam_mod_list.load() + + def _is_modlist_done(): + return modconfig.steam_mod_list.is_done() + + def _ensure_modlist_okay(): + # This should only be called once the modlist has finished + exception = modconfig.steam_mod_list.get_exception() + if exception is None: + return # Everything is good! + + + if isinstance(exception, steamhandler_extensions.CacheWriteError): + # CacheWriteError have a special error screen, as they're more severe + modloader.report_modlist_errors("The steam modlist cache file write has failed.\n" + "This should never happen under normal circumstances, and may cause the game to crash or not open.\n" + "If you're seeing this, please report it to the developers of the Modtools, or on the fan discord,\n" + " preferably with a screenshot.\n" + "\nError raised:\n" + + "".join(traceback.format_exception(type(exception), exception, exception.cause_traceback)) + ) + else: + modloader.report_modlist_errors("An error has occurred in trying to load the steam mod list.\n" + "\nError raised:\n" + + "".join(traceback.format_exception(type(exception), exception, exception.traceback)) + ) + return + + # Ensure error screens are available, as we may need them + renpy.load_module("modloader/patch_errorhandling_screens") + def _mod_check_internet_downloader(use_steam): if internet_on(): # (modid, name, author, description, image) (for github) # (id, name, author, desc, image) (for steam) if use_steam: from modloader.modconfig import steam_downloadable_mods as download_mods + _ensure_modlist_okay() else: from modloader.modconfig import github_downloadable_mods as download_mods contents = download_mods() - renpy.show_screen('modmenu_download', contents=contents, use_steam=use_steam) + renpy.show_screen('modmenu_paged', contents=contents, use_steam=use_steam) else: renpy.show_screen('modmenu_nointernet') -screen modmenu_download(contents, use_steam): + + # Paging methods + def _get_slice_lims_from_page(page, page_size): + return page_size * (page - 1), page_size * page # Pages are 1-indexed but lists are 0-indexed, so 1 is subtracted from page# to match them + + def _refresh_modlist_page(page, page_size, modlist, use_steam): + start, end = _get_slice_lims_from_page(page, page_size) + renpy.hide_screen('modmenu_paged_modlist') + renpy.show_screen('modmenu_paged_modlist', contents=modlist[start:end], use_steam=use_steam) + return + + + +screen modmenu_paged(contents, use_steam): modal True - frame id "modmenu_download" at alpha_dissolve: + default current_page = 1 + default PAGE_SIZE = 6 + $ MIN_PAGE = 1 # Do note, modpage numbers are 1-indexed + $ MAX_PAGE = int(math.ceil(len(contents) / float(PAGE_SIZE))) + + frame id "modmenu_paged" at alpha_dissolve: add "image/ui/ingame_menu_bg3.png" add "image/ui/ingame_menu_bg_light.png" at ingame_menu_light @@ -169,7 +227,8 @@ screen modmenu_download(contents, use_steam): hover "image/ui/close_hover.png" action [Show("modmenu", transition=dissolve), Hide("modmenu_mod_content", transition=dissolve), - Hide("modmenu_download", transition=dissolve), + Hide("modmenu_paged_modlist", transition=dissolve), + Hide("modmenu_paged", transition=dissolve), Stop("modmenu_music", fadeout=1.0), Play("music", "mx/menu.ogg", fadein=1.0), Play("audio", "se/sounds/close.ogg")] @@ -177,80 +236,119 @@ screen modmenu_download(contents, use_steam): xpos 0.94 ypos 0.02 - frame: - background None + + hbox id "page_number_hb": + yminimum 425 + ymaximum 425 + xmaximum 900 + xminimum 900 + + ypos 913 + xcenter 960 + yanchor 0.5 + + + textbutton "-5": + xalign 0.2 + ycenter 0.5 + # Tried to bind this to shift+scroll, but it didn't work... + action [SetScreenVariable("current_page", max(current_page-5, MIN_PAGE)), + Function(_refresh_modlist_page, max(current_page-5, MIN_PAGE), PAGE_SIZE, contents, use_steam=use_steam) + ] + sensitive (current_page > 1) + + textbutton "-": + xalign 0.4 + ycenter 0.5 + keysym "mousedown_4" + action [SetScreenVariable("current_page", current_page-1), + Function(_refresh_modlist_page, current_page-1, PAGE_SIZE, contents, use_steam=use_steam) + ] + sensitive (current_page > 1) + + label "Page #[current_page]/[MAX_PAGE]": + xalign 0.5 + ycenter 0.5 + + text_size 40 + + textbutton "+": + xalign 0.6 + ycenter 0.5 + keysym "mousedown_5" + action [SetScreenVariable("current_page", current_page+1), + Function(_refresh_modlist_page, current_page+1, PAGE_SIZE, contents, use_steam=use_steam) + ] + sensitive (current_page < MAX_PAGE) + + textbutton "+5": + xalign 0.8 + ycenter 0.5 + # Also tried to bind this to shift+scroll, but it didn't work... + action [SetScreenVariable("current_page", min(current_page+5, MAX_PAGE)), + Function(_refresh_modlist_page, min(current_page+5, MAX_PAGE), PAGE_SIZE, contents, use_steam=use_steam) + ] + sensitive (current_page < MAX_PAGE) + + on "show" action Function(_refresh_modlist_page, current_page, PAGE_SIZE, contents, use_steam=use_steam) + + + +screen modmenu_paged_modlist(contents, use_steam): + frame: + background None + yminimum 900 + ymaximum 900 + xmaximum 425 + xminimum 425 + xpos 65 + ypos 90 + + #button hieght 125 + vpgrid id "modselect_vp": + yminimum 900 ymaximum 900 xmaximum 425 xminimum 425 - xpos 65 - ypos 90 - #button hieght 125 - vpgrid id "modselect_vp": + cols 1 + spacing 30 - yminimum 900 - ymaximum 900 - xmaximum 425 - xminimum 425 + for modid, name, author, description, url in contents: + $ modname = modmenu_name_cleaner(name) - cols 1 - spacing 30 - draggable True - mousewheel True + if len(modname) > 21: + #if modname is greater than 21 characters, decrease size of font by 5 + if len(modname) <= 25: + $ modname = "{size=-5}" + modname + "{/size}" - for modid, name, author, description, url in contents: - $ modname = modmenu_name_cleaner(name) - - if len(modname) <= 21: - #if mod is installed - if str(modid) in modinfo.get_mod_folders(): - $ modname = modname + "\n{size=-5}(Installed){/size}" - #if mod is not installed - else: - $ modname = modname - - #if modname is greater than 21 characters, decrese size of font by 5 - elif len(modname) <= 25: - #if mod is installed - if str(modid) in modinfo.get_mod_folders(): - $ modname = "{size=-5}" + modname + "{/size}" + "\n{size=-5}(Installed){/size}" - #if mod is not installed - else: - $ modname = "{size=-5}" + modname + "{/size}" - - #if modname is greater than 25 characters, decrese size of font by 10 + #if modname is greater than 25 characters, decrease size of font by 10 else: - #if modname is greater than 30 characters, decrese size of font by 10 and cut all text after 30 places - if len(modname) > 30: - $ modname = modname[:30] - - #if mod is installed - if str(modid) in modinfo.get_mod_folders(): - $ modname = "{size=-10}" + modname + "{/size}" + "\n{size=-5}(Installed){/size}" - #if mod is not installed - else: - $ modname = "{size=-10}" + modname + "{/size}" - - - textbutton "[modname]": - style "modmenu_select_btn" - - action [Hide("modmenu_mod_content"), - Show("modmenu_mod_content", - modid=modid, - name=unicode(name, "utf8"), - author=unicode(author, "utf8"), - description=unicode(description, "utf8"), - url=url, - use_steam=use_steam, - transition=dissolve), - Play("audio", "se/sounds/open.ogg")] + #if modname is greater than 30 characters, decrease size of font by 10 and cut all text after 30 places +# if len(modname) > 30: + $ modname = modname[:30] + $ modname = "{size=-10}" + modname + "{/size}" + + if str(modid) in modinfo.get_mod_folders(): + $ modname = modname + "\n{size=-5}(Installed){/size}" + + + textbutton "[modname]": + style "modmenu_select_btn" + + action [Hide("modmenu_mod_content"), + Show("modmenu_mod_content", + modid=modid, + name=unicode(name, "utf8"), + author=unicode(author, "utf8"), + description=unicode(description, "utf8"), + url=url, + use_steam=use_steam, + ), + Play("audio", "se/sounds/open.ogg")] - bar value YScrollValue("modselect_vp"): - style "modmenu_select_slider" - #yalign 0.95