├── __init__.py ├── tkgui ├── __init__.py ├── tab.py ├── tkhelpers.py ├── binding.py ├── layout.py ├── dfhack.py ├── utilities.py ├── advanced.py └── mods.py ├── LNP.gif ├── LNP.ico ├── LNP.png ├── core ├── __init__.py ├── embarks.py ├── paths.py ├── errorlog.py ├── helpers.py ├── manifest.py ├── hacks.py ├── colors.py ├── rawlint.py ├── json_config.py ├── log.py ├── keybinds.py ├── legends_processor.py ├── baselines.py ├── launcher.py ├── importer.py ├── utilities.py ├── download.py ├── update.py └── lnp.py ├── LNP.icns ├── LNPSMALL.gif ├── LNPSMALL.png ├── .gitignore ├── .hgignore ├── docs ├── layout.html ├── index.html ├── developer.rst ├── static │ └── switcher.js ├── index.rst ├── Makefile ├── building.rst ├── conf.py └── pylnp-json.rst ├── readme.rst ├── README.html ├── COPYING.txt ├── launch.py ├── PyLNP.json ├── .hgtags └── lnp.spec /__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tkgui/__init__.py: -------------------------------------------------------------------------------- 1 | """TKinter UI for PyLNP.""" 2 | -------------------------------------------------------------------------------- /LNP.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pidgeot/python-lnp/HEAD/LNP.gif -------------------------------------------------------------------------------- /LNP.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pidgeot/python-lnp/HEAD/LNP.ico -------------------------------------------------------------------------------- /LNP.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pidgeot/python-lnp/HEAD/LNP.png -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- 1 | """Core, non-UI functionality for PyLNP.""" 2 | -------------------------------------------------------------------------------- /LNP.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pidgeot/python-lnp/HEAD/LNP.icns -------------------------------------------------------------------------------- /LNPSMALL.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pidgeot/python-lnp/HEAD/LNPSMALL.gif -------------------------------------------------------------------------------- /LNPSMALL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pidgeot/python-lnp/HEAD/LNPSMALL.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | *.pyc 4 | PyLNP.user 5 | stderr.txt 6 | stdout.txt 7 | 8 | Dwarf\ Fortress\ * 9 | LNP 10 | __pycache__ 11 | *.bat 12 | 13 | docs/_build 14 | docs/core 15 | docs/tkgui 16 | -------------------------------------------------------------------------------- /.hgignore: -------------------------------------------------------------------------------- 1 | syntax: glob 2 | build 3 | dist 4 | *.pyc 5 | .* 6 | PyLNP.user 7 | stderr.txt 8 | stdout.txt 9 | 10 | Dwarf Fortress * 11 | LNP 12 | __pycache__ 13 | *.bat 14 | 15 | docs/_build 16 | docs/core 17 | docs/tkgui -------------------------------------------------------------------------------- /docs/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | {% set script_files = script_files + ["https://pylnp.birdiesoft.dk/docs/_static/switcher.js"] %} 3 | {% block rootrellink %} 4 | 5 | {% endblock %} 6 | 7 | -------------------------------------------------------------------------------- /readme.rst: -------------------------------------------------------------------------------- 1 | PyLNP: A launcher for Dwarf Fortress 2 | ##################################### 3 | 4 | PyLNP has a variety of useful features to manage settings and configure the base 5 | game. It can also manage, configure, install, and run a wide variety of 6 | related content - from graphics to color schemes, and utility programs to 7 | content-replacing mods. 8 | 9 | The full documentation can be found at `./docs/index.rst`, or online at https://pylnp.birdiesoft.dk/docs/. 10 | -------------------------------------------------------------------------------- /README.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | Page Redirection 12 | 13 | 14 | Follow this link to the documentation, or view online at https://pylnp.birdiesoft.dk/docs/. 15 | 16 | 17 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 16 | Page Redirection 17 | 18 | 19 | Follow this link to the documentation. 20 | 21 | -------------------------------------------------------------------------------- /COPYING.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Michael Madsen 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 9 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 11 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 12 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 13 | PERFORMANCE OF THIS SOFTWARE. 14 | 15 | -------------------------------------------------------------------------------- /launch.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """This file is used to launch the program.""" 4 | 5 | import os 6 | import sys 7 | 8 | from core import lnp 9 | 10 | sys.path.insert(0, os.path.dirname(__file__)) 11 | # pylint: disable=redefined-builtin 12 | __package__ = "" 13 | 14 | try: 15 | lnp.PyLNP() 16 | except SystemExit: 17 | raise 18 | except Exception: 19 | import traceback 20 | message = ''.join(traceback.format_exception(*sys.exc_info())) 21 | # Log exception to stderr if possible 22 | try: 23 | print(message, file=sys.stderr) 24 | except Exception: 25 | pass 26 | 27 | # Also show error in Tkinter message box if possible 28 | try: 29 | from tkinter import messagebox 30 | messagebox.showerror(message=message) 31 | except Exception: 32 | pass 33 | -------------------------------------------------------------------------------- /PyLNP.json: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | ["Savegame folder","/data/save"], 4 | ["Utilities folder","LNP/Utilities"], 5 | ["Graphics folder","LNP/Graphics"], 6 | ["-","-"], 7 | ["Main folder",""], 8 | ["LNP folder","LNP"], 9 | ["Dwarf Fortress folder",""], 10 | ["Init folder","/data/init"] 11 | ], 12 | "links": [ 13 | ["DF Homepage","https://www.bay12games.com/dwarves/"], 14 | ["DF Wiki","https://dwarffortresswiki.org/"], 15 | ["DF Forums","http://www.bay12forums.com/smf/"] 16 | ], 17 | "to_import": [ 18 | ["text_prepend", "/gamelog.txt"], 19 | ["text_prepend", "/ss_fix.log"], 20 | ["text_prepend", "/dfhack.history"], 21 | ["copy_add", "/data/save"], 22 | ["copy_add", "/soundsense"] 23 | ], 24 | "hideUtilityPath": false, 25 | "hideUtilityExt": false, 26 | "updates": { 27 | "updateMethod": "" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tkgui/tab.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # pylint:disable=unused-wildcard-import,wildcard-import 4 | """Base class for notebook tabs for the TKinter GUI.""" 5 | 6 | from tkinter import BOTH, TOP, Y 7 | from tkinter.ttk import Frame 8 | 9 | 10 | # pylint: disable=unused-argument 11 | class Tab(Frame): 12 | """Base class for notebook tabs for the TKinter GUI.""" 13 | def __init__(self, parent, *args, **kwargs): 14 | super().__init__() 15 | self.parent = parent 16 | self.pack(side=TOP, fill=BOTH, expand=Y) 17 | self.create_variables() 18 | self.create_controls() 19 | self.read_data() 20 | 21 | def create_variables(self): 22 | """ 23 | Creates all TKinter variables needed by this tab. 24 | Overridden in child classes. 25 | """ 26 | 27 | def read_data(self): 28 | """Reads all external data needed. Overridden in child classes.""" 29 | 30 | def create_controls(self): 31 | """Creates all controls for this tab. Overriden in child classes.""" 32 | -------------------------------------------------------------------------------- /core/embarks.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Embark profile management.""" 4 | 5 | import os 6 | 7 | from . import helpers, log, paths 8 | from .dfraw import DFRaw 9 | 10 | 11 | def read_embarks(): 12 | """Returns a list of embark profiles.""" 13 | return tuple(sorted([ 14 | os.path.basename(o) for o in helpers.get_text_files( 15 | paths.get('embarks'))])) 16 | 17 | 18 | def install_embarks(files): 19 | """ 20 | Installs a list of embark profiles. 21 | 22 | Args: 23 | files: list of files to install. 24 | """ 25 | with DFRaw.open(paths.get('init', 'embark_profiles.txt'), 'wt') as out: 26 | log.i('Installing embark profiles: ' + str(files)) 27 | for f in files: 28 | embark = DFRaw.read(paths.get('embarks', f)) 29 | out.write(embark + "\n\n") 30 | 31 | 32 | def get_installed_files(): 33 | """Returns the names of the currently installed embark profiles.""" 34 | files = helpers.get_text_files(paths.get('embarks')) 35 | current = paths.get('init', 'embark_profiles.txt') 36 | result = helpers.detect_installed_files(current, files) 37 | return [os.path.basename(r) for r in result] 38 | -------------------------------------------------------------------------------- /tkgui/tkhelpers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Static utility methods that are needed in several parts of the TkGui module. 5 | """ 6 | 7 | from tkinter import messagebox 8 | 9 | from core import baselines, download 10 | from core.lnp import lnp 11 | 12 | 13 | def check_vanilla_raws(): 14 | """Validates status of vanilla raws are ready.""" 15 | if not download.get_queue('baselines').empty(): 16 | return False 17 | raw_status = baselines.find_vanilla_raws() 18 | if raw_status is None: 19 | messagebox.showerror( 20 | message='Your Dwarf Fortress version could not be detected ' 21 | 'accurately, which is necessary to process this request.' 22 | '\n\nYou will need to restore the file "release notes.txt" in ' 23 | 'order to use this launcher feature.', title='Cannot continue') 24 | return False 25 | if raw_status is False: 26 | if lnp.userconfig.get_bool('downloadBaselines'): 27 | messagebox.showinfo( 28 | message='A copy of Dwarf Fortress needs to be ' 29 | 'downloaded in order to use this. The download is ' 30 | 'currently in progress.\n\nPlease note: You ' 31 | 'will need to retry the action after the download ' 32 | 'completes.', title='Download required') 33 | return False 34 | return True 35 | -------------------------------------------------------------------------------- /core/paths.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Path management.""" 4 | 5 | import os 6 | 7 | from . import log 8 | 9 | __paths = {} 10 | 11 | 12 | def _identify_folder_name(base, name): 13 | """ 14 | Allows folder names to be lowercase on case-sensitive systems. 15 | Returns "base/name" where name is lowercase if the lower case version 16 | exists and the standard case version does not. 17 | 18 | Args: 19 | base: the path containing the desired folder. 20 | name: the standard case name of the desired folder. 21 | """ 22 | normal = os.path.join(base, name) 23 | lower = os.path.join(base, name.lower()) 24 | if os.path.isdir(lower) and not os.path.isdir(normal): 25 | return lower 26 | return normal 27 | 28 | 29 | def register(name, *path_elms, **kwargs): 30 | """Registers a path constructed by under . 31 | If multiple path elements are given, the last 32 | element will undergo case correction (see _identify_folder_name). 33 | 34 | Args: 35 | name: a registered name corresponding to some path segment 36 | path_elems: path elements, which will be joined to the root path 37 | allow_create: If True, the registered path will be created if it 38 | does not already exist. Defaults to True. 39 | """ 40 | if len(path_elms) > 1: 41 | __paths[name] = _identify_folder_name(os.path.join( 42 | *path_elms[:-1]), path_elms[-1]) 43 | else: 44 | __paths[name] = path_elms[0] 45 | log.i('Registering path %s as %s', name, __paths[name]) 46 | if kwargs.get('allow_create', True) and not os.path.exists(__paths[name]): 47 | os.makedirs(__paths[name]) 48 | 49 | 50 | def get(name, *paths): 51 | """Returns the path registered under , or an empty string if 52 | is not known.""" 53 | try: 54 | base = __paths[name] 55 | except KeyError: 56 | base = '' 57 | return os.path.join(base, *paths) 58 | 59 | 60 | def clear(): 61 | """Clears the path cache.""" 62 | __paths.clear() 63 | -------------------------------------------------------------------------------- /docs/developer.rst: -------------------------------------------------------------------------------- 1 | PyLNP Developer Reference 2 | ######################### 3 | 4 | This document will describe PyLNP from a developer's perspective. 5 | 6 | Licensing 7 | ========= 8 | PyLNP is licensed under the ISC license (see ``COPYING.txt``), which essentially 9 | allows you to modify and distribute changes as you see fit. (This only 10 | applies to the launcher. Any bundled utilities, graphics packs, etc. have 11 | their own licenses; refer to those projects separately.) 12 | 13 | Acquiring the source code 14 | ========================= 15 | The source code is available at `Github `. 16 | 17 | If you wish to contribute to PyLNP development, a pull request will be much appreciated. 18 | 19 | Coding guidelines 20 | ================= 21 | - All source files must start with the following preamble (followed by a blank line for separation):: 22 | 23 | #!/usr/bin/env python 24 | # -*- coding: utf-8 -*- 25 | """""" 26 | 27 | - As a rule of thumb, `PEP 8 ` should be followed. *In particular*, `pylint` should return no errors or warnings when run on the source code (using the provided `pylintrc` file). Any and all messages generated by `pylint` must either be fixed or explicitly suppressed on a case-by-case basis. Formatting-related messages (line length, indentation, etc.) must *always* be fixed. Examples of valid suppressions: 28 | 29 | - PyInstaller requires a certain package to appear in an `import` statement in order to create working binaries, but the statement doesn't need to actually run at any point in time. Here, it is valid to place the `import` statement in an `if False:` block and suppress the resulting `using-constant-test` message. 30 | - Inner functions defined for the purpose of callbacks are permitted to not have docstrings, but the warning for this must be suppressed for the individual function. 31 | 32 | - PyLNP is divided into two main components: `core`, which is the main application, and `tkgui`, which represents the UI. The UI *must only* perform non-UI work by calling into functions of the `core` library. This is to simplify implementation of alternative user interfaces. Where possible, `core` methods should not call into the UI; in cases where this is unavoidable, the method must be documented in the `UI` class defined in `lnp.py`. 33 | 34 | List of Modules 35 | =============== 36 | 37 | .. toctree:: 38 | :maxdepth: 1 39 | :glob: 40 | 41 | core/* 42 | tkgui/* 43 | -------------------------------------------------------------------------------- /tkgui/binding.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Handles control binding for the TKinter GUI.""" 4 | 5 | from tkinter import END 6 | from tkinter.ttk import Entry 7 | 8 | __controls = {} 9 | __lnp = None 10 | __ui = None 11 | 12 | 13 | def init(lnp, ui): 14 | """Connect to LNP and TkGui instances.""" 15 | # pylint:disable=global-statement 16 | global __lnp, __ui 17 | __lnp = lnp 18 | __ui = ui 19 | __controls.clear() 20 | 21 | 22 | def bind(control, option, update_func=None): 23 | """Binds a control to an option.""" 24 | 25 | if option not in __controls: 26 | __controls[option] = [] 27 | 28 | if update_func: 29 | value = (control, update_func) 30 | else: 31 | value = control 32 | __controls[option].append(value) 33 | 34 | 35 | def version_has_option(field): 36 | """Returns True if the current DF version has the provided field.""" 37 | o = field 38 | if not isinstance(field, str): 39 | o = field[0] 40 | return __lnp.settings.version_has_option(o) 41 | 42 | 43 | def get(field): 44 | """ 45 | Returns the value of the control known as . 46 | If multiple controls are bound, the earliest binding is used. 47 | """ 48 | return __controls[field][0].get() 49 | 50 | 51 | def update(): 52 | """Updates configuration displays (buttons, etc.).""" 53 | def disabled_change_entry(*args, **kwargs): # pylint: disable=unused-argument 54 | """Prevents entry change callbacks from being processed.""" 55 | 56 | old_change_entry = __ui.change_entry 57 | __ui.change_entry = disabled_change_entry 58 | for key, option in __controls.items(): 59 | try: 60 | k = key 61 | if not isinstance(k, str): 62 | k = key[0] 63 | value = getattr(__lnp.settings, k) 64 | except KeyError: 65 | value = '' 66 | for entry in option: 67 | if hasattr(entry, '__iter__'): 68 | # Allow (control, func) tuples, etc. to customize value 69 | control = entry[0] 70 | value = entry[1](value) 71 | else: 72 | control = entry 73 | if isinstance(control, Entry): 74 | control.delete(0, END) 75 | control.insert(0, value) 76 | else: 77 | control["text"] = ( 78 | control["text"].split(':')[0] + ': ' 79 | + str(value)) 80 | __ui.change_entry = old_change_entry 81 | 82 | # vim:expandtab 83 | -------------------------------------------------------------------------------- /docs/static/switcher.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 'use strict'; 3 | 4 | // Parses versions in URL segments like: 5 | // "3", "dev", "release/2.7" or "3.6rc2" 6 | var version_regexs = [ 7 | '(?:\\d)', 8 | '(?:\\d\\.\\d[\\w\\d\\.]*)', 9 | '(?:dev)', 10 | '(?:release/\\d.\\d[\\x\\d\\.]*)']; 11 | 12 | var all_versions = { 13 | 'dev': 'dev', 14 | '0.13': '0.13', 15 | '0.12c': '0.12c', 16 | }; 17 | 18 | function build_version_select(current_version, current_release) { 19 | var buf = [''); 30 | return buf.join(''); 31 | } 32 | 33 | function navigate_to_first_existing(urls) { 34 | // Navigate to the first existing URL in urls. 35 | var url = urls.shift(); 36 | if (urls.length == 0) { 37 | window.location.href = url; 38 | return; 39 | } 40 | $.ajax({ 41 | url: url, 42 | success: function() { 43 | window.location.href = url; 44 | }, 45 | error: function() { 46 | navigate_to_first_existing(urls); 47 | } 48 | }); 49 | } 50 | 51 | function on_version_switch() { 52 | var selected_version = $(this).children('option:selected').attr('value') + '/'; 53 | var url = window.location.href; 54 | var current_version = version_segment_in_url(url); 55 | var new_url = url.replace('/' + current_version, 56 | '/' + selected_version); 57 | if (new_url != url) { 58 | navigate_to_first_existing([ 59 | new_url, 60 | url.replace('/' + current_version, 61 | '/' + selected_version) 62 | ]); 63 | } 64 | } 65 | 66 | 67 | // Returns the path segment of the version as a string, like '3.6/' 68 | // or '' if not found. 69 | function version_segment_in_url(url) { 70 | var version_segment = '(?:(?:' + version_regexs.join('|') + ')/)'; 71 | var version_regexp = '/(' + version_segment + ')'; 72 | var match = url.match(version_regexp); 73 | if (match !== null) 74 | return match[1]; 75 | return '' 76 | } 77 | 78 | $(document).ready(function() { 79 | var release = DOCUMENTATION_OPTIONS.VERSION; 80 | var version = release; 81 | var version_select = build_version_select(version, release); 82 | 83 | $('.version-placeholder').html(version_select); 84 | $('.version-placeholder select').bind('change', on_version_switch); 85 | }); 86 | })(); 87 | 88 | -------------------------------------------------------------------------------- /tkgui/layout.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # pylint:disable=unused-wildcard-import,wildcard-import,attribute-defined-outside-init 4 | """Layout helpers for the TKinter GUI.""" 5 | 6 | from . import controls 7 | 8 | 9 | class GridLayouter(object): 10 | """Class to automate grid layouts.""" 11 | def __init__(self, cols, pad=(0, 0)): 12 | """ 13 | Constructor for GridLayouter. 14 | 15 | Args: 16 | cols: number of columns for the grid. 17 | pad: the amount (x, y) of padding between elements 18 | """ 19 | self.cols = cols 20 | self.controls = [] 21 | self.used = [] 22 | try: 23 | self.pad = (int(pad), int(pad)) 24 | except TypeError: # not an int; assume tuple 25 | self.pad = pad 26 | 27 | def add(self, control, span=1, **opts): 28 | """ 29 | Adds a control to the grid. 30 | 31 | Args: 32 | control: the control to add. 33 | span: the number of columns to span (defaults to 1). 34 | opts: extra options for the grid layout. 35 | """ 36 | if control is controls.fake_control: 37 | return 38 | self.controls.append((control, span, opts)) 39 | self.layout() 40 | 41 | def layout(self): 42 | """Applies layout to the added controls.""" 43 | cells_used = 0 44 | max_index = len(self.controls) - 1 45 | for i, c in enumerate(self.controls): 46 | c = list(c) 47 | while True: 48 | row = cells_used // self.cols 49 | col = cells_used % self.cols 50 | if (row, col) not in self.used: 51 | break 52 | cells_used += 1 53 | 54 | padx = 0 if col == 0 else (self.pad[0], 0) 55 | pady = 0 if row == 0 else (self.pad[1], 0) 56 | 57 | if ((i == max_index and col != self.cols - 1) or ( 58 | i < max_index 59 | and col + c[1] + self.controls[i + 1][1] > self.cols)): 60 | # Pad colspan if last control, or next control won't fit 61 | colspan = self.cols - col 62 | for n in range(col + 1, self.cols): 63 | if (row, n) in self.used: 64 | colspan = n - col 65 | break 66 | c[1] = colspan 67 | 68 | c[0].grid( 69 | row=row, column=col, sticky="nsew", columnspan=c[1], padx=padx, 70 | pady=pady, **c[2]) 71 | if 'rowspan' in c[2]: 72 | for n in range(1, c[2]['rowspan'] + 1): 73 | self.used.append((row + n, col)) 74 | cells_used += c[1] 75 | -------------------------------------------------------------------------------- /.hgtags: -------------------------------------------------------------------------------- 1 | f585d2edeb011edc65ec4acabd61d32020f30699 0.1 2 | 0f81a4c9ac0531b5eb727e50b9a44a2504ac61df 0.2 3 | 0f81a4c9ac0531b5eb727e50b9a44a2504ac61df 0.2 4 | ed8da97fc286be2f5aa312b5f010cd078d33f411 0.2 5 | 7bb6b0f3eacd0bd34850c671dd4b0dbe6b7d6875 0.3 6 | 7bb6b0f3eacd0bd34850c671dd4b0dbe6b7d6875 0.3 7 | b659e76b9fad151adcd68ff999e17faed87f91b6 0.3 8 | b659e76b9fad151adcd68ff999e17faed87f91b6 0.3 9 | 24d3573354804de05acdb2658ece1db8848544c6 0.3 10 | baac1e9ba968ab545beb01ed5410430092e30167 0.4 11 | 74be5f566b0daa30e5f33ded57e0ee6d7b7d411c 0.5 12 | 19e70c903455254da0ce8c68e21ccf51b294a6e7 0.5.1 13 | fe60b7abcd8bf17179cd22dec5956b9826fa40ed 0.5.2 14 | c3dbee512700b7d081930f29b617d1be925785ff 0.6 15 | 68bfd106be8c303fc42cb8ae5f69471c8f1d7147 0.7 16 | 066e76d6aec3b8108e2b5fc7a1631e88cc4811e9 0.7.1 17 | de100756dab84d6be45d6cee6e1fe738b4d3e072 0.8 18 | 9b689b63c38b9389b40af75b7817f7edb0d27d1a 0.8a 19 | 246a60c96c5b0f3276cb18806749ab03e12bf265 0.8b 20 | f76f9eda5213ad277690bd28f47ca93cc3ba7acf 0.9 21 | 0b234231cd979ffe3973f04c45e8f1eec77386f4 0.9.1 22 | aaf223fc7ab84a04812be7e83d5d29e7a3342d8f 0.9.1a 23 | 9c35c852d4cd9840ce6c312d373d5242130d081e 0.9.2 24 | 9c35c852d4cd9840ce6c312d373d5242130d081e 0.9.2 25 | 6b119d9af8ca58feb9f1bb3d71a05ecf45b0d238 0.9.2 26 | 373edd61071f11336373901fe6a50eaf81fae539 0.9.2a 27 | 8f7d25bfaf25539a618291f19da8603ee6d226eb 0.9.3 28 | 0ddce7f4e3df4e3801f933388057f8adb2614767 0.9.4 29 | 7a8681a430f0d91007dbb5e3b888bcefffb340ff 0.9.5 30 | 6e32f68645bd5c0f6559d6fe1ff4637a5e81f05b 0.9.5a 31 | 75dbda09a431f56eda7db430c119b0745dcf8ac6 0.9.6 32 | b5a749292cc4b1f59b46bcc8f921d3dcb2feaacb 0.10 33 | 5d9da7b71045f0e0beec719776625d8e9c36232b 0.10a 34 | 3a1cf73201bb56967483721334b38fb1071e46a7 0.10b 35 | 80d760b59cc55440ee1575b7364c070939970f82 0.10c 36 | 652e55aebafe822f9d4ff2c7960114db6db62f0d 0.10d 37 | 427519e67058d9ac2a6444586e5178ebc7d9c366 0.10e 38 | 427519e67058d9ac2a6444586e5178ebc7d9c366 0.10e 39 | 28e9c79af2879c0fb780a39d96e1ec30ff35e17a 0.10e 40 | 2a486b049ceb77f410462d8c47c63199f80fcbfe 0.10f 41 | 338ec9f62c919ef3601f1df7313b5d028a3a26ab 0.11 42 | 338ec9f62c919ef3601f1df7313b5d028a3a26ab 0.11 43 | 4145af6bbe21761ac24e5f9e70b66b0bf2eeab37 0.11 44 | 793accf034e73af3e6f389009817ca0a869c2374 0.12 45 | e5ee63fda86383b7ea525246b5146e586ac79d67 0.12a 46 | b399ba23d6c227b7150c823e5d587f54feaf4510 0.12b 47 | 0bb39798fe8f85312b32186b453398862b040e6d 0.12c 48 | 5a681f0dd4929b4bb3363e7e671b5c00f7e70e45 0.13 49 | e86355507a529529eb245a3cdc8f2ab52ec29885 0.13a 50 | a45542d6e7fdbe0c2bc24ab5bba99d2b8faafdbb 0.13b 51 | 8b0477460243e1c69ca8a3d3d560061cbe0be5f6 0.14 52 | cf7126bc39684d16b2fd5aa9bf60f1d79d182378 0.14a 53 | 926437db6a1714bfdf9c51482c640ceef55e1acf 0.14b 54 | b4805d4b4e81f9b8f7759fb356fe0c75d824f97e 0.14c 55 | a6a2758323918a867325c29e12f283e1a53e751d 0.14d 56 | d482fb0ccf48054718e1b00faf5e4fcc9c8370ca 0.14e-pre1 57 | -------------------------------------------------------------------------------- /core/errorlog.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Framework for logging errors.""" 4 | 5 | import sys 6 | 7 | from . import paths 8 | 9 | 10 | class CaptureStream(object): 11 | """ Redirects output to a file-like object to an internal list as well as a 12 | file.""" 13 | def __init__(self, name, add_header=False, tee=True): 14 | """ 15 | Constructor for CaptureStream. Call redirect() to start redirection. 16 | 17 | Args: 18 | name: the name of the sys stream to capture (e.g. 'stdout' for 19 | sys.stdout) 20 | add_header: if True, extra information will be printed to the file 21 | when it is initially written to. The text contains the PyLNP 22 | version number, the OS it's running on, and whether it's a 23 | compiled executable. 24 | tee: if True, forward writing to the original stream after 25 | capturing. If False, the redirected stream is not used. 26 | """ 27 | self.softspace = 0 28 | self.lines = [] 29 | self.name = name 30 | self.add_header = add_header 31 | self.tee = tee 32 | self.stream = getattr(sys, name) 33 | self.outfile = None 34 | 35 | def write(self, string): 36 | """ 37 | Writes a string to the captured stream. 38 | 39 | Args: 40 | string: The string to write. 41 | """ 42 | self.lines.append(string) 43 | if not self.outfile: 44 | # TODO: See if it's possible to use a with statment here 45 | # pylint: disable=consider-using-with 46 | self.outfile = open( 47 | paths.get('root', self.name + '.txt'), 'w', encoding='utf-8') 48 | # pylint: enable=consider-using-with 49 | if self.add_header: 50 | from .lnp import VERSION, lnp 51 | self.outfile.write( 52 | "Running PyLNP {} (OS: {}, Compiled: {})\n".format( 53 | VERSION, lnp.os, lnp.os == lnp.bundle)) 54 | self.outfile.write(string) 55 | self.flush() 56 | if self.tee: 57 | return self.stream.write(string) 58 | return None 59 | 60 | def flush(self): 61 | """Flushes the output file.""" 62 | if self.outfile is not None: 63 | self.outfile.flush() 64 | 65 | def hook(self): 66 | """Replaces the named stream with the redirected stream.""" 67 | setattr(sys, self.name, self) 68 | 69 | def unhook(self): 70 | """Restores the original stream object.""" 71 | setattr(sys, self.name, self.stream) 72 | 73 | 74 | def start(): 75 | """Starts redirection of stdout and stderr.""" 76 | out.hook() 77 | err.hook() 78 | 79 | 80 | def stop(): 81 | """Stops redirection of stdout and stderr.""" 82 | out.unhook() 83 | err.unhook() 84 | 85 | 86 | out = CaptureStream('stdout') 87 | err = CaptureStream('stderr', True) 88 | 89 | # vim:expandtab 90 | -------------------------------------------------------------------------------- /core/helpers.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Helper functions.""" 4 | 5 | import glob 6 | import os 7 | import platform 8 | import sys 9 | 10 | from . import log 11 | from .dfraw import DFRaw 12 | 13 | 14 | def get_text_files(directory): 15 | """ 16 | Returns a list of .txt files in . 17 | Excludes all filenames beginning with "readme" (case-insensitive). 18 | 19 | Args: 20 | directory: the directory to search. 21 | """ 22 | temp = glob.glob(os.path.join(directory, '*.txt')) 23 | result = [] 24 | for f in temp: 25 | if not os.path.basename(f).lower().startswith('readme'): 26 | result.append(f) 27 | return result 28 | 29 | 30 | def detect_installed_file(current_file, test_files): 31 | """Returns the file in which is contained in 32 | , or "Unknown".""" 33 | try: 34 | current = DFRaw.read(current_file) 35 | for f in test_files: 36 | tested = DFRaw.read(f) 37 | if tested.endswith('\n'): 38 | tested = tested[:-1] 39 | if tested in current: 40 | return f 41 | except IOError: 42 | pass 43 | return "Unknown" 44 | 45 | 46 | def detect_installed_files(current_file, test_files): 47 | """Returns a list of files in that are contained in 48 | .""" 49 | if not os.path.isfile(current_file): 50 | log.d('Nothing installed in nonexistent file {}'.format(current_file)) 51 | return [] 52 | installed = [] 53 | try: 54 | current = DFRaw.read(current_file) 55 | for f in test_files: 56 | try: 57 | tested = DFRaw.read(f) 58 | if tested.endswith('\n'): 59 | tested = tested[:-1] 60 | if tested in current: 61 | installed.append(f) 62 | except IOError: 63 | log.e('Cannot tell if {} is installed; read failed'.format(f)) 64 | except IOError: 65 | log.e('Cannot check installs in {}; read failed'.format(current_file)) 66 | return installed 67 | 68 | 69 | def get_resource(filename): 70 | """ 71 | If running in a bundle, this will point to the place internal 72 | resources are located; if running the script directly, 73 | no modification takes place. 74 | 75 | Args: 76 | filename (str): the ordinary path to the resource 77 | 78 | Returns: 79 | (str): Path for bundled filename 80 | """ 81 | from .lnp import lnp 82 | if lnp.bundle == 'osx': 83 | # file is inside application bundle on OS X 84 | return os.path.join(os.path.dirname(sys.executable), filename) 85 | if lnp.bundle in ['win', 'linux']: 86 | # file is inside executable on Linux and Windows 87 | # pylint: disable=protected-access, no-member 88 | return os.path.join(sys._MEIPASS, filename) 89 | return os.path.abspath(filename) 90 | 91 | 92 | def os_is_64bit(): 93 | """Returns true if running on a 64-bit OS.""" 94 | return platform.machine().endswith('64') 95 | 96 | 97 | def key_from_underscore_prefixed_string(s): 98 | """Converts a string to a key such that strings prefixed with an underscore 99 | will be sorted before other strings.""" 100 | return not s.startswith('_'), s 101 | -------------------------------------------------------------------------------- /core/manifest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Manages content manifests for graphics, mods, and utilities.""" 4 | 5 | import os 6 | 7 | from . import json_config, paths 8 | from .lnp import lnp 9 | 10 | 11 | def get_cfg(content_type, item): 12 | """Returns a JSONConfiguration object for the given item. 13 | 14 | **Manifest format:** 15 | 16 | The manifest is a dictionary of values, which can be saved as manifest.json 17 | in the top level of the content folder. Content is as below, except 18 | that True or False should not be capitalised. Whitespace is irrelevant. 19 | Unused lines can be left out of the file. 20 | 21 | 'title' and 'tooltip' control presentation in the list for that kind of 22 | content. Both should be strings. Title is the name in the list; tooltip 23 | is the hovertext - linebreaks are inserted with ``\\n``, since it must be 24 | one ine in the manifest file. 25 | 26 | 'folder_prefix' controls what the name of the graphics pack's folder must 27 | begin with. 28 | 29 | 'author' and 'version' are strings for the author and version of the 30 | content. Both are for information only at this stage. 31 | 32 | 'df_min_version', 'df_max_version', and 'df_incompatible_versions' allow 33 | you to specify versions of DF with which the content is incompatible. 34 | Versions are strings of numbers, of the format '0.40.24'. Min and max are 35 | the lowest and highest compatible versions; anything outside that range has 36 | the content hidden. If they are not set, they assume all earlier or later 37 | versions are compatible. incompatible_versions is a list of specific 38 | versions which are incompatible, for when the range alone is insufficient. 39 | 40 | 'needs_dfhack' is a boolean value, and should only be True if the content 41 | does not function *at all* without DFHack. Partial requirements can be 42 | explained to the user with the 'tooltip' field. 43 | 44 | Args: 45 | content_type: 'graphics', 'mods', or 'utilities' 46 | item: content identifier path segment, such that 47 | the full path is ``'LNP/content_type/item/*'`` 48 | 49 | Returns: 50 | core.json_config.JSONConfiguration: manifest object 51 | """ 52 | default_config = { 53 | 'author': '', 54 | 'content_version': '', 55 | 'df_min_version': '', 56 | 'df_max_version': '', 57 | 'df_incompatible_versions': [], 58 | 'needs_dfhack': False, 59 | 'title': '', 60 | 'folder_prefix': '', 61 | 'tooltip': '' 62 | } 63 | if content_type == 'utilities': 64 | default_config.update({ 65 | 'win_exe': '', 66 | 'osx_exe': '', 67 | 'linux_exe': '', 68 | 'launch_with_terminal': False, 69 | 'readme': '', 70 | }) 71 | manifest = paths.get(content_type, item, 'manifest.json') 72 | return json_config.JSONConfiguration(manifest, default_config, warn=False) 73 | 74 | 75 | def exists(content_type, item): 76 | """Returns a bool, that the given item has a manifest. 77 | Used before calling get_cfg if logging a warning isn't required.""" 78 | return os.path.isfile(paths.get(content_type, item, 'manifest.json')) 79 | 80 | 81 | def is_compatible(content_type, item, ver=''): 82 | """Boolean compatibility rating; True unless explicitly incompatible.""" 83 | if not exists(content_type, item): 84 | return True 85 | if not ver: 86 | ver = lnp.df_info.version 87 | cfg = get_cfg(content_type, item) 88 | df_min_version = cfg.get_string('df_min_version') 89 | df_max_version = cfg.get_string('df_max_version') 90 | return not any([ 91 | ver < df_min_version, 92 | (ver > df_max_version and df_max_version), 93 | ver in cfg.get_list('incompatible_df_versions'), 94 | cfg.get_bool('needs_dfhack') and 'dfhack' not in lnp.df_info.variations 95 | ]) 96 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | PyLNP: A launcher for Dwarf Fortress 2 | ##################################### 3 | 4 | PyLNP has a variety of useful features to manage settings and configure the base 5 | game. It can also manage, configure, install, and run a wide variety of 6 | related content - from graphics to color schemes, and utility programs to 7 | content-replacing mods. 8 | 9 | It forms the core of various bundles for new, lazy, or impatient players - 10 | such as the old "Lazy Newb Pack". While this project is just the launcher, 11 | you can download a complete bundle for Windows, OSX, or Linux put together by members of 12 | the community. See https://dwarffortresswiki.org/Lazy_Newb_Pack for current links to these. 13 | 14 | If you have a question that is not answered here, go ahead and ask it in the 15 | `Bay12 forum thread for PyLNP `_. 16 | 17 | 18 | .. contents:: 19 | 20 | 21 | Documentation 22 | ============= 23 | .. toctree:: 24 | :maxdepth: 1 25 | 26 | content 27 | developer 28 | building 29 | 30 | Indices and tables 31 | ================== 32 | * :ref:`genindex` 33 | * :ref:`modindex` 34 | * :ref:`search` 35 | 36 | 37 | Usage Instructions 38 | ================== 39 | There's not much to it: run the launcher (see below) then click the buttons 40 | to configure your DF install, change settings or graphics, merge and install 41 | mods, run utility programs, start DF, and more. 42 | 43 | This section should probably be larger, but it should be clear how things 44 | work from the tooltips if you hover over a button. If not, ask in the forum 45 | thread linked above! The customisation section below, intended for advanced 46 | users and those compiling a custom package, may also be helpful. 47 | 48 | 49 | History 50 | ======= 51 | PyLNP started as a port of LucasUP and tolyK's Lazy Newb Pack Launcher to 52 | Python, making a launcher available on all the same platforms as Dwarf 53 | Fortress. 54 | 55 | The new edition includes many new and improved features; some of the 56 | non-obvious ones including: 57 | 58 | - Dwarf Fortress can be placed in an arbitrarily-named folder. 59 | - If multiple valid DF folders are detected, you will be asked to select the 60 | desired instance. This allows you to manage multiple installs separately with 61 | the same launcher, though this is not recommended. 62 | - A new menu item, File > Output log has been added. This opens a window 63 | containing various messages captured while executing the launcher. If errors 64 | occur, they will show up here, and are also written to a file. 65 | - In addition to excluding specific file names from utilities, you can also 66 | *include* specific file names, if they're found. Simply create a file 67 | include.txt in the Utilities folder and fill it in with the same syntax as 68 | exclude.txt. 69 | - Multiple utilities can be selected and launched simultaneously. 70 | - Utilities may be automatically started at the same time as Dwarf Fortress. 71 | - Color scheme installation and preview. 72 | - Installing graphics sets by patching instead of replacing init.txt and 73 | d_init.txt. This preserves all options not strictly related to graphics sets. 74 | 75 | 76 | When something goes wrong 77 | ========================= 78 | You may experience error messages or similar issues while running the 79 | program. As long as it has not crashed, you can retrieve these error messages 80 | by opening File > Output log. The contents shown in here can be very useful 81 | for fixing the problem, so include them if you report an error. 82 | 83 | If the program *does* crash, you can look at stdout.txt and stderr.txt which 84 | are automatically created in the application directory and show the same 85 | contents as the output log inside the program. Note that these files get 86 | overwritten every time the program launches. 87 | 88 | Please be as specific as possible when reporting an error - tell exactly what 89 | you were doing. If you were installing a graphics pack, mention which one 90 | (provide a link to where you got it). If the problem is with a utility, make 91 | sure the utility works if you launch it manually - if it doesn't, then it's a 92 | problem with the utility, not with PyLNP. 93 | -------------------------------------------------------------------------------- /core/hacks.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """DFHack management.""" 4 | 5 | import collections 6 | import filecmp 7 | import os 8 | import shutil 9 | import sys 10 | 11 | from . import log, paths 12 | from .lnp import lnp 13 | 14 | 15 | def open_dfhack_readme(): 16 | """Open the DFHack Readme in the default browser.""" 17 | from . import launcher 18 | index = paths.get('df', 'hack', 'docs', 'index.html') 19 | if os.path.isfile(index): 20 | launcher.open_file(index) 21 | else: 22 | launcher.open_url('https://dfhack.readthedocs.org') 23 | 24 | 25 | def read_hacks(): 26 | """Reads which hacks are enabled.""" 27 | hacklines = [] 28 | for init_file in ('dfhack', 'onLoad', 'onMapLoad'): 29 | try: 30 | with open(paths.get('dfhack_config', init_file + '_PyLNP.init'), 31 | encoding='latin1') as f: 32 | hacklines.extend(line.strip() for line in f.readlines()) 33 | except IOError: 34 | log.debug(init_file + '_PyLNP.init not found.') 35 | return {name: hack for name, hack in get_hacks().items() 36 | if hack['command'] in hacklines} 37 | 38 | 39 | def is_dfhack_enabled(): 40 | """Returns YES if DFHack should be used.""" 41 | if sys.platform == 'win32': 42 | if 'dfhack' not in lnp.df_info.variations: 43 | return False 44 | sdl = paths.get('df', 'SDL.dll') 45 | sdlreal = paths.get('df', 'SDLreal.dll') 46 | if not os.path.isfile(sdlreal): 47 | return False 48 | return not filecmp.cmp(sdl, sdlreal, 0) 49 | return lnp.userconfig.get_value('use_dfhack', True) 50 | 51 | 52 | def toggle_dfhack(): 53 | """Toggles the use of DFHack.""" 54 | if sys.platform == 'win32': 55 | if 'dfhack' not in lnp.df_info.variations: 56 | return 57 | sdl = paths.get('df', 'SDL.dll') 58 | sdlhack = paths.get('df', 'SDLhack.dll') 59 | sdlreal = paths.get('df', 'SDLreal.dll') 60 | if is_dfhack_enabled(): 61 | shutil.copyfile(sdl, sdlhack) 62 | shutil.copyfile(sdlreal, sdl) 63 | else: 64 | shutil.copyfile(sdl, sdlreal) 65 | shutil.copyfile(sdlhack, sdl) 66 | else: 67 | lnp.userconfig['use_dfhack'] = not lnp.userconfig.get_value( 68 | 'use_dfhack', True) 69 | lnp.save_config() 70 | 71 | 72 | def get_hacks(): 73 | """Returns dict of available hacks.""" 74 | return collections.OrderedDict(sorted( 75 | lnp.config.get_dict('dfhack').items(), key=lambda t: t[0])) 76 | 77 | 78 | def get_hack(title): 79 | """ 80 | Returns the hack titled , or None if this does not exist. 81 | 82 | Args: 83 | title: the title of the hack. 84 | """ 85 | try: 86 | return get_hacks()[title] 87 | except KeyError: 88 | log.d('No hack configured with name ' + title) 89 | return None 90 | 91 | 92 | def toggle_hack(name): 93 | """ 94 | Toggles the hack <name>. 95 | 96 | Args: 97 | name: the name of the hack to toggle. 98 | 99 | Returns: 100 | True if the hack is now enabled, 101 | False if the hack is now disabled, 102 | None on error (no change in status) 103 | """ 104 | # Setup - get the hack, which file, and validate 105 | hack = get_hack(name) 106 | init_file = hack.get('file', 'dfhack') 107 | if init_file not in ('dfhack', 'onLoad', 'onMapLoad'): 108 | log.e('Illegal file configured for hack %s; must be one of ' 109 | '"dfhack", "onLoad", "onMapLoad"', name) 110 | return None 111 | # Get the enabled hacks for this file, and toggle our state 112 | hacks = {name: h for name, h in read_hacks().items() 113 | if h.get('file', 'dfhack') == init_file} 114 | is_enabled = False 115 | if not hacks.pop(name, False): 116 | is_enabled = True 117 | hacks[name] = hack 118 | # Write back to the file 119 | fname = paths.get('dfhack_config', init_file + '_PyLNP.init') 120 | log.i('Rebuilding {} with the enabled hacks'.format(fname)) 121 | lines = ['# {}\n# {}\n{}\n\n'.format( 122 | k, h['tooltip'].replace('\n', '\n#'), h['command']) 123 | for k, h in hacks.items()] 124 | if lines: 125 | with open(fname, 'w', encoding='latin1') as f: 126 | f.write('# Generated by PyLNP\n\n') 127 | f.writelines(lines) 128 | elif os.path.isfile(fname): 129 | os.remove(fname) 130 | return is_enabled 131 | -------------------------------------------------------------------------------- /core/colors.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Color scheme management.""" 4 | 5 | import os 6 | import shutil 7 | 8 | from . import helpers, log, paths 9 | from .dfraw import DFRaw 10 | from .lnp import lnp 11 | 12 | _df_colors = ( 13 | 'BLACK', 'BLUE', 'GREEN', 'CYAN', 14 | 'RED', 'MAGENTA', 'BROWN', 'LGRAY', 15 | 'DGRAY', 'LBLUE', 'LGREEN', 'LCYAN', 16 | 'LRED', 'LMAGENTA', 'YELLOW', 'WHITE' 17 | ) 18 | 19 | 20 | def read_colors(): 21 | """Returns a sorted tuple of color scheme basenames, in LNP/Colors.""" 22 | return tuple(sorted( 23 | [os.path.splitext(os.path.basename(p))[0] for p in 24 | helpers.get_text_files(paths.get('colors'))], 25 | key=helpers.key_from_underscore_prefixed_string)) 26 | 27 | 28 | def get_colors(colorscheme=None): 29 | """ 30 | Returns RGB tuples for all 16 colors in <colorscheme>.txt, or 31 | data/init/colors.txt if no scheme is provided. On errors, returns an empty 32 | list.""" 33 | try: 34 | if colorscheme is not None: 35 | f = colorscheme 36 | if not f.endswith('.txt'): 37 | f = f + '.txt' 38 | if os.path.dirname(f) == '': 39 | f = paths.get('colors', f) 40 | else: 41 | if lnp.df_info.version <= '0.31.03': 42 | f = paths.get('init', 'init.txt') 43 | else: 44 | f = paths.get('init', 'colors.txt') 45 | color_fields = [(c + '_R', c + '_G', c + '_B') for c in _df_colors] 46 | result = DFRaw(f).get_values(*color_fields) 47 | return [tuple(int(x) for x in t) for t in result] 48 | except Exception: 49 | if colorscheme: 50 | log.e('Unable to read colorscheme %s', colorscheme, stack=True) 51 | else: 52 | log.e('Unable to read current colors', stack=True) 53 | return [] 54 | 55 | 56 | def load_colors(filename): 57 | """ 58 | Replaces the current DF color scheme. 59 | 60 | Args: 61 | filename: The name of the new colorscheme to apply (extension optional). 62 | If no path is specified, file is assumed to be in LNP/Colors. 63 | """ 64 | log.i('Loading colorscheme ' + filename) 65 | if not filename.endswith('.txt'): 66 | filename = filename + '.txt' 67 | if os.path.dirname(filename) == '': 68 | filename = paths.get('colors', filename) 69 | if lnp.df_info.version <= '0.31.03': 70 | colors = ([c + '_R' for c in _df_colors] + [c + '_G' for c in _df_colors] 71 | + [c + '_B' for c in _df_colors]) 72 | lnp.settings.read_file(filename, colors, False) 73 | lnp.settings.write_settings() 74 | else: 75 | shutil.copyfile(filename, paths.get('init', 'colors.txt')) 76 | 77 | 78 | def save_colors(filename): 79 | """ 80 | Save current keybindings to a file. 81 | 82 | Args: 83 | filename: the name of the new color scheme file. 84 | """ 85 | log.i('Saving colorscheme ' + filename) 86 | if not filename.endswith('.txt'): 87 | filename = filename + '.txt' 88 | filename = paths.get('colors', filename) 89 | if lnp.df_info.version <= '0.31.03': 90 | colors = ([c + '_R' for c in _df_colors] + [c + '_G' for c in _df_colors] 91 | + [c + '_B' for c in _df_colors]) 92 | lnp.settings.create_file(filename, colors) 93 | else: 94 | shutil.copyfile(paths.get('init', 'colors.txt'), filename) 95 | 96 | 97 | def color_exists(filename): 98 | """ 99 | Returns whether a color scheme already exists. 100 | 101 | Args: 102 | filename: the filename to check. 103 | """ 104 | if not filename.endswith('.txt'): 105 | filename = filename + '.txt' 106 | return os.access(paths.get('colors', filename), os.F_OK) 107 | 108 | 109 | def delete_colors(filename): 110 | """ 111 | Deletes a color scheme file. 112 | 113 | Args: 114 | filename: the filename to delete. 115 | """ 116 | log.i('Deleting colorscheme ' + filename) 117 | if not filename.endswith('.txt'): 118 | filename = filename + '.txt' 119 | os.remove(paths.get('colors', filename)) 120 | 121 | 122 | def get_installed_file(): 123 | """Returns the name of the currently installed color scheme, or None.""" 124 | files = helpers.get_text_files(paths.get('colors')) 125 | current_scheme = get_colors() 126 | for scheme in files: 127 | if get_colors(scheme) == current_scheme: 128 | return os.path.splitext(os.path.basename(scheme))[0] 129 | return None 130 | -------------------------------------------------------------------------------- /tkgui/dfhack.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # pylint:disable=unused-wildcard-import,wildcard-import,attribute-defined-outside-init 4 | """DFHack tab for the TKinter GUI.""" 5 | 6 | import sys 7 | from tkinter import * # noqa: F403 8 | from tkinter.ttk import * # noqa: F403 9 | 10 | from core import hacks 11 | 12 | from . import binding, controls 13 | from .layout import GridLayouter 14 | from .tab import Tab 15 | 16 | 17 | class DFHackTab(Tab): 18 | """DFHack tab for the TKinter GUI.""" 19 | def read_data(self): 20 | self.update_hack_list() 21 | # Fix focus bug 22 | if self.hacklist.get_children(): 23 | self.hacklist.focus(self.hacklist.get_children()[0]) 24 | 25 | def create_controls(self): 26 | button_group = controls.create_control_group(self, None, True) 27 | button_group.pack(side=TOP, fill=BOTH, expand=N) 28 | grid = GridLayouter(2) 29 | grid.add(controls.create_trigger_option_button( 30 | button_group, 'Enable DFHack', 31 | 'Controls whether DFHack should be enabled. Turning DFHack off ' 32 | 'also disables addons like TwbT.', 33 | self.toggle_dfhack, 'use_dfhack', lambda v: ('NO', 'YES')[ 34 | hacks.is_dfhack_enabled()])) 35 | 36 | grid.add(controls.create_trigger_button( 37 | button_group, 'Open DFHack Readme', 38 | 'Open the DFHack documentation in your browser.', 39 | hacks.open_dfhack_readme)) 40 | 41 | hacks_frame = controls.create_control_group(self, 'Available hacks') 42 | hacks_frame.pack(side=TOP, expand=Y, fill=BOTH) 43 | Grid.columnconfigure(hacks_frame, 0, weight=1) 44 | Grid.rowconfigure(hacks_frame, 1, weight=1) 45 | 46 | Label( 47 | hacks_frame, text='Click on a hack to toggle it.').grid( 48 | column=0, row=0) 49 | 50 | self.hacklist = controls.create_toggle_list(hacks_frame, ('tooltip'), { 51 | 'column': 0, 'row': 1, 'sticky': "nsew"}) 52 | self.hacklist.grid(column=0, row=0, sticky="nsew") 53 | self.configure_hacklist() 54 | 55 | def configure_hacklist(self): 56 | """Configures the treeview.""" 57 | hacklist = self.hacklist 58 | 59 | # Do not show headings 60 | hacklist.configure(show=['tree'], displaycolumns=(), selectmode="none") 61 | 62 | for seq in ("<space>", "<Return>", "<1>", 63 | "<2>" if sys.platform == 'darwin' else "<3>"): 64 | hacklist.bind(seq, self.toggle_hack) 65 | 66 | self.hack_tooltip = controls.create_tooltip(hacklist, '') 67 | hacklist.bind('<Motion>', self.update_hack_tooltip) 68 | 69 | # Make it easy to differentiate between enabled 70 | hacklist.tag_configure('enabled', background='pale green') 71 | 72 | def update_hack_tooltip(self, event): 73 | """ 74 | Event handler for mouse motion over items in the hack list. 75 | 76 | If the mouse has moved out of the last list element, hides the tooltip. 77 | Then, if the mouse is over a list item, wait controls._TOOLTIP_DELAY 78 | milliseconds (without mouse movement) before showing the tooltip""" 79 | tooltip = self.hack_tooltip 80 | hacklist = self.hacklist 81 | item = hacklist.identify_row(event.y) 82 | 83 | def show(): 84 | """Sets and shows a tooltip""" 85 | tooltip.settext(hacklist.set(item, 'tooltip')) 86 | tooltip.showtip() 87 | 88 | if tooltip.event: 89 | hacklist.after_cancel(tooltip.event) 90 | tooltip.event = None 91 | if hacklist.set(item, 'tooltip') != tooltip.text: 92 | tooltip.hidetip() 93 | if item: 94 | # pylint: disable=protected-access 95 | tooltip.event = hacklist.after(controls._TOOLTIP_DELAY, show) 96 | 97 | def update_hack_list(self): 98 | """Updates the hack list.""" 99 | for hack in self.hacklist.get_children(): 100 | self.hacklist.delete(hack) 101 | 102 | enabled = set(hacks.read_hacks()) 103 | for title, hack in hacks.get_hacks().items(): 104 | tags = ('enabled') if title in enabled else () 105 | self.hacklist.insert('', 'end', text=title, tags=tags, 106 | values=(hack['tooltip'],)) 107 | 108 | def toggle_hack(self, event): 109 | """Toggles the selected hack.""" 110 | if event.keysym == '??': 111 | item = self.hacklist.identify_row(event.y) 112 | else: 113 | item = self.hacklist.focus() 114 | 115 | if item: 116 | title = self.hacklist.item(item, 'text') 117 | is_enabled = hacks.toggle_hack(title) 118 | # pylint: disable=not-callable 119 | self.hacklist.tag_set('enabled', item, is_enabled) 120 | # pylint: enable=not-callable 121 | 122 | @staticmethod 123 | def toggle_dfhack(): 124 | """Toggles the use of DFHack.""" 125 | hacks.toggle_dfhack() 126 | binding.update() 127 | -------------------------------------------------------------------------------- /core/rawlint.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Linter for raw files. Ported from Lethosor's Lua script: 4 | https://github.com/lethosor/dfhack-scripts/blob/master/raw-lint.lua""" 5 | 6 | import os 7 | 8 | from . import log 9 | from .dfraw import DFRaw 10 | 11 | # TODO: Handle older versions correctly 12 | # For example, 40d and earlier use object names MATGLOSS and DESCRIPTOR 13 | valid_objnames = [ 14 | 'BODY_DETAIL_PLAN', 15 | 'BODY', 16 | 'BUILDING', 17 | 'CREATURE_VARIATION', 18 | 'CREATURE', 19 | 'DESCRIPTOR_COLOR', 20 | 'DESCRIPTOR_PATTERN', 21 | 'DESCRIPTOR_SHAPE', 22 | 'ENTITY', 23 | 'INORGANIC', 24 | 'INTERACTION', 25 | 'ITEM', 26 | 'LANGUAGE', 27 | 'MATERIAL_TEMPLATE', 28 | 'PLANT', 29 | 'REACTION', 30 | 'TISSUE_TEMPLATE', 31 | ] 32 | 33 | objname_overrides = { 34 | 'b_detail_plan': 'BODY_DETAIL_PLAN', 35 | 'c_variation': 'CREATURE_VARIATION', 36 | } 37 | 38 | 39 | def check_file(path): 40 | """Validates the raw file located at <path>. Error details are printed to 41 | the log with level WARNING. Returns True/False.""" 42 | # pylint:disable=too-many-branches 43 | file_ok = True 44 | if not path.endswith('.txt'): 45 | log.w('Unrecognized filename') 46 | return False 47 | contents = DFRaw.read(path) 48 | filename = os.path.basename(path)[:-4] 49 | try: 50 | realname = contents.splitlines()[0] 51 | except IndexError: 52 | realname = '' 53 | try: 54 | rawname = realname.split()[0] 55 | except IndexError: 56 | rawname = realname 57 | # Everything before first whitespace must match filename 58 | if not (realname == realname.lstrip() and rawname == filename): 59 | log.w('Name mismatch: expected %s, found %s' % (filename, rawname)) 60 | file_ok = False 61 | objname = filename 62 | check_objnames = [] 63 | for k, v in objname_overrides.items(): 64 | if filename.startswith(k) and v in valid_objnames: 65 | check_objnames.append(v) 66 | for o in valid_objnames: 67 | if filename.upper().startswith(o): 68 | check_objnames.append(o) 69 | if check_objnames: 70 | found = False 71 | for i, objname in enumerate(check_objnames): 72 | objname = '[OBJECT:' + objname.upper() + ']' 73 | if objname in contents: 74 | found = True 75 | check_objnames[i] = objname 76 | if not found: 77 | log.w('None of %s found' % ', '.join(check_objnames)) 78 | file_ok = False 79 | else: 80 | log.w('No valid object names') 81 | file_ok = False 82 | return file_ok 83 | 84 | 85 | def check_folder(path): 86 | """Validates all raw files in <path> and its subfolders. Problems with 87 | individual files are printed to the log with level WARNING. General problems 88 | are printed to the log with level ERROR. 89 | 90 | Returns: 91 | (passed, failed): two lists of paths of files that passed or failed, 92 | respectively. 93 | """ 94 | log.push_prefix('RawLint') 95 | files = [] 96 | for d in os.walk(path): 97 | files += [os.path.join(d[0], f) for f in d[2]] 98 | passed = [] 99 | failed = [] 100 | if not files: 101 | log.e('Could not find any files in ' + path) 102 | for f in files: 103 | f_parts = f.split(os.sep) 104 | if (f.endswith('.txt') and 'notes' not in f_parts 105 | and 'examples and notes' not in f_parts and 'text' not in f_parts): 106 | log.push_prefix(f) 107 | has_passed = check_file(f) 108 | log.pop_prefix() 109 | if has_passed: 110 | passed.append(f) 111 | else: 112 | failed.append(f) 113 | log.pop_prefix() 114 | return (passed, failed) 115 | 116 | 117 | def check_df(path): 118 | """Validates the raw/objects folder in the Dwarf Fortress folder located at 119 | <path>. Problem with individual files are printed to the log with level 120 | WARNING. General problems are printed to the log with level ERROR. 121 | 122 | Returns: 123 | (passed, failed): two lists of paths of files that passed or failed, 124 | respectively. 125 | """ 126 | return check_folder(os.path.join(path, 'raw', 'objects')) 127 | 128 | 129 | def check_folder_bool(path): 130 | """Returns True if all raw files in <path> pass validation. Problems with 131 | individual files are printed to the log with level WARNING. General 132 | problems are printed to the log with level ERROR.""" 133 | p, f = check_folder(path) 134 | return len(f) == 0 and len(p) != 0 135 | 136 | 137 | def check_df_bool(path): 138 | """Validates the raw/objects folder in the Dwarf Fortress folder located at 139 | <path> and returns True if all files pass validation. Problems with 140 | individual files are printed to the log with level WARNING. General 141 | problems are printed to the log with level ERROR.""" 142 | p, f = check_df(path) 143 | return len(f) == 0 and len(p) != 0 144 | -------------------------------------------------------------------------------- /core/json_config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Proxy to abstract access to JSON configuration and gracefully handle missing 4 | keys.""" 5 | 6 | import json 7 | import os 8 | 9 | from . import log 10 | 11 | 12 | class JSONConfiguration(object): 13 | """Proxy for JSON-based configuration files.""" 14 | 15 | def __init__(self, filename, default=None, warn=True): 16 | """ 17 | Constructor for JSONConfiguration. 18 | 19 | Args: 20 | filename: JSON filename to load data from. 21 | Use None to only use object from default. 22 | default: default value to use in case loading fails. 23 | warn (bool): Log a warning if the file is missing, default True. 24 | """ 25 | self.filename = filename 26 | self.data = default if default else {} 27 | if filename is None: 28 | return 29 | if not os.path.isfile(filename): 30 | if warn: 31 | log.w("JSONConfiguration: File " + filename + " does not exist") 32 | return 33 | try: 34 | with open(filename, encoding="utf-8") as file: 35 | self.data = json.load(file) 36 | except Exception: 37 | log.e('Note: Failed to read JSON from ' + filename 38 | + ', ignoring data - details follow', stack=True) 39 | 40 | @staticmethod 41 | def from_text(text): 42 | """Create a JSONConfiguration object from a string.""" 43 | return JSONConfiguration(None, json.loads(text)) 44 | 45 | def save_data(self): 46 | """Saves the data to the original JSON file. Has no effect if no 47 | filename was given during construction.""" 48 | if self.filename: 49 | with open(self.filename, 'w', encoding="utf-8") as file: 50 | json.dump(self.data, file, indent=2) 51 | 52 | def get(self, path, default=None): 53 | """ 54 | Retrieves a value from the configuration. 55 | Returns default if the path does not exist. 56 | 57 | Args: 58 | path: ``/``-delimited path to the string. 59 | default: value returned if path does not exist. 60 | """ 61 | try: 62 | path = path.split('/') 63 | result = self.data 64 | for p in path: 65 | result = result[p] 66 | return result 67 | except KeyError: 68 | return default 69 | 70 | def has_value(self, path): 71 | """Returns True if the path exists in the configuration.""" 72 | return self.get_value(path) is not None 73 | 74 | def get_value(self, path, default=None): 75 | """ 76 | Retrieves a value from the configuration. 77 | Returns default if the path does not exist. 78 | 79 | Args: 80 | path: ``/``-delimited path to the string. 81 | default: value returned if path does not exist. 82 | """ 83 | return self.get(path, default) 84 | 85 | def get_string(self, path): 86 | """ 87 | Retrieves a value from the configuration. 88 | Returns an empty string if the path does not exist. 89 | 90 | Args: 91 | path: ``/``-delimited path to the string. 92 | """ 93 | return self.get_value(path, "") 94 | 95 | def get_number(self, path): 96 | """ 97 | Retrieves a value from the configuration. 98 | Returns 0 if the path does not exist. 99 | 100 | Args: 101 | path: ``/``-delimited path to the string. 102 | """ 103 | return self.get_value(path, 0) 104 | 105 | def get_bool(self, path): 106 | """ 107 | Retrieves a value from the configuration. 108 | Returns False if the path does not exist. 109 | 110 | Args: 111 | path: ``/``-delimited path to the string. 112 | """ 113 | return self.get_value(path, False) 114 | 115 | def get_list(self, path): 116 | """ 117 | Retrieves a value from the configuration. 118 | Returns an empty list if the path does not exist. 119 | 120 | Args: 121 | path: ``/``-delimited path to the string. 122 | """ 123 | return self.get_value(path, []) 124 | 125 | def get_dict(self, path): 126 | """ 127 | Retrieves a value from the configuration. 128 | Returns an empty dictionary if the path does not exist. 129 | 130 | Args: 131 | path: ``/``-delimited path to the string. 132 | """ 133 | return self.get_value(path, {}) 134 | 135 | def set_value(self, key, value): 136 | """ 137 | Writes a value to a key. 138 | Note: Arbitrary paths not supported - you must refresh entire key. 139 | 140 | Args: 141 | key: the key to save the value under. 142 | value: the value to save. 143 | """ 144 | self.__setitem__(key, value) # pylint: disable=unnecessary-dunder-call 145 | 146 | def __getitem__(self, key): 147 | """Accessor for indexing directly into the configuration.""" 148 | return self.get_value(key) 149 | 150 | def __setitem__(self, key, value): 151 | """Accessor for writing into the configuration with indexing.""" 152 | self.data[key] = value 153 | -------------------------------------------------------------------------------- /core/log.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Logging module.""" 4 | 5 | import sys 6 | import traceback 7 | 8 | _log = None 9 | 10 | # Logging levels 11 | VERBOSE = 0 12 | DEBUG = 1 13 | INFO = 2 14 | WARNING = 3 15 | ERROR = 4 16 | 17 | 18 | class Log(object): 19 | """Logging class.""" 20 | def __init__(self): 21 | """Constructor for Log. Sets the maximum logging level to INFO.""" 22 | self.max_level = INFO 23 | self.output_err = False 24 | self.output_out = False 25 | self.level_stack = [] 26 | self.lines = [] 27 | self.prefixes = [] 28 | 29 | def push_level(self, level): 30 | """Temporarily changes the logging level to <level>. Call pop_level to 31 | restore the previous level.""" 32 | self.level_stack.append(level) 33 | self.set_level(level) 34 | 35 | def pop_level(self): 36 | """Restores the previous logging level, if the level stack is not 37 | empty.""" 38 | try: 39 | self.max_level = self.level_stack.pop() 40 | except IndexError: 41 | self.e('Tried to pop logging level, but stack empty', stack=True) 42 | 43 | def set_level(self, level): 44 | """Sets the maximum logging level to <level>.""" 45 | self.max_level = level 46 | 47 | def push_prefix(self, prefix): 48 | """Adds a prefix to future log messages. Old prefixes will appear before 49 | new prefixes.""" 50 | self.prefixes.append(prefix) 51 | 52 | def pop_prefix(self): 53 | """Removes the most recently added prefix from future log messages.""" 54 | try: 55 | self.prefixes.pop() 56 | except IndexError: 57 | self.e('Tried to pop logging prefix, but stack empty', stack=True) 58 | 59 | def __get_prefixes(self): 60 | """Returns a string containing the prefixes for this log message.""" 61 | if not self.prefixes: 62 | return '' 63 | return ': '.join(self.prefixes + ['']) 64 | 65 | def log(self, log_level, message, *args, **kwargs): 66 | """Logs a message if the current logging level includes messages at 67 | level <log_level>. 68 | 69 | Args: 70 | log_level: the level to log the message at. If less than the current 71 | logging level, nothing will happen. 72 | message: the message to log. 73 | *args: Used to format the message with the ``%`` operator. 74 | stack: if True, logs a stack trace. If sys.excinfo contains an 75 | exception, this will be formatted and logged instead. 76 | """ 77 | if log_level < self.max_level: 78 | return 79 | p = self.__get_level_string(log_level) + self.__get_prefixes() 80 | self.__write(p + str(message) % args + "\n") 81 | if kwargs.get('stack', False): 82 | ex = sys.exc_info() 83 | if ex[2]: 84 | for line in traceback.format_exception(*ex): 85 | self.__write(line) 86 | else: 87 | for line in traceback.format_stack(): 88 | self.__write(line) 89 | 90 | @staticmethod 91 | def __get_level_string(level): 92 | """Returns a prefix corresponding to the given logging level.""" 93 | return ["VERBOSE", "DEBUG", "INFO", "WARNING", "ERROR"][level] + ": " 94 | 95 | def d(self, message, *args, **kwargs): 96 | """Writes a DEBUG message to the log. See `Log.log` for details.""" 97 | return self.log(DEBUG, message, *args, **kwargs) 98 | 99 | def e(self, message, *args, **kwargs): 100 | """Writes an ERROR message to the log. See `Log.log` for details.""" 101 | return self.log(ERROR, message, *args, **kwargs) 102 | 103 | def i(self, message, *args, **kwargs): 104 | """Writes a INFO message to the log. See `Log.log` for details.""" 105 | return self.log(INFO, message, *args, **kwargs) 106 | 107 | def v(self, message, *args, **kwargs): 108 | """Writes a VERBOSE message to the log. See `Log.log` for details.""" 109 | return self.log(VERBOSE, message, *args, **kwargs) 110 | 111 | def w(self, message, *args, **kwargs): 112 | """Writes a WARNING message to the log. See `Log.log` for details.""" 113 | return self.log(WARNING, message, *args, **kwargs) 114 | 115 | def get_lines(self): 116 | """Returns all logged lines.""" 117 | return self.lines 118 | 119 | def __write(self, text): 120 | """Writes a line of text to the log.""" 121 | if self.output_err: 122 | sys.stderr.write(text) 123 | if self.output_out: 124 | sys.stdout.write(text) 125 | self.lines.append(text) 126 | 127 | 128 | def get(): 129 | """Returns the default Log instance.""" 130 | return _log 131 | 132 | 133 | # prepare the default instance 134 | _log = Log() 135 | # Output to error log on the default instance 136 | _log.output_err = True 137 | 138 | # expose the methods targeting the default instance on the module itself 139 | push_level = _log.push_level 140 | pop_level = _log.pop_level 141 | set_level = _log.set_level 142 | log = _log.log 143 | debug = d = _log.d 144 | error = e = _log.e 145 | info = i = _log.i 146 | verbose = v = _log.v 147 | warning = w = _log.w 148 | get_lines = _log.get_lines 149 | push_prefix = _log.push_prefix 150 | pop_prefix = _log.pop_prefix 151 | -------------------------------------------------------------------------------- /core/keybinds.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Keybinding management.""" 4 | 5 | import collections 6 | import os 7 | import shutil 8 | 9 | from . import baselines, helpers, log, paths 10 | from .lnp import lnp 11 | 12 | 13 | def _keybind_fname(filename): 14 | """Turn a string into a valid filename for storing keybindings.""" 15 | filename = os.path.basename(filename) 16 | if not filename.endswith('.txt'): 17 | filename = filename + '.txt' 18 | return paths.get('keybinds', filename) 19 | 20 | 21 | def read_keybinds(): 22 | """Returns a list of keybinding files.""" 23 | files = [] 24 | for fname in helpers.get_text_files(paths.get('keybinds')): 25 | with open(fname, encoding='cp437') as f: 26 | if ('[DISPLAY_STRING:' in f.read()) == \ 27 | ('legacy' in lnp.df_info.variations): 28 | files.append(fname) 29 | return tuple(sorted(os.path.basename(o) for o in files)) 30 | 31 | 32 | def _sdl_get_binds(filename, compressed=True): 33 | """Return serialised keybindings for the given file. 34 | Returns a compressed version, without vanilla entries, unless disabled. 35 | 36 | Allows keybindings to be stored as files with only the non-vanilla 37 | bindings, improving readability and compatibility across DF versions. 38 | Only compatible with SDL versions, however. 39 | """ 40 | with open(filename, encoding='cp437') as f: 41 | lines = f.readlines() 42 | od, lastkey = collections.OrderedDict(), None 43 | for line in (line.strip() for line in lines if line.strip()): 44 | if line.startswith('[BIND:'): 45 | od[line], lastkey = [], line 46 | elif lastkey is not None: 47 | od[lastkey].append(line) 48 | if not compressed: 49 | return od 50 | van = _get_vanilla_binds() 51 | if van is not None: 52 | return collections.OrderedDict( 53 | (k, v) for k, v in od.items() 54 | # only keep items with a vanilla counterpart, which is different 55 | if van.get(k) and set(van.get(k)) != set(v)) 56 | return None 57 | 58 | 59 | def _sdl_write_binds(filename, binds_od, expanded=False): 60 | """Write keybindings to the given file, optionally expanding them.""" 61 | if expanded: 62 | van = _get_vanilla_binds() 63 | if van is not None: 64 | binds_od = collections.OrderedDict( 65 | (k, binds_od.get(k) or v) for k, v in van.items()) 66 | lines = [''] 67 | for bind, vals in binds_od.items(): 68 | lines.append(bind) 69 | # no indent allowed in interface.txt; otherwise makes reading easier 70 | lines.extend(vals if expanded else [' ' + v for v in vals]) 71 | text = '\n'.join(lines) + '\n' 72 | if filename is None: 73 | return text 74 | with open(filename, 'w', encoding='cp437') as f: 75 | f.write(text) 76 | return None 77 | 78 | 79 | def _get_vanilla_binds(): 80 | """Return the vanilla keybindings for use in compression or expansion.""" 81 | try: 82 | vanfile = os.path.join( 83 | baselines.find_vanilla(False), 'data', 'init', 'interface.txt') 84 | return _sdl_get_binds(vanfile, compressed=False) 85 | except TypeError: 86 | log.w("Can't load or change keybinds with missing baseline!") 87 | return None 88 | 89 | 90 | def load_keybinds(filename): 91 | """ 92 | Overwrites Dwarf Fortress keybindings from a file. 93 | 94 | Params: 95 | filename 96 | The keybindings file to use. 97 | """ 98 | target = paths.get('init', 'interface.txt') 99 | filename = _keybind_fname(filename) 100 | log.i('Loading keybinds: ' + filename) 101 | if 'legacy' in lnp.df_info.variations: 102 | shutil.copyfile(filename, target) 103 | else: 104 | _sdl_write_binds(target, _sdl_get_binds(filename), expanded=True) 105 | 106 | 107 | def keybind_exists(filename): 108 | """ 109 | Returns whether a keybindings file already exists. 110 | 111 | Args: 112 | filename: the filename to check. 113 | """ 114 | return os.access(_keybind_fname(filename), os.F_OK) 115 | 116 | 117 | def save_keybinds(filename): 118 | """ 119 | Save current keybindings to a file. 120 | 121 | Args: 122 | filename: the name of the new keybindings file. 123 | """ 124 | installed = paths.get('init', 'interface.txt') 125 | filename = _keybind_fname(filename) 126 | log.i('Saving current keybinds as ' + filename) 127 | if 'legacy' in lnp.df_info.variations: 128 | shutil.copyfile(installed, filename) 129 | else: 130 | _sdl_write_binds(filename, _sdl_get_binds(installed)) 131 | 132 | 133 | def delete_keybinds(filename): 134 | """ 135 | Deletes a keybindings file. 136 | 137 | Args: 138 | filename: the filename to delete. 139 | """ 140 | log.i('Deleting ' + filename + 'keybinds') 141 | os.remove(_keybind_fname(filename)) 142 | 143 | 144 | def get_installed_file(): 145 | """Returns the name of the currently installed keybindings.""" 146 | def unordered(fname): 147 | """An order-independent representation of keybindings from a file.""" 148 | return {k: set(v) for k, v in _sdl_get_binds(fname).items()} 149 | 150 | try: 151 | installed = unordered(paths.get('df', 'data', 'init', 'interface.txt')) 152 | for fname in helpers.get_text_files(paths.get('keybinds')): 153 | if installed == unordered(fname): 154 | return os.path.basename(fname) 155 | except Exception: 156 | # Baseline missing, or interface.txt is missing from baseline - use 157 | # plain file comparison 158 | pass 159 | 160 | files = helpers.get_text_files(paths.get('keybinds')) 161 | current = paths.get('init', 'interface.txt') 162 | result = helpers.detect_installed_file(current, files) 163 | return os.path.basename(result) 164 | -------------------------------------------------------------------------------- /lnp.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | # If PIL or similar is available on this system, it will be available for the 3 | # generated executable. Since this is the only factor in whether or not we 4 | # will be able to use non-GIF images, we only include the appropriate version. 5 | import sys 6 | 7 | if sys.platform == 'win32': 8 | try: 9 | from PyInstaller.utils.winmanifest import Manifest 10 | except ImportError: 11 | # Newer PyInstaller versions 12 | from PyInstaller.utils.win32.winmanifest import Manifest 13 | Manifest.old_toprettyxml = Manifest.toprettyxml 14 | 15 | def new_toprettyxml(self, indent=" ", newl=os.linesep, encoding="UTF-8"): # noqa: F821 16 | s = self.old_toprettyxml(indent, newl, encoding) 17 | # Make sure we only modify our own manifest 18 | if 'name="lnp"' in s: 19 | d = (indent 20 | + '<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">' 21 | '<windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">' 22 | '<dpiAware>false</dpiAware></windowsSettings></asmv3:application>' 23 | + newl) 24 | s = s.replace('</assembly>', d + '</assembly>') 25 | return s 26 | Manifest.toprettyxml = new_toprettyxml 27 | 28 | try: 29 | from PIL import Image, ImageTk 30 | has_PIL = True 31 | except ImportError: # Some PIL installations live outside of the PIL package 32 | try: 33 | import Image # noqa: F401 34 | import ImageTk # noqa: F401 35 | has_PIL = True 36 | except ImportError: # No PIL compatible library 37 | has_PIL = False 38 | 39 | from tkinter import * # noqa: F403 40 | 41 | if has_PIL or TkVersion >= 8.6: 42 | logo = 'LNPSMALL.png' 43 | icon = 'LNP.png' 44 | else: 45 | logo = 'LNPSMALL.gif' 46 | icon = 'LNP.gif' 47 | 48 | extension = '' 49 | script = 'launch.py' 50 | if sys.platform == 'win32': 51 | icon = 'LNP.ico' 52 | extension = '.exe' 53 | 54 | hiddenimports = [] 55 | if sys.platform.startswith('linux'): 56 | hiddenimports = ['PIL', 'PIL._imagingtk', 'PIL._tkinter_finder'] 57 | 58 | needs_tcl_copy = False 59 | 60 | if sys.platform == 'darwin' and sys.hexversion >= 0x3070000: 61 | needs_tcl_copy = True 62 | try: 63 | # HACK: PyInstaller is not handling the bundled Tcl and Tk in Python 3.7 from python.org 64 | # properly. 65 | # 66 | # This patch intercepts the value that causes PyInstaller to attempt to use the wrong Tcl/Tk 67 | # version and triggers a fallback to treat Tcl/Tk as a Unix-style build. 68 | # 69 | # See https://github.com/pyinstaller/pyinstaller/issues/3753 for the relevant bug report for 70 | # PyInstaller 71 | from PyInstaller.depend import bindepend 72 | old_selectImports = bindepend.selectImports 73 | 74 | def patched_selectImports(pth, xtrapath=None): 75 | rv = old_selectImports(pth, xtrapath) 76 | if '_tkinter' in pth: 77 | import inspect 78 | caller = inspect.stack()[1] 79 | if ('hook-_tkinter.py' in caller.filename and 'Library/Frameworks' in rv[0][1] 80 | and 'Python' in rv[0][1]): 81 | return [('libtcl8.6.dylib', ''), ('libtk8.6.dylib', '')] 82 | return rv 83 | bindepend.selectImports = patched_selectImports 84 | except ImportError: 85 | pass 86 | 87 | a = Analysis( 88 | [script], pathex=['.'], hiddenimports=hiddenimports, hookspath=None, runtime_hooks=None) 89 | a.datas += [(logo, logo, 'DATA'), (icon, icon, 'DATA')] 90 | if sys.platform == 'win32': 91 | # Importing pkg_resources fails with Pillow on Windows due to 92 | # un-normalized case; this works around the problem 93 | a.datas = list({tuple(map(str.upper, t)) for t in a.datas}) 94 | pyz = PYZ(a.pure) 95 | if sys.platform != 'darwin': 96 | exe = EXE( 97 | pyz, a.scripts, a.binaries, a.zipfiles, a.datas, name='PyLNP' + extension, 98 | debug=False, strip=None, upx=False, console=False, icon='LNP.ico') 99 | else: 100 | info = {'NSHighResolutionCapable': 'True'} 101 | exe = EXE( 102 | pyz, a.scripts, exclude_binaries=True, name='PyLNP' + extension, 103 | debug=False, strip=None, upx=True, console=False) 104 | coll = COLLECT(exe, a.binaries, a.zipfiles, a.datas, strip=None, upx=True, name='PyLNP') 105 | app = BUNDLE(coll, name='PyLNP.app', icon='LNP.icns', info_plist=info) 106 | if needs_tcl_copy: 107 | import shutil 108 | import os 109 | 110 | def copytree(src, dst, symlinks=False, ignore=None): 111 | if not os.path.exists(dst): 112 | os.makedirs(dst) 113 | for item in os.listdir(src): 114 | s = os.path.join(src, item) 115 | d = os.path.join(dst, item) 116 | if os.path.isdir(s): 117 | copytree(s, d, symlinks, ignore) 118 | else: 119 | if not os.path.exists(d) or os.stat(s).st_mtime - os.stat(d).st_mtime > 1: 120 | shutil.copy2(s, d) 121 | 122 | # Manually copy tcl/tk files into .app - based on copy commands mentioned here: 123 | # https://github.com/pyinstaller/pyinstaller/issues/3753#issuecomment-432464838 124 | # https://stackoverflow.com/questions/56092383/how-to-fix-msgcatmc-error-after-running-app-from-pyinstaller-on-macos-mojave 125 | basepath = os.path.normpath(os.path.join(os.path.dirname(sys.executable), '..', 'lib')) 126 | for e in os.listdir(basepath): 127 | p = os.path.join(basepath, e) 128 | if not os.path.isdir(p): 129 | continue 130 | if e == 'tcl8': 131 | dst = os.path.abspath(os.path.join(app.name, 'Contents', 'MacOS', 'tcl8')) 132 | elif e.startswith('tcl'): 133 | dst = os.path.abspath(os.path.join(app.name, 'Contents', 'MacOS', 'tcl')) 134 | elif e.startswith('tk') or e.startswith('Tk'): 135 | dst = os.path.abspath(os.path.join(app.name, 'Contents', 'MacOS', 'tk')) 136 | else: 137 | continue 138 | copytree(p, dst) 139 | 140 | # vim:expandtab 141 | -------------------------------------------------------------------------------- /core/legends_processor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """A module to compress and sort legends exports from DF 0.40.09 and later. 4 | 5 | - .bmp converted to .png where possible. 6 | - Create an archive for Legends Viewer if possible, or just compress 7 | the (huge) xml. 8 | - Sort files into region folder, with maps subfolders, and move to user 9 | content folder if this folder exists. 10 | """ 11 | 12 | import glob 13 | import os 14 | import re 15 | import subprocess 16 | import zipfile 17 | 18 | from . import log, paths 19 | from .lnp import lnp 20 | 21 | 22 | def get_region_info(): 23 | """Returns a tuple of strings for an available region and date. 24 | Eg: ('region1', '00250-01-01') 25 | """ 26 | files = [f for f in glob.glob(paths.get('df', 'region*-*-??-??-*')) if 27 | os.path.isfile(f)] 28 | if files: 29 | fname = os.path.basename(files[0]) 30 | region = re.search( 31 | r'^.*(?=(-\d{5,}-\d{2}-\d{2}))', fname).group() 32 | date = re.search(r'\d{5,}-\d{2}-\d{2}', fname).group() 33 | return region, date 34 | return None 35 | 36 | 37 | def compress_bitmaps(): 38 | """Compresses all bitmap maps.""" 39 | # pylint: disable=import-error 40 | try: 41 | from PIL import Image 42 | except ImportError: 43 | try: 44 | import Image 45 | except ImportError: 46 | call_optipng() 47 | else: 48 | log.i('Compressing bitmaps with PIL/Pillow') 49 | for fname in glob.glob(paths.get( 50 | 'df', '-'.join(get_region_info()) + '-*.bmp')): 51 | f = Image.open(fname) 52 | f.save(fname[:-3] + 'png', format='PNG', optimize=True) 53 | os.remove(fname) 54 | 55 | 56 | def call_optipng(): 57 | """Calling optipng can work well, but isn't very portable.""" 58 | if os.name == 'nt' and os.path.isfile(paths.get('df', 'optipng.exe')): 59 | log.w('Falling back to optipng for image compression. ' 60 | 'It is recommended to install PIL.') 61 | for fname in glob.glob(paths.get( 62 | 'df', '-'.join(get_region_info()) + '-*.bmp')): 63 | ret = subprocess.call([paths.get('df', 'optipng'), '-zc9', '-zm9', 64 | '-zs0', '-f0', fname], 65 | creationflags=0x00000008) 66 | if ret == 0: 67 | os.remove(fname) 68 | else: 69 | log.e('A PIL-compatible library is required to compress bitmaps.') 70 | 71 | 72 | def choose_region_map(): 73 | """Returns the most-preferred region map available, or fallback.""" 74 | pattern = paths.get('df', '-'.join(get_region_info()) + '-') 75 | for name in ('detailed', 'world_map'): 76 | for ext in ('.png', '.bmp'): 77 | if os.path.isfile(pattern + name + ext): 78 | return pattern + name + ext 79 | return pattern + 'world_map.bmp' 80 | 81 | 82 | def create_archive(): 83 | """Creates a legends archive, or zips the xml if files are missing.""" 84 | pattern = paths.get('df', '-'.join(get_region_info()) + '-') 85 | worldgen = paths.get('df', get_region_info()[0] + '-world_gen_param.txt') 86 | filepaths = [pattern + 'legends.xml', pattern + 'world_history.txt', worldgen, 87 | choose_region_map(), pattern + 'world_sites_and_pops.txt'] 88 | if os.path.isfile(pattern + 'legends_plus.xml'): 89 | filepaths.append(pattern + 'legends_plus.xml') 90 | if all(os.path.isfile(f) for f in filepaths): 91 | with zipfile.ZipFile(pattern + 'legends_archive.zip', 92 | 'w', zipfile.ZIP_DEFLATED, allowZip64=True) as zipped: 93 | for f in filepaths: 94 | zipped.write(f, os.path.basename(f)) 95 | os.remove(f) 96 | elif os.path.isfile(pattern + 'legends.xml'): 97 | with zipfile.ZipFile(pattern + 'legends_xml.zip', 98 | 'w', zipfile.ZIP_DEFLATED, allowZip64=True) as zipped: 99 | zipped.write(pattern + 'legends.xml', 100 | os.path.basename(pattern + 'legends.xml')) 101 | os.remove(pattern + 'legends.xml') 102 | 103 | 104 | def move_files(): 105 | """Moves files to a subdir, and subdir to ../User Generated Content if 106 | that dir exists.""" 107 | pattern = paths.get('df', '-'.join(get_region_info())) 108 | region = get_region_info()[0] 109 | dirname = get_region_info()[0] + '_legends_exports' 110 | if os.path.isdir(paths.get('root', 'User Generated Content')): 111 | dirname = paths.get( 112 | 'root', 'User Generated Content', 'Legends', dirname) 113 | if not os.path.isdir(dirname): 114 | os.makedirs(dirname) 115 | else: 116 | dirname = paths.get('df', dirname) 117 | for site_map in glob.glob(pattern + '-site_map-*'): 118 | target = os.path.join(dirname, 'site_maps', os.path.basename(site_map)) 119 | if os.path.isfile(target): 120 | os.remove(site_map) 121 | continue 122 | os.renames(site_map, target) 123 | maps = ('world_map', 'bm', 'detailed', 'dip', 'drn', 'el', 'elw', 124 | 'evil', 'hyd', 'nob', 'rain', 'sal', 'sav', 'str', 'tmp', 125 | 'trd', 'veg', 'vol') 126 | for m in maps: 127 | m = glob.glob(pattern + '-' + m + '.???') 128 | if m: 129 | log.d('Found the following region map: ' + str(m[0])) 130 | t = os.path.join(dirname, 'region_maps', os.path.basename(m[0])) 131 | if os.path.isfile(t): 132 | os.remove(m[0]) 133 | continue 134 | os.renames(m[0], t) 135 | for f in glob.glob(paths.get('df', region + '-*')): 136 | log.d('Found the following misc files: ' + str(f)) 137 | if os.path.isfile(f): 138 | target = os.path.join(dirname, os.path.basename(f)) 139 | if os.path.isfile(target): 140 | os.remove(f) 141 | continue 142 | os.renames(f, target) 143 | for f in glob.glob(paths.get('df', '*_color_key.txt')): 144 | os.remove(f) 145 | 146 | 147 | def process_legends(): 148 | """Process all legends exports in sets.""" 149 | if lnp.df_info.version >= '0.40.09': 150 | i = 0 151 | while get_region_info(): 152 | log.i('Processing legends from ' + get_region_info()[0]) 153 | compress_bitmaps() 154 | create_archive() 155 | move_files() 156 | i += 1 157 | return i 158 | return None 159 | -------------------------------------------------------------------------------- /core/baselines.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Advanced raw and data folder management, for mods or graphics packs.""" 4 | 5 | import fnmatch 6 | import glob 7 | import os 8 | import shutil 9 | import tarfile 10 | import zipfile 11 | 12 | from . import log, paths, update 13 | from .lnp import lnp 14 | 15 | 16 | def find_vanilla(download_missing=True): 17 | """Finds the vanilla baseline for the current version. 18 | 19 | Starts by unzipping any DF releases in baselines and preprocessing them. 20 | If download_missing is set to True, missing baselines will be downloaded. 21 | 22 | Returns: 23 | Path to the vanilla folder, eg ``'LNP/Baselines/df_40_15'``, 24 | ``False`` if baseline not available (and start download), 25 | ``None`` if version detection is not accurate 26 | """ 27 | if lnp.df_info.source == "init detection": 28 | log.w('Baseline DF version from init detection; highly unreliable!') 29 | return None 30 | prepare_baselines() 31 | version = 'df_' + str(lnp.df_info.version)[2:].replace('.', '_') 32 | if os.path.isdir(paths.get('baselines', version)): 33 | return paths.get('baselines', version) 34 | if download_missing: 35 | update.download_df_baseline() 36 | return False 37 | 38 | 39 | def find_vanilla_raws(download_missing=True): 40 | """Finds vanilla raws for the current version.""" 41 | retval = find_vanilla(download_missing) 42 | if retval: 43 | return os.path.join(retval, 'raw') 44 | return retval 45 | 46 | 47 | def prepare_baselines(): 48 | """Unzip any DF releases found, and discard non-universal files.""" 49 | archives = glob.glob(os.path.join(paths.get('baselines'), 'df_??_?*.???')) 50 | if archives: 51 | log.i('Extracting archives in baselines: ' + str(archives)) 52 | for item in archives: 53 | version = os.path.basename(item) 54 | for s in ('_win32', '_osx32', '_linux32', '_legacy32', 55 | '_win', '_osx', '_linux', '_legacy', '_s', 56 | '.zip', '.tar.bz2'): 57 | version = version.replace(s, '') 58 | f = paths.get('baselines', version) 59 | if not os.path.isdir(f): 60 | if item.endswith('.zip'): 61 | with zipfile.ZipFile(item) as zipped: 62 | zipped.extractall(f) 63 | elif item.endswith('.tar.bz2'): 64 | with tarfile.TarFile(item) as tarred: 65 | tarred.extractall(f) 66 | for k in glob.glob(os.path.join(f, 'df_*x', '*')): 67 | shutil.move(k, f) 68 | simplify_pack(version, 'baselines') 69 | os.remove(item) 70 | 71 | 72 | def set_auto_download(value): 73 | """Sets the option for auto-download of baselines.""" 74 | lnp.userconfig['downloadBaselines'] = value 75 | lnp.userconfig.save_data() 76 | 77 | 78 | def simplify_pack(pack, folder): 79 | """Removes unnecessary files from LNP/<folder>/<pack>. 80 | 81 | Args: 82 | pack, folder: path segments in ``'./LNP/folder/pack/'`` as strings 83 | 84 | Returns: 85 | The number of files removed if successful, 86 | ``False`` if an exception occurred, 87 | ``None`` if folder is empty 88 | """ 89 | if folder not in ('graphics', 'mods', 'baselines'): 90 | return False 91 | log.i('Simplifying {}: {}'.format(folder, pack)) 92 | packdir = paths.get(folder, pack) 93 | files_before = sum(len(f) for _, _, f in os.walk(packdir)) 94 | if files_before == 0: 95 | return None 96 | keep = [('raw',), ('data', 'speech')] 97 | if folder == 'graphics': 98 | keep = [('raw', 'objects'), ('raw', 'graphics')] 99 | if folder != 'mods': 100 | keep += [('data', 'art')] + [ 101 | ('data', 'init', f + '.txt') for f in 102 | ('colors', 'd_init', 'init', 'overrides')] 103 | if folder == 'baselines': 104 | keep.append(('data', 'init', 'interface.txt')) 105 | keep = [os.path.join(*k) for k in keep] 106 | for root, _, files in os.walk(packdir): 107 | for k in files: 108 | if k == 'manifest.json' or 'readme' in k.lower(): 109 | continue 110 | f = os.path.join(root, k) 111 | if not any(fnmatch.fnmatch(f, os.path.join(packdir, pattern, '*')) 112 | for pattern in keep): 113 | os.remove(f) 114 | files_after = sum(len(f) for _, _, f in os.walk(packdir)) 115 | log.v('Removed {} files'.format(files_before - files_after)) 116 | return files_before - files_after 117 | 118 | 119 | def remove_vanilla_raws_from_pack(pack, folder): 120 | """Remove files identical to vanilla raws, return files removed 121 | 122 | Args: 123 | pack, folder: path segments in ``'./LNP/folder/pack/'`` as strings 124 | 125 | Returns: 126 | int: the number of files removed 127 | """ 128 | if not find_vanilla(): 129 | return 0 130 | i = 0 131 | for _folder, van_folder in ( 132 | [paths.get(folder, pack, 'raw'), find_vanilla_raws()], 133 | [paths.get(folder, pack, 'data', 'speech'), 134 | os.path.join(find_vanilla(), 'data', 'speech')]): 135 | for root, _, files in os.walk(_folder): 136 | for k in files: 137 | f = os.path.join(root, k) 138 | silently_kill = ('Thumbs.db', 'installed_raws.txt') 139 | if any(f.endswith(x) for x in silently_kill): 140 | os.remove(f) 141 | continue 142 | van_f = os.path.join(van_folder, os.path.relpath(f, _folder)) 143 | if os.path.isfile(van_f): 144 | with open(van_f, encoding='cp437', errors='replace') as v: 145 | vtext = v.read() 146 | with open(f, encoding='cp437', errors='replace') as m: 147 | mtext = m.read() 148 | if vtext == mtext: 149 | os.remove(f) 150 | i += 1 151 | return i 152 | 153 | 154 | def remove_empty_dirs(pack, folder): 155 | """Removes empty subdirs in a mods or graphics pack. 156 | 157 | Args: 158 | pack, folder: path segments in ``'./LNP/folder/pack/'`` as strings 159 | 160 | Returns: 161 | int: the number of dirs removed 162 | """ 163 | i = 0 164 | for _ in range(3): 165 | # only catches the lowest level each iteration 166 | for root, dirs, files in os.walk(paths.get(folder, pack)): 167 | if not dirs and not files: 168 | os.rmdir(root) 169 | i += 1 170 | return i 171 | -------------------------------------------------------------------------------- /tkgui/utilities.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # pylint:disable=unused-wildcard-import,wildcard-import,attribute-defined-outside-init 4 | """Utilities tab for the TKinter GUI.""" 5 | 6 | import sys 7 | from tkinter import * # noqa: F403 8 | from tkinter import messagebox 9 | from tkinter.ttk import * # noqa: F403 10 | 11 | from core import launcher, paths, utilities 12 | from core.lnp import lnp 13 | 14 | from . import controls 15 | from .tab import Tab 16 | 17 | 18 | class UtilitiesTab(Tab): 19 | """Utilities tab for the TKinter GUI.""" 20 | def read_data(self): 21 | self.read_utilities() 22 | 23 | # Fix focus bug 24 | if self.proglist.get_children(): 25 | self.proglist.focus(self.proglist.get_children()[0]) 26 | 27 | def create_controls(self): 28 | progs = controls.create_control_group( 29 | self, 'Programs/Utilities', True) 30 | progs.pack(side=TOP, expand=Y, fill=BOTH) 31 | Grid.rowconfigure(progs, 3, weight=1) 32 | 33 | controls.create_trigger_button( 34 | progs, 'Run Program', 'Runs the selected program(s).', 35 | self.run_selected_utilities).grid(column=0, row=0, sticky="nsew") 36 | controls.create_trigger_button( 37 | progs, 'Open Utilities Folder', 'Open the utilities folder', 38 | utilities.open_utils).grid(column=1, row=0, sticky="nsew") 39 | Label( 40 | progs, text='Double-click on a program to launch it.').grid( 41 | column=0, row=1, columnspan=2) 42 | Label( 43 | progs, text='Right-click on a program to toggle auto-launch.').grid( 44 | column=0, row=2, columnspan=2) 45 | 46 | self.proglist = controls.create_toggle_list( 47 | progs, ('path', 'tooltip'), { 48 | 'column': 0, 'row': 3, 'columnspan': 2, 'sticky': "nsew"}) 49 | self.configure_proglist() 50 | 51 | open_readme = controls.create_trigger_button( 52 | progs, 'Open Readme', 'Open the readme file associated with the ' 53 | 'selected utilities.', self.open_readmes) 54 | open_readme.grid(column=0, row=4, columnspan=2, sticky="nsew") 55 | 56 | refresh = controls.create_trigger_button( 57 | progs, 'Refresh List', 'Refresh the list of utilities', 58 | self.read_utilities) 59 | refresh.grid(column=0, row=5, columnspan=2, sticky="nsew") 60 | 61 | def configure_proglist(self): 62 | """Configures the treeview.""" 63 | proglist = self.proglist 64 | 65 | # Do not show headings 66 | proglist.configure(show=['tree'], displaycolumns=()) 67 | 68 | for seq in ("<Double-1>", "<Return>"): 69 | proglist.bind(seq, lambda e: self.run_selected_utilities()) 70 | 71 | for seq in ("<space>", "<2>" if sys.platform == 'darwin' else "<3>",): 72 | proglist.bind(seq, self.toggle_autorun) 73 | 74 | self.list_tooltip = controls.create_tooltip(proglist, '') 75 | proglist.bind('<Motion>', self.update_tooltip) 76 | 77 | # Make it easy to differentiate between autorun 78 | proglist.tag_configure('autorun', background='pale green') 79 | 80 | # Deselect everything if blank area is clicked 81 | proglist.bind("<1>", self.proglist_click) 82 | 83 | def proglist_click(self, event): 84 | """Deselect everything if event occurred in blank area""" 85 | region = self.proglist.identify_region(event.x, event.y) 86 | if region == 'nothing': 87 | self.proglist.selection_set('') 88 | 89 | def update_tooltip(self, event): 90 | """ 91 | Event handler for mouse motion over items in the utility list. 92 | 93 | If the mouse has moved out of the last list element, hides the tooltip. 94 | Then, if the mouse is over a list item, wait controls._TOOLTIP_DELAY 95 | milliseconds (without mouse movement) before showing the tooltip""" 96 | tooltip = self.list_tooltip 97 | proglist = self.proglist 98 | item = proglist.identify_row(event.y) 99 | 100 | def show(): 101 | """Sets and shows a tooltip""" 102 | tooltip.settext(proglist.set(item, 'tooltip')) 103 | tooltip.showtip() 104 | 105 | if tooltip.event: 106 | proglist.after_cancel(tooltip.event) 107 | tooltip.event = None 108 | if proglist.set(item, 'tooltip') != tooltip.text: 109 | tooltip.hidetip() 110 | if item: 111 | # pylint: disable=protected-access 112 | tooltip.event = proglist.after(controls._TOOLTIP_DELAY, show) 113 | 114 | def read_utilities(self): 115 | """Reads list of utilities.""" 116 | for prog in self.proglist.get_children(): 117 | self.proglist.delete(prog) 118 | 119 | for prog in utilities.read_utilities(): 120 | self.proglist.insert('', 'end', prog, 121 | text=utilities.get_title(prog), 122 | values=(prog, utilities.get_tooltip(prog))) 123 | self.update_autorun_list() 124 | 125 | def toggle_autorun(self, event): 126 | """ 127 | Toggles autorun for a utility. 128 | 129 | Args: 130 | event: Data for the click event that triggered this. 131 | """ 132 | if event.keysym == '??': 133 | item = self.proglist.identify_row(event.y) 134 | else: 135 | item = self.proglist.focus() 136 | 137 | if item: 138 | utilities.toggle_autorun(item) 139 | # pylint: disable=not-callable 140 | self.proglist.tag_set('autorun', item, item in lnp.autorun) 141 | # pylint: enable=not-callable 142 | 143 | def update_autorun_list(self): 144 | """Updates the autorun list.""" 145 | for item in self.proglist.get_children(): 146 | # pylint: disable=not-callable 147 | self.proglist.tag_set('autorun', item, item in lnp.autorun) 148 | # pylint: enable=not-callable 149 | 150 | def run_selected_utilities(self): 151 | """Runs selected utilities.""" 152 | for item in self.proglist.selection(): 153 | # utility_path = self.proglist.item(item, 'text') 154 | launcher.run_program(paths.get('utilities', item)) 155 | 156 | def open_readmes(self): 157 | """Attempts to open the readme for the selected utilities.""" 158 | launched_any = False 159 | if len(self.proglist.selection()) == 0: 160 | return 161 | for item in self.proglist.selection(): 162 | launched_any = utilities.open_readme(item) or launched_any 163 | if not launched_any: 164 | messagebox.showinfo( 165 | message='Readme not found for any of the selected utilities.', 166 | title='No readmes found') 167 | -------------------------------------------------------------------------------- /core/launcher.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Launching of programs, folders, URLs, etc..""" 4 | 5 | import copy 6 | import os 7 | import re 8 | import subprocess 9 | import sys 10 | 11 | from . import hacks, log, paths, terminal 12 | from .lnp import lnp 13 | 14 | 15 | def toggle_autoclose(): 16 | """Toggle automatic closing of the UI when launching DF.""" 17 | lnp.userconfig['autoClose'] = not lnp.userconfig.get_bool('autoClose') 18 | lnp.userconfig.save_data() 19 | 20 | 21 | def get_df_executable(): 22 | """Returns the path of the executable needed to launch Dwarf Fortress.""" 23 | spawn_terminal = False 24 | if sys.platform == 'win32': 25 | if ('legacy' in lnp.df_info.variations 26 | and lnp.df_info.version <= '0.31.14'): 27 | df_filename = 'dwarfort.exe' 28 | else: 29 | df_filename = 'Dwarf Fortress.exe' 30 | elif sys.platform == 'darwin' and lnp.df_info.version <= '0.28.181.40d': 31 | df_filename = 'Dwarf Fortress.app' 32 | else: 33 | # Linux/OSX: Run DFHack if available and enabled 34 | if (os.path.isfile(paths.get('df', 'dfhack')) 35 | and hacks.is_dfhack_enabled()): 36 | df_filename = 'dfhack' 37 | spawn_terminal = True 38 | else: 39 | df_filename = 'df' 40 | if lnp.args.df_executable: 41 | df_filename = lnp.args.df_executable 42 | return df_filename, spawn_terminal 43 | 44 | 45 | def run_df(force=False): 46 | """Launches Dwarf Fortress.""" 47 | validation_result = lnp.settings.validate_config() 48 | if validation_result: 49 | if not lnp.ui.on_invalid_config(validation_result): 50 | return None 51 | df_filename, spawn_terminal = get_df_executable() 52 | 53 | executable = paths.get('df', df_filename) 54 | result = run_program(executable, force, True, spawn_terminal) 55 | if (force and not result) or result is False: 56 | log.e('Could not launch ' + executable) 57 | raise Exception('Failed to run Dwarf Fortress.') 58 | 59 | for prog in lnp.autorun: 60 | utility = paths.get('utilities', prog) 61 | if os.access(utility, os.F_OK): 62 | run_program(utility) 63 | 64 | if lnp.userconfig.get_bool('autoClose'): 65 | sys.exit() 66 | return result 67 | 68 | 69 | def run_program(path, force=False, is_df=False, spawn_terminal=False): 70 | """ 71 | Launches an external program. 72 | 73 | Args: 74 | path: the path of the program to launch. 75 | spawn_terminal: whether to spawn a new terminal for this app. 76 | Used only for DFHack. 77 | """ 78 | path = os.path.abspath(path) 79 | check_nonchild = ((spawn_terminal and sys.platform.startswith('linux')) 80 | or (sys.platform == 'darwin' and ( 81 | path.endswith('.app') or spawn_terminal))) 82 | 83 | is_running = program_is_running(path, check_nonchild) 84 | if not force and is_running: 85 | log.i(path + ' is already running') 86 | lnp.ui.on_program_running(path, is_df) 87 | return None 88 | 89 | try: 90 | workdir = os.path.dirname(path) 91 | run_args = path 92 | if spawn_terminal and not sys.platform.startswith('win'): 93 | run_args = terminal.get_terminal_command([path,]) 94 | elif path.endswith('.jar'): # Explicitly launch JAR files with Java 95 | run_args = ['java', '-jar', os.path.basename(path)] 96 | elif path.endswith('.app'): # OS X application bundle 97 | run_args = ['open', path] 98 | workdir = path 99 | 100 | environ = os.environ 101 | if lnp.bundle: 102 | # pylint: disable=protected-access 103 | environ = copy.deepcopy(os.environ) 104 | if ('TCL_LIBRARY' in environ 105 | and sys._MEIPASS in environ['TCL_LIBRARY']): 106 | del environ['TCL_LIBRARY'] 107 | if ('TK_LIBRARY' in environ 108 | and sys._MEIPASS in environ['TK_LIBRARY']): 109 | del environ['TK_LIBRARY'] 110 | if 'LD_LIBRARY_PATH' in environ: 111 | del environ['LD_LIBRARY_PATH'] 112 | if 'PYTHONPATH' in environ: 113 | del environ['PYTHONPATH'] 114 | 115 | with subprocess.Popen(run_args, cwd=workdir, env=environ) as p: 116 | lnp.running[path] = p 117 | return True 118 | except OSError: 119 | sys.excepthook(*sys.exc_info()) 120 | return False 121 | 122 | 123 | def program_is_running(path, nonchild=False): 124 | """ 125 | Returns True if a program is currently running. 126 | 127 | Args: 128 | path: the path of the program. 129 | nonchild: if set to True, attempts to check for the process among all 130 | running processes, not just known child processes. Used for 131 | DFHack on Linux and OS X; currently unsupported for Windows. 132 | """ 133 | if nonchild: 134 | with subprocess.Popen(['ps', 'axww'], stdout=subprocess.PIPE) as ps: 135 | s = ps.stdout.read() 136 | encoding = sys.getfilesystemencoding() 137 | if encoding is None: 138 | # Encoding was not detected, assume UTF-8 139 | encoding = 'UTF-8' 140 | s = s.decode(encoding, 'replace') 141 | return re.search('\\B%s( |$)' % re.escape(path), s, re.M) is not None 142 | if path not in lnp.running: 143 | return False 144 | lnp.running[path].poll() 145 | return lnp.running[path].returncode is None 146 | 147 | 148 | def open_folder_idx(i): 149 | """Opens the folder specified by index i, as listed in PyLNP.json.""" 150 | open_file(os.path.join( 151 | paths.get('root'), lnp.config['folders'][i][1].replace( 152 | '<df>', paths.get('df')))) 153 | 154 | 155 | def open_savegames(): 156 | """Opens the save game folder.""" 157 | open_file(paths.get('save')) 158 | 159 | 160 | def open_link_idx(i): 161 | """Opens the link specified by index i, as listed in PyLNP.json.""" 162 | open_url(lnp.config['links'][i][1]) 163 | 164 | 165 | def open_url(url): 166 | """Launches a web browser to the Dwarf Fortress webpage.""" 167 | import webbrowser 168 | webbrowser.open(url) 169 | 170 | 171 | def open_file(path): 172 | """ 173 | Opens a file with the system default viewer for the respective file type. 174 | 175 | Args: 176 | path: the file path to open. 177 | """ 178 | path = os.path.normpath(path) 179 | try: 180 | if sys.platform == 'darwin': 181 | subprocess.check_call(['open', '--', path]) 182 | elif sys.platform.startswith('linux'): 183 | subprocess.check_call(['xdg-open', path]) 184 | elif sys.platform in ['windows', 'win32']: 185 | os.startfile(path) 186 | else: 187 | log.e('Unknown platform, cannot open file') 188 | except Exception: 189 | log.e('Could not open file ' + path) 190 | -------------------------------------------------------------------------------- /core/importer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Import user content from an old DF or Starter Pack install. 4 | 5 | The content to import is defined in PyLNP.json 6 | 7 | Two import strategies are currently supported: 8 | 9 | :copy_add: 10 | copy a file or directory contents, non-recursive, no overwriting 11 | :text_prepend: 12 | prepend imported file content (for logfiles) 13 | 14 | 15 | These strategies support the 'low-hanging fruit' of imports. Other content 16 | or more advanced strategies have been identified, but are difficult to 17 | implement without risking a 'bad import' scenario: 18 | 19 | :init files: 20 | Not simply copyable. Sophisticated merging (similar to graphics 21 | upgrades) may lead to bad config when using settings from an older 22 | version of DF. Will not be supported. 23 | :keybinds: 24 | Could be imported by minimising interface.txt (and ``LNP/Keybinds/*``) 25 | (see core/keybinds.py), and copying if a duplicate set is not yet 26 | available. Planned for future update. 27 | :world_gen, embark_profiles: 28 | Importing world gen and embark profiles may be supported eventually. 29 | No obvious downsides beyond tricky implementation. 30 | :other: 31 | Custom settings importer - e.g. which graphics pack, are aquifers 32 | disabled, other PyLNP settings... May be added later but no plans. 33 | 34 | """ 35 | 36 | import os 37 | import shutil 38 | 39 | from . import log, paths 40 | from .lnp import lnp 41 | 42 | 43 | def strat_fallback(strat): 44 | """Log error if an unknown strategy is attempted.""" 45 | def __fallback(src, dest): 46 | # pylint:disable=unused-argument 47 | log.w('Attempted to use unknown strategy ' + strat) 48 | return False 49 | return __fallback 50 | 51 | 52 | def strat_copy_add(src, dest): 53 | """Copy a file or directory contents from src to dest, without overwriting. 54 | If a single file, an existing file may be overwritten if it only contains 55 | whitespace. For directory contents, only the top level is 'filled in'. 56 | """ 57 | # handle the simple case, one file 58 | if os.path.isfile(src): 59 | if os.path.isfile(dest): 60 | with open(dest, encoding="utf-8") as f: 61 | if f.read().strip(): 62 | log.i('Skipping import of {} to {}; dest is non-empty file' 63 | .format(src, dest)) 64 | return False 65 | log.i('importing {} to {} by copying'.format(src, dest)) 66 | shutil.copy2(src, dest) 67 | return True 68 | # adding dir contents 69 | ret = False 70 | for it in os.listdir(src): 71 | if os.path.exists(os.path.join(dest, it)): 72 | log.i('Skipping import of {}/{}, exists in dest'.format(src, it)) 73 | continue 74 | ret = True # *something* was imported 75 | log.i('importing {} from {} to {}'.format(it, src, dest)) 76 | if not os.path.isdir(dest): 77 | os.makedirs(dest) 78 | item = os.path.join(src, it) 79 | if os.path.isfile(item): 80 | shutil.copy2(item, dest) 81 | else: 82 | shutil.copytree(item, os.path.join(dest, it)) 83 | return ret 84 | 85 | 86 | def strat_text_prepend(src, dest): 87 | """Prepend the src textfile to the dest textfile, creating it if needed.""" 88 | if not os.path.isfile(src): 89 | log.i('Cannot import {} - not a file'.format(src)) 90 | return False 91 | if not os.path.isfile(dest): 92 | log.i('importing {} to {} by copying'.format(src, dest)) 93 | shutil.copy2(src, dest) 94 | return True 95 | with open(src, encoding='latin1') as f: 96 | srctext = f.read() 97 | with open(dest, encoding='latin1') as f: 98 | desttext = f.read() 99 | with open(src, 'w', encoding='latin1') as f: 100 | log.i('importing {} to {} by prepending'.format(src, dest)) 101 | f.writelines([srctext, '\n', desttext]) 102 | return True 103 | 104 | 105 | def do_imports(from_df_dir): 106 | """Import content (defined in PyLNP.json) from the given previous df_dir, 107 | and associated LNP install if any. 108 | """ 109 | # pylint:disable=too-many-branches 110 | # validate that from_df_dir is, in fact, a DF dir 111 | if not all(os.path.exists(os.path.join(from_df_dir, *p)) for p in 112 | [('data', 'init', 'init.txt'), ('raw', 'objects')]): 113 | return (False, 'Does not seem to be a DF install directory.') 114 | # Get list of paths, and add dest where implicit (ie same as src) 115 | if not lnp.config.get('to_import'): 116 | return (False, 'Nothing is configured for import in PyLNP.json') 117 | raw_config = [(c + [c[1]])[:3] for c in lnp.config['to_import']] 118 | 119 | path_pairs = [] 120 | # Turn "paths" in PyLNP.json into real paths 121 | for st, src, dest in raw_config: 122 | if '<df>' in src: 123 | newsrc = src.replace('<df>', from_df_dir) 124 | elif '<dfhack_config>' in src: 125 | if os.path.exists(os.path.join(from_df_dir, 'hack', 'init')): 126 | newsrc = src.replace('<dfhack_config>', os.path.join( 127 | from_df_dir, 'dfhack-config', 'init')) 128 | else: 129 | newsrc = src.replace('<dfhack_config>', from_df_dir) 130 | else: 131 | newsrc = os.path.join(from_df_dir, '../', src) 132 | newsrc = os.path.abspath(os.path.normpath(newsrc)) 133 | if '<df>' in dest: 134 | newdest = dest.replace('<df>', paths.get('df')) 135 | elif '<dfhack_config>' in src: 136 | newdest = dest.replace( 137 | '<dfhack_config>', paths.get('dfhack_config')) 138 | else: 139 | newdest = paths.get('root', dest) 140 | newdest = os.path.abspath(os.path.normpath(newdest)) 141 | path_pairs.append((st, newsrc, newdest)) 142 | 143 | # Sanity-check the provided paths... 144 | src_prefix = os.path.commonprefix([src for _, src, _ in path_pairs]) 145 | dest_prefix = os.path.commonprefix([dest for _, _, dest in path_pairs]) 146 | log.i('Importing from {} to {}'.format(src_prefix, dest_prefix)) 147 | if not (os.path.isdir(src_prefix) or os.path.dirname(src_prefix)): 148 | # parent dir is a real path, even when os.path.commonprefix isn't 149 | msg = 'Can only import content from single basedir' 150 | log.w(msg) 151 | return (False, msg) 152 | if not dest_prefix: 153 | # checking <base>.startswith avoids the os.path.commonprefix issue 154 | msg = 'Can only import content to destinations below current basedir' 155 | log.w(msg) 156 | return (False, msg) 157 | 158 | strat_funcs = { 159 | 'copy_add': strat_copy_add, 160 | 'text_prepend': strat_text_prepend, 161 | } 162 | imported = [] 163 | for strat, src, dest in path_pairs: 164 | if not os.path.exists(src): 165 | log.w('Cannot import {} - does not exist'.format(src)) 166 | continue 167 | if strat_funcs.get(strat, strat_fallback(strat))(src, dest): 168 | imported.append(src) 169 | if not imported: 170 | return (False, 'Nothing was found to import!') 171 | return (True, '\n'.join(imported)) 172 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from https://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make <target>' where <target> is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/PyLNP.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/PyLNP.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/PyLNP" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/PyLNP" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/building.rst: -------------------------------------------------------------------------------- 1 | Running or Building PyLNP 2 | ######################### 3 | 4 | This document describes how to run PyLNP. For most users, we suggest 5 | just using `the latest stand-alone executable version 6 | <https://github.com/Pidgeot/python-lnp/releases>`_, 7 | which are available for Windows, OSX, and Linux. 8 | You may wish to download PyLNP as part of a complete package for beginners, 9 | `which can be found here <https://dwarffortresswiki.org/Lazy_Newb_Pack>`_. 10 | 11 | If you have configuration problems or other errors, want to run the source 12 | code directly, or want to build your own stand-alone executable, the 13 | remainder of this page is for you. 14 | 15 | 16 | .. contents:: 17 | 18 | 19 | Platform-specific notes 20 | ======================= 21 | Windows 22 | ------- 23 | If the program refuses to start, or gives an error message like: 24 | 25 | *The application has failed to start because the side-by-side configuration 26 | is incorrect. Please see the application event log for more details.* 27 | 28 | you most likely need to install the `Microsoft Visual C++ 2015 redistributable 29 | package <https://www.microsoft.com/en-us/download/details.aspx?id=48145>`_. 30 | 31 | The user interface library used by PyLNP has issues with high-DPI displays. 32 | For builds made after February 28, 2016 (ie PyLNP v0.11 and later), 33 | Windows should automatically scale the PyLNP window to match your 34 | DPI settings, thereby avoiding these problems. 35 | 36 | Linux 37 | ----- 38 | On Linux and OS X, it is necessary to spawn a new terminal when using DFHack. 39 | This is handled automatically on OS X, but unfortunately Linux provides no 40 | standard way of doing this; it varies depending on your setup. 41 | 42 | PyLNP will attempt to detect which terminals are available on your system. On 43 | first launch, you will be asked to select which terminal to use; only terminals 44 | available on your system will appear in the list. 45 | 46 | PyLNP should be able to detect the GNOME, KDE, i3, LXDE, Mate, Xfce desktop 47 | environments and window managers. It can also handle the [u]rxvt 48 | (urxvt is used if available, else rxvt) and xterm stand-alone terminals. 49 | 50 | For other setups, you must configure a custom command. 51 | For example, if your terminal can be spawned using:: 52 | 53 | term -e <command> 54 | 55 | then you should write this as ``term -e`` - the command will be automatically 56 | appended. If you need the command to be placed elsewhere, use ``$`` as a 57 | placeholder for the command. 58 | 59 | Depending on your choice of terminal, desktop environment, etc., it may also be 60 | necessary to use ``nohup`` with the command, e.g. ``nohup term -e``. 61 | 62 | The terminal configuration UI includes a button to test if your custom command 63 | is able to launch terminals correctly. The test consists of two processes - a 64 | parent and a child - which will communicate with each other in various ways to 65 | ensure that they are running independently of the other. 66 | 67 | If the test fails, you will get an error message describing the issue briefly. 68 | You will have to adjust your command accordingly. 69 | 70 | 71 | Running from source 72 | =================== 73 | If you think the download is too large, I suggest running from source 74 | instead. There really isn't much to it, especially if you can live with a 75 | slightly less pretty logo. 76 | 77 | You will need to match the directory structure of the normal LNP. A download 78 | without utilities is available in the Bay12 Forums thread for PyLNP. 79 | 80 | You need Python 3.3 or later installed to run the source code, optionally with 81 | Pillow for better icons. Linux users may also need to install ``tk``; see 82 | below. 83 | 84 | If Pillow is not available and you are using an old version of tk, the log 85 | (:menuselection:`File --> Output`) will contain a line that starts with:: 86 | 87 | Note: PIL not found and Tk version too old for PNG support... 88 | 89 | PyLNP will still work, it will just look a little less pretty. 90 | 91 | Windows 92 | ------- 93 | Download a Windows installer for Python from https://python.org, which will 94 | contain everything required to run PyLNP. To get a better looking logo, 95 | run the command ``pip install pillow`` in a terminal. 96 | 97 | To run the code, double-click ``launch.py`` in the LNP folder. If you want 98 | to get rid of the console window that pops up, rename it to ``launch.pyw``. 99 | 100 | Linux 101 | ----- 102 | Virtually all Linux distributions these days include Python, although 103 | especially older installations may not have an appropriate version, and 104 | some may not have Tk support installed by default. 105 | 106 | If you can't get it to work, you'll need to install those things. 107 | For Debian-based distributions, including Ubuntu and Linux Mint, the 108 | ``python-tk`` package is required, while ``python-imaging-tk`` is optional 109 | (used to show nicer version of icon and logo). For other distributions, 110 | look for similar packages in your package manager. 111 | 112 | To run the code, make sure launch.py is executable. Next, double-click and run it, or start 113 | a terminal and execute it from there with ``python launch.py`` or 114 | ``./launch.py``. 115 | 116 | OS X 117 | ---- 118 | If you're running OS X 10.7 or later, you should have everything that's 119 | required. For 10.6 or earlier, upgrade Python to the latest 3.x release; an 120 | installer is available on https://python.org . 121 | 122 | To make the logo look better, you will need to install Pillow, a python 123 | library for images. If you have MacPorts installed, use it to install the 124 | package py-Pillow. If not, keep reading. 125 | 126 | .. _osx_compilers: 127 | 128 | First, you need to install command-line compilers. The easiest way I've 129 | found is to install Xcode, then open it and go to :menuselection:`Preferences --> Downloads` 130 | and install them from there. It should also be possible to download these 131 | compilers directly from `Apple <https://developer.apple.com/downloads>`_, 132 | but you're on your own for that. 133 | 134 | Once the compilers are in place, open a Terminal and type ``sudo 135 | easy_install pillow``. OS X should come with the libraries needed to build 136 | Pillow to load the logo. 137 | 138 | OS X does not provide a way to launch a Python script from Finder, so 139 | to run the code you will need to start a terminal, navigate to the directory, 140 | and execute ``python launch.py`` or ``./launch.py``. 141 | 142 | 143 | Building your own executable 144 | ============================ 145 | If you want to make your own executable, you can do that. This is 146 | particularly useful on OS X, which doesn't have any good way of launching a 147 | Python script directly from Finder. 148 | 149 | The executables are built using `PyInstaller <https://www.pyinstaller.org>`_ 150 | (v4.2 or later), which can be usually be installed with 151 | ``pip install pyinstaller``. See below for specific instructions. 152 | 153 | Open the PyLNP directory in a terminal and type ``pyinstaller lnp.spec``. 154 | Wait for the build to finish, and you will find a new folder named dist. 155 | Inside that folder is the stand-alone executable, named ``lnp.exe`` on Windows, 156 | ``lnp`` on Linux, and ``PyLNP`` (an application bundle) on OS X. 157 | 158 | .. note:: 159 | The resulting executable must be placed somewhere such that the program can 160 | find the folder containing Dwarf Fortress by navigating up the folder tree. 161 | For example, if Dwarf Fortress is located in ``/Games/Dwarf Fortress``, the 162 | PyLNP executable may be located in ``/Games``, ``/Games/PyLNP``, 163 | ``/Games/Utilities/Launcher``, etc. 164 | 165 | If ``pip`` is not available on your system, you may need to install it, either from a package manager or by running ``python -m ensurepip`` from the command-line. If you can't use the regular pip command, ``python -m pip <command>`` works too. 166 | 167 | Windows 168 | ------- 169 | PyInstaller 4.8 introduces a hook script which will break DFHack. A `bug report <https://github.com/pyinstaller/pyinstaller/issues/7118>`_ already exists for Pyinstaller for this issue, but at time of writing, it's still an issue. For now, use an older version; anything from 4.2 to 4.7 should definitely work; 4.6 is being used for the official builds. Use ``pip install PyInstaller==4.6`` to install that one. 170 | 171 | Note that your resulting build will have the same Windows requirements as the Python version used to build. To support Windows Vista and 7, you need to use Python 3.8 or earlier. 172 | 173 | Linux 174 | ----- 175 | If your package manager provides PyInstaller, install it from there. Otherwise, use pip. 176 | 177 | OS X 178 | ---- 179 | You may need to :ref:`install command-line compilers <osx_compilers>`. 180 | 181 | -------------------------------------------------------------------------------- /core/utilities.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Utility management. 4 | 5 | There are now two separate metadata systems: 6 | 7 | - the manifest system, which applies to each dir with a manifest (and subdirs) 8 | - the global system, which applies to everything else 9 | 10 | Utilities are uniformly and uniquely identified by the relative path 11 | from ``LNP/Utilities/`` to the executable file. 12 | 13 | Metadata for each is found by looking back up the path for a manifest, and 14 | in the global metadata if one is not found. 15 | 16 | Utilities are found by walking down from the base dir. 17 | 18 | For each dir, if a manifest is found it and all it's subdirs are only analysed 19 | by the manifest system. See the README for how this works, and note that it 20 | is more structured as well as more powerful, slightly decreasing flexibility - 21 | for example mandating only one executable per platform, but specifying 22 | requirements for DFHack or a terminal. 23 | 24 | Otherwise, each file (and on OSX, dir) is matched against standard patterns 25 | and user include patterns. Any matches that do not also match a user exclude 26 | pattern are added to the list of identified utilities. This global config is 27 | found in some combination of include.txt, exclude.txt, and utilities.txt. 28 | """ 29 | 30 | import os 31 | import re 32 | from fnmatch import fnmatch 33 | 34 | from . import log, manifest, paths 35 | from .launcher import open_file 36 | from .lnp import lnp 37 | 38 | 39 | def open_utils(): 40 | """Opens the utilities folder.""" 41 | open_file(paths.get('utilities')) 42 | 43 | 44 | def read_metadata(): 45 | """Read metadata from the utilities directory.""" 46 | metadata = {} 47 | for e in read_utility_lists(paths.get('utilities', 'utilities.txt')): 48 | fname, title, tooltip = (e.split(':', 2) + ['', ''])[:3] 49 | metadata[fname] = {'title': title, 'tooltip': tooltip} 50 | return metadata 51 | 52 | 53 | def manifest_for(path): 54 | """Returns the JsonConfiguration from manifest for the given utility, 55 | or None if no manifest exists.""" 56 | while path: 57 | path = os.path.dirname(path) 58 | if os.path.isfile(os.path.join( 59 | paths.get('utilities'), path, 'manifest.json')): 60 | return manifest.get_cfg('utilities', path) 61 | return None 62 | 63 | 64 | def get_title(path): 65 | """ 66 | Returns a title for the given utility. If a non-blank override exists, it 67 | will be used; otherwise, the filename will be manipulated according to 68 | PyLNP.json settings.""" 69 | config = manifest_for(path) 70 | if config is not None: 71 | if config.get_string('title'): 72 | return config.get_string('title') 73 | else: 74 | metadata = read_metadata() 75 | if os.path.basename(path) in metadata: 76 | if metadata[os.path.basename(path)]['title']: 77 | return metadata[os.path.basename(path)]['title'] 78 | head, result = os.path.split(path) 79 | if not lnp.config.get_bool('hideUtilityPath'): 80 | result = os.path.join(os.path.basename(head), result) 81 | if lnp.config.get_bool('hideUtilityExt'): 82 | result = os.path.splitext(result)[0] 83 | return result 84 | 85 | 86 | def get_tooltip(path): 87 | """Returns the tooltip for the given utility, or an empty string.""" 88 | config = manifest_for(path) 89 | if config is not None: 90 | return config.get_string('tooltip') 91 | return read_metadata().get(os.path.basename(path), {}).get('tooltip', '') 92 | 93 | 94 | def read_utility_lists(path): 95 | """ 96 | Reads a list of filenames/tags from a utility list (e.g. include.txt). 97 | 98 | Args: 99 | path: The file to read. 100 | """ 101 | result = [] 102 | try: 103 | with open(path, encoding='utf-8') as util_file: 104 | for line in util_file: 105 | for match in re.findall(r'\[(.+?)]', line): 106 | result.append(match) 107 | except IOError: 108 | pass 109 | return result 110 | 111 | 112 | def scan_manifest_dir(root): 113 | """Yields the configured utility (or utilities) from root and subdirs.""" 114 | m_path = os.path.relpath(root, paths.get('utilities')) 115 | util = manifest.get_cfg('utilities', m_path).get_string(lnp.os + '_exe') 116 | if manifest.is_compatible('utilities', m_path): 117 | if os.path.isfile(os.path.join(root, util)): 118 | return os.path.join(m_path, util) 119 | log.w('Utility not found: {}'.format(os.path.join(m_path, util))) 120 | return None 121 | 122 | 123 | def any_match(filename, include, exclude): 124 | """Return True if at least one pattern matches the filename, or False.""" 125 | return any(fnmatch(filename, p) for p in include) and \ 126 | not any(fnmatch(filename, p) for p in exclude) 127 | 128 | 129 | def scan_normal_dir(root, dirnames, filenames): 130 | """Yields candidate utilities in the given root directory. 131 | 132 | Allow for an include list of filenames that will be treated as valid 133 | utilities. Useful for e.g. Linux, where executables rarely have 134 | extensions. Also accepts glob patterns for filename (not path). 135 | """ 136 | metadata = read_metadata() 137 | patterns = ['*.jar', '*.sh'] 138 | if lnp.os == 'win': 139 | patterns = ['*.jar', '*.exe', '*.bat'] 140 | exclude = read_utility_lists(paths.get('utilities', 'exclude.txt')) 141 | # pylint: disable=consider-using-dict-items 142 | exclude += [u for u in metadata if metadata[u]['title'] == 'EXCLUDE'] 143 | include = read_utility_lists(paths.get('utilities', 'include.txt')) 144 | include += [u for u in metadata if metadata[u]['title'] != 'EXCLUDE'] 145 | # pylint: enable=consider-using-dict-items 146 | if lnp.os == 'osx': 147 | # OS X application bundles are really directories, and always end .app 148 | for dirname in dirnames: 149 | if any_match(dirname, ['*.app'], exclude): 150 | yield os.path.relpath(os.path.join(root, dirname), 151 | paths.get('utilities')) 152 | for filename in filenames: 153 | if any_match(filename, patterns + include, exclude): 154 | yield os.path.relpath(os.path.join(root, filename), 155 | paths.get('utilities')) 156 | 157 | 158 | def read_utilities(): 159 | """Returns a sorted list of utility programs.""" 160 | utilities = [] 161 | for root, dirs, files in os.walk(paths.get('utilities')): 162 | if 'manifest.json' in files: 163 | util = scan_manifest_dir(root) 164 | if util is not None: 165 | utilities.append(util) 166 | dirs[:] = [] # Don't run normal scan in subdirs 167 | else: 168 | utilities.extend(scan_normal_dir(root, dirs, files)) 169 | return sorted(utilities, key=get_title) 170 | 171 | 172 | def toggle_autorun(item): 173 | """ 174 | Toggles autorun for the specified item. 175 | 176 | Args: 177 | item: the item to toggle autorun for. 178 | """ 179 | if item in lnp.autorun: 180 | lnp.autorun.remove(item) 181 | else: 182 | lnp.autorun.append(item) 183 | save_autorun() 184 | 185 | 186 | def load_autorun(): 187 | """Loads autorun settings.""" 188 | lnp.autorun = [] 189 | try: 190 | with open(paths.get('utilities', 'autorun.txt'), 191 | encoding='utf-8') as file: 192 | for line in file: 193 | lnp.autorun.append(line.rstrip('\n')) 194 | except IOError: 195 | pass 196 | 197 | 198 | def save_autorun(): 199 | """Saves autorun settings.""" 200 | filepath = paths.get('utilities', 'autorun.txt') 201 | with open(filepath, 'w', encoding="utf-8") as autofile: 202 | autofile.write("\n".join(lnp.autorun)) 203 | 204 | 205 | def open_readme(path): 206 | """ 207 | Opens the readme associated with the utility <path>, if one exists. 208 | Returns False if no readme was found; otherwise True. 209 | """ 210 | readme = None 211 | log.d('Finding readme for ' + path) 212 | m = manifest_for(path) 213 | path = paths.get('utilities', os.path.dirname(path)) 214 | if m: 215 | readme = m.get('readme', None) 216 | if not readme: 217 | dir_contents = os.listdir(path) 218 | for s in sorted(dir_contents): 219 | if re.match('read[ _]?me', s, re.IGNORECASE): 220 | readme = s 221 | break 222 | else: 223 | log.d('No readme found') 224 | return False 225 | readme = os.path.join(path, readme) 226 | log.d('Found readme at ' + readme) 227 | open_file(readme) 228 | return True 229 | -------------------------------------------------------------------------------- /core/download.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Background download management.""" 4 | 5 | import os 6 | import shutil 7 | import tempfile 8 | from threading import Lock, Thread 9 | from urllib.error import URLError 10 | from urllib.request import Request, urlopen 11 | 12 | from . import log 13 | from .lnp import VERSION 14 | 15 | __download_queues = {} 16 | 17 | 18 | def download_str(url, **kwargs): 19 | """Instantly download a file from <url> and return its contents. Failed 20 | downloads return None. NOTE: This is a blocking method. Use a download queue 21 | for non-blocking downloads. 22 | 23 | Args: 24 | url: the URL to download 25 | encoding: used to decode the data to text. Defaults to UTF-8. 26 | timeout: timeout used for the URL request, in seconds. Defaults to 3. 27 | 28 | Returns: 29 | The contents of the downloaded file as text, or None. 30 | """ 31 | try: 32 | req = Request(url, headers={'User-Agent': 'PyLNP/' + VERSION}) 33 | return urlopen( 34 | req, timeout=kwargs.get('timeout', 3)).read().decode( 35 | kwargs.get('encoding', 'utf-8')) 36 | except URLError as ex: 37 | log.e('Error downloading ' + url + ': ' + ex.reason) 38 | except Exception: 39 | log.e('Error downloading ' + url) 40 | return None 41 | 42 | 43 | def download(queue, url, destination, end_callback=None, **kwargs): 44 | """Adds a download to the specified queue.""" 45 | return get_queue(queue).add(url, destination, end_callback, **kwargs) 46 | 47 | 48 | def queue_empty(queue): 49 | """Returns True if the specified queue does not exist, or is empty; 50 | otherwise False.""" 51 | return queue not in __download_queues or __download_queues[queue].empty() 52 | 53 | 54 | def get_queue(queue): 55 | """Returns the specified queue object, creating a new queue if necessary.""" 56 | __download_queues.setdefault(queue, DownloadQueue(queue)) 57 | return __download_queues[queue] 58 | 59 | 60 | class DownloadQueue(object): 61 | """Queue used for downloading files.""" 62 | # pylint: disable=too-many-instance-attributes 63 | def __init__(self, name): 64 | self.name = name 65 | self.queue = [] 66 | self.on_start_queue = [] 67 | self.on_begin_download = [] 68 | self.on_progress = [] 69 | self.on_end_download = [] 70 | self.on_end_queue = [] 71 | self.thread = None 72 | self.lock = Lock() 73 | if name == 'immediate': 74 | def _immediate_progress(_, url, progress, total): 75 | if total != -1: 76 | msg = "Downloading %s... (%s/%s)" % ( 77 | os.path.basename(url), progress, total) 78 | else: 79 | msg = ("Downloading %s... (%s bytes downloaded)" % ( 80 | os.path.basename(url), progress)) 81 | print("\r%s" % msg, end='') 82 | self.register_progress(_immediate_progress) 83 | 84 | def add(self, url, target, end_callback): 85 | """Adds a download to the queue. 86 | 87 | Args: 88 | url: the URL to download. 89 | target: the target path for the download. 90 | end_callback: a function(url, filename, success) which is called 91 | when the download finishes. 92 | """ 93 | with self.lock: 94 | if url not in [q[0] for q in self.queue]: 95 | self.queue.append((url, target, end_callback)) 96 | log.d(self.name + ': queueing ' + url + ' for download to ' + target) 97 | else: 98 | log.d(self.name + ': skipping add of ' + url + ', already in queue') 99 | if not self.thread and self.name != 'immediate': 100 | log.d('Download queue ' + self.name + ' not running, starting it') 101 | self.thread = t = Thread(target=self.__process_queue) 102 | t.daemon = True 103 | t.start() 104 | if self.name == 'immediate': 105 | log.i('Downloading immediately...') 106 | self.__process_queue() 107 | 108 | def empty(self): 109 | """Returns True if the queue is empty, otherwise False.""" 110 | return len(self.queue) == 0 111 | 112 | def register_start_queue(self, func): 113 | """Registers a function func(queue_name) to be called when the queue is 114 | started. If False is returned by any function, the queue is cleared.""" 115 | self.on_start_queue.append(func) 116 | 117 | def unregister_start_queue(self, func): 118 | """Unregisters a function func from being called when the queue is 119 | started.""" 120 | self.on_start_queue.remove(func) 121 | 122 | def register_begin_download(self, func): 123 | """Registers a function func(queue_name, url) to be called when a 124 | download is started.""" 125 | self.on_begin_download.append(func) 126 | 127 | def unregister_begin_download(self, func): 128 | """Unregisters a function func from being called when a download is 129 | started.""" 130 | self.on_begin_download.remove(func) 131 | 132 | def register_progress(self, func): 133 | """Registers a function func(queue_name, url, downloaded, total_size) 134 | to be called for download progress reports. 135 | If total size is unknown, None will be sent.""" 136 | self.on_progress.append(func) 137 | 138 | def unregister_progress(self, func): 139 | """Unregisters a function from being called for download progress 140 | reports.""" 141 | self.on_progress.remove(func) 142 | 143 | def register_end_download(self, func): 144 | """Registers a function func(queue_name, url, filename, success) to be 145 | called when a download is finished.""" 146 | self.on_end_download.append(func) 147 | 148 | def unregister_end_download(self, func): 149 | """Unregisters a function func from being called when a download is 150 | finished.""" 151 | self.on_end_download.remove(func) 152 | 153 | def register_end_queue(self, func): 154 | """Registers a function func(queue_name) to be called when the 155 | queue is emptied.""" 156 | self.on_end_queue.append(func) 157 | 158 | def unregister_end_queue(self, func): 159 | """Unregisters a function func from being called when the queue is 160 | emptied.""" 161 | self.on_end_queue.remove(func) 162 | 163 | def __process_callbacks(self, callbacks, *args): 164 | """Calls the provided set of callback functions with <args>.""" 165 | results = [] 166 | for c in callbacks: 167 | try: 168 | results.append(c(self.name, *args)) 169 | except Exception: 170 | results.append(None) 171 | return results 172 | 173 | def __process_queue(self): 174 | """Processes the download queue.""" 175 | if False in self.__process_callbacks(self.on_start_queue): 176 | with self.lock: 177 | self.queue = [] 178 | self.thread = None 179 | return 180 | 181 | while True: 182 | with self.lock: 183 | if self.empty(): 184 | self.thread = None 185 | break 186 | url, target, end_callback = self.queue[0] 187 | log.d(self.name + ': About to download ' + url + ' to ' + target) 188 | self.__process_callbacks(self.on_begin_download, url, target) 189 | dirname = os.path.dirname(target) 190 | if not os.path.isdir(dirname): 191 | os.makedirs(dirname) 192 | outhandle, outpath = tempfile.mkstemp(dir=dirname) 193 | outfile = os.fdopen(outhandle, 'wb') 194 | try: 195 | req = Request(url, headers={'User-Agent': 'PyLNP/' + VERSION}) 196 | with urlopen(req, timeout=5) as response: 197 | data = 0 198 | while True: 199 | chunk = response.read(8192) 200 | if not chunk: 201 | break 202 | total = response.info().get('Content-Length') 203 | data += len(chunk) 204 | outfile.write(chunk) 205 | self.__process_callbacks( 206 | self.on_progress, url, data, total) 207 | except Exception: 208 | outfile.close() 209 | os.remove(outpath) 210 | log.e(self.name + ': Error downloading ' + url, stack=True) 211 | self.__process_callbacks( 212 | self.on_end_download, url, target, False) 213 | if end_callback: 214 | end_callback(url, target, False) 215 | else: 216 | outfile.close() 217 | shutil.move(outpath, target) 218 | log.d(self.name + ': Finished downloading ' + url) 219 | self.__process_callbacks( 220 | self.on_end_download, url, target, True) 221 | if end_callback: 222 | end_callback(url, target, True) 223 | with self.lock: 224 | self.queue.pop(0) 225 | 226 | self.__process_callbacks(self.on_end_queue) 227 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Sphinx docs configuration for PyLNP""" 4 | # 5 | # PyLNP documentation build configuration file, created by 6 | # sphinx-quickstart on Tue Oct 11 18:59:22 2016. 7 | # 8 | # This file is execfile()d with the current directory set to its 9 | # containing dir. 10 | # 11 | # Note that not all possible configuration values are present in this 12 | # autogenerated file. 13 | # 14 | # All configuration values have a default; values that are commented out 15 | # serve to show the default. 16 | 17 | import datetime 18 | import glob 19 | import os 20 | import re 21 | import shutil 22 | import subprocess 23 | import sys 24 | 25 | # Before we get started, let's generate fresh API documentation from the code 26 | # pylint: disable=missing-function-docstring 27 | def ages(dname): 28 | return [os.stat(f).st_mtime for f in glob.glob(os.path.join(dname, '*'))] 29 | 30 | 31 | for mod in ('core', 'tkgui'): 32 | code_dir = os.path.join('..', mod) 33 | if not os.path.isdir(mod) or min(ages(mod)) <= max(ages(code_dir)): 34 | print('Regenerating {} API docs'.format(mod)) 35 | shutil.rmtree(mod, ignore_errors=True) 36 | subprocess.check_output(['sphinx-apidoc', '--separate', '--force', 37 | '--no-toc', '-o', mod, code_dir]) 38 | # pylint: enable=missing-function-docstring 39 | 40 | 41 | # If extensions (or modules to document with autodoc) are in another directory, 42 | # add these directories to sys.path here. If the directory is relative to the 43 | # documentation root, use os.path.abspath to make it absolute, like shown here. 44 | sys.path.insert(0, os.path.abspath('..')) 45 | 46 | from core import lnp 47 | 48 | # -- General configuration ------------------------------------------------ 49 | 50 | # If your documentation needs a minimal Sphinx version, state it here. 51 | needs_sphinx = '1.0' 52 | 53 | # Add any Sphinx extension module names here, as strings. They can be 54 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 55 | # ones. 56 | extensions = [ 57 | 'sphinx.ext.autodoc', 58 | 'sphinx.ext.napoleon', 59 | 'sphinx.ext.viewcode', 60 | ] 61 | 62 | # Napoleon settings 63 | # napoleon_google_docstring = True 64 | # napoleon_numpy_docstring = True 65 | # napoleon_include_private_with_doc = False 66 | # napoleon_include_special_with_doc = True 67 | # napoleon_use_admonition_for_examples = False 68 | # napoleon_use_admonition_for_notes = False 69 | # napoleon_use_admonition_for_references = False 70 | # napoleon_use_ivar = False 71 | # napoleon_use_param = True 72 | napoleon_use_rtype = False 73 | 74 | # Add any paths that contain templates here, relative to this directory. 75 | templates_path = ['.'] 76 | 77 | # The suffix(es) of source filenames. 78 | source_suffix = '.rst' 79 | 80 | # The master toctree document. 81 | master_doc = 'index' 82 | 83 | # General information about the project. 84 | project = 'PyLNP' 85 | author = 'Michael Madsen (Pidgeot) and collaborators' 86 | # pylint: disable=redefined-builtin 87 | copyright = '{}, {}'.format(datetime.datetime.now().year, author) 88 | # pylint: enable=redefined-builtin 89 | 90 | # The version info for the project you're documenting, acts as replacement for 91 | # |version| and |release|, also used in various other places throughout the 92 | # built documents. 93 | # 94 | # The full version, including alpha/beta/rc tags. 95 | release = lnp.VERSION 96 | # The short X.Y version. 97 | try: 98 | version = re.match(r'\d*\.\d*', release).group() 99 | except AttributeError: 100 | version = release 101 | 102 | # today_fmt is used as the format for a strftime call replacing |today|. 103 | today_fmt = '%B %d, %Y-%m-%d' 104 | 105 | # List of patterns, relative to source directory, that match files and 106 | # directories to ignore when looking for source files. 107 | exclude_patterns = ['_build'] 108 | 109 | # The reST default role (used for this markup: `text`) to use for all 110 | # documents. 111 | default_role = 'any' 112 | 113 | # If true, '()' will be appended to :func: etc. cross-reference text. 114 | # add_function_parentheses = True 115 | 116 | # If true, the current module name will be prepended to all description 117 | # unit titles (such as .. function::). 118 | # add_module_names = True 119 | 120 | # If true, sectionauthor and moduleauthor directives will be shown in the 121 | # output. They are ignored by default. 122 | # show_authors = False 123 | 124 | # The name of the Pygments (syntax highlighting) style to use. 125 | pygments_style = 'sphinx' 126 | 127 | 128 | # -- Options for HTML output ---------------------------------------------- 129 | 130 | # The theme to use for HTML and HTML Help pages. See the documentation for 131 | # a list of builtin themes. 132 | html_theme = 'classic' 133 | 134 | # Theme options are theme-specific and customize the look and feel of a theme 135 | # further. For a list of options available for each theme, see the 136 | # documentation. 137 | # html_theme_options = {} 138 | 139 | # Add any paths that contain custom themes here, relative to this directory. 140 | # html_theme_path = [] 141 | 142 | # The name for this set of Sphinx documents. If None, it defaults to 143 | # "<project> v<release> documentation". 144 | # html_title = None 145 | 146 | # A shorter title for the navigation bar. Default is the same as html_title. 147 | # html_short_title = None 148 | 149 | # The name of an image file (relative to this directory) to place at the top 150 | # of the sidebar. 151 | # html_logo = None 152 | 153 | # The name of an image file (within the static path) to use as favicon of the 154 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 155 | # pixels large. 156 | # html_favicon = None 157 | 158 | # Add any paths that contain custom static files (such as style sheets) here, 159 | # relative to this directory. They are copied after the builtin static files, 160 | # so a file named "default.css" will overwrite the builtin "default.css". 161 | html_static_path = ['static'] 162 | 163 | # Add any extra paths that contain custom files (such as robots.txt or 164 | # .htaccess) here, relative to this directory. These files are copied 165 | # directly to the root of the documentation. 166 | # html_extra_path = [] 167 | 168 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 169 | # using the given strftime format. 170 | html_last_updated_fmt = today_fmt 171 | 172 | # If false, no index is generated. 173 | # html_use_index = True 174 | 175 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 176 | html_show_sphinx = False 177 | 178 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 179 | html_show_copyright = False 180 | 181 | # Output file base name for HTML help builder. 182 | htmlhelp_basename = 'PyLNPdoc' 183 | 184 | # -- Options for LaTeX output --------------------------------------------- 185 | 186 | latex_elements = { 187 | # The paper size ('letterpaper' or 'a4paper'). 188 | # 'papersize': 'letterpaper', 189 | 190 | # The font size ('10pt', '11pt' or '12pt'). 191 | # 'pointsize': '10pt', 192 | 193 | # Additional stuff for the LaTeX preamble. 194 | # 'preamble': '', 195 | 196 | # Latex figure (float) alignment 197 | # 'figure_align': 'htbp', 198 | } 199 | 200 | # Grouping the document tree into LaTeX files. List of tuples 201 | # (source start file, target name, title, 202 | # author, documentclass [howto, manual, or own class]). 203 | latex_documents = [ 204 | (master_doc, 'PyLNP.tex', 'PyLNP Documentation', 205 | 'Michael Madsen (Pidgeot) and collaborators', 'manual'), 206 | ] 207 | 208 | # The name of an image file (relative to this directory) to place at the top of 209 | # the title page. 210 | # latex_logo = None 211 | 212 | # For "manual" documents, if this is true, then toplevel headings are parts, 213 | # not chapters. 214 | # latex_use_parts = False 215 | 216 | # If true, show page references after internal links. 217 | # latex_show_pagerefs = False 218 | 219 | # If true, show URL addresses after external links. 220 | # latex_show_urls = False 221 | 222 | # Documents to append as an appendix to all manuals. 223 | # latex_appendices = [] 224 | 225 | # If false, no module index is generated. 226 | # latex_domain_indices = True 227 | 228 | 229 | # -- Options for manual page output --------------------------------------- 230 | 231 | # One entry per manual page. List of tuples 232 | # (source start file, name, description, authors, manual section). 233 | man_pages = [ 234 | (master_doc, 'pylnp', 'PyLNP Documentation', 235 | [author], 1) 236 | ] 237 | 238 | # If true, show URL addresses after external links. 239 | # man_show_urls = False 240 | 241 | 242 | # -- Options for Texinfo output ------------------------------------------- 243 | 244 | # Grouping the document tree into Texinfo files. List of tuples 245 | # (source start file, target name, title, author, 246 | # dir menu entry, description, category) 247 | texinfo_documents = [ 248 | (master_doc, 'PyLNP', 'PyLNP Documentation', 249 | author, 'PyLNP', 'One line description of project.', 250 | 'Miscellaneous'), 251 | ] 252 | 253 | # Documents to append as an appendix to all manuals. 254 | # texinfo_appendices = [] 255 | 256 | # If false, no module index is generated. 257 | # texinfo_domain_indices = True 258 | 259 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 260 | # texinfo_show_urls = 'footnote' 261 | 262 | # If true, do not generate a @detailmenu in the "Top" node's menu. 263 | # texinfo_no_detailmenu = False 264 | -------------------------------------------------------------------------------- /tkgui/advanced.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # pylint:disable=unused-wildcard-import,wildcard-import,attribute-defined-outside-init 4 | """Advanced tab for the TKinter GUI.""" 5 | 6 | from tkinter import * # noqa: F403 7 | from tkinter import messagebox 8 | from tkinter.ttk import * # noqa: F403 9 | 10 | from core import launcher, legends_processor 11 | from core.lnp import lnp 12 | 13 | from . import controls 14 | from .layout import GridLayouter 15 | from .tab import Tab 16 | 17 | 18 | class AdvancedTab(Tab): 19 | """Advanced tab for the TKinter GUI.""" 20 | def create_variables(self): 21 | self.volume_var = StringVar() 22 | self.fps_var = StringVar() 23 | self.gps_var = StringVar() 24 | self.winX_var = StringVar() 25 | self.winY_var = StringVar() 26 | self.fullX_var = StringVar() 27 | self.fullY_var = StringVar() 28 | 29 | def create_controls(self): 30 | # pylint: disable=too-many-statements 31 | Grid.columnconfigure(self, 0, weight=1) 32 | Grid.columnconfigure(self, 1, weight=1) 33 | 34 | main_grid = GridLayouter(2, pad=(4, 0)) 35 | 36 | if lnp.settings.version_has_option('sound'): 37 | sound = controls.create_control_group(self, 'Sound') 38 | main_grid.add(sound) 39 | 40 | controls.create_option_button( 41 | sound, 'Sound', 'Turn game music on/off', 'sound').pack( 42 | side=LEFT, fill=X, expand=Y) 43 | if lnp.settings.version_has_option('volume'): 44 | controls.create_numeric_entry( 45 | sound, self.volume_var, 'volume', 46 | 'Music volume (0 to 255)').pack(side=LEFT, padx=(6, 0)) 47 | Label(sound, text='/255').pack(side=LEFT) 48 | if lnp.settings.version_has_option('fpsCounter'): 49 | fps = controls.create_control_group(self, 'FPS') 50 | main_grid.add(fps, rowspan=2) 51 | 52 | controls.create_option_button( 53 | fps, 'FPS Counter', 'Whether or not to display your FPS', 54 | 'fpsCounter').pack(fill=BOTH) 55 | 56 | caps = controls.create_control_group(fps, 'FPS Caps') 57 | caps.rowconfigure((1, 2), weight=1) 58 | caps.columnconfigure((1, 3), weight=1) 59 | if lnp.settings.version_has_option('fpsCap'): 60 | Label(caps, text='Calculation ').grid( 61 | row=1, column=1, sticky='e') 62 | controls.create_numeric_entry( 63 | caps, self.fps_var, 'fpsCap', 64 | 'How fast the game runs').grid( 65 | row=1, column=2) 66 | Label(caps, text='FPS').grid(row=1, column=3, sticky='w') 67 | if lnp.settings.version_has_option('gpsCap'): 68 | Label(caps, text='Graphical ').grid(row=2, column=1, sticky='e') 69 | controls.create_numeric_entry( 70 | caps, self.gps_var, 'gpsCap', 'How fast the game visually ' 71 | 'updates.\nLower value may give small boost to FPS but ' 72 | 'will be less responsive.').grid( 73 | row=2, column=2, pady=(3, 0)) 74 | Label(caps, text='FPS').grid(row=2, column=3, sticky='w') 75 | if caps.children: 76 | caps.pack(fill=BOTH, expand=Y) 77 | 78 | if lnp.settings.version_has_option('introMovie'): 79 | startup = controls.create_control_group(self, 'Startup') 80 | main_grid.add(startup) 81 | Grid.columnconfigure(startup, 0, weight=1) 82 | 83 | controls.create_option_button( 84 | startup, 'Intro Movie', 85 | 'Do you want to see the beautiful ASCII intro movie?', 86 | 'introMovie').grid(column=0, row=0, sticky="nsew") 87 | controls.create_option_button( 88 | startup, 'Windowed', 'Start windowed or fullscreen', 89 | 'startWindowed').grid(column=0, row=1, sticky="nsew") 90 | 91 | resolution = controls.create_control_group(self, 'Resolution') 92 | main_grid.add(resolution, 2) 93 | resolution['pad'] = (4, 0, 4, 8) 94 | resolution.columnconfigure((0, 5), weight=1) 95 | resolution.rowconfigure((2, 4), minsize=3) 96 | 97 | Label(resolution, text='Windowed').grid(row=1, column=1, sticky='e') 98 | Label(resolution, text='Fullscreen').grid(row=3, column=1, sticky='e') 99 | Label(resolution, text='Width').grid(row=0, column=2) 100 | Label(resolution, text='Height').grid(row=0, column=4) 101 | Label(resolution, text='x').grid(row=1, column=3) 102 | Label(resolution, text='x').grid(row=3, column=3) 103 | if lnp.df_info.version < '50.01': 104 | Label(resolution, justify=CENTER, 105 | text='Values less than 255 represent # tiles,\n' 106 | 'values greater than 255 represent # pixels.\n' 107 | 'Fullscreen "0" to autodetect.').grid( 108 | row=5, column=0, columnspan=6) 109 | controls.create_numeric_entry( 110 | resolution, self.winX_var, ('WINDOWEDX', 'GRAPHICS_WINDOWEDX'), 111 | '').grid(row=1, column=2) 112 | controls.create_numeric_entry( 113 | resolution, self.winY_var, ('WINDOWEDY', 'GRAPHICS_WINDOWEDY'), 114 | '').grid(row=1, column=4) 115 | controls.create_numeric_entry( 116 | resolution, self.fullX_var, ('FULLSCREENX', 'GRAPHICS_FULLSCREENX'), 117 | '').grid(row=3, column=2) 118 | controls.create_numeric_entry( 119 | resolution, self.fullY_var, ('FULLSCREENY', 'GRAPHICS_FULLSCREENY'), 120 | '').grid(row=3, column=4) 121 | 122 | saverelated = controls.create_control_group( 123 | self, 'Save-related', True) 124 | main_grid.add(saverelated, 2) 125 | 126 | grid = GridLayouter(2) 127 | grid.add(controls.create_option_button( 128 | saverelated, 'Autosave', 129 | 'How often the game will automatically save', 'autoSave')) 130 | grid.add(controls.create_option_button( 131 | saverelated, 'Initial Save', 'Saves as soon as you embark', 132 | 'initialSave')) 133 | grid.add(controls.create_option_button( 134 | saverelated, 'Pause on Save', 'Pauses the game after auto-saving', 135 | 'autoSavePause')) 136 | grid.add(controls.create_option_button( 137 | saverelated, 'Pause on Load', 'Pauses the game as soon as it loads', 138 | 'pauseOnLoad')) 139 | grid.add(controls.create_option_button( 140 | saverelated, 'Backup Saves', 'Makes a backup of every autosave', 141 | 'autoBackup')) 142 | if lnp.df_info.version >= '0.31.01': 143 | grid.add(controls.create_option_button( 144 | saverelated, 'Compress Saves', 'Whether to compress the ' 145 | 'savegames (keep this on unless you experience problems with ' 146 | 'your saves', 'compressSaves')) 147 | grid.add(controls.create_trigger_button( 148 | saverelated, 'Open Savegame Folder', 'Open the savegame folder', 149 | launcher.open_savegames)) 150 | 151 | if lnp.df_info.version >= '0.31.01': 152 | announcements_group = controls.create_control_group( 153 | self, 'Announcements') 154 | main_grid.add(announcements_group, 2) 155 | controls.create_option_button( 156 | announcements_group, 'Damp Stone', 157 | 'Pause and center view when damp stone is found', 158 | 'focusDamp').pack(fill=X) 159 | controls.create_option_button( 160 | announcements_group, 'Warm Stone', 161 | 'Pause and center view when warm stone is found', 162 | 'focusWarm').pack(fill=X) 163 | 164 | misc_group = controls.create_control_group(self, 'Miscellaneous') 165 | main_grid.add(misc_group, 2) 166 | controls.create_option_button( 167 | misc_group, 'Processor Priority', 168 | 'Adjusts the priority given to Dwarf Fortress by your OS', 169 | 'procPriority').pack(fill=X) 170 | 171 | if lnp.df_info.version >= '0.40.09': 172 | controls.create_trigger_button( 173 | misc_group, 'Process Legends Exports', 174 | 'Compress and sort files exported from legends mode', 175 | self.process_legends).pack(fill=X) 176 | 177 | @staticmethod 178 | def process_legends(): 179 | """Process legends exports.""" 180 | if not legends_processor.get_region_info(): 181 | messagebox.showinfo('No legends exports', 182 | 'There were no legends exports to process.') 183 | else: 184 | messagebox.showinfo('Exports will be compressed', 185 | 'Maps exported from legends mode will be ' 186 | 'converted to .png format, a compressed archive' 187 | ' will be made, and files will be sorted and ' 188 | 'moved to a subfolder. Please wait...') 189 | i = legends_processor.process_legends() 190 | string = str(i) + ' region' 191 | if i > 1: 192 | string += 's' 193 | messagebox.showinfo(string + ' processed', 194 | 'Legends exported from ' + string 195 | + ' were found and processed') 196 | -------------------------------------------------------------------------------- /tkgui/mods.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | # pylint:disable=unused-wildcard-import,wildcard-import,attribute-defined-outside-init 4 | """Mods tab for the TKinter GUI.""" 5 | 6 | from tkinter import * # noqa: F403 7 | from tkinter import messagebox, simpledialog 8 | from tkinter.ttk import * # noqa: F403 9 | 10 | from core import mods 11 | 12 | from . import controls, tkhelpers 13 | from .layout import GridLayouter 14 | from .tab import Tab 15 | 16 | 17 | class ModsTab(Tab): 18 | """Mods tab for the TKinter GUI.""" 19 | def create_variables(self): 20 | self.installed = [] 21 | self.available = [] 22 | self.installed_var = Variable() 23 | self.available_var = Variable() 24 | self.status = 3 25 | 26 | def read_data(self): 27 | mods.clear_temp() 28 | self.available = mods.read_mods() 29 | self.installed = mods.get_installed_mods_from_log() 30 | self.available = [m for m in self.available if m not in self.installed] 31 | self.update_lists() 32 | 33 | def create_controls(self): 34 | Grid.columnconfigure(self, 0, weight=1, uniform="mods") 35 | Grid.columnconfigure(self, 1, weight=1, uniform="mods") 36 | Grid.rowconfigure(self, 0, weight=1) 37 | Grid.rowconfigure(self, 2, weight=1) 38 | main_grid = GridLayouter(2) 39 | 40 | f = controls.create_control_group(self, 'Merged') 41 | install_frame, self.installed_list = controls.create_file_list( 42 | f, None, self.installed_var, selectmode='multiple') 43 | controls.listbox_dyn_tooltip( 44 | self.installed_list, lambda i: self.installed[i], mods.get_tooltip) 45 | self.installed_list.bind( 46 | "<Double-1>", lambda e: self.remove_from_installed()) 47 | reorder_frame = controls.create_control_group(install_frame, None) 48 | controls.create_trigger_button( 49 | reorder_frame, '↑', 'Move up', self.move_up).pack() 50 | controls.create_trigger_button( 51 | reorder_frame, '↓', 'Move down', self.move_down).pack() 52 | reorder_frame.grid(row=0, column=2, sticky="nse") 53 | 54 | main_grid.add(f, 2) 55 | 56 | main_grid.add(controls.create_trigger_button( 57 | self, '⇑', 'Add', self.add_to_installed)) 58 | main_grid.add(controls.create_trigger_button( 59 | self, '⇓', 'Remove', self.remove_from_installed)) 60 | 61 | f = controls.create_control_group(self, 'Available') 62 | _, self.available_list = controls.create_file_list( 63 | f, None, self.available_var, selectmode='multiple') 64 | controls.listbox_dyn_tooltip( 65 | self.available_list, lambda i: self.available[i], mods.get_tooltip) 66 | self.available_list.bind( 67 | "<Double-1>", lambda e: self.add_to_installed()) 68 | main_grid.add(f, 2) 69 | 70 | main_grid.add(controls.create_trigger_button( 71 | self, 'Install Mods', 'Copy merged mods to DF folder.', 72 | self.install_mods)) 73 | main_grid.add(controls.create_trigger_option_button( 74 | self, 'Pre-merge Graphics', 75 | 'Whether to start with the current graphics pack, or ' 76 | 'vanilla (ASCII) raws', self.toggle_preload, 'premerge_graphics', 77 | lambda v: ('NO', 'YES')[mods.will_premerge_gfx()])) 78 | main_grid.add(controls.create_trigger_button( 79 | self, 'Simplify Mods', 'Removes unnecessary files.', 80 | self.simplify_mods)) 81 | main_grid.add(controls.create_trigger_button( 82 | self, 'Extract Installed', 'Creates a mod from unique changes ' 83 | 'to your installed raws. Use to preserve custom tweaks.', 84 | self.create_from_installed)) 85 | 86 | def update_lists(self): 87 | """Updates the lists.""" 88 | self.available.sort(key=mods.get_title) 89 | self.available_var.set(tuple(mods.get_title(m) for m in self.available)) 90 | self.installed_var.set(tuple(mods.get_title(m) for m in self.installed)) 91 | 92 | @staticmethod 93 | def toggle_preload(): 94 | """Toggles whether to preload graphics before merging mods.""" 95 | mods.toggle_premerge_gfx() 96 | 97 | def move_up(self): 98 | """Moves the selected item/s up in the merge order and re-merges.""" 99 | if len(self.installed_list.curselection()) == 0: 100 | return 101 | selection = [int(i) for i in self.installed_list.curselection()] 102 | lst = self.installed 103 | for i in range(1, len(lst)): 104 | j = i 105 | while j in selection and i - 1 not in selection and j < len(lst): 106 | lst[j - 1], lst[j] = lst[j], lst[j - 1] 107 | j += 1 108 | self.update_lists() 109 | first_missed = False 110 | self.installed_list.selection_clear(0, END) 111 | for i in range(0, len(lst)): 112 | if i not in selection: 113 | first_missed = True 114 | else: 115 | self.installed_list.select_set(i - int(first_missed)) 116 | self.perform_merge() 117 | 118 | def move_down(self): 119 | """Moves the selected item/s down in the merge order and re-merges.""" 120 | if len(self.installed_list.curselection()) == 0: 121 | return 122 | selection = [int(i) for i in self.installed_list.curselection()] 123 | lst = self.installed 124 | for i in range(len(lst) - 1, 0, -1): 125 | j = i 126 | while i not in selection and j - 1 in selection and j > 0: 127 | lst[j - 1], lst[j] = lst[j], lst[j - 1] 128 | j -= 1 129 | self.update_lists() 130 | first_missed = False 131 | self.installed_list.selection_clear(0, END) 132 | for i in range(len(lst), 0, -1): 133 | if i - 1 not in selection: 134 | first_missed = True 135 | else: 136 | self.installed_list.select_set(i - 1 + int(first_missed)) 137 | self.perform_merge() 138 | 139 | def create_from_installed(self): 140 | """Extracts a mod from the currently installed raws.""" 141 | if mods.make_mod_from_installed_raws('') is not None: 142 | name = simpledialog.askstring("Create Mod", "New mod name:") 143 | if name: 144 | if mods.make_mod_from_installed_raws(name): 145 | messagebox.showinfo('Mod extracted', 146 | 'Your mod was extracted as ' + name) 147 | else: 148 | messagebox.showinfo( 149 | 'Error', 'There is already a mod with that name.') 150 | self.read_data() 151 | else: 152 | messagebox.showinfo('Error', 'No unique mods were found.') 153 | 154 | def add_to_installed(self): 155 | """Move selected mod/s from available to merged list and re-merge.""" 156 | if len(self.available_list.curselection()) == 0: 157 | return 158 | for i in self.available_list.curselection(): 159 | self.installed.append(self.available[int(i)]) 160 | for i in self.available_list.curselection()[::-1]: 161 | self.available.remove(self.available[int(i)]) 162 | self.available_list.selection_clear(0, END) 163 | self.update_lists() 164 | self.perform_merge() 165 | 166 | def remove_from_installed(self): 167 | """Move selected mod/s from merged to available list and re-merge.""" 168 | if len(self.installed_list.curselection()) == 0: 169 | return 170 | for i in self.installed_list.curselection()[::-1]: 171 | self.available.append(self.installed[int(i)]) 172 | self.installed.remove(self.installed[int(i)]) 173 | self.installed_list.selection_clear(0, END) 174 | self.update_lists() 175 | self.perform_merge() 176 | 177 | def perform_merge(self): 178 | """Merge the selected mods, with background color for user feedback.""" 179 | if not tkhelpers.check_vanilla_raws(): 180 | return 181 | colors = ['pale green', 'yellow', 'orange', 'red', 'white'] 182 | result = mods.merge_all_mods(self.installed) 183 | for i, status in enumerate(result): 184 | self.installed_list.itemconfig(i, bg=colors[status]) 185 | self.status = max(result + [0]) 186 | 187 | def install_mods(self): 188 | """Replaces <df>/raw with the contents LNP/Baselines/temp/raw""" 189 | if messagebox.askokcancel( 190 | message=('Your raws will be changed.\n\n' 191 | 'The mod merging function is still in beta. This ' 192 | 'could break new worlds, or even cause crashes.\n\n' 193 | 'Changing mods or graphics later might break a save, ' 194 | 'so keep backups of everything you care about!'), 195 | title='Are you sure?'): 196 | if self.status < 2: 197 | if mods.install_mods(): 198 | messagebox.showinfo( 199 | 'Mods installed', 200 | 'The selected mods were installed.\nGenerate a new ' 201 | 'world to start playing with them!') 202 | else: 203 | messagebox.showinfo( 204 | 'Mods not installed', 205 | 'No mods were merged to install.') 206 | else: 207 | messagebox.showinfo( 208 | 'Mods not ready', 209 | 'The selected mods have merge conflicts and should not be ' 210 | 'installed.\n\nResolve merge issues and try again.') 211 | 212 | @staticmethod 213 | def simplify_mods(): 214 | """Simplify mods; runs on startup if called directly by button.""" 215 | if not tkhelpers.check_vanilla_raws(): 216 | return 217 | m, f = mods.simplify_mods() 218 | messagebox.showinfo( 219 | str(m) + ' mods simplified', 220 | str(f) + ' files were removed from ' + str(m) + ' mods.') 221 | -------------------------------------------------------------------------------- /core/update.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """Update handling.""" 4 | 5 | import os 6 | import re 7 | import tarfile 8 | import threading 9 | import time 10 | import zipfile 11 | from urllib.parse import quote, unquote, urlparse 12 | 13 | from . import download, launcher, log, paths 14 | from .json_config import JSONConfiguration 15 | from .lnp import lnp 16 | 17 | 18 | def updates_configured(): 19 | """Returns True if update checking have been configured.""" 20 | return prepare_updater() is not None 21 | 22 | 23 | def check_update(): 24 | """Checks for updates using the URL specified in PyLNP.json.""" 25 | if not updates_configured(): 26 | return 27 | if not lnp.userconfig.has_value('updateDays'): 28 | interval = lnp.config.get_value('updates/defaultInterval', -1) 29 | if interval != -1 and lnp.ui.on_request_update_permission(interval): 30 | next_update(interval) 31 | else: 32 | next_update(-1) 33 | if lnp.userconfig.get_value('updateDays', -1) == -1: 34 | return 35 | if lnp.userconfig.get_number('nextUpdate') < time.time(): 36 | t = threading.Thread(target=perform_update_check) 37 | t.daemon = True 38 | t.start() 39 | 40 | 41 | def perform_update_check(): 42 | """Performs the actual update check. Runs in a thread.""" 43 | prepare_updater() 44 | if lnp.updater.update_needed(): 45 | lnp.new_version = lnp.updater.get_version() 46 | lnp.ui.on_update_available() 47 | 48 | 49 | def prepare_updater(): 50 | """Returns an Updater object for the configured updater.""" 51 | if lnp.updater: 52 | return lnp.updater 53 | updaters = {'regex': RegexUpdater, 'json': JSONUpdater, 'dffd': DFFDUpdater} 54 | updater_id = lnp.config.get('updates/updateMethod', None) 55 | if updater_id is None: 56 | # TODO: Remove this after packs have had time to migrate 57 | log.w( 58 | 'Update method not configured in PyLNP.json! Will attempt to ' 59 | 'auto-detect. Please set this value correctly, auto-detection will ' 60 | 'go away eventually!') 61 | if lnp.config.get_string('updates/dffdID'): 62 | updater_id = 'dffd' 63 | log.w('Updater detected: dffd') 64 | elif lnp.config.get_string('updates/versionRegex'): 65 | updater_id = 'regex' 66 | log.w('Updater detected: regex') 67 | elif lnp.config.get_string('updates/versionJsonPath'): 68 | updater_id = 'json' 69 | log.w('Updater detected: json') 70 | else: 71 | log.w('Could not detect update method, updates will not work') 72 | return None 73 | elif updater_id == '' or not lnp.config.get('updates'): 74 | return None 75 | if updater_id not in updaters: 76 | log.e('Unknown update method: ' + updater_id) 77 | return None 78 | lnp.updater = updaters[updater_id]() 79 | return lnp.updater 80 | 81 | 82 | def next_update(days): 83 | """Sets the next update check to occur in <days> days.""" 84 | lnp.userconfig['nextUpdate'] = (time.time() + days * 24 * 60 * 60) 85 | lnp.userconfig['updateDays'] = days 86 | lnp.save_config() 87 | 88 | 89 | def start_update(): 90 | """Launches a web browser to the specified update URL.""" 91 | launcher.open_url(lnp.updater.get_download_url()) 92 | 93 | 94 | def download_df_baseline(immediate=False): 95 | """Download the current version of DF from Bay12 Games to serve as a 96 | baseline, in LNP/Baselines/""" 97 | filename = lnp.df_info.get_archive_name() 98 | url = 'https://www.bay12games.com/dwarves/' + filename 99 | target = os.path.join(paths.get('baselines'), filename) 100 | queue_name = 'immediate' if immediate else 'baselines' 101 | download.download(queue_name, url, target) 102 | 103 | 104 | def direct_download_pack(): 105 | """Directly download a new version of the pack to the current BASEDIR""" 106 | url = lnp.updater.get_direct_url() 107 | fname = lnp.updater.get_direct_filename() 108 | target = os.path.join(lnp.BASEDIR, fname) 109 | download.download('updates', url, target, 110 | end_callback=extract_new_pack) 111 | 112 | 113 | def extract_new_pack(_, fname, bool_val): 114 | """Extract a downloaded new pack to a sibling dir of the current pack.""" 115 | exts = ('.zip', '.bz2', '.gz', '.7z', '.xz') 116 | if not bool_val or not any(fname.endswith(ext) for ext in exts): 117 | return None 118 | archive = os.path.join(lnp.BASEDIR, os.path.basename(fname)) 119 | return extract_archive(archive, os.path.join(lnp.BASEDIR, '..')) 120 | 121 | 122 | def extract_archive(fname, target): 123 | """Extract the archive fname to dir target, avoiding explosions.""" 124 | if zipfile.is_zipfile(fname): 125 | with zipfile.ZipFile(fname) as zf: 126 | namelist = zf.namelist() 127 | topdir = namelist[0].split(os.path.sep)[0] 128 | if not all(f.startswith(topdir) for f in namelist): 129 | target = os.path.join(target, os.path.basename(fname).split('.')[0]) 130 | zf.extractall(target) 131 | os.remove(fname) 132 | return True 133 | if tarfile.is_tarfile(fname): 134 | with tarfile.open(fname) as tf: 135 | namelist = tf.getmembers() 136 | topdir = namelist[0].split(os.path.sep)[0] 137 | if not all(f.startswith(topdir) for f in namelist): 138 | target = os.path.join(target, fname.split('.')[0]) 139 | tf.extractall(target) 140 | os.remove(fname) 141 | return True 142 | # TODO: support '*.xz' and '*.7z' files. 143 | return False 144 | 145 | 146 | # pylint: disable=attribute-defined-outside-init 147 | 148 | class Updater(object): 149 | """General class for checking for updates.""" 150 | def update_needed(self): 151 | """Checks if an update is necessary.""" 152 | self.text = download.download_str(self.get_check_url()) 153 | if self.text is None: 154 | log.e("Error checking for updates, could not download text") 155 | curr_version = lnp.config.get_string('updates/packVersion') 156 | if not curr_version: 157 | log.e("Current pack version is not set, cannot check for updates") 158 | return False 159 | return self.get_version() != curr_version 160 | 161 | def get_check_url(self): 162 | """Returns the URL used to check for updates.""" 163 | return lnp.config.get_string('updates/checkURL') 164 | 165 | def get_version(self): 166 | """Returns the version listed at the update URL. Must be overridden by 167 | subclasses.""" 168 | 169 | def get_download_url(self): 170 | """Returns a URL from which the user can download the update.""" 171 | return lnp.config.get_string('updates/downloadURL') 172 | 173 | def get_direct_url(self): 174 | """Returns a URL pointing directly to the update, for download by the 175 | program.""" 176 | return lnp.config.get_string('updates/directURL') 177 | 178 | def get_direct_filename(self): 179 | """Returns the filename that should be used for direct downloads.""" 180 | directFilename = lnp.config.get_string('updates/directFilename') 181 | if directFilename: 182 | return directFilename 183 | url_fragments = urlparse(self.get_direct_url()) 184 | return os.path.basename(unquote(url_fragments.path)) 185 | 186 | 187 | class RegexUpdater(Updater): 188 | """Updater class which uses regular expressions to locate the version (and 189 | optionally also the download URLs).""" 190 | def get_version(self): 191 | versionRegex = lnp.config.get_string('updates/versionRegex') 192 | if not versionRegex: 193 | log.e('Version regex not configured!') 194 | return re.search(versionRegex, self.text).group(1) 195 | 196 | def get_download_url(self): 197 | urlRegex = lnp.config.get_string('updates/downloadURLRegex') 198 | result = '' 199 | if urlRegex: 200 | result = re.search(urlRegex, self.text).group(1) 201 | if result: 202 | return result 203 | return super().get_download_url() 204 | 205 | def get_direct_url(self): 206 | urlRegex = lnp.config.get_string('updates/directURLRegex') 207 | result = '' 208 | if urlRegex: 209 | result = re.search(urlRegex, self.text).group(1) 210 | if result: 211 | return result 212 | return super().get_direct_url() 213 | 214 | 215 | class JSONUpdater(Updater): 216 | """Updater class which uses a JSON object to locate the version (and 217 | optionally also the download URLs).""" 218 | def get_version(self): 219 | self.json = JSONConfiguration.from_text(self.text) 220 | jsonPath = lnp.config.get_string('updates/versionJsonPath') 221 | if not jsonPath: 222 | log.e('JSON path to version not configured!') 223 | return self.json.get_string(jsonPath) 224 | 225 | def get_download_url(self): 226 | jsonPath = lnp.config.get_string('updates/downloadURLJsonPath') 227 | result = '' 228 | if jsonPath: 229 | result = self.json.get_string(jsonPath) 230 | if result: 231 | return result 232 | return super().get_download_url() 233 | 234 | def get_direct_url(self): 235 | jsonPath = lnp.config.get_string('updates/directURLJsonPath') 236 | result = '' 237 | if jsonPath: 238 | result = self.json.get_string(jsonPath) 239 | if result: 240 | return result 241 | return super().get_direct_url() 242 | 243 | def get_direct_filename(self): 244 | jsonPath = lnp.config.get_string('updates/directFilenameJsonPath') 245 | result = '' 246 | if jsonPath: 247 | result = self.json.get_string(jsonPath) 248 | if result: 249 | return result 250 | return super().get_direct_filename() 251 | 252 | 253 | class DFFDUpdater(Updater): 254 | """Updater class for DFFD-hosted downloads.""" 255 | def get_check_url(self): 256 | self.dffd_id = lnp.config.get_string('updates/dffdID') 257 | if not self.dffd_id: 258 | log.e('Field "updates/dffdID" must be set in PyLNP.json') 259 | return 'https://dffd.bay12games.com/file_data/{}.json'.format( 260 | self.dffd_id) 261 | 262 | def get_version(self): 263 | self.json = JSONConfiguration.from_text(self.text) 264 | return self.json.get_string('version') 265 | 266 | def get_download_url(self): 267 | return 'https://dffd.bay12games.com/file.php?id=' + self.dffd_id 268 | 269 | def get_direct_url(self): 270 | result = 'https://dffd.bay12games.com/download.php?id={0}&f={1}' 271 | return result.format( 272 | self.dffd_id, quote(self.json.get_string('filename'))) 273 | 274 | def get_direct_filename(self): 275 | return self.json.get_string('filename') 276 | -------------------------------------------------------------------------------- /docs/pylnp-json.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | .. _PyLNP.json: 4 | 5 | PyLNP.json 6 | ########## 7 | 8 | For basic pack customization, a JSON file named PyLNP.json is used. This file 9 | must be stored in either the base folder (the folder containing the Dwarf 10 | Fortress folder itself), or in `the LNP folder <LNP-directory>`. If both exist, the 11 | one in the LNP folder will be used. 12 | 13 | This file configures several aspects of the launcher. All parts are optional 14 | in the sense that the launcher will work even if nothing is there. 15 | 16 | Each key in the file is documented below. 17 | 18 | 19 | .. contents:: 20 | 21 | 22 | ``folders``, ``links`` 23 | ---------------------- 24 | ``folders`` and ``links`` are both lists containing other lists. These are 25 | used to populate the Folders and Links menu in the program. 26 | 27 | Each entry is a list containing 2 values: the caption for the menu item, and 28 | the destination to be opened when the menu item is activated. To insert a 29 | separator, use a dash as a caption (``-``). 30 | 31 | Folder paths are relative to the base directory, meaning the directory 32 | containing the Dwarf Fortress directory. Use ``<df>`` as a placeholder for the 33 | actual Dwarf Fortress directory. 34 | 35 | Example:: 36 | 37 | "folders": [ 38 | ["Savegame folder","<df>/data/save"], 39 | ["Utilities folder","LNP/Utilities"], 40 | ["Graphics folder","LNP/Graphics"], 41 | ["-","-"], 42 | ["Main folder",""], 43 | ["LNP folder","LNP"], 44 | ["Dwarf Fortress folder","<df>"], 45 | ["Init folder","<df>/data/init"] 46 | ], 47 | links: [ 48 | ["DF Homepage","https://www.bay12games.com/dwarves/"], 49 | ["DF Wiki","https://dwarffortresswiki.org/"], 50 | ["DF Forums","http://www.bay12forums.com/smf/"] 51 | ] 52 | 53 | ``hideUtilityPath``, ``hideUtilityExt`` 54 | --------------------------------------- 55 | These options control whether to hide the path and extension of utilities in 56 | the utility list. 57 | 58 | Using "DwarfTool/DwarfTool.exe" as an example: 59 | 60 | ``hideUtilityPath`` is false, ``hideUtilityExt`` is false: 61 | DwarfTool/DwarfTool.exe 62 | 63 | ``hideUtilityPath`` is false, ``hideUtilityExt`` is true: 64 | DwarfTool/DwarfTool 65 | 66 | ``hideUtilityPath`` is true, ``hideUtilityExt`` is false: 67 | DwarfTool.exe 68 | 69 | ``hideUtilityPath`` is true, ``hideUtilityExt`` is true: 70 | DwarfTool 71 | 72 | Only the *last* folder name is ever displayed: if the full path is 73 | "Utilities/Foo/DwarfTool", only "DwarfTool" will be shown for the path name. 74 | 75 | For customization of displayed utility titles, see `relabeling-utilities`. 76 | 77 | ``updates`` 78 | ----------- 79 | This object contains information used to check for pack updates. 80 | 81 | The most important field in this object is ``updateMethod``, which controls how 82 | PyLNP checks for updates. 83 | 84 | There are three methods available, ``dffd``, ``regex`` and ``json``, each of 85 | which require additional fields to be specified. These will be described below. 86 | 87 | If ``updateMethod`` is missing, a warning will be printed when checking for 88 | updates, and the program will attempt to auto-detect the correct method. *Please 89 | set this field correctly*, since auto-detection is a temporary measure to 90 | handle backwards compatibility. 91 | 92 | When checking for updates, the version retrieved online will be compared with 93 | the ``packVersion`` field. If they are different, PyLNP will show a notice that 94 | updates are available. *All update methods require this field to be specified.* 95 | 96 | If you do not want update checking, remove the ``updates`` object, or set 97 | ``updateMethod`` to a blank string. 98 | 99 | By default, the user must explicitly enable automatic checking for updates. 100 | However, pack authors may add an additional field to the ``updates`` object, 101 | ``defaultInterval`` which specifies the suggested number of days between each 102 | check. If this field is present in PyLNP.json, and the user has not previously 103 | chosen an update frequency, the user will be prompted to enable updates when 104 | they first launch the program, using the specified frequency as the default. 105 | 106 | It is strongly recommended that you use one of the options already visible in 107 | the program (0, 1, 3, 7, 14, 30). 108 | 109 | Note that the time for the next update check is determined when the option is 110 | set, i.e. when the user makes a choice. If you default to 0 days (every 111 | launch), the first check will happen immediately after the user has been 112 | prompted. 113 | 114 | ``dffd`` 115 | ~~~~~~~~ 116 | For files hosted on https://dffd.bay12games.com/, simply add a field ``dffdID`` 117 | which contains the ID of your hosted file. No other configuration is necessary. 118 | Example:: 119 | 120 | "updates": { 121 | "updateMethod": "dffd", 122 | "packVersion": "x.yy.zz r2", 123 | "dffdID": "1234" 124 | } 125 | 126 | 127 | ``regex`` 128 | ~~~~~~~~~ 129 | This method extracts version information using a regular expression. All regular 130 | expressions must capture a single group containing the appropriate value. 131 | 132 | This method uses five extra values: 133 | 134 | * ``checkURL``: A URL to a page containing the latest version string of 135 | your pack. 136 | * ``versionRegex``: A regular expression that extracts the latest version 137 | from the page contents of the aforementioned URL. If you do not understand 138 | regular expressions, ask on the forums or use DFFD for hosting. 139 | * ``downloadURL``: the URL of the pack's download webpage, to be opened in a 140 | browser **or** 141 | * ``downloadURLRegex``: A regular expression that extracts the pack's download 142 | webpage from the same URL that contained the version string. 143 | * ``directURL`` is the URL of the (future) package for direct download **or** 144 | * ``directURLRegex``: A regular expression that extracts the pack's direct 145 | download webpage from the same URL that contained the version string. 146 | * ``directFilename``: Filename to use when downloading directly (optional) 147 | **or** 148 | * ``directFilenameRegex``: A regular expression that extracts the file name to 149 | use when downloading directly. 150 | 151 | ``downloadURL`` and ``directURL`` are both optional, but at least one should be 152 | provided (or their regular expression counterparts). 153 | 154 | When doing direct downloads, the URL's file name will be used as the target file 155 | name (e.g. ``https://example.com/downloads/my_pack.zip`` gets downloaded as 156 | ``my_pack.zip``) if neither ``directFilename`` or ``directFilenameRegex`` is 157 | set. 158 | 159 | Example:: 160 | 161 | "updates": { 162 | "updateMethod": "regex", 163 | "packVersion": "x.yy.zz r2", 164 | "checkURL": "https://example.com/my_df_pack.html", 165 | "downloadURL": "https://example.com/my_df_pack.html", 166 | "versionRegex": "Version: (.+)" 167 | } 168 | 169 | ``json`` 170 | ~~~~~~~~~ 171 | This method extracts version information from a JSON document. 172 | 173 | This method uses *JSON paths*, which are strings which provide a path into the 174 | JSON object. The path is specified by a slash-separated string of object names. 175 | Example:: 176 | 177 | { 178 | "foo": "" //path is "foo" 179 | "bar": { //path is "bar" 180 | "baz": "" //path is "bar/baz" 181 | "quux": { //path is "bar/quux" 182 | "xyzzy": "" //path is "bar/quux/xyzzy" 183 | } 184 | } 185 | } 186 | 187 | This method requires four extra values: 188 | 189 | * ``checkURL``: A URL to a JSON document containing the necessary information. 190 | * ``versionJsonPath``: A JSON path that points to the latest version of your 191 | pack. 192 | * ``downloadURL``: the URL of the pack's download webpage, to be opened in a 193 | browser **or** 194 | * ``downloadURLJsonPath``: A JSON path that points to the pack's download 195 | webpage. 196 | * ``directURL`` is the URL of the (future) package for direct download **or** 197 | * ``directURLJsonPath``: A JSON path that points to the pack's direct download 198 | webpage from the same URL that contained the version string. 199 | * ``directFilename``: Filename to use when downloading directly (optional) 200 | **or** 201 | * ``directFilenameJsonPath``: A JSON path that points to the file name to use 202 | when downloading directly 203 | 204 | ``downloadURL`` and ``directURL`` are both optional, but at least one should be 205 | provided (or their JSON path counterparts). 206 | 207 | When doing direct downloads, the URL's file name will be used as the target file 208 | name (e.g. ``https://example.com/downloads/my_pack.zip`` gets downloaded as 209 | ``my_pack.zip``) if neither ``directFilename`` or ``directFilenameJsonPath`` is 210 | set. 211 | 212 | Example:: 213 | 214 | "updates": { 215 | "updateMethod": "json", 216 | "packVersion": "x.yy.zz r2", 217 | "checkURL": "https://example.com/my_df_pack_version.json", 218 | "downloadURL": "https://example.com/my_df_pack.html", 219 | "versionJsonPath": "version" 220 | } 221 | 222 | 223 | .. _pylnp-json-dfhack: 224 | 225 | ``dfhack`` 226 | ---------- 227 | This is an object containing hacks that can be toggled on or off on the 228 | DFHack tab. 229 | 230 | Each individual hack consists of three elements: a title, a command to be 231 | executed by DFHack, and a tooltip. The ``dfhack`` object should contain 232 | subobjects where the title is used as the name of the key for a subobject, 233 | and the subobject itself contains two keys: ``command`` and ``tooltip``. 234 | 235 | The ``enabled`` and ``file`` keys are optional; ``file`` may be any of 236 | "dfhack" (default), "onLoad", or "onMapLoad" and if "enabled" is ``true`` 237 | the command will be saved to ``<file>_PyLNP.init`` and executed by DFHack 238 | at the appropriate time. See the `DFHack docs on init files`__. 239 | 240 | .. __: https://dfhack.readthedocs.org/en/stable/docs/Core.html#init-files 241 | 242 | Example:: 243 | 244 | "dfhack": { 245 | "Partial Mouse Control": { 246 | "command": "mousequery edge enable", 247 | "tooltip": "allows scrolling by hovering near edge of map" 248 | }, 249 | "Performance Tweaks": { 250 | "command": "repeat -time 3 months -command cleanowned x", 251 | "tooltip": "regularly confiscates worn clothes and old items" 252 | "enabled": true, 253 | "file": "onMapLoad" 254 | } 255 | } 256 | 257 | ``to_import`` 258 | ------------- 259 | This configuration lists paths and strategies used to import user content 260 | from an older install or package (triggered from the ``file>Import...`` 261 | menu). Each item in the list is of the form [strategy, source, dest]; 262 | if the destination is not different to the source it may be omitted. 263 | 264 | Available strategies are: 265 | 266 | :copy_add: Copies the given file or directory contents. A source file 267 | which exists at the destination will be skipped. 268 | A destination directory will be created if it does not exist; 269 | files and subdirectories are copied without overwriting. 270 | This is safe for e.g. save files. 271 | :text_prepend: Prepends the text of source to dest (for logfiles). 272 | 273 | Example:: 274 | 275 | "to_import": [ 276 | ["text_prepend", "<df>/gamelog.txt"], 277 | ["copy_add", "<df>/data/save"], 278 | ["copy_add", "<df>/soundsense", "LNP/Utilities/Soundsense/packs"] 279 | ] 280 | -------------------------------------------------------------------------------- /core/lnp.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | """PyLNP main library.""" 4 | 5 | import os 6 | import sys 7 | 8 | from . import log 9 | from .json_config import JSONConfiguration 10 | 11 | VERSION = '0.14e-pre1' 12 | 13 | 14 | class UI(object): 15 | """ 16 | Specifies the interface required by the core PyLNP library for communicating 17 | with the user. Provided for reference; UIs do not need to inherit from this. 18 | """ 19 | def start(self): 20 | """Notifies the UI to start. On return, PyLNP will terminate.""" 21 | 22 | def on_update_available(self): 23 | """ 24 | Called when an update is available. Use this to show a notification 25 | and prompt the user for further action. 26 | """ 27 | 28 | def on_program_running(self, path, is_df): 29 | """ 30 | Called when attempting to launch a program that is already running. 31 | <path> provides the path to the program that is being launched, so you 32 | can request a forced launch. 33 | <is_df> specifies if the program is DF (True) or a utility (False). 34 | """ 35 | 36 | def on_invalid_config(self, errors): 37 | """ 38 | Called before running DF if an invalid configuration is detected. 39 | <errors> contains a list of discovered problems, which should be shown 40 | to the user. 41 | A true return value will launch DF anyway; a false return value cancels. 42 | """ 43 | 44 | def on_request_update_permission(self, interval): 45 | """ 46 | Called when PyLNP.json specifies a desired update interval but the 47 | user configuration does not hold a value for this. 48 | <interval> contains the number of days between update checks. 49 | A true return value will change the configuration to use the specified 50 | interval. A false return value will turn off automatic update checks. 51 | """ 52 | 53 | def on_query_migration(self): 54 | """ 55 | When no saves are detected, this function will be called. 56 | This should provide the user with an option to import a previous 57 | DF install or starter pack into the newly selected DF version. 58 | """ 59 | 60 | 61 | lnp = None 62 | 63 | 64 | class PyLNP(object): 65 | """ 66 | PyLNP library class. 67 | 68 | Acts as an abstraction layer between the UI and the Dwarf Fortress 69 | instance. 70 | """ 71 | # pylint: disable=too-many-instance-attributes 72 | def __init__(self): 73 | """Constructor for the PyLNP library.""" 74 | # pylint:disable=global-statement 75 | global lnp 76 | lnp = self 77 | self.args = self.parse_commandline() 78 | 79 | self.BASEDIR = '.' 80 | if sys.platform == 'win32': 81 | self.os = 'win' 82 | elif sys.platform.startswith('linux'): 83 | self.os = 'linux' 84 | elif sys.platform == 'darwin': 85 | self.os = 'osx' 86 | 87 | self.bundle = '' 88 | if hasattr(sys, 'frozen'): 89 | self.bundle = self.os 90 | os.chdir(os.path.dirname(sys.executable)) 91 | if self.bundle == 'osx': 92 | # OS X bundles start in different directory 93 | os.chdir('../../..') 94 | else: 95 | os.chdir(os.path.join(os.path.dirname(__file__), '..')) 96 | 97 | from . import update 98 | 99 | self.folders = [] 100 | self.df_info = None 101 | self.settings = None 102 | self.running = {} 103 | self.autorun = [] 104 | self.updater = None 105 | self.config = None 106 | self.userconfig = None 107 | self.ui = None 108 | 109 | self.initialize_program() 110 | 111 | self.initialize_df() 112 | 113 | self.new_version = None 114 | 115 | self.initialize_ui() 116 | update.check_update() 117 | from . import paths 118 | save_dir = paths.get('save') 119 | saves_exist = os.path.isdir(save_dir) and os.listdir(save_dir) 120 | if paths.get('df') and not saves_exist: 121 | self.ui.on_query_migration() 122 | self.ui.start() 123 | 124 | def initialize_program(self): 125 | """Initializes the main program (errorlog, path registration, etc.).""" 126 | from . import errorlog, paths, utilities 127 | self.BASEDIR = '.' 128 | self.detect_basedir() 129 | paths.clear() 130 | paths.register('root', self.BASEDIR) 131 | errorlog.start() 132 | 133 | paths.register('lnp', self.BASEDIR, 'LNP') 134 | if not os.path.isdir(paths.get('lnp')): 135 | log.w('LNP folder is missing!') 136 | paths.register('keybinds', paths.get('lnp'), 'Keybinds') 137 | paths.register('graphics', paths.get('lnp'), 'Graphics') 138 | paths.register('utilities', paths.get('lnp'), 'Utilities') 139 | paths.register('colors', paths.get('lnp'), 'Colors') 140 | paths.register('embarks', paths.get('lnp'), 'Embarks') 141 | paths.register('tilesets', paths.get('lnp'), 'Tilesets') 142 | paths.register('baselines', paths.get('lnp'), 'Baselines') 143 | paths.register('mods', paths.get('lnp'), 'Mods') 144 | 145 | config_file = 'PyLNP.json' 146 | if os.access(paths.get('lnp', 'PyLNP.json'), os.F_OK): 147 | config_file = paths.get('lnp', 'PyLNP.json') 148 | 149 | default_config = { 150 | "folders": [ 151 | ["Savegame folder", "<df>/data/save"], 152 | ["Utilities folder", "LNP/Utilities"], 153 | ["Graphics folder", "LNP/Graphics"], 154 | ["-", "-"], 155 | ["Main folder", ""], 156 | ["LNP folder", "LNP"], 157 | ["Dwarf Fortress folder", "<df>"], 158 | ["Init folder", "<df>/data/init"] 159 | ], 160 | "links": [ 161 | ["DF Homepage", "https://www.bay12games.com/dwarves/"], 162 | ["DF Wiki", "https://dwarffortresswiki.org/"], 163 | ["DF Forums", "http://www.bay12forums.com/smf/"] 164 | ], 165 | "to_import": [ 166 | ['text_prepend', '<df>/gamelog.txt'], 167 | ['text_prepend', '<df>/ss_fix.log'], 168 | ['text_prepend', '<dfhack_config>/dfhack.history'], 169 | ['copy_add', '<df>/data/save'], 170 | ['copy_add', '<df>/soundsense', 171 | 'LNP/Utilities/Soundsense/packs'], 172 | ['copy_add', 'LNP/Utilities/Soundsense/packs'], 173 | ['copy_add', 'User Generated Content'] 174 | ], 175 | "hideUtilityPath": False, 176 | "hideUtilityExt": False, 177 | "updates": { 178 | "updateMethod": "" 179 | } 180 | } 181 | self.config = JSONConfiguration(config_file, default_config) 182 | self.userconfig = JSONConfiguration('PyLNP.user') 183 | self.autorun = [] 184 | utilities.load_autorun() 185 | 186 | if self.args.terminal_test_parent: 187 | from . import terminal 188 | errorlog.stop() 189 | sys.exit(terminal.terminal_test_parent( 190 | self.args.terminal_test_parent[0])) 191 | if self.args.terminal_test_child: 192 | from . import terminal 193 | errorlog.stop() 194 | sys.exit(terminal.terminal_test_child( 195 | self.args.terminal_test_child[0])) 196 | 197 | def initialize_df(self): 198 | """Initializes the DF folder and related variables.""" 199 | from . import df 200 | self.df_info = None 201 | self.folders = [] 202 | self.settings = None 203 | df.find_df_folder() 204 | 205 | def initialize_ui(self): 206 | """Instantiates the UI object.""" 207 | from tkgui.tkgui import TkGui 208 | self.ui = TkGui() 209 | 210 | def reload_program(self): 211 | """Reloads the program to allow the user to change DF folders.""" 212 | self.args.df_folder = None 213 | self.initialize_program() 214 | self.initialize_df() 215 | self.initialize_ui() 216 | self.ui.start() 217 | 218 | def parse_commandline(self): 219 | """Parses and acts on command line options.""" 220 | args = self.get_commandline_args() 221 | if args.debug == 1: 222 | log.set_level(log.DEBUG) 223 | elif args.debug is not None and args.debug > 1: 224 | log.set_level(log.VERBOSE) 225 | if args.release_prep: 226 | args.raw_lint = True 227 | log.d(args) 228 | return args 229 | 230 | @staticmethod 231 | def get_commandline_args(): 232 | """Responsible for the actual parsing of command line options.""" 233 | import argparse 234 | parser = argparse.ArgumentParser( 235 | description="PyLNP " + VERSION) 236 | parser.add_argument( 237 | '-d', '--debug', action='count', 238 | help='Turn on debugging output (use twice for extra verbosity)') 239 | parser.add_argument( 240 | '--raw-lint', action='store_true', 241 | help='Verify contents of raw files and exit') 242 | parser.add_argument( 243 | 'df_folder', nargs='?', 244 | help='Dwarf Fortress folder to use (if it exists)') 245 | parser.add_argument( 246 | '--version', action='version', version="PyLNP " + VERSION) 247 | parser.add_argument( 248 | '--df-executable', action='store', 249 | help='Override DF/DFHack executable name') 250 | parser.add_argument( 251 | '--release-prep', action='store_true', 252 | help=argparse.SUPPRESS) 253 | parser.add_argument( 254 | '--terminal-test-parent', nargs=1, 255 | help=argparse.SUPPRESS) 256 | parser.add_argument( 257 | '--terminal-test-child', nargs=1, 258 | help=argparse.SUPPRESS) 259 | return parser.parse_known_args()[0] 260 | 261 | def save_config(self): 262 | """Saves LNP configuration.""" 263 | self.userconfig.save_data() 264 | 265 | def macos_check_translocated(self): 266 | """Verify that macOS isn't isolating our application.""" 267 | assert self.os == 'osx' 268 | if '/AppTranslocation/' in sys.executable: 269 | try: 270 | from tkinter import messagebox 271 | except ImportError: 272 | import tkMessageBox as messagebox 273 | messagebox.showinfo( 274 | 'Cannot launch PyLNP', 275 | 'PyLNP cannot be run from a disk image or from the Downloads ' 276 | 'folder. Please copy the PyLNP app and its other Dwarf ' 277 | 'Fortress files elsewhere, such as to the Applications folder.') 278 | 279 | def detect_basedir(self): 280 | """Detects the location of Dwarf Fortress by walking up the directory 281 | tree.""" 282 | prev_path = '.' 283 | 284 | from . import df 285 | try: 286 | while os.path.abspath(self.BASEDIR) != prev_path: 287 | df.find_df_folders() 288 | if len(self.folders) != 0: 289 | return 290 | prev_path = os.path.abspath(self.BASEDIR) 291 | self.BASEDIR = os.path.join(self.BASEDIR, '..') 292 | except UnicodeDecodeError: 293 | # This seems to no longer be an issue, but leaving in the check 294 | # just in case 295 | log.e( 296 | "PyLNP is being stored in a path containing non-ASCII " 297 | "characters, and cannot continue. Folder names may only use " 298 | "the characters A-Z, 0-9, and basic punctuation.\n" 299 | "Alternatively, you may run PyLNP from source using Python 3.") 300 | sys.exit(1) 301 | log.e("Could not find any Dwarf Fortress installations.") 302 | if self.os == 'osx': 303 | self.macos_check_translocated() 304 | sys.exit(2) 305 | 306 | 307 | # vim:expandtab 308 | --------------------------------------------------------------------------------