#!/usr/bin/env python
# -*- coding: utf-8 -*-
# pylint:disable=unused-wildcard-import,wildcard-import
"""TKinter-based GUI for PyLNP."""
import os
import queue as Queue
import sys
import tkinter.font as tkFont
from threading import Semaphore
from tkinter import * # noqa: F403
from tkinter import filedialog, messagebox
from tkinter.ttk import * # noqa: F403
from core import (baselines, df, download, importer, launcher, log, mods,
paths, terminal, update)
from core.helpers import get_resource
from core.lnp import VERSION, lnp
from . import binding, controls
from .advanced import AdvancedTab
from .child_windows import (ConfirmRun, InitEditor, LogWindow, SelectDF,
TerminalSelector, UpdateWindow)
from .dfhack import DFHackTab
from .graphics import GraphicsTab
from .mods import ModsTab
from .options import OptionsTab
from .utilities import UtilitiesTab
# Workaround to use Pillow in PyInstaller
if False: # pylint:disable=using-constant-test
# pylint:disable=unused-import
import pkg_resources # noqa: F401
try: # PIL-compatible library (e.g. Pillow); used to load PNG images (optional)
# pylint:disable=import-error
from PIL import Image, ImageTk
has_PIL = True
except ImportError: # Some PIL installations live outside the PIL package
# pylint:disable=import-error
try:
import Image
import ImageTk
has_PIL = True
except ImportError: # No PIL compatible library
has_PIL = False
has_PNG = has_PIL or (TkVersion >= 8.6) # Tk 8.6 supports PNG natively
if not has_PIL:
log.w("No PIL support available - cannot perform image manipulation")
if not has_PNG:
log.w(
'Note: PIL not found and Tk version too old for PNG support (%s).'
'Falling back to GIF images.', TkVersion)
[docs]def get_image(filename):
"""
Open the image with the appropriate extension.
Args:
filename: The base name of the image file.
Returns:
A PhotoImage object ready to use with Tkinter.
"""
if has_PNG:
filename = filename + '.png'
else:
filename = filename + '.gif'
try:
if has_PIL:
return ImageTk.PhotoImage(Image.open(filename))
return PhotoImage(file=filename)
except Exception:
log.w('Unable to load image: ' + filename)
return None
[docs]def validate_number(value_if_allowed):
"""
Validation method used by Tkinter. Accepts empty and float-coercible
strings.
Args:
value_if_allowed: Value to validate.
Returns:
True if value_if_allowed is empty, or can be interpreted as a float.
"""
if value_if_allowed == '':
return True
try:
float(value_if_allowed)
return True
except ValueError:
return False
[docs]def fixed_map(option):
"""Sets text colour for Tkinter 8.6.9"""
# Fix for setting text colour for Tkinter 8.6.9
# From: https://core.tcl.tk/tk/info/509cafafae
#
# Returns the style map for 'option' with any styles starting with
# ('!disabled', '!selected', ...) filtered out.
# style.map() returns an empty list for missing options, so this
# should be future-safe.
return [elm for elm in Style().map('Treeview', query_opt=option) if
elm[:2] != ('!disabled', '!selected')]
[docs]class TkGui(object):
"""Main GUI window."""
# pylint: disable=too-many-instance-attributes,too-many-public-methods
def __init__(self):
"""
Constructor for TkGui.
Args:
lnp: A PyLNP instance to perform actual work.
"""
# pylint: disable=too-many-statements
self.root = root = Tk()
self.updateDays = IntVar()
self.downloadBaselines = BooleanVar()
self.show_scrollbars = BooleanVar()
self.autoclose = BooleanVar()
self.do_reload = False
controls.init(self)
binding.init(lnp, self)
if not self.ensure_df():
return
if sys.version_info[0] == 3:
Style().map('Treeview', foreground=fixed_map('foreground'),
background=fixed_map('background'))
if lnp.os == 'linux' and not terminal.terminal_configured():
self.root.withdraw()
messagebox.showinfo(
'PyLNP',
'You need to configure a terminal to allow things like DFHack '
'to work correctly. Press OK to do this now.')
self.configure_terminal(True)
self.root.deiconify()
root.option_add('*tearOff', FALSE)
windowing = root.tk.call('tk', 'windowingsystem')
if windowing == "win32":
root.tk.call(
'wm', 'iconbitmap', root, "-default",
get_resource('LNP.ico'))
elif windowing == "x11":
root.tk.call(
'wm', 'iconphoto', root, "-default",
get_image(get_resource('LNP')))
elif windowing == "aqua": # OS X has no window icons
pass
root.title("PyLNP")
self.vcmd = (root.register(validate_number), '%P')
main = Frame(root)
self.logo = logo = get_image(get_resource('LNPSMALL'))
Label(root, image=logo, anchor=CENTER).pack(fill=X)
main.pack(side=TOP, fill=BOTH, expand=Y)
self.download_panel = controls.create_control_group(
main, 'Download status')
self.download_text = StringVar()
self.download_status = Label(
self.download_panel, textvariable=self.download_text)
self.download_panel.pack(fill=X, expand=N, side=BOTTOM)
self.download_status.pack(side=BOTTOM)
self.n = n = Notebook(main)
self.create_tab(OptionsTab, 'Options')
self.create_tab(GraphicsTab, 'Graphics')
self.create_tab(UtilitiesTab, 'Utilities')
self.create_tab(AdvancedTab, 'Advanced')
if 'dfhack' in lnp.df_info.variations:
self.create_tab(DFHackTab, 'DFHack')
if mods.read_mods():
self.create_tab(ModsTab, 'Mods')
n.enable_traversal()
n.pack(fill=BOTH, expand=Y, padx=2, pady=3)
play_font = tkFont.Font(font='TkDefaultFont')
play_font.config(weight=tkFont.BOLD, size=int(play_font['size'] * 1.5))
Style().configure('Big.TButton', font=play_font)
play_button = controls.create_trigger_button(
main, 'Play Dwarf Fortress!', 'Play the game!', self.run_df)
if sys.platform != 'darwin':
play_button.configure(style='Big.TButton')
play_button.pack(side=BOTTOM, fill=X, padx=(1, 3), pady=(0, 3))
else:
play_button.pack(side=BOTTOM, fill=X, padx=(30, 30), pady=(0, 3))
self.menubar = self.create_menu(root)
self.save_size = None
root.update()
height = root.winfo_height()
if windowing == "x11":
# On Linux, the menu bar height isn't being calculated correctly
# for minsize
height += self.menubar.winfo_reqheight()
root.minsize(width=root.winfo_width(), height=height)
self.download_panel.pack_forget()
root.geometry('{}x{}'.format(
lnp.userconfig.get_number('tkgui_width'),
lnp.userconfig.get_number('tkgui_height')))
root.bind("<Configure>", lambda e: self.on_resize())
root.update()
queue = download.get_queue('baselines')
queue.register_start_queue(self.start_download_queue)
queue.register_begin_download(self.start_download)
queue.register_progress(self.download_progress)
queue.register_end_download(self.end_download)
queue.register_end_queue(self.end_download_queue)
binding.update()
root.bind('<<UpdateAvailable>>', lambda e: UpdateWindow(self.root))
# Used for cross-thread signaling and communication during downloads
self.update_pending = Semaphore(1)
self.queue = Queue.Queue()
self.cross_thread_data = None
self.reply_semaphore = Semaphore(0)
self.download_text_string = ''
root.bind('<<ConfirmDownloads>>', lambda e: self.confirm_downloading())
root.bind('<<ForceUpdate>>', lambda e: self.update_download_text())
root.bind('<<ShowDLPanel>>', lambda e: self.download_panel.pack(
fill=X, expand=N, side=BOTTOM))
root.bind(
'<<HideDLPanel>>', lambda e: self.download_panel.pack_forget())
self.cross_thread_timer = self.root.after(100, self.check_cross_thread)
[docs] def on_resize(self):
"""Called when the window is resized."""
lnp.userconfig['tkgui_width'] = self.root.winfo_width()
lnp.userconfig['tkgui_height'] = self.root.winfo_height()
if self.save_size:
self.root.after_cancel(self.save_size)
self.save_size = self.root.after(1000, lnp.userconfig.save_data)
[docs] def start(self):
"""Starts the UI."""
self.root.mainloop()
if self.do_reload:
lnp.reload_program()
[docs] def on_update_available(self):
"""Called by the main LNP class if an update is available."""
self.queue.put('<<UpdateAvailable>>')
[docs] def on_program_running(self, path, is_df):
"""Called by the main LNP class if a program is already running."""
ConfirmRun(self.root, path, is_df)
[docs] @staticmethod
def on_invalid_config(errors):
"""Notifies a user about an invalid configuration."""
return messagebox.askyesno(
message='Some problems were found with your current '
'configuration:\n\n' + '\n'.join(errors) + '\n\nRun DF anyway?',
title='Invalid configuration', icon='warning', default='no')
[docs] def on_request_update_permission(self, interval):
"""Asks the user if update checking should be performed."""
if interval == 0:
days = 'launch'
elif interval == 1:
days = 'day'
else:
days = str(interval) + ' days'
result = messagebox.askyesno(
message='This pack can automatically check for updates. The author '
'of this pack suggests checking every ' + days + '.\n\nAllow automatic '
'update checks? You can change this behavior at any time from '
'Options > Check for Updates.', title='Update checks',
icon='question', default='yes')
if result:
self.updateDays.set(interval)
else:
self.updateDays.set(-1)
[docs] def create_tab(self, class_, caption):
"""
Creates a new tab and adds it to the main Notebook.
Args:
``class_``: Reference to the class representing the tab.
caption: Caption for the newly created tab.
"""
tab = class_(self.n, pad=(4, 2))
self.n.add(tab, text=caption)
[docs] def ensure_df(self):
"""Ensures a DF installation is active before proceeding."""
if paths.get('df') == '':
self.root.withdraw()
if lnp.folders:
selector = SelectDF(self.root, lnp.folders)
if selector.result == '':
messagebox.showerror(
'PyLNP',
'No Dwarf Fortress install was selected, quitting.')
self.root.destroy()
return False
try:
df.set_df_folder(selector.result)
except IOError as e:
messagebox.showerror(self.root.title(), str(e))
self.exit_program()
return False
else:
messagebox.showerror(
'PyLNP',
"Could not find Dwarf Fortress, quitting.")
self.root.destroy()
return False
self.root.deiconify()
return True
[docs] def reload_program(self):
"""Reloads the program to allow the user to change DF folders."""
self.do_reload = True
self.exit_program()
[docs] def set_downloads(self):
"""Sets the option for auto-download of baselines."""
baselines.set_auto_download(self.downloadBaselines.get())
[docs] @staticmethod
def set_autoclose():
"""Toggles automatic closing of the launcher."""
launcher.toggle_autoclose()
[docs] @staticmethod
def change_entry(key, var):
"""
Commits a change for the control specified by key.
Args:
key: The key for the control that changed.
var: The variable bound to the control.
"""
if not isinstance(key, str):
for k in key:
TkGui.change_entry(k, var)
return
if var.get() != '':
df.set_option(key, var.get())
[docs] def load_params(self):
"""Reads configuration data."""
try:
df.load_params()
except IOError as e:
messagebox.showerror(self.root.title(), str(e))
self.exit_program()
binding.update()
[docs] @staticmethod
def save_params():
"""Writes configuration data."""
df.save_params()
[docs] def exit_program(self):
"""Quits the program."""
self.root.after_cancel(self.cross_thread_timer)
self.root.quit()
self.root.destroy()
[docs] @staticmethod
def run_program(path):
"""
Launches another program.
Args:
path: Path to the program to launch.
"""
path = os.path.abspath(path)
launcher.run_program(path)
[docs] @staticmethod
def run_df():
"""Launches Dwarf Fortress, reporting any errors when launching."""
try:
launcher.run_df()
except Exception:
exc_info = sys.exc_info()
messagebox.showerror(
title='Error launching Dwarf Fortress',
message=exc_info[1].message)
[docs] def run_init(self):
"""Opens the init editor."""
InitEditor(self.root, self)
[docs] @staticmethod
def show_help():
"""Shows help for the program."""
if lnp.bundle:
launcher.open_url('https://pylnp.birdiesoft.dk/docs/' + VERSION + '/')
else:
launcher.open_url('https://pylnp.birdiesoft.dk/docs/dev/')
[docs] @staticmethod
def show_about():
"""Shows about dialog for the program."""
messagebox.showinfo(
title='About',
message="PyLNP " + VERSION
+ " - Lazy Newb Pack Python "
"Edition\n\nPort by Pidgeot\nContributions by PeridexisErrant, "
"rx80, dricus, James Morgensen, jecowa, carterscottm, McArcady, "
"fournm, rgov, cryzed, pjf, TV4Fun\n\n"
"Original program: LucasUP, TolyK/aTolyK")
[docs] @staticmethod
def cycle_option(field):
"""
Cycles through possible values for an option.
Args:
field: The option to cycle.
"""
if not isinstance(field, str):
for f in field:
TkGui.cycle_option(f)
return
df.cycle_option(field)
binding.update()
[docs] @staticmethod
def set_option(field):
"""
Sets an option directly.
Args:
field: The field name to change. The corresponding value is
automatically read.
"""
if not isinstance(field, str):
for f in field:
df.set_option(f, binding.get(field))
else:
df.set_option(field, binding.get(field))
binding.update()
[docs] @staticmethod
def show_df_info():
"""Shows basic information about the current DF install."""
messagebox.showinfo(title='DF info', message=str(lnp.df_info))
[docs] def confirm_downloading(self):
"""Ask the user if downloading may proceed."""
if self.cross_thread_data == 'baselines':
message = (
'PyLNP needs to download a copy of Dwarf Fortress to '
'complete this action. Is this OK?\n\nPlease note: You will '
'need to retry the action after the download completes.')
if sys.platform != 'win32':
message += ('\n\nThe windows_small edition will be used to '
'minimise required download size. '
'Platform-specific files are discarded.')
else:
message = (
'PyLNP needs to download data to process this action. '
'Is this OK?\n\nPlease note: You may need to retry the action '
'after the download completes.')
self.cross_thread_data = messagebox.askyesno(
message=message, title='Download data?', icon='question')
self.reply_semaphore.release()
[docs] def start_download_queue(self, queue):
"""Event handler for starting a download queue."""
result = True
if queue == 'baselines':
if not lnp.userconfig.get_bool('downloadBaselines'):
self.cross_thread_data = queue
self.queue.put('<<ConfirmDownloads>>')
# pylint: disable=consider-using-with
self.reply_semaphore.acquire()
# pylint: enable=consider-using-with
result = self.cross_thread_data
elif queue == 'updates':
result = True
if result:
self.queue.put('<<ShowDLPanel>>')
self.send_update_event(True)
return result
[docs] def send_update_event(self, force=False):
"""Schedules an update for the download text, if not already pending."""
# pylint: disable=consider-using-with
if self.update_pending.acquire(force):
# pylint: enable=consider-using-with
self.queue.put('<<ForceUpdate>>')
# pylint: disable=unused-argument
[docs] def start_download(self, queue, url, target):
"""Event handler for the start of a download."""
self.download_text_string = "Downloading %s..." % os.path.basename(url)
self.send_update_event()
[docs] def update_download_text(self):
"""Updates the text in the download information."""
s = self.download_text_string
self.download_text.set(s)
# Delay to prevent crash from event flood
self.root.after(200, self.update_pending.release)
[docs] def download_progress(self, queue, url, progress, total):
"""Event handler for download progress."""
if total != -1:
self.download_text_string = "Downloading %s... (%s/%s)" % (
os.path.basename(url), progress, total)
else:
self.download_text_string = (
"Downloading %s... (%s bytes downloaded)" % (
os.path.basename(url), progress))
self.send_update_event(False)
[docs] def end_download(self, queue, url, target, success):
"""Event handler for the end of a download."""
if success:
self.download_text_string = "Download finished"
else:
self.download_text_string = "Download failed"
self.send_update_event(True)
[docs] def end_download_queue(self, queue):
"""Event handler for the end of a download queue."""
self.root.after(5000, lambda: self.root.event_generate(
'<<HideDLPanel>>', when='tail'))
self.send_update_event()
# pylint: enable=unused-argument
[docs] def check_cross_thread(self):
"""Used to raise cross-thread events in the UI thread."""
while True:
try:
v = self.queue.get(False)
except Exception:
break
self.root.event_generate(v, when='tail')
self.cross_thread_timer = self.root.after(100, self.check_cross_thread)
[docs] def restore_defaults(self):
"""Restores default configuration data."""
if messagebox.askyesno(
message='Are you sure? '
'ALL SETTINGS will be reset to game defaults.\n'
'You may need to re-install graphics afterwards.',
title='Reset all settings to Defaults?', icon='question'):
df.restore_defaults()
messagebox.showinfo(
self.root.title(),
'All settings reset to defaults!')
[docs] def on_query_migration(self):
"""If no saves are detected, offer to import from an older pack."""
if messagebox.askyesno(
message='Import user content from an older pack?\n'
'This function will copy saves from an older DF install '
'or Starter Pack. It can be used at any time from the '
'menu File > Import from previous install.',
title='Import from an older pack?', icon='question'):
self.migrate_settings()
[docs] def migrate_settings(self):
"""Migrates settings from a previous DF install."""
old_df = filedialog.askdirectory(
title='Locate previous DF for import...', mustexist=True)
if old_df:
done, msg = importer.do_imports(old_df)
box = 'Successfully imported:' if done else 'Import failed.'
messagebox.showinfo(
self.root.title(),
'{}\n\n{}\n\nSee the log for more details.'.format(box, msg))
# vim:expandtab