From cd79eb28e94cbf4a93dad46ca77b57d4e5ea7e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Thu, 11 Jul 2019 22:10:57 +0300 Subject: [PATCH 01/31] Split the code into modules --- superpaper.pyw | 2665 --------------------------- superpaper/__version__.py | 3 + superpaper/cli.py | 97 + superpaper/configuration_dialogs.py | 751 ++++++++ superpaper/data.py | 794 ++++++++ superpaper/message_dialog.py | 9 + superpaper/sp_logging.py | 29 + superpaper/sp_paths.py | 16 + superpaper/superpaper.py | 26 + superpaper/tray.py | 459 +++++ superpaper/wallpaper_processing.py | 777 ++++++++ 11 files changed, 2961 insertions(+), 2665 deletions(-) delete mode 100755 superpaper.pyw create mode 100644 superpaper/__version__.py create mode 100644 superpaper/cli.py create mode 100644 superpaper/configuration_dialogs.py create mode 100644 superpaper/data.py create mode 100644 superpaper/message_dialog.py create mode 100644 superpaper/sp_logging.py create mode 100644 superpaper/sp_paths.py create mode 100644 superpaper/superpaper.py create mode 100644 superpaper/tray.py create mode 100644 superpaper/wallpaper_processing.py diff --git a/superpaper.pyw b/superpaper.pyw deleted file mode 100755 index 74933a2..0000000 --- a/superpaper.pyw +++ /dev/null @@ -1,2665 +0,0 @@ -#!/usr/bin/env python3 - -import os -import platform -import subprocess -import math -import random -import sys -import argparse -import logging -from time import sleep -from pathlib import Path -from operator import itemgetter -from threading import Timer, Lock, Thread - -from PIL import Image -from screeninfo import get_monitors -try: - import wx - import wx.adv -except ImportError: - pass -if platform.system() == "Windows": - import ctypes -elif platform.system() == "Linux": - # KDE has special needs - if os.environ.get("DESKTOP_SESSION") == "/usr/share/xsessions/plasma": - import dbus - - -# Global variables - -# list of display resolutions (width,height), use tuples. -RESOLUTION_ARRAY = [] -# list of display offsets (width,height), use tuples. -DISPLAY_OFFSET_ARRAY = [] - -DEBUG = False -VERBOSE = False -LOGGING = False -G_LOGGER = logging.getLogger("default") -NUM_DISPLAYS = 0 - -# Set path to binary / script -if getattr(sys, 'frozen', False): - # If the application is run as a bundle, the pyInstaller bootloader - # extends the sys module by a flag frozen=True and sets the app - # path into variable _MEIPASS'. - # PATH = sys._MEIPASS - PATH = os.path.dirname(os.path.realpath(sys.executable)) -else: - PATH = os.path.dirname(os.path.realpath(__file__)) -# Derivative paths -TEMP_PATH = PATH + "/temp/" -if not os.path.isdir(TEMP_PATH): - os.mkdir(TEMP_PATH) -PROFILES_PATH = PATH + "/profiles/" -TRAY_TOOLTIP = "Superpaper" -TRAY_ICON = PATH + "/resources/default.png" -VERSION_STRING = "1.1.2" -G_SET_COMMAND_STRING = "" -G_WALLPAPER_CHANGE_LOCK = Lock() -G_SUPPORTED_IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".bmp", ".gif", ".tiff", ".webp") - -if DEBUG and not LOGGING: - G_LOGGER.setLevel(logging.INFO) - CONSOLE_HANDLER = logging.StreamHandler() - G_LOGGER.addHandler(CONSOLE_HANDLER) - -if LOGGING: - DEBUG = True - # sys.stdout = open(PATH + "/log.txt", "w") - G_LOGGER.setLevel(logging.INFO) - FILE_HANDLER = logging.FileHandler("{0}/{1}.log".format(PATH, "log"), - mode="w") - G_LOGGER.addHandler(FILE_HANDLER) - CONSOLE_HANDLER = logging.StreamHandler() - G_LOGGER.addHandler(CONSOLE_HANDLER) - -def custom_exception_handler(exceptiontype, value, tb_var): - """Log uncaught exceptions.""" - G_LOGGER.exception("Uncaught exceptionn type: %s", str(exceptiontype)) - G_LOGGER.exception("Exception: %s", str(value)) - G_LOGGER.exception(str(tb_var)) - # G_LOGGER.exception("Uncaught exception.") - -def show_message_dialog(message, msg_type="Info"): - """General purpose info dialog in GUI mode.""" - # Type can be 'Info', 'Error', 'Question', 'Exclamation' - if "wx" in sys.modules: - dial = wx.MessageDialog(None, message, msg_type, wx.OK) - dial.ShowModal() - else: - pass - -class GeneralSettingsData(object): - """Object to store and save application wide settings.""" - - def __init__(self): - self.logging = False - self.use_hotkeys = True - self.hk_binding_next = None - self.hk_binding_pause = None - self.set_command = "" - self.show_help = True - self.parse_settings() - - def parse_settings(self): - """Parse general_settings file. Create it if it doesn't exists.""" - global DEBUG, LOGGING, G_SET_COMMAND_STRING - global G_LOGGER, FILE_HANDLER, CONSOLE_HANDLER - fname = os.path.join(PATH, "general_settings") - if os.path.isfile(fname): - general_settings_file = open(fname, "r") - try: - for line in general_settings_file: - words = line.strip().split("=") - if words[0] == "logging": - wrds1 = words[1].strip().lower() - if wrds1 == "true": - self.logging = True - LOGGING = True - DEBUG = True - G_LOGGER = logging.getLogger("default") - G_LOGGER.setLevel(logging.INFO) - # Install exception handler - sys.excepthook = custom_exception_handler - FILE_HANDLER = logging.FileHandler( - "{0}/{1}.log".format(PATH, "log"), - mode="w") - G_LOGGER.addHandler(FILE_HANDLER) - CONSOLE_HANDLER = logging.StreamHandler() - G_LOGGER.addHandler(CONSOLE_HANDLER) - G_LOGGER.info("Enabled logging to file.") - elif words[0] == "use hotkeys": - wrds1 = words[1].strip().lower() - if wrds1 == "true": - self.use_hotkeys = True - else: - self.use_hotkeys = False - if DEBUG: - G_LOGGER.info("use_hotkeys: %s", self.use_hotkeys) - elif words[0] == "next wallpaper hotkey": - binding_strings = words[1].strip().split("+") - if binding_strings: - self.hk_binding_next = tuple(binding_strings) - if DEBUG: - G_LOGGER.info("hk_binding_next: %s", self.hk_binding_next) - elif words[0] == "pause wallpaper hotkey": - binding_strings = words[1].strip().split("+") - if binding_strings: - self.hk_binding_pause = tuple(binding_strings) - if DEBUG: - G_LOGGER.info("hk_binding_pause: %s", self.hk_binding_pause) - elif words[0] == "set_command": - G_SET_COMMAND_STRING = words[1].strip() - self.set_command = G_SET_COMMAND_STRING - elif words[0].strip() == "show_help_at_start": - show_state = words[1].strip().lower() - if show_state == "false": - self.show_help = False - else: - pass - else: - G_LOGGER.info("Exception: Unkown general setting: %s", - words[0]) - finally: - general_settings_file.close() - else: - # if file does not exist, create it and write default values. - general_settings_file = open(fname, "x") - general_settings_file.write("logging=false\n") - general_settings_file.write("use hotkeys=true\n") - general_settings_file.write("next wallpaper hotkey=control+super+w\n") - self.hk_binding_next = ("control", "super", "w") - general_settings_file.write("pause wallpaper hotkey=control+super+shift+p\n") - self.hk_binding_pause = ("control", "super", "shift", "p") - general_settings_file.write("set_command=") - general_settings_file.close() - - def save_settings(self): - """Save the current state of the general settings object.""" - - fname = os.path.join(PATH, "general_settings") - general_settings_file = open(fname, "w") - - if self.logging: - general_settings_file.write("logging=true\n") - else: - general_settings_file.write("logging=false\n") - - if self.use_hotkeys: - general_settings_file.write("use hotkeys=true\n") - else: - general_settings_file.write("use hotkeys=false\n") - - if self.hk_binding_next: - hk_string = "+".join(self.hk_binding_next) - general_settings_file.write("next wallpaper hotkey={}\n".format(hk_string)) - - if self.hk_binding_pause: - hk_string_p = "+".join(self.hk_binding_pause) - general_settings_file.write("pause wallpaper hotkey={}\n".format(hk_string_p)) - - if self.show_help: - general_settings_file.write("show_help_at_start=true\n") - else: - general_settings_file.write("show_help_at_start=false\n") - - general_settings_file.write("set_command={}".format(self.set_command)) - general_settings_file.close() - - - - - -class ProfileData(object): - def __init__(self, file): - self.name = "default_profile" - self.spanmode = "single" # single / multi - self.slideshow = True - self.delay_list = [600] - self.sortmode = "shuffle" # shuffle ( random , sorted? ) - self.ppimode = False - self.ppiArray = NUM_DISPLAYS * [100] - self.ppiArrayRelDensity = [] - self.inches = [] - self.manual_offsets = NUM_DISPLAYS * [(0, 0)] - self.manual_offsets_useronly = [] - self.bezels = [] - self.bezel_px_offsets = [] - self.hkBinding = None - self.pathsArray = [] - - self.parse_profile(file) - if self.ppimode is True: - self.compute_relative_densities() - if self.bezels: - self.compute_bezel_px_offsets() - self.file_handler = self.Filehandler(self.pathsArray, self.sortmode) - - def parse_profile(self, file): - """Read wallpaper profile settings from file.""" - profile_file = open(file, "r") - try: - for line in profile_file: - line.strip() - words = line.split("=") - if words[0] == "name": - self.name = words[1].strip() - elif words[0] == "spanmode": - wrd1 = words[1].strip().lower() - if wrd1 == "single": - self.spanmode = wrd1 - elif wrd1 == "multi": - self.spanmode = wrd1 - else: - G_LOGGER.info("Exception: unknown spanmode: %s \ - in profile: %s", words[1], self.name) - elif words[0] == "slideshow": - wrd1 = words[1].strip().lower() - if wrd1 == "true": - self.slideshow = True - else: - self.slideshow = False - elif words[0] == "delay": - self.delay_list = [] - delay_strings = words[1].strip().split(";") - for delstr in delay_strings: - self.delay_list.append(int(delstr)) - elif words[0] == "sortmode": - wrd1 = words[1].strip().lower() - if wrd1 == "shuffle": - self.sortmode = wrd1 - elif wrd1 == "sort": - self.sortmode = wrd1 - else: - G_LOGGER.info("Exception: unknown sortmode: %s \ - in profile: %s", words[1], self.name) - elif words[0] == "offsets": - # Use PPI mode algorithm to do cuts. - # Defaults assume uniform pixel density - # if no custom values are given. - self.ppimode = True - self.manual_offsets = [] - self.manual_offsets_useronly = [] - # w1,h1;w2,h2;... - offset_strings = words[1].strip().split(";") - for offstr in offset_strings: - res_str = offstr.split(",") - self.manual_offsets.append((int(res_str[0]), - int(res_str[1]))) - self.manual_offsets_useronly.append((int(res_str[0]), - int(res_str[1]))) - elif words[0] == "bezels": - bez_mm_strings = words[1].strip().split(";") - for bezstr in bez_mm_strings: - self.bezels.append(float(bezstr)) - elif words[0] == "ppi": - self.ppimode = True - # overwrite initialized arrays. - self.ppiArray = [] - self.ppiArrayRelDensity = [] - ppi_strings = words[1].strip().split(";") - for ppistr in ppi_strings: - self.ppiArray.append(int(ppistr)) - elif words[0] == "diagonal_inches": - self.ppimode = True - # overwrite initialized arrays. - self.ppiArray = [] - self.ppiArrayRelDensity = [] - inch_strings = words[1].strip().split(";") - self.inches = [] - for inchstr in inch_strings: - self.inches.append(float(inchstr)) - self.ppiArray = self.computePPIs(self.inches) - elif words[0] == "hotkey": - binding_strings = words[1].strip().split("+") - self.hkBinding = tuple(binding_strings) - if DEBUG: - G_LOGGER.info("hkBinding: %s", self.hkBinding) - elif words[0].startswith("display"): - paths = words[1].strip().split(";") - paths = list(filter(None, paths)) # drop empty strings - self.pathsArray.append(paths) - else: - G_LOGGER.info("Unknown setting line in config: %s", line) - finally: - profile_file.close() - - def computePPIs(self, inches): - if len(inches) < NUM_DISPLAYS: - G_LOGGER.info("Exception: Number of read display diagonals was: \ - %s , but the number of displays was found to be: %s", - str(len(inches)), - str(NUM_DISPLAYS) - ) - G_LOGGER.info("Falling back to no PPI correction.") - self.ppimode = False - return NUM_DISPLAYS * [100] - else: - ppiArray = [] - for inch, res in zip(inches, RESOLUTION_ARRAY): - diagonal_px = math.sqrt(res[0]**2 + res[1]**2) - px_per_inch = diagonal_px / inch - ppiArray.append(px_per_inch) - if DEBUG: - G_LOGGER.info("Computed PPIs: %s", ppiArray) - return ppiArray - - def compute_relative_densities(self): - max_density = max(self.ppiArray) - for ppi in self.ppiArray: - self.ppiArrayRelDensity.append((1 / max_density) * float(ppi)) - if DEBUG: - G_LOGGER.info("relative pixel densities: %s", self.ppiArrayRelDensity) - - def compute_bezel_px_offsets(self): - inch_per_mm = 1.0 / 25.4 - for bez_mm, ppi in zip(self.bezels, self.ppiArray): - self.bezel_px_offsets.append( - round(float(ppi) * inch_per_mm * bez_mm)) - if DEBUG: - G_LOGGER.info( - "Bezel px calculation: initial manual offset: %s, \ - and bezel pixels: %s", - self.manual_offsets, - self.bezel_px_offsets) - # Add these horizontal offsets to manual_offsets: - # Avoid offsetting the leftmost anchored display i==0 - # -1 since last display doesn't have a next display. - for i in range(len(self.bezel_px_offsets) - 1): - self.manual_offsets[i + 1] = (self.manual_offsets[i + 1][0] + - self.bezel_px_offsets[i + 1] + - self.bezel_px_offsets[i], - self.manual_offsets[i + 1][1]) - if DEBUG: - G_LOGGER.info( - "Bezel px calculation: resulting combined manual offset: %s", - self.manual_offsets) - - def NextWallpaperFiles(self): - return self.file_handler.next_wallpaper_files() - - class Filehandler(object): - def __init__(self, pathsArray, sortmode): - # A list of lists if there is more than one monitor with distinct - # input paths. - self.all_files_in_paths = [] - self.pathsArray = pathsArray - self.sortmode = sortmode - for paths_list in pathsArray: - list_of_images = [] - for path in paths_list: - # Add list items to the end of the list instead of - # appending the list to the list. - if not os.path.exists(path): - message = "A path was not found: '{}'.\n\ -Use absolute paths for best reliabilty.".format(path) - G_LOGGER.info(message) - show_message_dialog(message, "Error") - continue - else: - # List only images that are of supported type. - list_of_images += [os.path.join(path, f) - for f in os.listdir(path) - if f.endswith(G_SUPPORTED_IMAGE_EXTENSIONS) - ] - # Append the list of monitor_i specific files to the list of - # lists of images. - self.all_files_in_paths.append(list_of_images) - self.iterators = [] - for diplay_image_list in self.all_files_in_paths: - self.iterators.append( - self.ImageList( - diplay_image_list, - self.sortmode)) - - def next_wallpaper_files(self): - files = [] - for iterable in self.iterators: - next_image = iterable.__next__() - if os.path.isfile(next_image): - files.append(next_image) - else: - # reload all files by initializing - if DEBUG: - G_LOGGER.info("Ran into an invalid file, reinitializing..") - self.__init__(self.pathsArray, self.sortmode) - files = self.next_wallpaper_files() - break - return files - - class ImageList: - def __init__(self, filelist, sortmode): - self.counter = 0 - self.files = filelist - self.sortmode = sortmode - self.arrange_list() - - def __iter__(self): - return self - - def __next__(self): - if self.counter < len(self.files): - image = self.files[self.counter] - self.counter += 1 - return image - else: - self.counter = 0 - self.arrange_list() - image = self.files[self.counter] - self.counter += 1 - return image - - def arrange_list(self): - if self.sortmode == "shuffle": - if DEBUG and VERBOSE: - G_LOGGER.info("Shuffling files: %s", self.files) - random.shuffle(self.files) - if DEBUG and VERBOSE: - G_LOGGER.info("Shuffled files: %s", self.files) - elif self.sortmode == "alphabetical": - self.files.sort() - if DEBUG and VERBOSE: - G_LOGGER.info("Sorted files: %s", self.files) - else: - G_LOGGER.info( - "ImageList.arrange_list: unknown sortmode: %s", - self.sortmode) - - -class CLIProfileData(ProfileData): - - def __init__(self, files, ppiarr, inches, bezels, offsets): - self.name = "cli" - self.spanmode = "" # single / multi - if len(files) == 1: - self.spanmode = "single" - else: - self.spanmode = "multi" - - self.ppimode = None - if ppiarr is None and inches is None: - self.ppimode = False - self.ppiArray = NUM_DISPLAYS * [100] - else: - self.ppimode = True - if inches: - self.ppiArray = self.computePPIs(inches) - else: - self.ppiArray = ppiarr - - if offsets is None: - self.manual_offsets = NUM_DISPLAYS * [(0, 0)] - else: - self.manual_offsets = NUM_DISPLAYS * [(0, 0)] - off_pairs_zip = zip(*[iter(offsets)]*2) - off_pairs = [tuple(p) for p in off_pairs_zip] - for off, i in zip(off_pairs, range(len(self.manual_offsets))): - self.manual_offsets[i] = off - # print(self.manual_offsets) - for pair in self.manual_offsets: - self.manual_offsets[self.manual_offsets.index(pair)] = (int(pair[0]), int(pair[1])) - # print(self.manual_offsets) - - self.ppiArrayRelDensity = [] - self.bezels = bezels - self.bezel_px_offsets = [] - #self.files = files - self.files = [] - for item in files: - self.files.append(os.path.realpath(item)) - # - if self.ppimode is True: - self.compute_relative_densities() - if self.bezels: - self.compute_bezel_px_offsets() - - def NextWallpaperFiles(self): - return self.files - -class TempProfileData(object): - def __init__(self): - self.name = None - self.spanmode = None - self.slideshow = None - self.delay = None - self.sortmode = None - # self.ppiArray = None - self.inches = None - self.manual_offsets = None - self.bezels = None - self.hkBinding = None - self.pathsArray = [] - - def Save(self): - if self.name is not None: - fname = PROFILES_PATH + self.name + ".profile" - try: - tpfile = open(fname, "w") - except IOError: - msg = "Cannot write to file {}".format(fname) - show_message_dialog(msg, "Error") - return None - tpfile.write("name=" + str(self.name) + "\n") - if self.spanmode: - tpfile.write("spanmode=" + str(self.spanmode) + "\n") - if self.slideshow is not None: - tpfile.write("slideshow=" + str(self.slideshow) + "\n") - if self.delay: - tpfile.write("delay=" + str(self.delay) + "\n") - if self.sortmode: - tpfile.write("sortmode=" + str(self.sortmode) + "\n") - # f.write("ppi=" + str(self.ppiArray) + "\n") - if self.inches: - tpfile.write("diagonal_inches=" + str(self.inches) + "\n") - if self.manual_offsets: - tpfile.write("offsets=" + str(self.manual_offsets) + "\n") - if self.bezels: - tpfile.write("bezels=" + str(self.bezels) + "\n") - if self.hkBinding: - tpfile.write("hotkey=" + str(self.hkBinding) + "\n") - if self.pathsArray: - for paths in self.pathsArray: - tpfile.write("display" + str(self.pathsArray.index(paths)) - + "paths=" + paths + "\n") - - tpfile.close() - return fname - else: - print("tmp.Save(): name is not set.") - return None - - def test_save(self): - valid_profile = False - if self.name is not None and self.name.strip() is not "": - fname = PROFILES_PATH + self.name + ".deleteme" - try: - testfile = open(fname, "w") - testfile.close() - os.remove(fname) - except IOError: - msg = "Cannot write to file {}".format(fname) - show_message_dialog(msg, "Error") - return False - if self.spanmode == "single": - if len(self.pathsArray) > 1: - msg = "When spanning a single image across all monitors, \ -only one paths field is needed." - show_message_dialog(msg, "Error") - return False - if self.spanmode == "multi": - if len(self.pathsArray) < 2: - msg = "When setting a different image on every display, \ -each display needs its own paths field." - show_message_dialog(msg, "Error") - return False - if self.slideshow is True and not self.delay: - msg = "When using slideshow you need to enter a delay." - show_message_dialog(msg, "Info") - return False - if self.delay: - try: - val = int(self.delay) - if val < 20: - msg = "It is advisable to set the slideshow delay to \ -be at least 20 seconds due to the time the image processing takes." - show_message_dialog(msg, "Info") - return False - except ValueError: - msg = "Slideshow delay must be an integer of seconds." - show_message_dialog(msg, "Error") - return False - # if self.sortmode: - # No test needed - if self.inches: - if self.is_list_float(self.inches): - pass - else: - msg = "Display diagonals must be given in numeric values \ -using decimal point and separated by semicolon ';'." - show_message_dialog(msg, "Error") - return False - if self.manual_offsets: - if self.is_list_offsets(self.manual_offsets): - pass - else: - msg = "Display offsets must be given in width,height pixel \ -pairs and separated by semicolon ';'." - show_message_dialog(msg, "Error") - return False - if self.bezels: - if self.is_list_float(self.bezels): - if self.manual_offsets: - if len(self.manual_offsets.split(";")) < len(self.bezels.split(";")): - msg = "When using both offset and bezel \ -corrections, take care to enter an offset for each display that you \ -enter a bezel thickness." - show_message_dialog(msg, "Error") - return False - else: - pass - else: - pass - else: - msg = "Display bezels must be given in millimeters using \ -decimal point and separated by semicolon ';'." - show_message_dialog(msg, "Error") - return False - if self.hkBinding: - if self.is_valid_hotkey(self.hkBinding): - pass - else: - msg = "Hotkey must be given as 'mod1+mod2+mod3+key'. \ -Valid modifiers are 'control', 'super', 'alt', 'shift'." - show_message_dialog(msg, "Error") - return False - if self.pathsArray: - if self.is_list_valid_paths(self.pathsArray): - pass - else: - # msg = "Paths must be separated by a semicolon ';'." - # show_message_dialog(msg, "Error") - return False - else: - msg = "You must enter at least one path for images." - show_message_dialog(msg, "Error") - return False - # Passed all tests. - valid_profile = True - return valid_profile - else: - print("tmp.Save(): name is not set.") - msg = "You must enter a name for the profile." - show_message_dialog(msg, "Error") - return False - - def is_list_float(self, input_string): - is_floats = True - list_input = input_string.split(";") - for item in list_input: - try: - val = float(item) - except ValueError: - G_LOGGER.info("float type check failed for: '%s'", val) - return False - return is_floats - - def is_list_offsets(self, input_string): - list_input = input_string.split(";") - # if len(list_input) < NUM_DISPLAYS: - # msg = "Enter an offset for every display, even if it is (0,0)." - # show_message_dialog(msg, "Error") - # return False - try: - for off_pair in list_input: - offset = off_pair.split(",") - if len(offset) > 2: - return False - try: - val_w = int(offset[0]) - val_h = int(offset[1]) - except ValueError: - G_LOGGER.info("int type check failed for: '%s' or '%s", - val_w, val_h) - return False - except TypeError: - return False - # Passed tests. - return True - - def is_valid_hotkey(self, input_string): - # Validity is hard to properly verify here. - # Instead do it when registering hotkeys at startup. - input_string = "" + input_string - return True - - def is_list_valid_paths(self, input_list): - if input_list == [""]: - msg = "At least one path for wallpapers must be given." - show_message_dialog(msg, "Error") - return False - if "" in input_list: - msg = "Take care not to save a profile with an empty display paths field." - show_message_dialog(msg, "Error") - return False - for path_list_str in input_list: - path_list = path_list_str.split(";") - for path in path_list: - if os.path.isdir(path) is True: - supported_files = [f for f in os.listdir(path) - if f.endswith(G_SUPPORTED_IMAGE_EXTENSIONS)] - if supported_files: - continue - else: - msg = "Path '{}' does not contain supported image files.".format(path) - show_message_dialog(msg, "Error") - return False - else: - msg = "Path '{}' was not recognized as a directory.".format(path) - show_message_dialog(msg, "Error") - return False - valid_pathsarray = True - return valid_pathsarray - - - -class RepeatedTimer(object): - # Credit: - # https://stackoverflow.com/questions/3393612/run-certain-code-every-n-seconds/13151299#13151299 - def __init__(self, interval, function, *args, **kwargs): - self._timer = None - self.interval = interval - self.function = function - self.args = args - self.kwargs = kwargs - self.is_running = False - self.start() - - def _run(self): - self.is_running = False - self.start() - self.function(*self.args, **self.kwargs) - - def start(self): - if not self.is_running: - self._timer = Timer(self.interval, self._run) - self._timer.daemon = True - self._timer.start() - self.is_running = True - - def stop(self): - self._timer.cancel() - self.is_running = False - - -def get_display_data(): - # https://github.com/rr-/screeninfo - global NUM_DISPLAYS, RESOLUTION_ARRAY, DISPLAY_OFFSET_ARRAY - RESOLUTION_ARRAY = [] - DISPLAY_OFFSET_ARRAY = [] - monitors = get_monitors() - NUM_DISPLAYS = len(monitors) - for m_index in range(len(monitors)): - res = [] - offset = [] - res.append(monitors[m_index].width) - res.append(monitors[m_index].height) - offset.append(monitors[m_index].x) - offset.append(monitors[m_index].y) - RESOLUTION_ARRAY.append(tuple(res)) - DISPLAY_OFFSET_ARRAY.append(tuple(offset)) - # Check that the display offsets are sane, i.e. translate the values if - # there are any negative values (Windows). - # Top-most edge of the crop tuples. - leftmost_offset = min(DISPLAY_OFFSET_ARRAY, key=itemgetter(0))[0] - topmost_offset = min(DISPLAY_OFFSET_ARRAY, key=itemgetter(1))[1] - if leftmost_offset < 0 or topmost_offset < 0: - if DEBUG: - G_LOGGER.info("Negative display offset: %s", DISPLAY_OFFSET_ARRAY) - translate_offsets = [] - for offset in DISPLAY_OFFSET_ARRAY: - translate_offsets.append((offset[0] - leftmost_offset, offset[1] - topmost_offset)) - DISPLAY_OFFSET_ARRAY = translate_offsets - if DEBUG: - G_LOGGER.info("Sanitised display offset: %s", DISPLAY_OFFSET_ARRAY) - if DEBUG: - G_LOGGER.info( - "get_display_data output: NUM_DISPLAYS = %s, %s, %s", - NUM_DISPLAYS, - RESOLUTION_ARRAY, - DISPLAY_OFFSET_ARRAY) - # Sort displays left to right according to offset data - display_indices = list(range(len(DISPLAY_OFFSET_ARRAY))) - display_indices.sort(key=DISPLAY_OFFSET_ARRAY.__getitem__) - DISPLAY_OFFSET_ARRAY = list(map(DISPLAY_OFFSET_ARRAY.__getitem__, display_indices)) - RESOLUTION_ARRAY = list(map(RESOLUTION_ARRAY.__getitem__, display_indices)) - if DEBUG: - G_LOGGER.info( - "SORTED get_display_data output: NUM_DISPLAYS = %s, %s, %s", - NUM_DISPLAYS, - RESOLUTION_ARRAY, - DISPLAY_OFFSET_ARRAY) - - -def compute_canvas(res_array, offset_array): - # Take the subtractions of right-most right - left-most left - # and bottom-most bottom - top-most top (=0). - leftmost = 0 - topmost = 0 - right_edges = [] - bottom_edges = [] - for res, off in zip(res_array, offset_array): - right_edges.append(off[0]+res[0]) - bottom_edges.append(off[1]+res[1]) - # Right-most edge. - rightmost = max(right_edges) - # Bottom-most edge. - bottommost = max(bottom_edges) - canvas_size = [rightmost - leftmost, bottommost - topmost] - if DEBUG: - G_LOGGER.info("Canvas size: %s", canvas_size) - return canvas_size - - -def compute_ppi_corrected_res_array(res_array, ppi_list_rel_density): - """Return ppi density normalized sizes of the real resolutions.""" - eff_res_array = [] - for i in range(len(res_array)): - effw = round(res_array[i][0] / ppi_list_rel_density[i]) - effh = round(res_array[i][1] / ppi_list_rel_density[i]) - eff_res_array.append((effw, effh)) - return eff_res_array - - -# resize image to fill given rectangle and do a centered crop to size. -# Return output image. -def resize_to_fill(img, res): - image_size = img.size # returns image (width,height) - if image_size == res: - # input image is already of the correct size, no action needed. - return img - image_ratio = image_size[0] / image_size[1] - target_ratio = res[0] / res[1] - # resize along the shorter edge to get an image that is at least of the - # target size on the shorter edge. - if image_ratio < target_ratio: # img not wide enough / is too tall - resize_multiplier = res[0] / image_size[0] - new_size = ( - round(resize_multiplier * image_size[0]), - round(resize_multiplier * image_size[1])) - img = img.resize(new_size, resample=Image.LANCZOS) - # crop vertically to target height - extraH = new_size[1] - res[1] - if extraH < 0: - G_LOGGER.info( - "Error with cropping vertically, resized image \ - wasn't taller than target size.") - return -1 - if extraH == 0: - # image is already at right height, no cropping needed. - return img - # (left edge, half of extra height from top, - # right edge, bottom = top + res[1]) : force correct height - cropTuple = ( - 0, - round(extraH/2), - new_size[0], - round(extraH/2) + res[1]) - cropped_res = img.crop(cropTuple) - if cropped_res.size == res: - return cropped_res - else: - G_LOGGER.info( - "Error: result image not of correct size. crp:%s, res:%s", - cropped_res.size, res) - return -1 - elif image_ratio >= target_ratio: # img not tall enough / is too wide - resize_multiplier = res[1] / image_size[1] - new_size = ( - round(resize_multiplier * image_size[0]), - round(resize_multiplier * image_size[1])) - img = img.resize(new_size, resample=Image.LANCZOS) - # crop horizontally to target width - extraW = new_size[0] - res[0] - if extraW < 0: - G_LOGGER.info( - "Error with cropping horizontally, resized image \ - wasn't wider than target size.") - return -1 - if extraW == 0: - # image is already at right width, no cropping needed. - return img - # (half of extra from left edge, top edge, - # right = left + desired width, bottom) : force correct width - cropTuple = ( - round(extraW/2), - 0, - round(extraW/2) + res[0], - new_size[1]) - cropped_res = img.crop(cropTuple) - if cropped_res.size == res: - return cropped_res - else: - G_LOGGER.info( - "Error: result image not of correct size. crp:%s, res:%s", - cropped_res.size, res) - return -1 - - -def get_center(res): - return (round(res[0] / 2), round(res[1] / 2)) - - -def get_all_centers(resarr_eff, manual_offsets): - centers = [] - sum_widths = 0 - # get the vertical pixel distance of the center of the left most display - # from the top. - center_standard_height = get_center(resarr_eff[0])[1] - if len(manual_offsets) < len(resarr_eff): - G_LOGGER.info("get_all_centers: Not enough manual offsets: \ - %s for displays: %s", - len(manual_offsets), - len(resarr_eff)) - else: - for i in range(len(resarr_eff)): - horiz_radius = get_horizontal_radius(resarr_eff[i]) - # here take the center height to be the same for all the displays - # unless modified with the manual offset - center_pos_from_anchor_left_top = ( - sum_widths + manual_offsets[i][0] + horiz_radius, - center_standard_height + manual_offsets[i][1]) - centers.append(center_pos_from_anchor_left_top) - sum_widths += resarr_eff[i][0] - if DEBUG: - G_LOGGER.info("centers: %s", centers) - return centers - - -def get_lefttop_from_center(center, res): - """Compute top left coordinate of a rectangle from its center.""" - return (center[0] - round(res[0] / 2), center[1] - round(res[1] / 2)) - - -def get_rightbottom_from_lefttop(lefttop, res): - return (lefttop[0] + res[0], lefttop[1] + res[1]) - - -def get_horizontal_radius(res): - return round(res[0] / 2) - - -def computeCropTuples(RESOLUTION_ARRAY_eff, manual_offsets): - # Assume the centers of the physical displays are aligned on common - # horizontal line. If this is not the case one must use the manual - # offsets defined in the profile for adjustment (and bezel corrections). - # Anchor positions to the top left corner of the left most display. If - # its size is scaled up, one will need to adjust the horizontal positions - # of all the displays. (This is automatically handled by using the - # effective resolution array). - # Additionally one must make sure that the highest point of the display - # arrangement is at y=0. - crop_tuples = [] - centers = get_all_centers(RESOLUTION_ARRAY_eff, manual_offsets) - for center, res in zip(centers, RESOLUTION_ARRAY_eff): - lefttop = get_lefttop_from_center(center, res) - rightbottom = get_rightbottom_from_lefttop(lefttop, res) - crop_tuples.append(lefttop + rightbottom) - # Translate crops so that the highest point is at y=0 -- remember to add - # translation to both top and bottom coordinates! - # Top-most edge of the crop tuples. - leftmost = min(crop_tuples, key=itemgetter(0))[0] - # Top-most edge of the crop tuples. - topmost = min(crop_tuples, key=itemgetter(1))[1] - if leftmost is 0 and topmost is 0: - if DEBUG: - G_LOGGER.info("crop_tuples: {}".format(crop_tuples)) - return crop_tuples # [(left, up, right, bottom),...] - else: - crop_tuples_translated = translate_crops( - crop_tuples, (leftmost, topmost)) - if DEBUG: - G_LOGGER.info("crop_tuples_translated: {}".format(crop_tuples_translated)) - return crop_tuples_translated # [(left, up, right, bottom),...] - - -def translate_crops(crop_tuples, translateTuple): - crop_tuples_translated = [] - for crop_tuple in crop_tuples: - crop_tuples_translated.append( - (crop_tuple[0] - translateTuple[0], - crop_tuple[1] - translateTuple[1], - crop_tuple[2] - translateTuple[0], - crop_tuple[3] - translateTuple[1])) - return crop_tuples_translated - - -def computeWorkingCanvas(crop_tuples): - # Take the subtractions of right-most right - left-most left - # and bottom-most bottom - top-most top (=0). - leftmost = 0 - topmost = 0 - # Right-most edge of the crop tuples. - rightmost = max(crop_tuples, key=itemgetter(2))[2] - # Bottom-most edge of the crop tuples. - bottommost = max(crop_tuples, key=itemgetter(3))[3] - canvas_size = [rightmost - leftmost, bottommost - topmost] - return canvas_size - - -def spanSingleImage(profile): - file = profile.NextWallpaperFiles()[0] - if DEBUG: - G_LOGGER.info(file) - img = Image.open(file) - canvasTuple = tuple(compute_canvas(RESOLUTION_ARRAY, DISPLAY_OFFSET_ARRAY)) - img_resize = resize_to_fill(img, canvasTuple) - outputfile = TEMP_PATH + profile.name + "-a.png" - if os.path.isfile(outputfile): - outputfile_old = outputfile - outputfile = TEMP_PATH + profile.name + "-b.png" - else: - outputfile_old = TEMP_PATH + profile.name + "-b.png" - img_resize.save(outputfile, "PNG") - setWallpaper(outputfile) - if os.path.exists(outputfile_old): - os.remove(outputfile_old) - return 0 - - -# Take pixel densities of displays into account to have the image match -# physically between displays. -def spanSingleImagePPIcorrection(profile): - file = profile.NextWallpaperFiles()[0] - if DEBUG: - G_LOGGER.info(file) - img = Image.open(file) - RESOLUTION_ARRAY_eff = compute_ppi_corrected_res_array( - RESOLUTION_ARRAY, profile.ppiArrayRelDensity) - - # Cropping now sections of the image to be shown, USE EFFECTIVE WORKING - # SIZES. Also EFFECTIVE SIZE Offsets are now required. - manual_offsets = profile.manual_offsets - cropped_images = [] - crop_tuples = computeCropTuples(RESOLUTION_ARRAY_eff, manual_offsets) - # larger working size needed to fill all the normalized lower density - # displays. Takes account manual offsets that might require extra space. - canvasTuple_eff = tuple(computeWorkingCanvas(crop_tuples)) - # Image is now the height of the eff tallest display + possible manual - # offsets and the width of the combined eff widths + possible manual - # offsets. - img_workingsize = resize_to_fill(img, canvasTuple_eff) - # Simultaneously make crops at working size and then resize down to actual - # resolution from RESOLUTION_ARRAY as needed. - for crop, res in zip(crop_tuples, RESOLUTION_ARRAY): - crop_img = img_workingsize.crop(crop) - if crop_img.size == res: - cropped_images.append(crop_img) - else: - crop_img = crop_img.resize(res, resample=Image.LANCZOS) - cropped_images.append(crop_img) - # Combine crops to a single canvas of the size of the actual desktop - # actual combined size of the display resolutions - canvasTuple_fin = tuple(compute_canvas(RESOLUTION_ARRAY, DISPLAY_OFFSET_ARRAY)) - combinedImage = Image.new("RGB", canvasTuple_fin, color=0) - combinedImage.load() - for i in range(len(cropped_images)): - combinedImage.paste(cropped_images[i], DISPLAY_OFFSET_ARRAY[i]) - # Saving combined image - outputfile = TEMP_PATH + profile.name + "-a.png" - if os.path.isfile(outputfile): - outputfile_old = outputfile - outputfile = TEMP_PATH + profile.name + "-b.png" - else: - outputfile_old = TEMP_PATH + profile.name + "-b.png" - combinedImage.save(outputfile, "PNG") - setWallpaper(outputfile) - if os.path.exists(outputfile_old): - os.remove(outputfile_old) - return 0 - - -def setMultipleImages(profile): - files = profile.NextWallpaperFiles() - if DEBUG: - G_LOGGER.info(str(files)) - img_resized = [] - for file, res in zip(files, RESOLUTION_ARRAY): - image = Image.open(file) - img_resized.append(resize_to_fill(image, res)) - canvasTuple = tuple(compute_canvas(RESOLUTION_ARRAY, DISPLAY_OFFSET_ARRAY)) - combinedImage = Image.new("RGB", canvasTuple, color=0) - combinedImage.load() - for i in range(len(files)): - combinedImage.paste(img_resized[i], DISPLAY_OFFSET_ARRAY[i]) - outputfile = TEMP_PATH + profile.name + "-a.png" - if os.path.isfile(outputfile): - outputfile_old = outputfile - outputfile = TEMP_PATH + profile.name + "-b.png" - else: - outputfile_old = TEMP_PATH + profile.name + "-b.png" - combinedImage.save(outputfile, "PNG") - setWallpaper(outputfile) - if os.path.exists(outputfile_old): - os.remove(outputfile_old) - return 0 - - -def errcheck(result, func, args): - if not result: - raise ctypes.WinError(ctypes.get_last_error()) - - -def setWallpaper(outputfile): - pltform = platform.system() - if pltform == "Windows": - SPI_SETDESKWALLPAPER = 20 - SPIF_UPDATEINIFILE = 1 - SPIF_SENDCHANGE = 2 - user32 = ctypes.WinDLL('user32', use_last_error=True) - SystemParametersInfo = user32.SystemParametersInfoW - SystemParametersInfo.argtypes = [ - ctypes.c_uint, - ctypes.c_uint, - ctypes.c_void_p, - ctypes.c_uint] - SystemParametersInfo.restype = ctypes.c_int - SystemParametersInfo.errcheck = errcheck - SystemParametersInfo( - SPI_SETDESKWALLPAPER, - 0, - outputfile, - SPIF_UPDATEINIFILE | SPIF_SENDCHANGE) - elif pltform == "Linux": - setWallpaper_linux(outputfile) - elif pltform == "Darwin": - SCRIPT = """/usr/bin/osascript< len(profile.pathsArray): - self.onRemoveDisplay(wx.EVT_BUTTON) - for text_field, paths_list in zip(self.paths_controls, profile.pathsArray): - text_field.ChangeValue(self.show_list_paths(paths_list)) - - if profile.slideshow: - self.cb_slideshow.SetValue(True) - else: - self.cb_slideshow.SetValue(False) - - if profile.spanmode == "single": - self.ch_span.SetSelection(0) - elif profile.spanmode == "multi": - self.ch_span.SetSelection(1) - else: - pass - if profile.sortmode == "shuffle": - self.ch_sort.SetSelection(0) - elif profile.sortmode == "alphabetical": - self.ch_sort.SetSelection(1) - else: - pass - - def val_list_to_colonstr(self, array): - list_strings = [] - if array: - for item in array: - list_strings.append(str(item)) - return ";".join(list_strings) - else: - return "" - - def show_offset(self, offarray): - offstr_arr = [] - offstr = "" - if offarray: - for offs in offarray: - offstr_arr.append(str(offs).strip("(").strip(")").replace(" ", "")) - offstr = ";".join(offstr_arr) - return offstr - else: - return "" - - def show_hkbinding(self, hktuple): - if hktuple: - hkstring = "+".join(hktuple) - return hkstring - else: - return "" - - - # Path display related functions. - def show_list_paths(self, paths_list): - # Format a list of paths into the set style of listed paths. - if paths_list: - pathsstring = ";".join(paths_list) - return pathsstring - else: - return "" - - def onAddDisplay(self, event): - new_disp_widget = self.createPathsWidget() - self.sizer_paths.Add(new_disp_widget, 0, wx.CENTER|wx.ALL, 5) - self.frame.fSizer.Layout() - self.frame.Fit() - - def onRemoveDisplay(self, event): - if self.sizer_paths.GetChildren(): - self.sizer_paths.Hide(len(self.paths_controls)-1) - self.sizer_paths.Remove(len(self.paths_controls)-1) - del self.paths_controls[-1] - self.frame.fSizer.Layout() - self.frame.Fit() - - def onBrowsePaths(self, event): - dlg = BrowsePaths(None, self, event) - dlg.ShowModal() - - - # Top level button definitions - def onClose(self, event): - self.frame.Close(True) - - def onSelect(self, event): - choiceObj = event.GetEventObject() - if choiceObj.GetName() == "ProfileChoice": - item = event.GetSelection() - if event.GetString() == "Create a new profile": - self.onCreateNewProfile(event) - else: - self.populateFields(self.list_of_profiles[item]) - else: - pass - - def onApply(self, event): - saved_file = self.onSave(event) - print(saved_file) - if saved_file is not None: - saved_profile = ProfileData(saved_file) - self.parent_tray_obj.reload_profiles(event) - self.parent_tray_obj.start_profile(event, saved_profile) - else: - pass - - def onCreateNewProfile(self, event): - self.choiceProfile.SetSelection(self.choiceProfile.FindString("Create a new profile")) - - self.tc_name.ChangeValue("") - self.tc_delay.ChangeValue("") - self.tc_offsets.ChangeValue("") - self.tc_inches.ChangeValue("") - # show_ppi = self.val_list_to_colonstr(profile.ppiArray) - # self.tc_ppis.ChangeValue(show_ppi) - self.tc_bez.ChangeValue("") - self.tc_hotkey.ChangeValue("") - - # Paths displays: get number to show from profile. - while len(self.paths_controls) < 1: - self.onAddDisplay(wx.EVT_BUTTON) - while len(self.paths_controls) > 1: - self.onRemoveDisplay(wx.EVT_BUTTON) - for text_field in self.paths_controls: - text_field.ChangeValue("") - - self.cb_slideshow.SetValue(False) - self.ch_span.SetSelection(-1) - self.ch_sort.SetSelection(-1) - - def onDeleteProfile(self, event): - profname = self.tc_name.GetLineText(0) - fname = PROFILES_PATH + profname + ".profile" - file_exists = os.path.isfile(fname) - if not file_exists: - msg = "Selected profile is not saved." - show_message_dialog(msg, "Error") - return - # Open confirmation dialog - dlg = wx.MessageDialog(None, - "Do you want to delete profile:"+ profname +"?", - 'Confirm Delete', - wx.YES_NO | wx.ICON_QUESTION) - result = dlg.ShowModal() - if result == wx.ID_YES and file_exists: - os.remove(fname) - else: - pass - - def onSave(self, event): - tmp_profile = TempProfileData() - tmp_profile.name = self.tc_name.GetLineText(0) - tmp_profile.spanmode = self.ch_span.GetString(self.ch_span.GetSelection()).lower() - tmp_profile.slideshow = self.cb_slideshow.GetValue() - tmp_profile.delay = self.tc_delay.GetLineText(0) - tmp_profile.sortmode = self.ch_sort.GetString(self.ch_sort.GetSelection()).lower() - # tmp_profile.ppiArray = self.tc_ppis.GetLineText(0) - tmp_profile.inches = self.tc_inches.GetLineText(0) - tmp_profile.manual_offsets = self.tc_offsets.GetLineText(0) - tmp_profile.bezels = self.tc_bez.GetLineText(0) - tmp_profile.hkBinding = self.tc_hotkey.GetLineText(0) - for text_field in self.paths_controls: - tmp_profile.pathsArray.append(text_field.GetLineText(0)) - - G_LOGGER.info(tmp_profile.name) - G_LOGGER.info(tmp_profile.spanmode) - G_LOGGER.info(tmp_profile.slideshow) - G_LOGGER.info(tmp_profile.delay) - G_LOGGER.info(tmp_profile.sortmode) - # G_LOGGER.info(tmp_profile.ppiArray) - G_LOGGER.info(tmp_profile.inches) - G_LOGGER.info(tmp_profile.manual_offsets) - G_LOGGER.info(tmp_profile.bezels) - G_LOGGER.info(tmp_profile.hkBinding) - G_LOGGER.info(tmp_profile.pathsArray) - - if tmp_profile.test_save(): - saved_file = tmp_profile.Save() - self.update_choiceprofile() - self.parent_tray_obj.reload_profiles(event) - self.parent_tray_obj.register_hotkeys() - # self.parent_tray_obj.register_hotkeys() - self.choiceProfile.SetSelection(self.choiceProfile.FindString(tmp_profile.name)) - return saved_file - else: - G_LOGGER.info("test_save failed.") - return None - - def onTestImage(self, event): - """Align test""" - # Use the settings currently written out in the fields! - testimage = [os.path.join(PATH, "resources/test.png")] - if not os.path.isfile(testimage[0]): - print(testimage) - msg = "Test image not found in {}.".format(testimage) - show_message_dialog(msg, "Error") - ppi = None - inches = self.tc_inches.GetLineText(0).split(";") - if (inches == "") or (len(inches) < NUM_DISPLAYS): - msg = "You must enter a diagonal inch value for every \ -display, serparated by a semicolon ';'." - show_message_dialog(msg, "Error") - - # print(inches) - inches = [float(i) for i in inches] - bezels = self.tc_bez.GetLineText(0).split(";") - bezels = [float(b) for b in bezels] - offsets = self.tc_offsets.GetLineText(0).split(";") - offsets = [[int(i.split(",")[0]), int(i.split(",")[1])] for i in offsets] - flat_offsets = [] - for off in offsets: - for pix in off: - flat_offsets.append(pix) - # print("flat_offsets= ", flat_offsets) - # Use the simplified CLI profile class - get_display_data() - profile = CLIProfileData(testimage, - ppi, - inches, - bezels, - flat_offsets, - ) - changeWallpaperJob(profile) - - def onHelp(self, event): - help_frame = HelpFrame() - - - class BrowsePaths(wx.Dialog): - def __init__(self, parent, parent_self, parent_event): - wx.Dialog.__init__(self, parent, -1, 'Choose Image Source Directories', size=(500, 700)) - self.parent_self = parent_self - self.parent_event = parent_event - self.paths = [] - sizer_main = wx.BoxSizer(wx.VERTICAL) - sizer_browse = wx.BoxSizer(wx.VERTICAL) - sizer_textfield = wx.BoxSizer(wx.VERTICAL) - sizer_buttons = wx.BoxSizer(wx.HORIZONTAL) - self.dir3 = wx.GenericDirCtrl(self, -1, - # style=wx.DIRCTRL_MULTIPLE, - # style=0, - size=(450, 550)) - sizer_browse.Add(self.dir3, 0, wx.CENTER|wx.ALL|wx.EXPAND, 5) - self.tc_paths = wx.TextCtrl(self, -1, size=(450, -1)) - sizer_textfield.Add(self.tc_paths, 0, wx.CENTER|wx.ALL, 5) - - self.button_add = wx.Button(self, label="Add") - self.button_remove = wx.Button(self, label="Remove") - self.button_ok = wx.Button(self, label="Ok") - self.button_cancel = wx.Button(self, label="Cancel") - - self.button_add.Bind(wx.EVT_BUTTON, self.onAdd) - self.button_remove.Bind(wx.EVT_BUTTON, self.onRemove) - self.button_ok.Bind(wx.EVT_BUTTON, self.onOk) - self.button_cancel.Bind(wx.EVT_BUTTON, self.onCancel) - - sizer_buttons.Add(self.button_add, 0, wx.CENTER|wx.ALL, 5) - sizer_buttons.Add(self.button_remove, 0, wx.CENTER|wx.ALL, 5) - sizer_buttons.Add(self.button_ok, 0, wx.CENTER|wx.ALL, 5) - sizer_buttons.Add(self.button_cancel, 0, wx.CENTER|wx.ALL, 5) - - sizer_main.Add(sizer_browse, 5, wx.ALL|wx.ALIGN_CENTER|wx.EXPAND) - sizer_main.Add(sizer_textfield, 5, wx.ALL|wx.ALIGN_CENTER) - sizer_main.Add(sizer_buttons, 5, wx.ALL|wx.ALIGN_CENTER) - self.SetSizer(sizer_main) - self.SetAutoLayout(True) - - def onAdd(self, event): - text_field = self.tc_paths.GetLineText(0) - new_path = self.dir3.GetPath() - self.paths.append(new_path) - if text_field == "": - text_field = new_path - else: - text_field = ";".join([text_field, new_path]) - self.tc_paths.SetValue(text_field) - self.tc_paths.SetInsertionPointEnd() - - def onRemove(self, event): - if len(self.paths) > 0: - del self.paths[-1] - text_field = ";".join(self.paths) - self.tc_paths.SetValue(text_field) - self.tc_paths.SetInsertionPointEnd() - - def onOk(self, event): - paths_string = self.tc_paths.GetLineText(0) - # If paths textctrl is empty, assume user wants current selection. - if paths_string == "": - paths_string = self.dir3.GetPath() - button_obj = self.parent_event.GetEventObject() - button_name = button_obj.GetName() - button_id = int(button_name.split("-")[1]) - text_field = self.parent_self.paths_controls[button_id] - old_text = text_field.GetLineText(0) - if old_text == "": - new_text = paths_string - else: - new_text = old_text + ";" + paths_string - text_field.ChangeValue(new_text) - self.Destroy() - - def onCancel(self, event): - self.Destroy() - - - - class SettingsFrame(wx.Frame): - def __init__(self, parent_tray_obj): - wx.Frame.__init__(self, parent=None, title="Superpaper General Settings") - self.fSizer = wx.BoxSizer(wx.VERTICAL) - settings_panel = SettingsPanel(self, parent_tray_obj) - self.fSizer.Add(settings_panel, 1, wx.EXPAND) - self.SetAutoLayout(True) - self.SetSizer(self.fSizer) - self.Fit() - self.Layout() - self.Center() - self.Show() - - class SettingsPanel(wx.Panel): - def __init__(self, parent, parent_tray_obj): - wx.Panel.__init__(self, parent) - self.frame = parent - self.parent_tray_obj = parent_tray_obj - self.sizer_main = wx.BoxSizer(wx.VERTICAL) - self.sizer_grid_settings = wx.GridSizer(5, 2, 5, 5) - self.sizer_buttons = wx.BoxSizer(wx.HORIZONTAL) - pnl = self - st_logging = wx.StaticText(pnl, -1, "Logging") - st_usehotkeys = wx.StaticText(pnl, -1, "Use hotkeys") - st_hk_next = wx.StaticText(pnl, -1, "Hotkey: Next wallpaper") - st_hk_pause = wx.StaticText(pnl, -1, "Hotkey: Pause slideshow") - st_setcmd = wx.StaticText(pnl, -1, "Custom command") - self.cb_logging = wx.CheckBox(pnl, -1, "") - self.cb_usehotkeys = wx.CheckBox(pnl, -1, "") - self.tc_hk_next = wx.TextCtrl(pnl, -1, size=(200, -1)) - self.tc_hk_pause = wx.TextCtrl(pnl, -1, size=(200, -1)) - self.tc_setcmd = wx.TextCtrl(pnl, -1, size=(200, -1)) - - self.sizer_grid_settings.AddMany( - [ - (st_logging, 0, wx.ALIGN_RIGHT), - (self.cb_logging, 0, wx.ALIGN_LEFT), - (st_usehotkeys, 0, wx.ALIGN_RIGHT), - (self.cb_usehotkeys, 0, wx.ALIGN_LEFT), - (st_hk_next, 0, wx.ALIGN_RIGHT), - (self.tc_hk_next, 0, wx.ALIGN_LEFT), - (st_hk_pause, 0, wx.ALIGN_RIGHT), - (self.tc_hk_pause, 0, wx.ALIGN_LEFT), - (st_setcmd, 0, wx.ALIGN_RIGHT), - (self.tc_setcmd, 0, wx.ALIGN_LEFT), - ] - ) - self.update_fields() - self.button_save = wx.Button(self, label="Save") - self.button_close = wx.Button(self, label="Close") - self.button_save.Bind(wx.EVT_BUTTON, self.onSave) - self.button_close.Bind(wx.EVT_BUTTON, self.onClose) - self.sizer_buttons.Add(self.button_save, 0, wx.CENTER|wx.ALL, 5) - self.sizer_buttons.Add(self.button_close, 0, wx.CENTER|wx.ALL, 5) - self.sizer_main.Add(self.sizer_grid_settings, 0, wx.CENTER|wx.EXPAND) - self.sizer_main.Add(self.sizer_buttons, 0, wx.CENTER|wx.EXPAND) - self.SetSizer(self.sizer_main) - self.sizer_main.Fit(parent) - - def update_fields(self): - g_settings = GeneralSettingsData() - self.cb_logging.SetValue(g_settings.logging) - self.cb_usehotkeys.SetValue(g_settings.use_hotkeys) - self.tc_hk_next.ChangeValue(self.show_hkbinding(g_settings.hk_binding_next)) - self.tc_hk_pause.ChangeValue(self.show_hkbinding(g_settings.hk_binding_pause)) - self.tc_setcmd.ChangeValue(g_settings.set_command) - - def show_hkbinding(self, hktuple): - hkstring = "+".join(hktuple) - return hkstring - - def onSave(self, event): - current_settings = GeneralSettingsData() - show_help = current_settings.show_help - - fname = os.path.join(PATH, "general_settings") - f = open(fname, "w") - if self.cb_logging.GetValue(): - f.write("logging=true\n") - else: - f.write("logging=false\n") - if self.cb_usehotkeys.GetValue(): - f.write("use hotkeys=true\n") - else: - f.write("use hotkeys=false\n") - f.write("next wallpaper hotkey=" + self.tc_hk_next.GetLineText(0) + "\n") - f.write("pause wallpaper hotkey=" + self.tc_hk_pause.GetLineText(0) + "\n") - if show_help: - f.write("show_help_at_start=true\n") - else: - f.write("show_help_at_start=false\n") - f.write("set_command=" + self.tc_setcmd.GetLineText(0)) - f.close() - # after saving file apply in tray object - self.parent_tray_obj.read_general_settings() - - def onClose(self, event): - self.frame.Close(True) - - - class HelpFrame(wx.Frame): - def __init__(self): - wx.Frame.__init__(self, parent=None, title="Superpaper Help") - self.fSizer = wx.BoxSizer(wx.VERTICAL) - help_panel = HelpPanel(self) - self.fSizer.Add(help_panel, 1, wx.EXPAND) - self.SetAutoLayout(True) - self.SetSizer(self.fSizer) - self.Fit() - self.Layout() - self.Center() - self.Show() - - class HelpPanel(wx.Panel): - def __init__(self, parent): - wx.Panel.__init__(self, parent) - self.frame = parent - self.sizer_main = wx.BoxSizer(wx.VERTICAL) - self.sizer_helpcontent = wx.BoxSizer(wx.VERTICAL) - self.sizer_buttons = wx.BoxSizer(wx.HORIZONTAL) - - current_settings = GeneralSettingsData() - show_help = current_settings.show_help - - st_show_at_start = wx.StaticText(self, -1, "Show this help at start") - self.cb_show_at_start = wx.CheckBox(self, -1, "") - self.cb_show_at_start.SetValue(show_help) - self.button_close = wx.Button(self, label="Close") - self.button_close.Bind(wx.EVT_BUTTON, self.onClose) - self.sizer_buttons.Add(st_show_at_start, 0, wx.CENTER|wx.ALL, 5) - self.sizer_buttons.Add(self.cb_show_at_start, 0, wx.CENTER|wx.ALL, 5) - self.sizer_buttons.Add(self.button_close, 0, wx.CENTER|wx.ALL, 5) - - help_str = """ -How to use Superpaper: - -In the Profile Configuration you can adjust all your wallpaper settings. -Only required options are name and wallpaper paths. Other application -wide settings can be changed in the Settings menu. Both are accessible -from the system tray menu. - -IMPORTANT NOTE: For the wallpapers to be set correctly, you must set -in your OS the background fitting option to 'Span'. - -NOTE: If your displays are not in a horizontal row, the pixel density -and offset corrections unfortunately do not work. In this case leave -the 'Diagonal inches', 'Offsets' and 'Bezels' fields empty. - -Description of Profile Configuration options: -In the text field description an example is shown in parantheses and in -brackets the expected units of numerical values. - -"Diagonal inches": The diagonal diameters of your monitors in - order starting from the left most monitor. - These affect the wallpaper only in "Single" - spanmode. - -"Spanmode": "Single" (span a single image across all monitors) - "Multi" (set a different image on every monitor.) - -"Sort": Applies to slideshow mode wallpaper order. - -"Offsets": Wallpaper alignment correction offsets for your displays - if using "Single" spanmode. Entered as "width,height" - pixel value pairs, pairs separated by a semicolon ";". - Positive offsets move the portion of the image on - the monitor down and to the right, negative offets - up or left. - -"Bezels": Bezel correction for "Single" spanmode wallpaper. Use this - if you want the image to continue behind the bezels, - like a scenery does behind a window frame. - -"Hotkey": An optional key combination to apply/start the profile. - Supports up to 3 modifiers and a key. Valid modifiers - are 'control', 'super', 'alt' and 'shift'. Separate - keys with a '+', like 'control+alt+w'. - -"display{N}paths": Wallpaper folder paths for the display in the Nth - position from the left. Multiple can be entered with - the browse tool using "Add". If you have more than - one vertically stacked row, they should be listed - row by row starting from the top most row. - -Tips: - - You can use the given example profiles as templates: just change - the name and whatever else, save, and its a new profile. - - 'Align Test' feature allows you to test your offset and bezel settings. - Display diagonals, offsets and bezels need to be entered. -""" - st_help = wx.StaticText(self, -1, help_str) - self.sizer_helpcontent.Add(st_help, 0, wx.EXPAND|wx.CENTER|wx.ALL, 5) - - self.sizer_main.Add(self.sizer_helpcontent, 0, wx.CENTER|wx.EXPAND) - self.sizer_main.Add(self.sizer_buttons, 0, wx.CENTER|wx.EXPAND) - self.SetSizer(self.sizer_main) - self.sizer_main.Fit(parent) - - def onClose(self, event): - if self.cb_show_at_start.GetValue() is True: - current_settings = GeneralSettingsData() - if current_settings.show_help is False: - current_settings.show_help = True - current_settings.save_settings() - else: - # Save that the help at start is not wanted. - current_settings = GeneralSettingsData() - show_help = current_settings.show_help - if show_help: - current_settings.show_help = False - current_settings.save_settings() - self.frame.Close(True) - - - - class TaskBarIcon(wx.adv.TaskBarIcon): - def __init__(self, frame): - self.g_settings = GeneralSettingsData() - - self.frame = frame - super(TaskBarIcon, self).__init__() - self.set_icon(TRAY_ICON) - self.Bind(wx.adv.EVT_TASKBAR_LEFT_DOWN, self.on_left_down) - # profile initialization - self.jobLock = Lock() - get_display_data() - self.repeating_timer = None - self.pause_item = None - self.is_paused = False - if DEBUG: - G_LOGGER.info("START Listing profiles for menu.") - self.list_of_profiles = listProfiles() - if DEBUG: - G_LOGGER.info("END Listing profiles for menu.") - # Should now return an object if a previous profile was written or - # None if no previous data was found - self.active_profile = readActiveProfile() - self.start_prev_profile(self.active_profile) - # if self.active_profile is None: - # G_LOGGER.info("Starting up the first profile found.") - # self.start_profile(wx.EVT_MENU, self.list_of_profiles[0]) - - # self.hk = None - # self.hk2 = None - if self.g_settings.use_hotkeys is True: - try: - # import keyboard # https://github.com/boppreh/keyboard - from system_hotkey import SystemHotkey - self.hk = SystemHotkey(check_queue_interval=0.05) - self.hk2 = SystemHotkey( - consumer=self.profile_consumer, - check_queue_interval=0.05) - self.seen_binding = set() - except Exception as e: - G_LOGGER.info( - "WARNING: Could not import keyboard hotkey hook library, \ - hotkeys will not work. Exception: {}".format(e)) - self.register_hotkeys() - if self.g_settings.show_help is True: - config_frame = ConfigFrame(self) - help_frame = HelpFrame() - - - - def register_hotkeys(self): - if self.g_settings.use_hotkeys is True: - try: - # import keyboard # https://github.com/boppreh/keyboard - from system_hotkey import SystemHotkey - except Exception as e: - G_LOGGER.info( - "WARNING: Could not import keyboard hotkey hook library, \ - hotkeys will not work. Exception: {}".format(e)) - if "system_hotkey" in sys.modules: - try: - # Keyboard bindings: https://github.com/boppreh/keyboard - # - # Alternative KB bindings for X11 systems and Windows: - # system_hotkey https://github.com/timeyyy/system_hotkey - # seen_binding = set() - # self.hk = SystemHotkey(check_queue_interval=0.05) - # self.hk2 = SystemHotkey( - # consumer=self.profile_consumer, - # check_queue_interval=0.05) - - # Unregister previous hotkeys - if self.seen_binding: - for binding in self.seen_binding: - try: - self.hk.unregister(binding) - if DEBUG: - G_LOGGER.info("Unreg hotkey %s", - binding) - except: - try: - self.hk2.unregister(binding) - if DEBUG: - G_LOGGER.info("Unreg hotkey %s", - binding) - except: - if DEBUG: - G_LOGGER.info("Could not unreg hotkey '%s'", - binding) - self.seen_binding = set() - - - # register general bindings - if self.g_settings.hk_binding_next not in self.seen_binding: - try: - self.hk.register( - self.g_settings.hk_binding_next, - callback=lambda x: self.next_wallpaper(wx.EVT_MENU), - overwrite=False) - self.seen_binding.add(self.g_settings.hk_binding_next) - except: - msg = "Error: could not register hotkey {}. \ - Check that it is formatted properly and valid keys.".format(self.g_settings.hk_binding_next) - G_LOGGER.info(msg) - G_LOGGER.info(sys.exc_info()[0]) - show_message_dialog(msg, "Error") - if self.g_settings.hk_binding_pause not in self.seen_binding: - try: - self.hk.register( - self.g_settings.hk_binding_pause, - callback=lambda x: self.pause_timer(wx.EVT_MENU), - overwrite=False) - self.seen_binding.add(self.g_settings.hk_binding_pause) - except: - msg = "Error: could not register hotkey {}. \ - Check that it is formatted properly and valid keys.".format(self.g_settings.hk_binding_pause) - G_LOGGER.info(msg) - G_LOGGER.info(sys.exc_info()[0]) - show_message_dialog(msg, "Error") - try: - hk.register(('control', 'super', 'shift', 'q'), - callback=lambda x: self.on_exit(wx.EVT_MENU)) - except: - pass - # register profile specific bindings - self.list_of_profiles = listProfiles() - for profile in self.list_of_profiles: - if DEBUG: - G_LOGGER.info( - "Registering binding: \ - {} for profile: {}" - .format(profile.hkBinding, profile.name)) - if (profile.hkBinding is not None and - profile.hkBinding not in self.seen_binding): - try: - self.hk2.register(profile.hkBinding, profile, - overwrite=False) - self.seen_binding.add(profile.hkBinding) - except: - msg = "Error: could not register hotkey {}. \ - Check that it is formatted properly and valid keys.".format(profile.hkBinding) - G_LOGGER.info(msg) - G_LOGGER.info(sys.exc_info()[0]) - show_message_dialog(msg, "Error") - elif profile.hkBinding in self.seen_binding: - msg = "Could not register hotkey: '{}' for profile: '{}'.\n\ -It is already registered for another action.".format(profile.hkBinding, profile.name) - G_LOGGER.info(msg) - show_message_dialog(msg, "Error") - except: - if DEBUG: - G_LOGGER.info("Coulnd't register hotkeys, exception:") - G_LOGGER.info(sys.exc_info()[0]) - - - - def profile_consumer(self, event, hotkey, profile): - if DEBUG: - G_LOGGER.info("Profile object is: {}".format(profile)) - self.start_profile(wx.EVT_MENU, profile[0][0]) - - def read_general_settings(self): - # THIS FAILS MISERABLY, creating additional tray items. - # Reload settings by reloading the whole object. - # BUG if logging is on, this creates additional logging handlers - # leading to multiple log prints per event. - # self.__init__(self.frame) - self.g_settings = GeneralSettingsData() - self.register_hotkeys() - msg = "New settings are applied after an application restart. \ -New hotkeys are registered." - show_message_dialog(msg, "Info") - - def CreatePopupMenu(self): - menu = wx.Menu() - create_menu_item(menu, "Open Config Folder", self.open_config) - create_menu_item(menu, "Profile Configuration", self.configure_profiles) - create_menu_item(menu, "Settings", self.configure_settings) - create_menu_item(menu, "Reload Profiles", self.reload_profiles) - menu.AppendSeparator() - for item in self.list_of_profiles: - create_menu_item(menu, item.name, self.start_profile, item) - menu.AppendSeparator() - create_menu_item(menu, "Next Wallpaper", self.next_wallpaper) - self.pause_item = create_menu_item( - menu, "Pause Timer", self.pause_timer, kind=wx.ITEM_CHECK) - self.pause_item.Check(self.is_paused) - menu.AppendSeparator() - create_menu_item(menu, 'About', self.on_about) - create_menu_item(menu, 'Exit', self.on_exit) - return menu - - def set_icon(self, path): - icon = wx.Icon(path) - self.SetIcon(icon, TRAY_TOOLTIP) - - def on_left_down(self, *event): - G_LOGGER.info('Tray icon was left-clicked.') - - def open_config(self, event): - if platform.system() == "Windows": - try: - os.startfile(PATH) - except BaseException: - pass - elif platform.system() == "Darwin": - try: - subprocess.Popen(["open", PATH]) - except BaseException: - pass - else: - try: - subprocess.Popen(['xdg-open', PATH]) - except BaseException: - pass - - def configure_profiles(self, event): - config_frame = ConfigFrame(self) - - def configure_settings(self, event): - setting_frame = SettingsFrame(self) - - def reload_profiles(self, event): - self.list_of_profiles = listProfiles() - - def start_prev_profile(self, profile): - with self.jobLock: - if profile is None: - G_LOGGER.info("No previous profile was found.") - else: - self.repeating_timer = runProfileJob(profile) - - def start_profile(self, event, profile): - if DEBUG: - G_LOGGER.info("Start profile: {}".format(profile.name)) - if profile is None: - G_LOGGER.info( - "start_profile: profile is None. \ - Do you have any profiles in /profiles?") - elif self.active_profile is not None: - if DEBUG: - G_LOGGER.info( - "Check if the starting profile is already running: {}" - .format(profile.name)) - G_LOGGER.info( - "name check: {}, {}" - .format(profile.name, - self.active_profile.name)) - if profile.name == self.active_profile.name: - self.next_wallpaper(event) - return 0 - else: - with self.jobLock: - if (self.repeating_timer is not None and - self.repeating_timer.is_running): - self.repeating_timer.stop() - if DEBUG: - G_LOGGER.info( - "Running quick profile job with profile: {}" - .format(profile.name)) - quickProfileJob(profile) - if DEBUG: - G_LOGGER.info( - "Starting timed profile job with profile: {}" - .format(profile.name)) - self.repeating_timer = runProfileJob(profile) - self.active_profile = profile - writeActiveProfile(profile.name) - if DEBUG: - G_LOGGER.info("Wrote active profile: {}" - .format(profile.name)) - return 0 - else: - with self.jobLock: - if (self.repeating_timer is not None - and self.repeating_timer.is_running): - self.repeating_timer.stop() - if DEBUG: - G_LOGGER.info( - "Running quick profile job with profile: {}" - .format(profile.name)) - quickProfileJob(profile) - if DEBUG: - G_LOGGER.info( - "Starting timed profile job with profile: {}" - .format(profile.name)) - self.repeating_timer = runProfileJob(profile) - self.active_profile = profile - writeActiveProfile(profile.name) - if DEBUG: - G_LOGGER.info("Wrote active profile: {}" - .format(profile.name)) - return 0 - - def next_wallpaper(self, event): - with self.jobLock: - if (self.repeating_timer is not None - and self.repeating_timer.is_running): - self.repeating_timer.stop() - changeWallpaperJob(self.active_profile) - self.repeating_timer.start() - else: - changeWallpaperJob(self.active_profile) - - def rt_stop(self): - if (self.repeating_timer is not None - and self.repeating_timer.is_running): - self.repeating_timer.stop() - - def pause_timer(self, event): - # check if a timer is running and if it is, then try to stop/start - if (self.repeating_timer is not None - and self.repeating_timer.is_running): - self.repeating_timer.stop() - self.is_paused = True - if DEBUG: - G_LOGGER.info("Paused timer") - elif (self.repeating_timer is not None - and not self.repeating_timer.is_running): - self.repeating_timer.start() - self.is_paused = False - if DEBUG: - G_LOGGER.info("Resumed timer") - else: - G_LOGGER.info("Current profile isn't using a timer.") - - def on_about(self, event): - # Credit for AboutDiaglog example to Jan Bodnar of - # http://zetcode.com/wxpython/dialogs/ - description = ( - "Superpaper is an advanced multi monitor wallpaper\n" - +"manager for Unix and Windows operating systems.\n" - +"Features include setting a single or multiple image\n" - +"wallpaper, pixel per inch and bezel corrections,\n" - +"manual pixel offsets for tuning, slideshow with\n" - +"configurable file order, multiple path support and more." - ) - - licence = ( - "Superpaper is free software; you can redistribute\n" - +"it and/or modify it under the terms of the MIT" - +" License.\n\n" - +"Superpaper is distributed in the hope that it will" - +" be useful,\n" - +"but WITHOUT ANY WARRANTY; without even the implied" - +" warranty of\n" - +"MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.\n" - +"See the MIT License for more details." - ) - artists = "Icons kindly provided by Icons8 https://icons8.com" - - info = wx.adv.AboutDialogInfo() - info.SetIcon(wx.Icon(TRAY_ICON, wx.BITMAP_TYPE_PNG)) - info.SetName('Superpaper') - info.SetVersion(VERSION_STRING) - info.SetDescription(description) - info.SetCopyright('(C) 2019 Henri Hänninen') - info.SetWebSite('https://github.com/hhannine/Superpaper/') - info.SetLicence(licence) - info.AddDeveloper('Henri Hänninen') - info.AddArtist(artists) - # info.AddDocWriter('Doc Writer') - # info.AddTranslator('Tran Slator') - wx.adv.AboutBox(info) - - def on_exit(self, event): - self.rt_stop() - wx.CallAfter(self.Destroy) - self.frame.Close() - - class App(wx.App): - - def OnInit(self): - frame = wx.Frame(None) - self.SetTopWindow(frame) - TaskBarIcon(frame) - return True -except Exception as e: - if DEBUG: - G_LOGGER.info("Failed to define tray applet classes. Is wxPython installed?") - G_LOGGER.info(e) - - -def cli_logic(): - parser = argparse.ArgumentParser() - parser.add_argument("-s", "--setimages", nargs='*', - help="List of images to set as wallpaper, \ -starting from the left most monitor. \ -If a single image is given, it is spanned \ -across all monitors.") - parser.add_argument("-p", "--ppi", nargs='*', type=float, - help="List of monitor PPIs. \ -Only relevant for a spanned wallpaper.") - parser.add_argument("-i", "--inches", nargs='*', type=float, - help="List of monitor diagonals in inches for PPIs. \ -Only relevant for a spanned wallpaper.") - parser.add_argument("-b", "--bezels", nargs='*', type=float, - help="List of monitor bezels in millimeters for \ -bezel correction to spanned wallpapers. \ -N.B. Needs either --ppi or --inches!") - parser.add_argument("-o", "--offsets", nargs='*', - help="List of wallpaper offsets. \ -Should only be necessary with single spanned image.") - parser.add_argument("-c", "--command", nargs='*', - help="Custom command to set the wallpaper. \ -Substitute /path/to/image.jpg by '{image}'. \ -Must be in quotes.") - parser.add_argument("-d", "--debug", action="store_true", - help="Run the full application with debugging G_LOGGER.infos.") - args = parser.parse_args() - - if args.debug: - global DEBUG - DEBUG = True - G_LOGGER.setLevel(logging.INFO) - # Install exception handler - # sys.excepthook = custom_exception_handler - CONSOLE_HANDLER = logging.StreamHandler() - G_LOGGER.addHandler(CONSOLE_HANDLER) - G_LOGGER.info(args.setimages) - G_LOGGER.info(args.ppi) - G_LOGGER.info(args.inches) - G_LOGGER.info(args.bezels) - G_LOGGER.info(args.offsets) - G_LOGGER.info(args.command) - G_LOGGER.info(args.debug) - if args.debug and len(sys.argv) == 2: - tray_loop() - else: - if not args.setimages: - G_LOGGER.info("Exception: You must pass image(s) to set as \ -wallpaper with '-s' or '--setimages'. Exiting.") - exit() - else: - for file in args.setimages: - if not os.path.isfile(file): - G_LOGGER.error("Exception: One of the passed images was not \ -a file: ({fname}). Exiting.".format(fname=file)) - exit() - if args.bezels and not (args.ppi or args.inches): - G_LOGGER.info("The bezel correction feature needs display PPIs, \ -provide these with --inches or --ppi.") - if args.offsets and len(args.offsets) % 2 != 0: - G_LOGGER.error("Exception: Number of offset pixels not even. If passing manual \ -offsets, give width and height offset for each display, even if \ -not actually offsetting every display. Exiting.") - exit() - if args.command: - if len(args.command) > 1: - G_LOGGER.error("Exception: Remember to put the custom command in quotes. \ -Exiting.") - exit() - global G_SET_COMMAND_STRING - G_SET_COMMAND_STRING = args.command[0] - - get_display_data() - profile = CLIProfileData(args.setimages, - args.ppi, - args.inches, - args.bezels, - args.offsets, - ) - job_thread = changeWallpaperJob(profile) - job_thread.join() - - -def tray_loop(): - if not os.path.isdir(PROFILES_PATH): - os.mkdir(PROFILES_PATH) - if "wx" in sys.modules: - app = App(False) - app.MainLoop() - else: - print("ERROR: Module 'wx' import has failed. Is it installed? \ -GUI unavailable, exiting.") - G_LOGGER.error("ERROR: Module 'wx' import has failed. Is it installed? \ -GUI unavailable, exiting.") - exit() - - -# MAIN - - -def main(): - if not len(sys.argv) > 1: - tray_loop() - else: - cli_logic() - - -if __name__ == "__main__": - main() diff --git a/superpaper/__version__.py b/superpaper/__version__.py new file mode 100644 index 0000000..7df837e --- /dev/null +++ b/superpaper/__version__.py @@ -0,0 +1,3 @@ +"""Version string for Superpaper.""" + +__version__ = "1.1.3-alpha1" diff --git a/superpaper/cli.py b/superpaper/cli.py new file mode 100644 index 0000000..2afc76a --- /dev/null +++ b/superpaper/cli.py @@ -0,0 +1,97 @@ +"""CLI for Superpaper. --help switch prints usage.""" +import argparse +import logging +import os +import sys + +import sp_logging +from data import CLIProfileData +import wallpaper_processing as wpproc +from wallpaper_processing import get_display_data, change_wallpaper_job +from tray import tray_loop + + +def cli_logic(): + """ + CLI command parsing and acting. + + Allows setting a wallpaper using Superpaper features without running the full application. + """ + parser = argparse.ArgumentParser() + parser.add_argument("-s", "--setimages", nargs='*', + help="List of images to set as wallpaper, \ +starting from the left most monitor. \ +If a single image is given, it is spanned \ +across all monitors.") + parser.add_argument("-p", "--ppi", nargs='*', type=float, + help="List of monitor PPIs. \ +Only relevant for a spanned wallpaper.") + parser.add_argument("-i", "--inches", nargs='*', type=float, + help="List of monitor diagonals in inches for PPIs. \ +Only relevant for a spanned wallpaper.") + parser.add_argument("-b", "--bezels", nargs='*', type=float, + help="List of monitor bezels in millimeters for \ +bezel correction to spanned wallpapers. \ +N.B. Needs either --ppi or --inches!") + parser.add_argument("-o", "--offsets", nargs='*', + help="List of wallpaper offsets. \ +Should only be necessary with single spanned image.") + parser.add_argument("-c", "--command", nargs='*', + help="Custom command to set the wallpaper. \ +Substitute /path/to/image.jpg by '{image}'. \ +Must be in quotes.") + parser.add_argument("-d", "--debug", action="store_true", + help="Run the full application with debugging.") + args = parser.parse_args() + + if args.debug: + sp_logging.DEBUG = True + sp_logging.G_LOGGER.setLevel(logging.INFO) + # Install exception handler + # sys.excepthook = custom_exception_handler + sp_logging.CONSOLE_HANDLER = logging.StreamHandler() + sp_logging.G_LOGGER.addHandler(sp_logging.CONSOLE_HANDLER) + sp_logging.G_LOGGER.info(args.setimages) + sp_logging.G_LOGGER.info(args.ppi) + sp_logging.G_LOGGER.info(args.inches) + sp_logging.G_LOGGER.info(args.bezels) + sp_logging.G_LOGGER.info(args.offsets) + sp_logging.G_LOGGER.info(args.command) + sp_logging.G_LOGGER.info(args.debug) + if args.debug and len(sys.argv) == 2: + tray_loop() + else: + if not args.setimages: + sp_logging.G_LOGGER.info("Exception: You must pass image(s) to set as \ +wallpaper with '-s' or '--setimages'. Exiting.") + exit() + else: + for filename in args.setimages: + if not os.path.isfile(filename): + sp_logging.G_LOGGER.error("Exception: One of the passed images was not \ +a file: (%s). Exiting.", filename) + exit() + if args.bezels and not (args.ppi or args.inches): + sp_logging.G_LOGGER.info("The bezel correction feature needs display PPIs, \ +provide these with --inches or --ppi.") + if args.offsets and len(args.offsets) % 2 != 0: + sp_logging.G_LOGGER.error("Exception: Number of offset pixels not even. \ +If passing manual offsets, give width and height offset for each display, even if \ +not actually offsetting every display. Exiting.") + exit() + if args.command: + if len(args.command) > 1: + sp_logging.G_LOGGER.error("Exception: Remember to put the \ +custom command in quotes. Exiting.") + exit() + wpproc.G_SET_COMMAND_STRING = args.command[0] + + get_display_data() + profile = CLIProfileData(args.setimages, + args.ppi, + args.inches, + args.bezels, + args.offsets, + ) + job_thread = change_wallpaper_job(profile) + job_thread.join() diff --git a/superpaper/configuration_dialogs.py b/superpaper/configuration_dialogs.py new file mode 100644 index 0000000..0c7ef9f --- /dev/null +++ b/superpaper/configuration_dialogs.py @@ -0,0 +1,751 @@ +""" +GUI dialogs for Superpaper. +""" +import os + +import sp_logging +from data import GeneralSettingsData, ProfileData, TempProfileData, CLIProfileData, list_profiles +from message_dialog import show_message_dialog +from wallpaper_processing import NUM_DISPLAYS, get_display_data, change_wallpaper_job +from sp_paths import PATH, PROFILES_PATH + +try: + import wx + import wx.adv +except ImportError: + exit() + + + +class ConfigFrame(wx.Frame): + """Profile configuration dialog frame base class.""" + def __init__(self, parent_tray_obj): + wx.Frame.__init__(self, parent=None, title="Superpaper Profile Configuration") + self.frame_sizer = wx.BoxSizer(wx.VERTICAL) + config_panel = ConfigPanel(self, parent_tray_obj) + self.frame_sizer.Add(config_panel, 1, wx.EXPAND) + self.SetAutoLayout(True) + self.SetSizer(self.frame_sizer) + self.Fit() + self.Layout() + self.Center() + self.Show() + + +class ConfigPanel(wx.Panel): + """This class defines the config dialog UI.""" + def __init__(self, parent, parent_tray_obj): + wx.Panel.__init__(self, parent) + self.frame = parent + self.parent_tray_obj = parent_tray_obj + self.sizer_main = wx.BoxSizer(wx.HORIZONTAL) + self.sizer_left = wx.BoxSizer(wx.VERTICAL) # buttons and prof sel + self.sizer_right = wx.BoxSizer(wx.VERTICAL) # option fields + self.sizer_paths = wx.BoxSizer(wx.VERTICAL) + self.sizer_paths_buttons = wx.BoxSizer(wx.HORIZONTAL) + + self.paths_controls = [] + + self.list_of_profiles = list_profiles() + self.profnames = [] + for prof in self.list_of_profiles: + self.profnames.append(prof.name) + self.profnames.append("Create a new profile") + self.choice_profiles = wx.Choice(self, -1, name="ProfileChoice", choices=self.profnames) + self.choice_profiles.Bind(wx.EVT_CHOICE, self.onSelect) + self.sizer_grid_options = wx.GridSizer(5, 4, 5, 5) + pnl = self + st_name = wx.StaticText(pnl, -1, "Name") + st_span = wx.StaticText(pnl, -1, "Spanmode") + st_slide = wx.StaticText(pnl, -1, "Slideshow") + st_sort = wx.StaticText(pnl, -1, "Sort") + st_del = wx.StaticText(pnl, -1, "Delay (600) [sec]") + st_off = wx.StaticText(pnl, -1, "Offsets (w1,h1;w2,h2) [px]") + st_in = wx.StaticText(pnl, -1, "Diagonal inches (24.0;13.3) [in]") + # st_ppi = wx.StaticText(pnl, -1, "PPIs") + st_bez = wx.StaticText(pnl, -1, "Bezels (10.1;9.5) [mm]") + st_hk = wx.StaticText(pnl, -1, "Hotkey (control+alt+w)") + + tc_width = 160 + self.tc_name = wx.TextCtrl(pnl, -1, size=(tc_width, -1)) + self.ch_span = wx.Choice(pnl, -1, name="SpanChoice", + size=(tc_width, -1), + choices=["Single", "Multi"]) + self.ch_sort = wx.Choice(pnl, -1, name="SortChoice", + size=(tc_width, -1), + choices=["Shuffle", "Alphabetical"]) + self.tc_delay = wx.TextCtrl(pnl, -1, size=(tc_width, -1)) + self.tc_offsets = wx.TextCtrl(pnl, -1, size=(tc_width, -1)) + self.tc_inches = wx.TextCtrl(pnl, -1, size=(tc_width, -1)) + # self.tc_ppis = wx.TextCtrl(pnl, -1, size=(tc_width, -1)) + self.tc_bez = wx.TextCtrl(pnl, -1, size=(tc_width, -1)) + self.tc_hotkey = wx.TextCtrl(pnl, -1, size=(tc_width, -1)) + self.cb_slideshow = wx.CheckBox(pnl, -1, "") # Put the title in the left column + self.sizer_grid_options.AddMany( + [ + (st_name, 0, wx.ALIGN_RIGHT|wx.ALL|wx.ALIGN_CENTER_VERTICAL), + (self.tc_name, 0, wx.ALIGN_LEFT|wx.ALL), + (st_span, 0, wx.ALIGN_RIGHT|wx.ALL|wx.ALIGN_CENTER_VERTICAL), + (self.ch_span, 0, wx.ALIGN_LEFT), + (st_slide, 0, wx.ALIGN_RIGHT|wx.ALL|wx.ALIGN_CENTER_VERTICAL), + (self.cb_slideshow, 0, wx.ALIGN_LEFT|wx.ALL|wx.ALIGN_CENTER_VERTICAL), + (st_sort, 0, wx.ALIGN_RIGHT|wx.ALL|wx.ALIGN_CENTER_VERTICAL), + (self.ch_sort, 0, wx.ALIGN_LEFT), + (st_del, 0, wx.ALIGN_RIGHT|wx.ALL|wx.ALIGN_CENTER_VERTICAL), + (self.tc_delay, 0, wx.ALIGN_LEFT), + (st_off, 0, wx.ALIGN_RIGHT|wx.ALL|wx.ALIGN_CENTER_VERTICAL), + (self.tc_offsets, 0, wx.ALIGN_LEFT), + (st_in, 0, wx.ALIGN_RIGHT|wx.ALL|wx.ALIGN_CENTER_VERTICAL), + (self.tc_inches, 0, wx.ALIGN_LEFT), + # (st_ppi, 0, wx.ALIGN_RIGHT), + # (self.tc_ppis, 0, wx.ALIGN_LEFT), + (st_bez, 0, wx.ALIGN_RIGHT|wx.ALL|wx.ALIGN_CENTER_VERTICAL), + (self.tc_bez, 0, wx.ALIGN_LEFT), + (st_hk, 0, wx.ALIGN_RIGHT|wx.ALL|wx.ALIGN_CENTER_VERTICAL), + (self.tc_hotkey, 0, wx.ALIGN_LEFT), + ] + ) + + # Paths display + self.paths_widget_default = self.create_paths_widget() + self.sizer_paths.Add(self.paths_widget_default, 0, wx.CENTER|wx.ALL, 5) + + + # Left column buttons + self.button_apply = wx.Button(self, label="Apply") + self.button_new = wx.Button(self, label="New") + self.button_delete = wx.Button(self, label="Delete") + self.button_save = wx.Button(self, label="Save") + self.button_align_test = wx.Button(self, label="Align Test") + self.button_help = wx.Button(self, label="Help") + self.button_close = wx.Button(self, label="Close") + + self.button_apply.Bind(wx.EVT_BUTTON, self.onApply) + self.button_new.Bind(wx.EVT_BUTTON, self.onCreateNewProfile) + self.button_delete.Bind(wx.EVT_BUTTON, self.onDeleteProfile) + self.button_save.Bind(wx.EVT_BUTTON, self.onSave) + self.button_align_test.Bind(wx.EVT_BUTTON, self.onAlignTest) + self.button_help.Bind(wx.EVT_BUTTON, self.onHelp) + self.button_close.Bind(wx.EVT_BUTTON, self.onClose) + + # Right column buttons + self.button_add_paths = wx.Button(self, label="Add path") + self.button_remove_paths = wx.Button(self, label="Remove path") + + self.button_add_paths.Bind(wx.EVT_BUTTON, self.onAddDisplay) + self.button_remove_paths.Bind(wx.EVT_BUTTON, self.onRemoveDisplay) + + self.sizer_paths_buttons.Add(self.button_add_paths, 0, wx.CENTER|wx.ALL, 5) + self.sizer_paths_buttons.Add(self.button_remove_paths, 0, wx.CENTER|wx.ALL, 5) + + + # Left add items + self.sizer_left.Add(self.choice_profiles, 0, wx.CENTER|wx.ALL, 5) + self.sizer_left.Add(self.button_apply, 0, wx.CENTER|wx.ALL, 5) + self.sizer_left.Add(self.button_new, 0, wx.CENTER|wx.ALL, 5) + self.sizer_left.Add(self.button_delete, 0, wx.CENTER|wx.ALL, 5) + self.sizer_left.Add(self.button_save, 0, wx.CENTER|wx.ALL, 5) + # self.sizer_left.Add(self.button_settings, 0, wx.CENTER|wx.ALL, 5) + self.sizer_left.Add(self.button_align_test, 0, wx.CENTER|wx.ALL, 5) + self.sizer_left.Add(self.button_help, 0, wx.CENTER|wx.ALL, 5) + self.sizer_left.Add(self.button_close, 0, wx.CENTER|wx.ALL, 5) + + # Right add items + self.sizer_right.Add(self.sizer_grid_options, 0, wx.CENTER|wx.ALL, 5) + self.sizer_right.Add(self.sizer_paths, 0, wx.CENTER|wx.ALL, 5) + self.sizer_right.Add(self.sizer_paths_buttons, 0, wx.CENTER|wx.ALL, 5) + + # Collect items at main sizer + self.sizer_main.Add(self.sizer_left, 0, wx.CENTER|wx.EXPAND) + self.sizer_main.Add(self.sizer_right, 0, wx.CENTER|wx.EXPAND) + + self.SetSizer(self.sizer_main) + self.sizer_main.Fit(parent) + + ### End __init__. + + def update_choiceprofile(self): + """Reload profile list into the choice box.""" + self.list_of_profiles = list_profiles() + self.profnames = [] + for prof in self.list_of_profiles: + self.profnames.append(prof.name) + self.profnames.append("Create a new profile") + self.choice_profiles.SetItems(self.profnames) + + def create_paths_widget(self): + """Creates a path input field on the config dialog.""" + new_paths_widget = wx.BoxSizer(wx.HORIZONTAL) + static_text = "display" + str(len(self.paths_controls)+1) + "paths" + st_new_paths = wx.StaticText(self, -1, static_text) + tc_new_paths = wx.TextCtrl(self, -1, size=(500, -1)) + self.paths_controls.append(tc_new_paths) + + new_paths_widget.Add(st_new_paths, 0, wx.CENTER|wx.ALL, 5) + new_paths_widget.Add(tc_new_paths, 0, wx.CENTER|wx.ALL|wx.EXPAND, 5) + + button_name = "browse-"+str(len(self.paths_controls)-1) + button_new_browse = wx.Button(self, label="Browse", name=button_name) + button_new_browse.Bind(wx.EVT_BUTTON, self.onBrowsePaths) + new_paths_widget.Add(button_new_browse, 0, wx.CENTER|wx.ALL, 5) + + return new_paths_widget + + # Profile setting displaying functions. + def populate_fields(self, profile): + """Populates config dialog fields with data from a profile.""" + self.tc_name.ChangeValue(profile.name) + self.tc_delay.ChangeValue(str(profile.delay_list[0])) + self.tc_offsets.ChangeValue(self.show_offset(profile.manual_offsets_useronly)) + show_inch = self.val_list_to_colonstr(profile.inches) + self.tc_inches.ChangeValue(show_inch) + show_bez = self.val_list_to_colonstr(profile.bezels) + self.tc_bez.ChangeValue(show_bez) + self.tc_hotkey.ChangeValue(self.show_hkbinding(profile.hk_binding)) + + # Paths displays: get number to show from profile. + while len(self.paths_controls) < len(profile.paths_array): + self.onAddDisplay(wx.EVT_BUTTON) + while len(self.paths_controls) > len(profile.paths_array): + self.onRemoveDisplay(wx.EVT_BUTTON) + for text_field, paths_list in zip(self.paths_controls, profile.paths_array): + text_field.ChangeValue(self.show_list_paths(paths_list)) + + if profile.slideshow: + self.cb_slideshow.SetValue(True) + else: + self.cb_slideshow.SetValue(False) + + if profile.spanmode == "single": + self.ch_span.SetSelection(0) + elif profile.spanmode == "multi": + self.ch_span.SetSelection(1) + else: + pass + if profile.sortmode == "shuffle": + self.ch_sort.SetSelection(0) + elif profile.sortmode == "alphabetical": + self.ch_sort.SetSelection(1) + else: + pass + + def val_list_to_colonstr(self, array): + """Formats a list into a colon separated list.""" + list_strings = [] + if array: + for item in array: + list_strings.append(str(item)) + return ";".join(list_strings) + else: + return "" + + def show_offset(self, offarray): + """Format an offset array into the user string formatting.""" + offstr_arr = [] + offstr = "" + if offarray: + for offs in offarray: + offstr_arr.append(str(offs).strip("(").strip(")").replace(" ", "")) + offstr = ";".join(offstr_arr) + return offstr + else: + return "" + + def show_hkbinding(self, hktuple): + """Format a hotkey tuple into a '+' separated string.""" + if hktuple: + hkstring = "+".join(hktuple) + return hkstring + else: + return "" + + + # Path display related functions. + def show_list_paths(self, paths_list): + """Formats a nested list of paths into a user readable string.""" + # Format a list of paths into the set style of listed paths. + if paths_list: + pathsstring = ";".join(paths_list) + return pathsstring + else: + return "" + + def onAddDisplay(self, event): + """Appends a new display paths widget the the list.""" + new_disp_widget = self.create_paths_widget() + self.sizer_paths.Add(new_disp_widget, 0, wx.CENTER|wx.ALL, 5) + self.frame.frame_sizer.Layout() + self.frame.Fit() + + def onRemoveDisplay(self, event): + """Removes the last display paths widget.""" + if self.sizer_paths.GetChildren(): + self.sizer_paths.Hide(len(self.paths_controls)-1) + self.sizer_paths.Remove(len(self.paths_controls)-1) + del self.paths_controls[-1] + self.frame.frame_sizer.Layout() + self.frame.Fit() + + def onBrowsePaths(self, event): + """Opens the pick paths dialog.""" + dlg = BrowsePaths(None, self, event) + dlg.ShowModal() + + + # Top level button definitions + def onClose(self, event): + """Closes the profile config panel.""" + self.frame.Close(True) + + def onSelect(self, event): + """Acts once a profile is picked in the dropdown menu.""" + event_object = event.GetEventObject() + if event_object.GetName() == "ProfileChoice": + item = event.GetSelection() + if event.GetString() == "Create a new profile": + self.onCreateNewProfile(event) + else: + self.populate_fields(self.list_of_profiles[item]) + else: + pass + + def onApply(self, event): + """Applies the currently open profile. Saves it first.""" + saved_file = self.onSave(event) + print(saved_file) + if saved_file is not None: + saved_profile = ProfileData(saved_file) + self.parent_tray_obj.reload_profiles(event) + self.parent_tray_obj.start_profile(event, saved_profile) + else: + pass + + def onCreateNewProfile(self, event): + """Empties the config dialog fields.""" + self.choice_profiles.SetSelection( + self.choice_profiles.FindString("Create a new profile") + ) + + self.tc_name.ChangeValue("") + self.tc_delay.ChangeValue("") + self.tc_offsets.ChangeValue("") + self.tc_inches.ChangeValue("") + self.tc_bez.ChangeValue("") + self.tc_hotkey.ChangeValue("") + + # Paths displays: get number to show from profile. + while len(self.paths_controls) < 1: + self.onAddDisplay(wx.EVT_BUTTON) + while len(self.paths_controls) > 1: + self.onRemoveDisplay(wx.EVT_BUTTON) + for text_field in self.paths_controls: + text_field.ChangeValue("") + + self.cb_slideshow.SetValue(False) + self.ch_span.SetSelection(-1) + self.ch_sort.SetSelection(-1) + + def onDeleteProfile(self, event): + """Deletes the currently selected profile after getting confirmation.""" + profname = self.tc_name.GetLineText(0) + fname = PROFILES_PATH + profname + ".profile" + file_exists = os.path.isfile(fname) + if not file_exists: + msg = "Selected profile is not saved." + show_message_dialog(msg, "Error") + return + # Open confirmation dialog + dlg = wx.MessageDialog(None, + "Do you want to delete profile:"+ profname +"?", + 'Confirm Delete', + wx.YES_NO | wx.ICON_QUESTION) + result = dlg.ShowModal() + if result == wx.ID_YES and file_exists: + os.remove(fname) + else: + pass + + def onSave(self, event): + """Saves currently open profile into file. A test method is called to verify data.""" + tmp_profile = TempProfileData() + tmp_profile.name = self.tc_name.GetLineText(0) + tmp_profile.spanmode = self.ch_span.GetString(self.ch_span.GetSelection()).lower() + tmp_profile.slideshow = self.cb_slideshow.GetValue() + tmp_profile.delay = self.tc_delay.GetLineText(0) + tmp_profile.sortmode = self.ch_sort.GetString(self.ch_sort.GetSelection()).lower() + tmp_profile.inches = self.tc_inches.GetLineText(0) + tmp_profile.manual_offsets = self.tc_offsets.GetLineText(0) + tmp_profile.bezels = self.tc_bez.GetLineText(0) + tmp_profile.hk_binding = self.tc_hotkey.GetLineText(0) + for text_field in self.paths_controls: + tmp_profile.paths_array.append(text_field.GetLineText(0)) + + sp_logging.G_LOGGER.info(tmp_profile.name) + sp_logging.G_LOGGER.info(tmp_profile.spanmode) + sp_logging.G_LOGGER.info(tmp_profile.slideshow) + sp_logging.G_LOGGER.info(tmp_profile.delay) + sp_logging.G_LOGGER.info(tmp_profile.sortmode) + sp_logging.G_LOGGER.info(tmp_profile.inches) + sp_logging.G_LOGGER.info(tmp_profile.manual_offsets) + sp_logging.G_LOGGER.info(tmp_profile.bezels) + sp_logging.G_LOGGER.info(tmp_profile.hk_binding) + sp_logging.G_LOGGER.info(tmp_profile.paths_array) + + if tmp_profile.test_save(): + saved_file = tmp_profile.save() + self.update_choiceprofile() + self.parent_tray_obj.reload_profiles(event) + self.parent_tray_obj.register_hotkeys() + # self.parent_tray_obj.register_hotkeys() + self.choice_profiles.SetSelection(self.choice_profiles.FindString(tmp_profile.name)) + return saved_file + else: + sp_logging.G_LOGGER.info("test_save failed.") + return None + + def onAlignTest(self, event): + """Align test, takes alignment settings from open profile and sets a test image wp.""" + # Use the settings currently written out in the fields! + testimage = [os.path.join(PATH, "resources/test.png")] + if not os.path.isfile(testimage[0]): + print(testimage) + msg = "Test image not found in {}.".format(testimage) + show_message_dialog(msg, "Error") + ppi = None + inches = self.tc_inches.GetLineText(0).split(";") + if (inches == "") or (len(inches) < NUM_DISPLAYS): + msg = "You must enter a diagonal inch value for every \ +display, serparated by a semicolon ';'." + show_message_dialog(msg, "Error") + + # print(inches) + inches = [float(i) for i in inches] + bezels = self.tc_bez.GetLineText(0).split(";") + bezels = [float(b) for b in bezels] + offsets = self.tc_offsets.GetLineText(0).split(";") + offsets = [[int(i.split(",")[0]), int(i.split(",")[1])] for i in offsets] + flat_offsets = [] + for off in offsets: + for pix in off: + flat_offsets.append(pix) + # print("flat_offsets= ", flat_offsets) + # Use the simplified CLI profile class + get_display_data() + profile = CLIProfileData(testimage, + ppi, + inches, + bezels, + flat_offsets, + ) + change_wallpaper_job(profile) + + def onHelp(self, event): + """Open help dialog.""" + help_frame = HelpFrame() + + +class BrowsePaths(wx.Dialog): + """Path picker dialog class.""" + def __init__(self, parent, parent_self, parent_event): + wx.Dialog.__init__(self, parent, -1, 'Choose Image Source Directories', size=(500, 700)) + self.parent_self = parent_self + self.parent_event = parent_event + self.paths = [] + sizer_main = wx.BoxSizer(wx.VERTICAL) + sizer_browse = wx.BoxSizer(wx.VERTICAL) + sizer_textfield = wx.BoxSizer(wx.VERTICAL) + sizer_buttons = wx.BoxSizer(wx.HORIZONTAL) + self.dir3 = wx.GenericDirCtrl(self, -1, + size=(450, 550)) + sizer_browse.Add(self.dir3, 0, wx.CENTER|wx.ALL|wx.EXPAND, 5) + self.tc_paths = wx.TextCtrl(self, -1, size=(450, -1)) + sizer_textfield.Add(self.tc_paths, 0, wx.CENTER|wx.ALL, 5) + + self.button_add = wx.Button(self, label="Add") + self.button_remove = wx.Button(self, label="Remove") + self.button_ok = wx.Button(self, label="Ok") + self.button_cancel = wx.Button(self, label="Cancel") + + self.button_add.Bind(wx.EVT_BUTTON, self.onAdd) + self.button_remove.Bind(wx.EVT_BUTTON, self.onRemove) + self.button_ok.Bind(wx.EVT_BUTTON, self.onOk) + self.button_cancel.Bind(wx.EVT_BUTTON, self.onCancel) + + sizer_buttons.Add(self.button_add, 0, wx.CENTER|wx.ALL, 5) + sizer_buttons.Add(self.button_remove, 0, wx.CENTER|wx.ALL, 5) + sizer_buttons.Add(self.button_ok, 0, wx.CENTER|wx.ALL, 5) + sizer_buttons.Add(self.button_cancel, 0, wx.CENTER|wx.ALL, 5) + + sizer_main.Add(sizer_browse, 5, wx.ALL|wx.ALIGN_CENTER|wx.EXPAND) + sizer_main.Add(sizer_textfield, 5, wx.ALL|wx.ALIGN_CENTER) + sizer_main.Add(sizer_buttons, 5, wx.ALL|wx.ALIGN_CENTER) + self.SetSizer(sizer_main) + self.SetAutoLayout(True) + + def onAdd(self, event): + """Adds selected path to export field.""" + text_field = self.tc_paths.GetLineText(0) + new_path = self.dir3.GetPath() + self.paths.append(new_path) + if text_field == "": + text_field = new_path + else: + text_field = ";".join([text_field, new_path]) + self.tc_paths.SetValue(text_field) + self.tc_paths.SetInsertionPointEnd() + + def onRemove(self, event): + """Removes last appended path from export field.""" + if len(self.paths) > 0: + del self.paths[-1] + text_field = ";".join(self.paths) + self.tc_paths.SetValue(text_field) + self.tc_paths.SetInsertionPointEnd() + + def onOk(self, event): + """Exports path to parent Profile Config dialog.""" + paths_string = self.tc_paths.GetLineText(0) + # If paths textctrl is empty, assume user wants current selection. + if paths_string == "": + paths_string = self.dir3.GetPath() + button_obj = self.parent_event.GetEventObject() + button_name = button_obj.GetName() + button_id = int(button_name.split("-")[1]) + text_field = self.parent_self.paths_controls[button_id] + old_text = text_field.GetLineText(0) + if old_text == "": + new_text = paths_string + else: + new_text = old_text + ";" + paths_string + text_field.ChangeValue(new_text) + self.Destroy() + + def onCancel(self, event): + """Closes path picker, throwing away selections.""" + self.Destroy() + + + +class SettingsFrame(wx.Frame): + """Settings dialog frame.""" + def __init__(self, parent_tray_obj): + wx.Frame.__init__(self, parent=None, title="Superpaper General Settings") + self.frame_sizer = wx.BoxSizer(wx.VERTICAL) + settings_panel = SettingsPanel(self, parent_tray_obj) + self.frame_sizer.Add(settings_panel, 1, wx.EXPAND) + self.SetAutoLayout(True) + self.SetSizer(self.frame_sizer) + self.Fit() + self.Layout() + self.Center() + self.Show() + +class SettingsPanel(wx.Panel): + """Settings dialog contents.""" + def __init__(self, parent, parent_tray_obj): + wx.Panel.__init__(self, parent) + self.frame = parent + self.parent_tray_obj = parent_tray_obj + self.sizer_main = wx.BoxSizer(wx.VERTICAL) + self.sizer_grid_settings = wx.GridSizer(5, 2, 5, 5) + self.sizer_buttons = wx.BoxSizer(wx.HORIZONTAL) + pnl = self + st_logging = wx.StaticText(pnl, -1, "Logging") + st_usehotkeys = wx.StaticText(pnl, -1, "Use hotkeys") + st_hk_next = wx.StaticText(pnl, -1, "Hotkey: Next wallpaper") + st_hk_pause = wx.StaticText(pnl, -1, "Hotkey: Pause slideshow") + st_setcmd = wx.StaticText(pnl, -1, "Custom command") + self.cb_logging = wx.CheckBox(pnl, -1, "") + self.cb_usehotkeys = wx.CheckBox(pnl, -1, "") + self.tc_hk_next = wx.TextCtrl(pnl, -1, size=(200, -1)) + self.tc_hk_pause = wx.TextCtrl(pnl, -1, size=(200, -1)) + self.tc_setcmd = wx.TextCtrl(pnl, -1, size=(200, -1)) + + self.sizer_grid_settings.AddMany( + [ + (st_logging, 0, wx.ALIGN_RIGHT), + (self.cb_logging, 0, wx.ALIGN_LEFT), + (st_usehotkeys, 0, wx.ALIGN_RIGHT), + (self.cb_usehotkeys, 0, wx.ALIGN_LEFT), + (st_hk_next, 0, wx.ALIGN_RIGHT), + (self.tc_hk_next, 0, wx.ALIGN_LEFT), + (st_hk_pause, 0, wx.ALIGN_RIGHT), + (self.tc_hk_pause, 0, wx.ALIGN_LEFT), + (st_setcmd, 0, wx.ALIGN_RIGHT), + (self.tc_setcmd, 0, wx.ALIGN_LEFT), + ] + ) + self.update_fields() + self.button_save = wx.Button(self, label="Save") + self.button_close = wx.Button(self, label="Close") + self.button_save.Bind(wx.EVT_BUTTON, self.onSave) + self.button_close.Bind(wx.EVT_BUTTON, self.onClose) + self.sizer_buttons.Add(self.button_save, 0, wx.CENTER|wx.ALL, 5) + self.sizer_buttons.Add(self.button_close, 0, wx.CENTER|wx.ALL, 5) + self.sizer_main.Add(self.sizer_grid_settings, 0, wx.CENTER|wx.EXPAND) + self.sizer_main.Add(self.sizer_buttons, 0, wx.CENTER|wx.EXPAND) + self.SetSizer(self.sizer_main) + self.sizer_main.Fit(parent) + + def update_fields(self): + """Updates dialog field contents.""" + g_settings = GeneralSettingsData() + self.cb_logging.SetValue(g_settings.logging) + self.cb_usehotkeys.SetValue(g_settings.use_hotkeys) + self.tc_hk_next.ChangeValue(self.show_hkbinding(g_settings.hk_binding_next)) + self.tc_hk_pause.ChangeValue(self.show_hkbinding(g_settings.hk_binding_pause)) + self.tc_setcmd.ChangeValue(g_settings.set_command) + + def show_hkbinding(self, hktuple): + """Formats hotkey tuple as a readable string.""" + hkstring = "+".join(hktuple) + return hkstring + + def onSave(self, event): + """Saves settings to file.""" + current_settings = GeneralSettingsData() + show_help = current_settings.show_help + + fname = os.path.join(PATH, "general_settings") + general_settings_file = open(fname, "w") + if self.cb_logging.GetValue(): + general_settings_file.write("logging=true\n") + else: + general_settings_file.write("logging=false\n") + if self.cb_usehotkeys.GetValue(): + general_settings_file.write("use hotkeys=true\n") + else: + general_settings_file.write("use hotkeys=false\n") + general_settings_file.write("next wallpaper hotkey=" + + self.tc_hk_next.GetLineText(0) + "\n") + general_settings_file.write("pause wallpaper hotkey=" + + self.tc_hk_pause.GetLineText(0) + "\n") + if show_help: + general_settings_file.write("show_help_at_start=true\n") + else: + general_settings_file.write("show_help_at_start=false\n") + general_settings_file.write("set_command=" + self.tc_setcmd.GetLineText(0)) + general_settings_file.close() + # after saving file apply in tray object + self.parent_tray_obj.read_general_settings() + + def onClose(self, event): + """Closes settings panel.""" + self.frame.Close(True) + + +class HelpFrame(wx.Frame): + """Help dialog frame.""" + def __init__(self): + wx.Frame.__init__(self, parent=None, title="Superpaper Help") + self.frame_sizer = wx.BoxSizer(wx.VERTICAL) + help_panel = HelpPanel(self) + self.frame_sizer.Add(help_panel, 1, wx.EXPAND) + self.SetAutoLayout(True) + self.SetSizer(self.frame_sizer) + self.Fit() + self.Layout() + self.Center() + self.Show() + +class HelpPanel(wx.Panel): + """Help dialog contents.""" + def __init__(self, parent): + wx.Panel.__init__(self, parent) + self.frame = parent + self.sizer_main = wx.BoxSizer(wx.VERTICAL) + self.sizer_helpcontent = wx.BoxSizer(wx.VERTICAL) + self.sizer_buttons = wx.BoxSizer(wx.HORIZONTAL) + + current_settings = GeneralSettingsData() + show_help = current_settings.show_help + + st_show_at_start = wx.StaticText(self, -1, "Show this help at start") + self.cb_show_at_start = wx.CheckBox(self, -1, "") + self.cb_show_at_start.SetValue(show_help) + self.button_close = wx.Button(self, label="Close") + self.button_close.Bind(wx.EVT_BUTTON, self.onClose) + self.sizer_buttons.Add(st_show_at_start, 0, wx.CENTER|wx.ALL, 5) + self.sizer_buttons.Add(self.cb_show_at_start, 0, wx.CENTER|wx.ALL, 5) + self.sizer_buttons.Add(self.button_close, 0, wx.CENTER|wx.ALL, 5) + + help_str = """ +How to use Superpaper: + +In the Profile Configuration you can adjust all your wallpaper settings. +Only required options are name and wallpaper paths. Other application +wide settings can be changed in the Settings menu. Both are accessible +from the system tray menu. + +IMPORTANT NOTE: For the wallpapers to be set correctly, you must set +in your OS the background fitting option to 'Span'. + +NOTE: If your displays are not in a horizontal row, the pixel density +and offset corrections unfortunately do not work. In this case leave +the 'Diagonal inches', 'Offsets' and 'Bezels' fields empty. + +Description of Profile Configuration options: +In the text field description an example is shown in parantheses and in +brackets the expected units of numerical values. + +"Diagonal inches": The diagonal diameters of your monitors in + order starting from the left most monitor. + These affect the wallpaper only in "Single" + spanmode. + +"Spanmode": "Single" (span a single image across all monitors) + "Multi" (set a different image on every monitor.) + +"Sort": Applies to slideshow mode wallpaper order. + +"Offsets": Wallpaper alignment correction offsets for your displays + if using "Single" spanmode. Entered as "width,height" + pixel value pairs, pairs separated by a semicolon ";". + Positive offsets move the portion of the image on + the monitor down and to the right, negative offets + up or left. + +"Bezels": Bezel correction for "Single" spanmode wallpaper. Use this + if you want the image to continue behind the bezels, + like a scenery does behind a window frame. + +"Hotkey": An optional key combination to apply/start the profile. + Supports up to 3 modifiers and a key. Valid modifiers + are 'control', 'super', 'alt' and 'shift'. Separate + keys with a '+', like 'control+alt+w'. + +"display{N}paths": Wallpaper folder paths for the display in the Nth + position from the left. Multiple can be entered with + the browse tool using "Add". If you have more than + one vertically stacked row, they should be listed + row by row starting from the top most row. + +Tips: +- You can use the given example profiles as templates: just change + the name and whatever else, save, and its a new profile. +- 'Align Test' feature allows you to test your offset and bezel settings. + Display diagonals, offsets and bezels need to be entered. +""" + st_help = wx.StaticText(self, -1, help_str) + self.sizer_helpcontent.Add(st_help, 0, wx.EXPAND|wx.CENTER|wx.ALL, 5) + + self.sizer_main.Add(self.sizer_helpcontent, 0, wx.CENTER|wx.EXPAND) + self.sizer_main.Add(self.sizer_buttons, 0, wx.CENTER|wx.EXPAND) + self.SetSizer(self.sizer_main) + self.sizer_main.Fit(parent) + + def onClose(self, event): + """Closes help dialog. Saves checkbox state as needed.""" + if self.cb_show_at_start.GetValue() is True: + current_settings = GeneralSettingsData() + if current_settings.show_help is False: + current_settings.show_help = True + current_settings.save_settings() + else: + # Save that the help at start is not wanted. + current_settings = GeneralSettingsData() + show_help = current_settings.show_help + if show_help: + current_settings.show_help = False + current_settings.save_settings() + self.frame.Close(True) diff --git a/superpaper/data.py b/superpaper/data.py new file mode 100644 index 0000000..ec80bb4 --- /dev/null +++ b/superpaper/data.py @@ -0,0 +1,794 @@ +""" +Data storage classes for Superpaper. + +Written by Henri Hänninen. +""" + +import logging +import math +import os +import random +import sys + +import sp_logging +from message_dialog import show_message_dialog +import wallpaper_processing as wpproc +import sp_paths +from sp_paths import (PATH, PROFILES_PATH) + + + +# Profile and data handling, back-end interface. +def list_profiles(): + """Lists profiles as initiated objects from the sp_paths.PROFILES_PATH.""" + files = sorted(os.listdir(sp_paths.PROFILES_PATH)) + profile_list = [] + for i in range(len(files)): + try: + profile_list.append(ProfileData(sp_paths.PROFILES_PATH + files[i])) + except Exception as exep: # TODO implement proper error catching for ProfileData init + msg = "There was an error when loading profile '{}'. Exiting.".format(files[i]) + sp_logging.G_LOGGER.info(msg) + sp_logging.G_LOGGER.info(exep) + show_message_dialog(msg, "Error") + exit() + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Listed profile: %s", profile_list[i].name) + return profile_list + +def read_active_profile(): + """Reads last active profile from file at startup.""" + fname = sp_paths.TEMP_PATH + "running_profile" + profname = "" + profile = None + if os.path.isfile(fname): + rp_file = open(fname, "r") + try: + for line in rp_file: # loop through line by line + line.rstrip("\r\n") + profname = line + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("read profile name from 'running_profile': %s", + profname) + prof_file = sp_paths.PROFILES_PATH + profname + ".profile" + if os.path.isfile(prof_file): + profile = ProfileData(prof_file) + else: + profile = None + sp_logging.G_LOGGER.info("Exception: Previously run profile configuration \ + file not found. Is the filename same as the \ + profile name: %s?", profname) + finally: + rp_file.close() + else: + rp_file = open(fname, "x") + rp_file.close() + profile = None + return profile + +def write_active_profile(profname): + """Writes active profile name to file after profile has changed.""" + fname = sp_paths.TEMP_PATH + "running_profile" + rp_file = open(fname, "w") + rp_file.write(profname) + rp_file.close() + + + +class GeneralSettingsData(object): + """Object to store and save application wide settings.""" + + def __init__(self): + self.logging = False + self.use_hotkeys = True + self.hk_binding_next = None + self.hk_binding_pause = None + self.set_command = "" + self.show_help = True + self.parse_settings() + + def parse_settings(self): + """Parse general_settings file. Create it if it doesn't exists.""" + fname = os.path.join(PATH, "general_settings") + if os.path.isfile(fname): + general_settings_file = open(fname, "r") + try: + for line in general_settings_file: + words = line.strip().split("=") + if words[0] == "logging": + wrds1 = words[1].strip().lower() + if wrds1 == "true": + self.logging = True + sp_logging.LOGGING = True + sp_logging.DEBUG = True + sp_logging.G_LOGGER = logging.getLogger("default") + sp_logging.G_LOGGER.setLevel(logging.INFO) + # Install exception handler + sys.excepthook = sp_logging.custom_exception_handler + sp_logging.FILE_HANDLER = logging.FileHandler( + "{0}/{1}.log".format(PATH, "log"), + mode="w") + sp_logging.G_LOGGER.addHandler(sp_logging.FILE_HANDLER) + sp_logging.CONSOLE_HANDLER = logging.StreamHandler() + sp_logging.G_LOGGER.addHandler(sp_logging.CONSOLE_HANDLER) + sp_logging.G_LOGGER.info("Enabled logging to file.") + elif words[0] == "use hotkeys": + wrds1 = words[1].strip().lower() + if wrds1 == "true": + self.use_hotkeys = True + else: + self.use_hotkeys = False + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("use_hotkeys: %s", self.use_hotkeys) + elif words[0] == "next wallpaper hotkey": + binding_strings = words[1].strip().split("+") + if binding_strings: + self.hk_binding_next = tuple(binding_strings) + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("hk_binding_next: %s", self.hk_binding_next) + elif words[0] == "pause wallpaper hotkey": + binding_strings = words[1].strip().split("+") + if binding_strings: + self.hk_binding_pause = tuple(binding_strings) + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("hk_binding_pause: %s", self.hk_binding_pause) + elif words[0] == "set_command": + wpproc.G_SET_COMMAND_STRING = words[1].strip() + self.set_command = wpproc.G_SET_COMMAND_STRING + elif words[0].strip() == "show_help_at_start": + show_state = words[1].strip().lower() + if show_state == "false": + self.show_help = False + else: + pass + else: + sp_logging.G_LOGGER.info("Exception: Unkown general setting: %s", + words[0]) + finally: + general_settings_file.close() + else: + # if file does not exist, create it and write default values. + general_settings_file = open(fname, "x") + general_settings_file.write("logging=false\n") + general_settings_file.write("use hotkeys=true\n") + general_settings_file.write("next wallpaper hotkey=control+super+w\n") + self.hk_binding_next = ("control", "super", "w") + general_settings_file.write("pause wallpaper hotkey=control+super+shift+p\n") + self.hk_binding_pause = ("control", "super", "shift", "p") + general_settings_file.write("set_command=") + general_settings_file.close() + + def save_settings(self): + """Save the current state of the general settings object.""" + + fname = os.path.join(PATH, "general_settings") + general_settings_file = open(fname, "w") + + if self.logging: + general_settings_file.write("logging=true\n") + else: + general_settings_file.write("logging=false\n") + + if self.use_hotkeys: + general_settings_file.write("use hotkeys=true\n") + else: + general_settings_file.write("use hotkeys=false\n") + + if self.hk_binding_next: + hk_string = "+".join(self.hk_binding_next) + general_settings_file.write("next wallpaper hotkey={}\n".format(hk_string)) + + if self.hk_binding_pause: + hk_string_p = "+".join(self.hk_binding_pause) + general_settings_file.write("pause wallpaper hotkey={}\n".format(hk_string_p)) + + if self.show_help: + general_settings_file.write("show_help_at_start=true\n") + else: + general_settings_file.write("show_help_at_start=false\n") + + general_settings_file.write("set_command={}".format(self.set_command)) + general_settings_file.close() + + + +class ProfileDataException(Exception): + """ProfileData initialization error handler.""" + def __init__(self, message, profile_name, parse_file, errors): + super().__init__(message) + print(message, profile_name, parse_file) + print(errors) + + +class ProfileData(object): + """ + Central data type of Superpaper, in which wallpaper settings are recorded. + + A cornerstone goal of Superpaper is to allow the user to save wallpaper + presets that are easy to change between. These settings include the + images to use, slideshow timer, spanning mode etc. Profiles are saved to + .profile files and parsed when creating a profile data object. + """ + def __init__(self, profile_file): + if not wpproc.RESOLUTION_ARRAY: + msg = "Cannot parse profile, monitor resolution data is missing." + show_message_dialog(msg) + sp_logging.G_LOGGER(msg) + exit() + + self.file = profile_file + self.name = "default_profile" + self.spanmode = "single" # single / multi + self.slideshow = True + self.delay_list = [600] + self.sortmode = "shuffle" # shuffle ( random , sorted? ) + self.ppimode = False + self.ppi_array = wpproc.NUM_DISPLAYS * [100] + self.ppi_array_relative_density = [] + self.inches = [] + self.manual_offsets = wpproc.NUM_DISPLAYS * [(0, 0)] + self.manual_offsets_useronly = [] + self.bezels = [] + self.bezel_px_offsets = [] + self.hk_binding = None + self.paths_array = [] + + self.parse_profile(self.file) + if self.ppimode is True: + self.compute_relative_densities() + if self.bezels: + self.compute_bezel_px_offsets() + self.file_handler = self.Filehandler(self.paths_array, self.sortmode) + + def parse_profile(self, parse_file): + """Read wallpaper profile settings from file.""" + profile_file = open(parse_file, "r") + try: + for line in profile_file: + line.strip() + words = line.split("=") + if words[0] == "name": + self.name = words[1].strip() + elif words[0] == "spanmode": + wrd1 = words[1].strip().lower() + if wrd1 == "single": + self.spanmode = wrd1 + elif wrd1 == "multi": + self.spanmode = wrd1 + else: + sp_logging.G_LOGGER.info("Exception: unknown spanmode: %s \ + in profile: %s", words[1], self.name) + elif words[0] == "slideshow": + wrd1 = words[1].strip().lower() + if wrd1 == "true": + self.slideshow = True + else: + self.slideshow = False + elif words[0] == "delay": + self.delay_list = [] + delay_strings = words[1].strip().split(";") + for delstr in delay_strings: + self.delay_list.append(int(delstr)) + elif words[0] == "sortmode": + wrd1 = words[1].strip().lower() + if wrd1 == "shuffle": + self.sortmode = wrd1 + elif wrd1 == "sort": + self.sortmode = wrd1 + else: + sp_logging.G_LOGGER.info("Exception: unknown sortmode: %s \ + in profile: %s", words[1], self.name) + elif words[0] == "offsets": + # Use PPI mode algorithm to do cuts. + # Defaults assume uniform pixel density + # if no custom values are given. + self.ppimode = True + self.manual_offsets = [] + self.manual_offsets_useronly = [] + # w1,h1;w2,h2;... + offset_strings = words[1].strip().split(";") + for offstr in offset_strings: + res_str = offstr.split(",") + self.manual_offsets.append((int(res_str[0]), + int(res_str[1]))) + self.manual_offsets_useronly.append((int(res_str[0]), + int(res_str[1]))) + elif words[0] == "bezels": + bez_mm_strings = words[1].strip().split(";") + for bezstr in bez_mm_strings: + self.bezels.append(float(bezstr)) + elif words[0] == "ppi": + self.ppimode = True + # overwrite initialized arrays. + self.ppi_array = [] + self.ppi_array_relative_density = [] + ppi_strings = words[1].strip().split(";") + for ppistr in ppi_strings: + self.ppi_array.append(int(ppistr)) + elif words[0] == "diagonal_inches": + self.ppimode = True + # overwrite initialized arrays. + self.ppi_array = [] + self.ppi_array_relative_density = [] + inch_strings = words[1].strip().split(";") + self.inches = [] + for inchstr in inch_strings: + self.inches.append(float(inchstr)) + self.ppi_array = self.compute_ppis(self.inches) + elif words[0] == "hotkey": + binding_strings = words[1].strip().split("+") + self.hk_binding = tuple(binding_strings) + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("hkBinding: %s", self.hk_binding) + elif words[0].startswith("display"): + paths = words[1].strip().split(";") + paths = list(filter(None, paths)) # drop empty strings + self.paths_array.append(paths) + else: + sp_logging.G_LOGGER.info("Unknown setting line in config: %s", line) + except Exception as excep: + raise ProfileDataException("There was an error parsing the profile:", + self.name, self.file, excep) + finally: + profile_file.close() + + def compute_ppis(self, inches): + """Compute monitor PPIs from user input diagonal inches.""" + if len(inches) < wpproc.NUM_DISPLAYS: + sp_logging.G_LOGGER.info("Exception: Number of read display diagonals was: \ + %s , but the number of displays was found to be: %s", + str(len(inches)), + str(wpproc.NUM_DISPLAYS) + ) + sp_logging.G_LOGGER.info("Falling back to no PPI correction.") + self.ppimode = False + return wpproc.NUM_DISPLAYS * [100] + else: + ppi_array = [] + for inch, res in zip(inches, wpproc.RESOLUTION_ARRAY): + diagonal_px = math.sqrt(res[0]**2 + res[1]**2) + px_per_inch = diagonal_px / inch + ppi_array.append(px_per_inch) + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Computed PPIs: %s", ppi_array) + return ppi_array + + def compute_relative_densities(self): + """ + Normalizes the ppi_array list such that the max ppi has the relative value 1.0. + + This means that every other display has an equal relative density or a lesser + value. The benefit of this normalization is that the resulting corrected + image sections never have to be scaled up in the end, which would happen with + relative densities of over 1.0. This presumably yields a slight improvement + in the resulting image quality in some worst case scenarios. + """ + if self.ppi_array: + max_density = max(self.ppi_array) + else: + print("Can't pick a max from empty list.") + print("ppi_array:", self.ppi_array) + print("RES_ARR", wpproc.RESOLUTION_ARRAY) + sp_logging.G_LOGGER("Couldn't compute relative densities: %s, %s", self.name, self.file) + return 1 + for ppi in self.ppi_array: + self.ppi_array_relative_density.append((1 / max_density) * float(ppi)) + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("relative pixel densities: %s", + self.ppi_array_relative_density) + + def compute_bezel_px_offsets(self): + """Computes bezel sizes in pixels based on display PPIs.""" + inch_per_mm = 1.0 / 25.4 + for bez_mm, ppi in zip(self.bezels, self.ppi_array): + self.bezel_px_offsets.append( + round(float(ppi) * inch_per_mm * bez_mm)) + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info( + "Bezel px calculation: initial manual offset: %s, \ + and bezel pixels: %s", + self.manual_offsets, + self.bezel_px_offsets) + # Add these horizontal offsets to manual_offsets: + # Avoid offsetting the leftmost anchored display i==0 + # -1 since last display doesn't have a next display. + for i in range(len(self.bezel_px_offsets) - 1): + self.manual_offsets[i + 1] = (self.manual_offsets[i + 1][0] + + self.bezel_px_offsets[i + 1] + + self.bezel_px_offsets[i], + self.manual_offsets[i + 1][1]) + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info( + "Bezel px calculation: resulting combined manual offset: %s", + self.manual_offsets) + + def next_wallpaper_files(self): + """Asks the file handler iterator for next image(s) for the wallpaper.""" + return self.file_handler.next_wallpaper_files() + + class Filehandler(object): + """ + Handles picking wallpapers from the assigned paths. + + Since multiple paths are supported per monitor, this class + lists all valid images on a monitor by monitor basis and then + orders the list according to sortmode. Allows for shuffling of the + wallpapers, i.e. non-repeating randomized list, which is re-randomized + once it has been exhausted. + """ + def __init__(self, paths_array, sortmode): + # A list of lists if there is more than one monitor with distinct + # input paths. + self.all_files_in_paths = [] + self.paths_array = paths_array + self.sortmode = sortmode + for paths_list in paths_array: + list_of_images = [] + for path in paths_list: + # Add list items to the end of the list instead of + # appending the list to the list. + if not os.path.exists(path): + message = "A path was not found: '{}'.\n\ +Use absolute paths for best reliabilty.".format(path) + sp_logging.G_LOGGER.info(message) + show_message_dialog(message, "Error") + continue + else: + # List only images that are of supported type. + list_of_images += [os.path.join(path, f) + for f in os.listdir(path) + if f.endswith(wpproc.G_SUPPORTED_IMAGE_EXTENSIONS) + ] + # Append the list of monitor_i specific files to the list of + # lists of images. + self.all_files_in_paths.append(list_of_images) + self.iterators = [] + for diplay_image_list in self.all_files_in_paths: + self.iterators.append( + self.ImageList( + diplay_image_list, + self.sortmode)) + + def next_wallpaper_files(self): + """Calls its internal iterators to give the next image for each monitor.""" + files = [] + for iterable in self.iterators: + next_image = iterable.__next__() + if os.path.isfile(next_image): + files.append(next_image) + else: + # reload all files by initializing + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Ran into an invalid file, reinitializing..") + self.__init__(self.paths_array, self.sortmode) + files = self.next_wallpaper_files() + break + return files + + class ImageList: + """Image list iterable that can reinitialize itself once it has been gone through.""" + def __init__(self, filelist, sortmode): + self.counter = 0 + self.files = filelist + self.sortmode = sortmode + self.arrange_list() + + def __iter__(self): + return self + + def __next__(self): + if self.counter < len(self.files): + image = self.files[self.counter] + self.counter += 1 + return image + else: + self.counter = 0 + self.arrange_list() + image = self.files[self.counter] + self.counter += 1 + return image + + def arrange_list(self): + """Reorders the image list as requested. Mostly for reoccuring shuffling.""" + if self.sortmode == "shuffle": + if sp_logging.DEBUG and sp_logging.VERBOSE: + sp_logging.G_LOGGER.info("Shuffling files: %s", self.files) + random.shuffle(self.files) + if sp_logging.DEBUG and sp_logging.VERBOSE: + sp_logging.G_LOGGER.info("Shuffled files: %s", self.files) + elif self.sortmode == "alphabetical": + self.files.sort() + if sp_logging.DEBUG and sp_logging.VERBOSE: + sp_logging.G_LOGGER.info("Sorted files: %s", self.files) + else: + sp_logging.G_LOGGER.info( + "ImageList.arrange_list: unknown sortmode: %s", + self.sortmode) + + +class CLIProfileData(ProfileData): + """ + Stripped down version of the ProfileData object for CLI usage. + + Notable differences are that this can be initialized with input data + and this redefines the next_wallpaper_files function to just return + the images given as input. + """ + + def __init__(self, files, ppiarr, inches, bezels, offsets): + self.name = "cli" + self.spanmode = "" # single / multi + if len(files) == 1: + self.spanmode = "single" + else: + self.spanmode = "multi" + + self.ppimode = None + if ppiarr is None and inches is None: + self.ppimode = False + self.ppi_array = wpproc.NUM_DISPLAYS * [100] + else: + self.ppimode = True + if inches: + self.ppi_array = self.compute_ppis(inches) + else: + self.ppi_array = ppiarr + + if offsets is None: + self.manual_offsets = wpproc.NUM_DISPLAYS * [(0, 0)] + else: + self.manual_offsets = wpproc.NUM_DISPLAYS * [(0, 0)] + off_pairs_zip = zip(*[iter(offsets)]*2) + off_pairs = [tuple(p) for p in off_pairs_zip] + for off, i in zip(off_pairs, range(len(self.manual_offsets))): + self.manual_offsets[i] = off + # print(self.manual_offsets) + for pair in self.manual_offsets: + self.manual_offsets[self.manual_offsets.index(pair)] = (int(pair[0]), int(pair[1])) + # print(self.manual_offsets) + + self.ppi_array_relative_density = [] + self.bezels = bezels + self.bezel_px_offsets = [] + #self.files = files + self.files = [] + for item in files: + self.files.append(os.path.realpath(item)) + # + if self.ppimode is True: + self.compute_relative_densities() + if self.bezels: + self.compute_bezel_px_offsets() + + def next_wallpaper_files(self): + """Returns the images given at construction time.""" + return self.files + +class TempProfileData(object): + """Data object to test the validity of user input and for saving said input into profiles.""" + def __init__(self): + self.name = None + self.spanmode = None + self.slideshow = None + self.delay = None + self.sortmode = None + self.inches = None + self.manual_offsets = None + self.bezels = None + self.hk_binding = None + self.paths_array = [] + + def save(self): + """Saves the TempProfile into a file.""" + if self.name is not None: + fname = PROFILES_PATH + self.name + ".profile" + try: + tpfile = open(fname, "w") + except IOError: + msg = "Cannot write to file {}".format(fname) + show_message_dialog(msg, "Error") + return None + tpfile.write("name=" + str(self.name) + "\n") + if self.spanmode: + tpfile.write("spanmode=" + str(self.spanmode) + "\n") + if self.slideshow is not None: + tpfile.write("slideshow=" + str(self.slideshow) + "\n") + if self.delay: + tpfile.write("delay=" + str(self.delay) + "\n") + if self.sortmode: + tpfile.write("sortmode=" + str(self.sortmode) + "\n") + if self.inches: + tpfile.write("diagonal_inches=" + str(self.inches) + "\n") + if self.manual_offsets: + tpfile.write("offsets=" + str(self.manual_offsets) + "\n") + if self.bezels: + tpfile.write("bezels=" + str(self.bezels) + "\n") + if self.hk_binding: + tpfile.write("hotkey=" + str(self.hk_binding) + "\n") + if self.paths_array: + for paths in self.paths_array: + tpfile.write("display" + str(self.paths_array.index(paths)) + + "paths=" + paths + "\n") + + tpfile.close() + return fname + else: + print("tmp.Save(): name is not set.") + return None + + def test_save(self): + """Tests whether the user input for profile settings is valid.""" + valid_profile = False + if self.name is not None and self.name.strip() is not "": + fname = PROFILES_PATH + self.name + ".deleteme" + try: + testfile = open(fname, "w") + testfile.close() + os.remove(fname) + except IOError: + msg = "Cannot write to file {}".format(fname) + show_message_dialog(msg, "Error") + return False + if self.spanmode == "single": + if len(self.paths_array) > 1: + msg = "When spanning a single image across all monitors, \ +only one paths field is needed." + show_message_dialog(msg, "Error") + return False + if self.spanmode == "multi": + if len(self.paths_array) < 2: + msg = "When setting a different image on every display, \ +each display needs its own paths field." + show_message_dialog(msg, "Error") + return False + if self.slideshow is True and not self.delay: + msg = "When using slideshow you need to enter a delay." + show_message_dialog(msg, "Info") + return False + if self.delay: + try: + val = int(self.delay) + if val < 20: + msg = "It is advisable to set the slideshow delay to \ +be at least 20 seconds due to the time the image processing takes." + show_message_dialog(msg, "Info") + return False + except ValueError: + msg = "Slideshow delay must be an integer of seconds." + show_message_dialog(msg, "Error") + return False + # if self.sortmode: + # No test needed + if self.inches: + if self.is_list_float(self.inches): + pass + else: + msg = "Display diagonals must be given in numeric values \ +using decimal point and separated by semicolon ';'." + show_message_dialog(msg, "Error") + return False + if self.manual_offsets: + if self.is_list_offsets(self.manual_offsets): + pass + else: + msg = "Display offsets must be given in width,height pixel \ +pairs and separated by semicolon ';'." + show_message_dialog(msg, "Error") + return False + if self.bezels: + if self.is_list_float(self.bezels): + if self.manual_offsets: + if len(self.manual_offsets.split(";")) < len(self.bezels.split(";")): + msg = "When using both offset and bezel \ +corrections, take care to enter an offset for each display that you \ +enter a bezel thickness." + show_message_dialog(msg, "Error") + return False + else: + pass + else: + pass + else: + msg = "Display bezels must be given in millimeters using \ +decimal point and separated by semicolon ';'." + show_message_dialog(msg, "Error") + return False + if self.hk_binding: + if self.is_valid_hotkey(self.hk_binding): + pass + else: + msg = "Hotkey must be given as 'mod1+mod2+mod3+key'. \ +Valid modifiers are 'control', 'super', 'alt', 'shift'." + show_message_dialog(msg, "Error") + return False + if self.paths_array: + if self.is_list_valid_paths(self.paths_array): + pass + else: + # msg = "Paths must be separated by a semicolon ';'." + # show_message_dialog(msg, "Error") + return False + else: + msg = "You must enter at least one path for images." + show_message_dialog(msg, "Error") + return False + # Passed all tests. + valid_profile = True + return valid_profile + else: + print("tmp.Save(): name is not set.") + msg = "You must enter a name for the profile." + show_message_dialog(msg, "Error") + return False + + def is_list_float(self, input_string): + """Tests if input string is a colon separated list of floats.""" + is_floats = True + list_input = input_string.split(";") + for item in list_input: + try: + val = float(item) + except ValueError: + sp_logging.G_LOGGER.info("float type check failed for: '%s'", val) + return False + return is_floats + + def is_list_offsets(self, input_string): + """Checks that input string is a valid list of offsets.""" + list_input = input_string.split(";") + # if len(list_input) < wpproc.NUM_DISPLAYS: + # msg = "Enter an offset for every display, even if it is (0,0)." + # show_message_dialog(msg, "Error") + # return False + try: + for off_pair in list_input: + offset = off_pair.split(",") + if len(offset) > 2: + return False + try: + val_w = int(offset[0]) + val_h = int(offset[1]) + except ValueError: + sp_logging.G_LOGGER.info("int type check failed for: '%s' or '%s", + val_w, val_h) + return False + except TypeError: + return False + # Passed tests. + return True + + def is_valid_hotkey(self, input_string): + """A dummy / placeholder method for checking input hotkey.""" + # Validity is hard to properly verify here. + # Instead do it when registering hotkeys at startup. + input_string = "" + input_string + return True + + def is_list_valid_paths(self, input_list): + """Verifies that input list contains paths and that they're valid.""" + if input_list == [""]: + msg = "At least one path for wallpapers must be given." + show_message_dialog(msg, "Error") + return False + if "" in input_list: + msg = "Take care not to save a profile with an empty display paths field." + show_message_dialog(msg, "Error") + return False + for path_list_str in input_list: + path_list = path_list_str.split(";") + for path in path_list: + if os.path.isdir(path) is True: + supported_files = [f for f in os.listdir(path) + if f.endswith(wpproc.G_SUPPORTED_IMAGE_EXTENSIONS)] + if supported_files: + continue + else: + msg = "Path '{}' does not contain supported image files.".format(path) + show_message_dialog(msg, "Error") + return False + else: + msg = "Path '{}' was not recognized as a directory.".format(path) + show_message_dialog(msg, "Error") + return False + valid_pathsarray = True + return valid_pathsarray diff --git a/superpaper/message_dialog.py b/superpaper/message_dialog.py new file mode 100644 index 0000000..21654aa --- /dev/null +++ b/superpaper/message_dialog.py @@ -0,0 +1,9 @@ +"""Error etc. info dialog.""" + +import wx + +def show_message_dialog(message, msg_type="Info"): + """General purpose info dialog in GUI mode.""" + # Type can be 'Info', 'Error', 'Question', 'Exclamation' + dial = wx.MessageDialog(None, message, msg_type, wx.OK) + dial.ShowModal() diff --git a/superpaper/sp_logging.py b/superpaper/sp_logging.py new file mode 100644 index 0000000..cb03b30 --- /dev/null +++ b/superpaper/sp_logging.py @@ -0,0 +1,29 @@ +"""Logging tools for Superpaper.""" + +import logging + +import sp_paths + +DEBUG = False +VERBOSE = False +LOGGING = False +G_LOGGER = logging.getLogger("default") + +if DEBUG and not LOGGING: + G_LOGGER.setLevel(logging.INFO) + CONSOLE_HANDLER = logging.StreamHandler() + G_LOGGER.addHandler(CONSOLE_HANDLER) +elif LOGGING: + DEBUG = True + G_LOGGER.setLevel(logging.INFO) + FILE_HANDLER = logging.FileHandler("{0}/{1}.log".format(sp_paths.PATH, "log"), + mode="w") + G_LOGGER.addHandler(FILE_HANDLER) + CONSOLE_HANDLER = logging.StreamHandler() + G_LOGGER.addHandler(CONSOLE_HANDLER) + +def custom_exception_handler(exceptiontype, value, tb_var): + """Log uncaught exceptions.""" + G_LOGGER.exception("Uncaught exception type: %s", str(exceptiontype)) + G_LOGGER.exception("Exception: %s", str(value)) + G_LOGGER.exception(str(tb_var)) diff --git a/superpaper/sp_paths.py b/superpaper/sp_paths.py new file mode 100644 index 0000000..9ae156c --- /dev/null +++ b/superpaper/sp_paths.py @@ -0,0 +1,16 @@ +"""Define paths used by Superpaper.""" + +import os +import sys + +# Set path to binary / script +if getattr(sys, 'frozen', False): + PATH = os.path.dirname(os.path.dirname(os.path.realpath(sys.executable))) +else: + PATH = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) + +# Derivative paths +TEMP_PATH = PATH + "/temp/" +if not os.path.isdir(TEMP_PATH): + os.mkdir(TEMP_PATH) +PROFILES_PATH = PATH + "/profiles/" diff --git a/superpaper/superpaper.py b/superpaper/superpaper.py new file mode 100644 index 0000000..fac4bc8 --- /dev/null +++ b/superpaper/superpaper.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +""" +Superpaper is a cross-platform multi monitor wallpaper manager. + +Written by Henri Hänninen. +""" + +#__all__ to be set at some point. Defines the APIs of the module(s). +__author__ = "Henri Hänninen" + +import sys + +from cli import cli_logic +from tray import tray_loop + + +def main(): + """Runs tray applet if no command line arguments are passed, CLI parsing otherwise.""" + if len(sys.argv) <= 1: + tray_loop() + else: + cli_logic() + + +if __name__ == "__main__": + main() diff --git a/superpaper/tray.py b/superpaper/tray.py new file mode 100644 index 0000000..1dfb24c --- /dev/null +++ b/superpaper/tray.py @@ -0,0 +1,459 @@ +"""Tray applet for Superpaper.""" +# from configuration_dialogs import * # Katso ensin että tuleeko tästä liian pitkä dialogien kanssa. + +import os +import platform +import subprocess +import sys +from threading import Lock + +from __version__ import __version__ +import sp_logging +import sp_paths +from configuration_dialogs import ConfigFrame, SettingsFrame, HelpFrame +from message_dialog import show_message_dialog +from data import (GeneralSettingsData, + list_profiles, read_active_profile, write_active_profile) +from wallpaper_processing import (get_display_data, + run_profile_job, quick_profile_job, + change_wallpaper_job + ) + +try: + import wx + import wx.adv +except ImportError as import_e: + sp_logging.G_LOGGER.info("Failed to define tray applet classes. Is wxPython installed?") + sp_logging.G_LOGGER.info(import_e) + exit() + + + +# Constants +TRAY_TOOLTIP = "Superpaper" +TRAY_ICON = sp_paths.PATH + "/resources/default.png" + + + +def tray_loop(): + """Runs the tray applet.""" + if not os.path.isdir(sp_paths.PROFILES_PATH): + os.mkdir(sp_paths.PROFILES_PATH) + if "wx" in sys.modules: + app = App(False) + app.MainLoop() + else: + print("ERROR: Module 'wx' import has failed. Is it installed? \ +GUI unavailable, exiting.") + sp_logging.G_LOGGER.error("ERROR: Module 'wx' import has failed. Is it installed? \ +GUI unavailable, exiting.") + exit() + + + +# Tray applet definitions +def create_menu_item(menu, label, func, *args, **kwargs): + """Helper function to create menu items for the tray menu.""" + item = wx.MenuItem(menu, -1, label, **kwargs) + menu.Bind(wx.EVT_MENU, lambda event: func(event, *args), id=item.GetId()) + menu.Append(item) + return item + +class TaskBarIcon(wx.adv.TaskBarIcon): + """Taskbar icon and menu class.""" + def __init__(self, frame): + self.g_settings = GeneralSettingsData() + + self.frame = frame + super(TaskBarIcon, self).__init__() + self.set_icon(TRAY_ICON) + self.Bind(wx.adv.EVT_TASKBAR_LEFT_DOWN, self.on_left_down) + # profile initialization + self.job_lock = Lock() + get_display_data() + self.repeating_timer = None + self.pause_item = None + self.is_paused = False + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("START Listing profiles for menu.") + self.list_of_profiles = list_profiles() + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("END Listing profiles for menu.") + # Should now return an object if a previous profile was written or + # None if no previous data was found + self.active_profile = read_active_profile() + self.start_prev_profile(self.active_profile) + # if self.active_profile is None: + # sp_logging.G_LOGGER.info("Starting up the first profile found.") + # self.start_profile(wx.EVT_MENU, self.list_of_profiles[0]) + + # self.hk = None + # self.hk2 = None + if self.g_settings.use_hotkeys is True: + try: + # import keyboard # https://github.com/boppreh/keyboard + # This import is here to have the module in the class scope + from system_hotkey import SystemHotkey + self.hk = SystemHotkey(check_queue_interval=0.05) + self.hk2 = SystemHotkey( + consumer=self.profile_consumer, + check_queue_interval=0.05) + self.seen_binding = set() + self.register_hotkeys() + except ImportError as excep: + sp_logging.G_LOGGER.info( + "WARNING: Could not import keyboard hotkey hook library, \ +hotkeys will not work. Exception: %s", excep) + if self.g_settings.show_help is True: + config_frame = ConfigFrame(self) + help_frame = HelpFrame() + + + + def register_hotkeys(self): + """Registers system-wide hotkeys for profiles and application interaction.""" + if self.g_settings.use_hotkeys is True: + try: + # import keyboard # https://github.com/boppreh/keyboard + # This import allows access to the specific errors in this method. + from system_hotkey import (SystemHotkey, SystemHotkeyError, + SystemRegisterError, + UnregisterError, InvalidKeyError) + except ImportError as import_e: + sp_logging.G_LOGGER.info( + "WARNING: Could not import keyboard hotkey hook library, \ +hotkeys will not work. Exception: %s", import_e) + if "system_hotkey" in sys.modules: + try: + # Keyboard bindings: https://github.com/boppreh/keyboard + # + # Alternative KB bindings for X11 systems and Windows: + # system_hotkey https://github.com/timeyyy/system_hotkey + # seen_binding = set() + # self.hk = SystemHotkey(check_queue_interval=0.05) + # self.hk2 = SystemHotkey( + # consumer=self.profile_consumer, + # check_queue_interval=0.05) + + # Unregister previous hotkeys + if self.seen_binding: + for binding in self.seen_binding: + try: + self.hk.unregister(binding) + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Unreg hotkey %s", + binding) + except (SystemHotkeyError, UnregisterError, InvalidKeyError): + try: + self.hk2.unregister(binding) + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Unreg hotkey %s", + binding) + except (SystemHotkeyError, UnregisterError, InvalidKeyError): + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Could not unreg hotkey '%s'", + binding) + self.seen_binding = set() + + + # register general bindings + if self.g_settings.hk_binding_next not in self.seen_binding: + try: + self.hk.register( + self.g_settings.hk_binding_next, + callback=lambda x: self.next_wallpaper(wx.EVT_MENU), + overwrite=False) + self.seen_binding.add(self.g_settings.hk_binding_next) + except (SystemHotkeyError, SystemRegisterError, InvalidKeyError): + msg = "Error: could not register hotkey {}. \ +Check that it is formatted properly and valid keys.".format(self.g_settings.hk_binding_next) + sp_logging.G_LOGGER.info(msg) + sp_logging.G_LOGGER.info(sys.exc_info()[0]) + show_message_dialog(msg, "Error") + if self.g_settings.hk_binding_pause not in self.seen_binding: + try: + self.hk.register( + self.g_settings.hk_binding_pause, + callback=lambda x: self.pause_timer(wx.EVT_MENU), + overwrite=False) + self.seen_binding.add(self.g_settings.hk_binding_pause) + except (SystemHotkeyError, SystemRegisterError, InvalidKeyError): + msg = "Error: could not register hotkey {}. \ +Check that it is formatted properly and valid keys.".format(self.g_settings.hk_binding_pause) + sp_logging.G_LOGGER.info(msg) + sp_logging.G_LOGGER.info(sys.exc_info()[0]) + show_message_dialog(msg, "Error") + # try: + # self.hk.register(('control', 'super', 'shift', 'q'), + # callback=lambda x: self.on_exit(wx.EVT_MENU)) + # except (SystemHotkeyError, SystemRegisterError, InvalidKeyError): + # pass + + # register profile specific bindings + self.list_of_profiles = list_profiles() + for profile in self.list_of_profiles: + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info( + "Registering binding: \ + %s for profile: %s", + profile.hk_binding, profile.name) + if (profile.hk_binding is not None and + profile.hk_binding not in self.seen_binding): + try: + self.hk2.register(profile.hk_binding, profile, + overwrite=False) + self.seen_binding.add(profile.hk_binding) + except (SystemHotkeyError, SystemRegisterError, InvalidKeyError): + msg = "Error: could not register hotkey {}. \ +Check that it is formatted properly and valid keys.".format(profile.hk_binding) + sp_logging.G_LOGGER.info(msg) + sp_logging.G_LOGGER.info(sys.exc_info()[0]) + show_message_dialog(msg, "Error") + elif profile.hk_binding in self.seen_binding: + msg = "Could not register hotkey: '{}' for profile: '{}'.\n\ +It is already registered for another action.".format(profile.hk_binding, profile.name) + sp_logging.G_LOGGER.info(msg) + show_message_dialog(msg, "Error") + except (SystemHotkeyError, SystemRegisterError, + UnregisterError, InvalidKeyError): + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Coulnd't register hotkeys, exception:") + sp_logging.G_LOGGER.info(sys.exc_info()[0]) + + + + def profile_consumer(self, event, hotkey, profile): + """Hotkey bindable method that starts up a profile.""" + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Profile object is: %s", profile) + self.start_profile(wx.EVT_MENU, profile[0][0]) + + def read_general_settings(self): + """Refreshes general settings from file and applies hotkey bindings.""" + self.g_settings = GeneralSettingsData() + self.register_hotkeys() + msg = "New settings are applied after an application restart. \ +New hotkeys are registered." + show_message_dialog(msg, "Info") + + def CreatePopupMenu(self): + """Method called by WX library when user right clicks tray icon. Opens tray menu.""" + menu = wx.Menu() + create_menu_item(menu, "Open Config Folder", self.open_config) + create_menu_item(menu, "Profile Configuration", self.configure_profiles) + create_menu_item(menu, "Settings", self.configure_settings) + create_menu_item(menu, "Reload Profiles", self.reload_profiles) + menu.AppendSeparator() + for item in self.list_of_profiles: + create_menu_item(menu, item.name, self.start_profile, item) + menu.AppendSeparator() + create_menu_item(menu, "Next Wallpaper", self.next_wallpaper) + self.pause_item = create_menu_item( + menu, "Pause Timer", self.pause_timer, kind=wx.ITEM_CHECK) + self.pause_item.Check(self.is_paused) + menu.AppendSeparator() + create_menu_item(menu, 'About', self.on_about) + create_menu_item(menu, 'Exit', self.on_exit) + return menu + + def set_icon(self, path): + """Sets tray icon.""" + icon = wx.Icon(path) + self.SetIcon(icon, TRAY_TOOLTIP) + + def on_left_down(self, *event): + """Allows binding left click event.""" + sp_logging.G_LOGGER.info('Tray icon was left-clicked.') + + def open_config(self, event): + """Opens Superpaper base folder, PATH.""" + if platform.system() == "Windows": + try: + os.startfile(sp_paths.PATH) + except BaseException: + show_message_dialog("There was an error trying to open the config folder.") + elif platform.system() == "Darwin": + try: + subprocess.check_call(["open", sp_paths.PATH]) + except subprocess.CalledProcessError: + show_message_dialog("There was an error trying to open the config folder.") + else: + try: + subprocess.check_call(['xdg-open', sp_paths.PATH]) + except subprocess.CalledProcessError: + show_message_dialog("There was an error trying to open the config folder.") + + def configure_profiles(self, event): + """Opens profile configuration panel.""" + config_frame = ConfigFrame(self) + + def configure_settings(self, event): + """Opens general settings panel.""" + setting_frame = SettingsFrame(self) + + def reload_profiles(self, event): + """Reloads profiles from disk.""" + self.list_of_profiles = list_profiles() + + def start_prev_profile(self, profile): + """Checks if a previously running profile has been recorded and starts it.""" + with self.job_lock: + if profile is None: + sp_logging.G_LOGGER.info("No previous profile was found.") + else: + self.repeating_timer = run_profile_job(profile) + + def start_profile(self, event, profile): + """ + Starts a profile job, i.e. runs a slideshow or a one time wallpaper change. + + If the input profile is the currently active profile, initiate a wallpaper change. + """ + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Start profile: %s", profile.name) + if profile is None: + sp_logging.G_LOGGER.info( + "start_profile: profile is None. \ + Do you have any profiles in /profiles?") + elif self.active_profile is not None: + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info( + "Check if the starting profile is already running: %s", + profile.name) + sp_logging.G_LOGGER.info( + "name check: %s, %s", + profile.name, self.active_profile.name) + if profile.name == self.active_profile.name: + self.next_wallpaper(event) + return 0 + else: + with self.job_lock: + if (self.repeating_timer is not None and + self.repeating_timer.is_running): + self.repeating_timer.stop() + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info( + "Running quick profile job with profile: %s", + profile.name) + quick_profile_job(profile) + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info( + "Starting timed profile job with profile: %s", + profile.name) + self.repeating_timer = run_profile_job(profile) + self.active_profile = profile + write_active_profile(profile.name) + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Wrote active profile: %s", + profile.name) + return 0 + else: + with self.job_lock: + if (self.repeating_timer is not None + and self.repeating_timer.is_running): + self.repeating_timer.stop() + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info( + "Running quick profile job with profile: %s", + profile.name) + quick_profile_job(profile) + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info( + "Starting timed profile job with profile: %s", + profile.name) + self.repeating_timer = run_profile_job(profile) + self.active_profile = profile + write_active_profile(profile.name) + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Wrote active profile: %s", + profile.name) + return 0 + + def next_wallpaper(self, event): + """Calls the next wallpaper changer method of the running profile.""" + with self.job_lock: + if (self.repeating_timer is not None + and self.repeating_timer.is_running): + self.repeating_timer.stop() + change_wallpaper_job(self.active_profile) + self.repeating_timer.start() + else: + change_wallpaper_job(self.active_profile) + + def rt_stop(self): + """Stops running slideshow timer if one is active.""" + if (self.repeating_timer is not None + and self.repeating_timer.is_running): + self.repeating_timer.stop() + + def pause_timer(self, event): + """Check if a slideshow timer is running and if it is, then try to stop/start.""" + if (self.repeating_timer is not None + and self.repeating_timer.is_running): + self.repeating_timer.stop() + self.is_paused = True + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Paused timer") + elif (self.repeating_timer is not None + and not self.repeating_timer.is_running): + self.repeating_timer.start() + self.is_paused = False + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Resumed timer") + else: + sp_logging.G_LOGGER.info("Current profile isn't using a timer.") + + def on_about(self, event): + """Opens About dialog.""" + # Credit for AboutDiaglog example to Jan Bodnar of + # http://zetcode.com/wxpython/dialogs/ + description = ( + "Superpaper is an advanced multi monitor wallpaper\n" + +"manager for Unix and Windows operating systems.\n" + +"Features include setting a single or multiple image\n" + +"wallpaper, pixel per inch and bezel corrections,\n" + +"manual pixel offsets for tuning, slideshow with\n" + +"configurable file order, multiple path support and more." + ) + licence = ( + "Superpaper is free software; you can redistribute\n" + +"it and/or modify it under the terms of the MIT" + +" License.\n\n" + +"Superpaper is distributed in the hope that it will" + +" be useful,\n" + +"but WITHOUT ANY WARRANTY; without even the implied" + +" warranty of\n" + +"MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.\n" + +"See the MIT License for more details." + ) + artists = "Icons kindly provided by Icons8 https://icons8.com" + + info = wx.adv.AboutDialogInfo() + info.SetIcon(wx.Icon(TRAY_ICON, wx.BITMAP_TYPE_PNG)) + info.SetName('Superpaper') + info.SetVersion(__version__) + info.SetDescription(description) + info.SetCopyright('(C) 2019 Henri Hänninen') + info.SetWebSite('https://github.com/hhannine/Superpaper/') + info.SetLicence(licence) + info.AddDeveloper('Henri Hänninen') + info.AddArtist(artists) + # info.AddDocWriter('Doc Writer') + # info.AddTranslator('Tran Slator') + wx.adv.AboutBox(info) + + def on_exit(self, event): + """Exits Superpaper.""" + self.rt_stop() + wx.CallAfter(self.Destroy) + self.frame.Close() + +class App(wx.App): + """wx base class for tray icon.""" + + def OnInit(self): + """Starts tray icon loop.""" + frame = wx.Frame(None) + self.SetTopWindow(frame) + TaskBarIcon(frame) + return True diff --git a/superpaper/wallpaper_processing.py b/superpaper/wallpaper_processing.py new file mode 100644 index 0000000..13f1fec --- /dev/null +++ b/superpaper/wallpaper_processing.py @@ -0,0 +1,777 @@ +""" +Wallpaper image processing back-end for Superpaper. + +Applies image corrections, crops, merges etc. and sets the wallpaper +with native platform methods whenever possible. + +Written by Henri Hänninen, copyright 2019 under MIT licence. +""" + +import os +import platform +import subprocess +import sys +from operator import itemgetter +from threading import Lock, Thread, Timer + +from PIL import Image +from screeninfo import get_monitors + +import sp_logging +from message_dialog import show_message_dialog +from sp_paths import TEMP_PATH + +if platform.system() == "Windows": + import ctypes +elif platform.system() == "Linux": + # KDE has special needs + if os.environ.get("DESKTOP_SESSION") == "/usr/share/xsessions/plasma": + import dbus + + +# Global constants + +NUM_DISPLAYS = 0 +# list of display resolutions (width,height), use tuples. +RESOLUTION_ARRAY = [] +# list of display offsets (width,height), use tuples. +DISPLAY_OFFSET_ARRAY = [] + +G_WALLPAPER_CHANGE_LOCK = Lock() +G_SUPPORTED_IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".bmp", ".gif", ".tiff", ".webp") +G_SET_COMMAND_STRING = "" + + + +class RepeatedTimer(object): + """Threaded timer used for slideshow.""" + # Credit: + # https://stackoverflow.com/questions/3393612/run-certain-code-every-n-seconds/13151299#13151299 + def __init__(self, interval, function, *args, **kwargs): + self._timer = None + self.interval = interval + self.function = function + self.args = args + self.kwargs = kwargs + self.is_running = False + self.start() + + def _run(self): + self.is_running = False + self.start() + self.function(*self.args, **self.kwargs) + + def start(self): + """Starts timer.""" + if not self.is_running: + self._timer = Timer(self.interval, self._run) + self._timer.daemon = True + self._timer.start() + self.is_running = True + + def stop(self): + """Stops timer.""" + self._timer.cancel() + self.is_running = False + + +def get_display_data(): + """Updates global display variables: number of displays, resolutions and offsets.""" + # https://github.com/rr-/screeninfo + global NUM_DISPLAYS, RESOLUTION_ARRAY, DISPLAY_OFFSET_ARRAY + RESOLUTION_ARRAY = [] + DISPLAY_OFFSET_ARRAY = [] + monitors = get_monitors() + NUM_DISPLAYS = len(monitors) + for m_index in range(len(monitors)): + res = [] + offset = [] + res.append(monitors[m_index].width) + res.append(monitors[m_index].height) + offset.append(monitors[m_index].x) + offset.append(monitors[m_index].y) + RESOLUTION_ARRAY.append(tuple(res)) + DISPLAY_OFFSET_ARRAY.append(tuple(offset)) + # Check that the display offsets are sane, i.e. translate the values if + # there are any negative values (Windows). + # Top-most edge of the crop tuples. + leftmost_offset = min(DISPLAY_OFFSET_ARRAY, key=itemgetter(0))[0] + topmost_offset = min(DISPLAY_OFFSET_ARRAY, key=itemgetter(1))[1] + if leftmost_offset < 0 or topmost_offset < 0: + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Negative display offset: %s", DISPLAY_OFFSET_ARRAY) + translate_offsets = [] + for offset in DISPLAY_OFFSET_ARRAY: + translate_offsets.append((offset[0] - leftmost_offset, offset[1] - topmost_offset)) + DISPLAY_OFFSET_ARRAY = translate_offsets + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Sanitised display offset: %s", DISPLAY_OFFSET_ARRAY) + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info( + "get_display_data output: NUM_DISPLAYS = %s, %s, %s", + NUM_DISPLAYS, + RESOLUTION_ARRAY, + DISPLAY_OFFSET_ARRAY) + # Sort displays left to right according to offset data + display_indices = list(range(len(DISPLAY_OFFSET_ARRAY))) + display_indices.sort(key=DISPLAY_OFFSET_ARRAY.__getitem__) + DISPLAY_OFFSET_ARRAY = list(map(DISPLAY_OFFSET_ARRAY.__getitem__, display_indices)) + RESOLUTION_ARRAY = list(map(RESOLUTION_ARRAY.__getitem__, display_indices)) + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info( + "SORTED get_display_data output: NUM_DISPLAYS = %s, %s, %s", + NUM_DISPLAYS, + RESOLUTION_ARRAY, + DISPLAY_OFFSET_ARRAY) + + +def compute_canvas(res_array, offset_array): + """Computes the size of the total desktop area from monitor resolutions and offsets.""" + # Take the subtractions of right-most right - left-most left + # and bottom-most bottom - top-most top (=0). + leftmost = 0 + topmost = 0 + right_edges = [] + bottom_edges = [] + for res, off in zip(res_array, offset_array): + right_edges.append(off[0]+res[0]) + bottom_edges.append(off[1]+res[1]) + # Right-most edge. + rightmost = max(right_edges) + # Bottom-most edge. + bottommost = max(bottom_edges) + canvas_size = [rightmost - leftmost, bottommost - topmost] + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Canvas size: %s", canvas_size) + return canvas_size + + +def compute_ppi_corrected_res_array(res_array, ppi_list_rel_density): + """Return ppi density normalized sizes of the real resolutions.""" + eff_res_array = [] + for i in range(len(res_array)): + effw = round(res_array[i][0] / ppi_list_rel_density[i]) + effh = round(res_array[i][1] / ppi_list_rel_density[i]) + eff_res_array.append((effw, effh)) + return eff_res_array + + +# resize image to fill given rectangle and do a centered crop to size. +# Return output image. +def resize_to_fill(img, res): + """Resize image to fill given rectangle and do a centered crop to size.""" + image_size = img.size # returns image (width,height) + if image_size == res: + # input image is already of the correct size, no action needed. + return img + image_ratio = image_size[0] / image_size[1] + target_ratio = res[0] / res[1] + # resize along the shorter edge to get an image that is at least of the + # target size on the shorter edge. + if image_ratio < target_ratio: # img not wide enough / is too tall + resize_multiplier = res[0] / image_size[0] + new_size = ( + round(resize_multiplier * image_size[0]), + round(resize_multiplier * image_size[1])) + img = img.resize(new_size, resample=Image.LANCZOS) + # crop vertically to target height + extra_height = new_size[1] - res[1] + if extra_height < 0: + sp_logging.G_LOGGER.info( + "Error with cropping vertically, resized image \ + wasn't taller than target size.") + return -1 + if extra_height == 0: + # image is already at right height, no cropping needed. + return img + # (left edge, half of extra height from top, + # right edge, bottom = top + res[1]) : force correct height + crop_tuple = ( + 0, + round(extra_height/2), + new_size[0], + round(extra_height/2) + res[1]) + cropped_res = img.crop(crop_tuple) + if cropped_res.size == res: + return cropped_res + else: + sp_logging.G_LOGGER.info( + "Error: result image not of correct size. crp:%s, res:%s", + cropped_res.size, res) + return -1 + elif image_ratio >= target_ratio: # img not tall enough / is too wide + resize_multiplier = res[1] / image_size[1] + new_size = ( + round(resize_multiplier * image_size[0]), + round(resize_multiplier * image_size[1])) + img = img.resize(new_size, resample=Image.LANCZOS) + # crop horizontally to target width + extra_width = new_size[0] - res[0] + if extra_width < 0: + sp_logging.G_LOGGER.info( + "Error with cropping horizontally, resized image \ + wasn't wider than target size.") + return -1 + if extra_width == 0: + # image is already at right width, no cropping needed. + return img + # (half of extra from left edge, top edge, + # right = left + desired width, bottom) : force correct width + crop_tuple = ( + round(extra_width/2), + 0, + round(extra_width/2) + res[0], + new_size[1]) + cropped_res = img.crop(crop_tuple) + if cropped_res.size == res: + return cropped_res + else: + sp_logging.G_LOGGER.info( + "Error: result image not of correct size. crp:%s, res:%s", + cropped_res.size, res) + return -1 + + +def get_center(res): + """Computes center point of a resolution rectangle.""" + return (round(res[0] / 2), round(res[1] / 2)) + + +def get_all_centers(resarr_eff, manual_offsets): + """Computes center points of given resolution list taking into account their offsets.""" + centers = [] + sum_widths = 0 + # get the vertical pixel distance of the center of the left most display + # from the top. + center_standard_height = get_center(resarr_eff[0])[1] + if len(manual_offsets) < len(resarr_eff): + sp_logging.G_LOGGER.info("get_all_centers: Not enough manual offsets: \ + %s for displays: %s", + len(manual_offsets), + len(resarr_eff)) + else: + for i in range(len(resarr_eff)): + horiz_radius = get_horizontal_radius(resarr_eff[i]) + # here take the center height to be the same for all the displays + # unless modified with the manual offset + center_pos_from_anchor_left_top = ( + sum_widths + manual_offsets[i][0] + horiz_radius, + center_standard_height + manual_offsets[i][1]) + centers.append(center_pos_from_anchor_left_top) + sum_widths += resarr_eff[i][0] + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("centers: %s", centers) + return centers + + +def get_lefttop_from_center(center, res): + """Compute top left coordinate of a rectangle from its center.""" + return (center[0] - round(res[0] / 2), center[1] - round(res[1] / 2)) + + +def get_rightbottom_from_lefttop(lefttop, res): + """Compute right bottom corner of a rectangle from its left top.""" + return (lefttop[0] + res[0], lefttop[1] + res[1]) + + +def get_horizontal_radius(res): + """Returns half the width of the input rectangle.""" + return round(res[0] / 2) + + +def compute_crop_tuples(resolution_array_ppinormalized, manual_offsets): + # Assume the centers of the physical displays are aligned on common + # horizontal line. If this is not the case one must use the manual + # offsets defined in the profile for adjustment (and bezel corrections). + # Anchor positions to the top left corner of the left most display. If + # its size is scaled up, one will need to adjust the horizontal positions + # of all the displays. (This is automatically handled by using the + # effective resolution array). + # Additionally one must make sure that the highest point of the display + # arrangement is at y=0. + crop_tuples = [] + centers = get_all_centers(resolution_array_ppinormalized, manual_offsets) + for center, res in zip(centers, resolution_array_ppinormalized): + lefttop = get_lefttop_from_center(center, res) + rightbottom = get_rightbottom_from_lefttop(lefttop, res) + crop_tuples.append(lefttop + rightbottom) + # Translate crops so that the highest point is at y=0 -- remember to add + # translation to both top and bottom coordinates! Same horizontally. + # Left-most edge of the crop tuples. + leftmost = min(crop_tuples, key=itemgetter(0))[0] + # Top-most edge of the crop tuples. + topmost = min(crop_tuples, key=itemgetter(1))[1] + if leftmost is 0 and topmost is 0: + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("crop_tuples: %s", crop_tuples) + return crop_tuples # [(left, up, right, bottom),...] + else: + crop_tuples_translated = translate_crops( + crop_tuples, (leftmost, topmost)) + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("crop_tuples_translated: %s", crop_tuples_translated) + return crop_tuples_translated # [(left, up, right, bottom),...] + + +def translate_crops(crop_tuples, translate_tuple): + """Translate crop tuples to be over the image are, i.e. left top at (0,0).""" + crop_tuples_translated = [] + for crop_tuple in crop_tuples: + crop_tuples_translated.append( + (crop_tuple[0] - translate_tuple[0], + crop_tuple[1] - translate_tuple[1], + crop_tuple[2] - translate_tuple[0], + crop_tuple[3] - translate_tuple[1])) + return crop_tuples_translated + + +def compute_working_canvas(crop_tuples): + """Computes effective size of the desktop are taking into account PPI/offsets/bezels.""" + # Take the subtractions of right-most right - left-most left + # and bottom-most bottom - top-most top (=0). + leftmost = 0 + topmost = 0 + # Right-most edge of the crop tuples. + rightmost = max(crop_tuples, key=itemgetter(2))[2] + # Bottom-most edge of the crop tuples. + bottommost = max(crop_tuples, key=itemgetter(3))[3] + canvas_size = [rightmost - leftmost, bottommost - topmost] + return canvas_size + + +def span_single_image_simple(profile): + """ + Spans a single image across all monitors. No corrections. + + This simple method resizes the source image so it fills the whole + desktop canvas. Since no corrections are applied, no offset dependent + cuts are needed and so this should work on any monitor arrangement. + """ + file = profile.next_wallpaper_files()[0] + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info(file) + img = Image.open(file) + canvas_tuple = tuple(compute_canvas(RESOLUTION_ARRAY, DISPLAY_OFFSET_ARRAY)) + img_resize = resize_to_fill(img, canvas_tuple) + outputfile = TEMP_PATH + profile.name + "-a.png" + if os.path.isfile(outputfile): + outputfile_old = outputfile + outputfile = TEMP_PATH + profile.name + "-b.png" + else: + outputfile_old = TEMP_PATH + profile.name + "-b.png" + img_resize.save(outputfile, "PNG") + set_wallpaper(outputfile) + if os.path.exists(outputfile_old): + os.remove(outputfile_old) + return 0 + + +# Take pixel densities of displays into account to have the image match +# physically between displays. +def span_single_image_advanced(profile): + """ + Applies wallpaper using PPI, bezel, offset corrections. + + Further description todo. + """ + file = profile.next_wallpaper_files()[0] + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info(file) + img = Image.open(file) + resolution_array_ppinormalized = compute_ppi_corrected_res_array( + RESOLUTION_ARRAY, profile.ppi_array_relative_density) + + # Cropping now sections of the image to be shown, USE EFFECTIVE WORKING + # SIZES. Also EFFECTIVE SIZE Offsets are now required. + manual_offsets = profile.manual_offsets + cropped_images = [] + crop_tuples = compute_crop_tuples(resolution_array_ppinormalized, manual_offsets) + # larger working size needed to fill all the normalized lower density + # displays. Takes account manual offsets that might require extra space. + canvas_tuple_eff = tuple(compute_working_canvas(crop_tuples)) + # Image is now the height of the eff tallest display + possible manual + # offsets and the width of the combined eff widths + possible manual + # offsets. + img_workingsize = resize_to_fill(img, canvas_tuple_eff) + # Simultaneously make crops at working size and then resize down to actual + # resolution from RESOLUTION_ARRAY as needed. + for crop, res in zip(crop_tuples, RESOLUTION_ARRAY): + crop_img = img_workingsize.crop(crop) + if crop_img.size == res: + cropped_images.append(crop_img) + else: + crop_img = crop_img.resize(res, resample=Image.LANCZOS) + cropped_images.append(crop_img) + # Combine crops to a single canvas of the size of the actual desktop + # actual combined size of the display resolutions + canvas_tuple_fin = tuple(compute_canvas(RESOLUTION_ARRAY, DISPLAY_OFFSET_ARRAY)) + combined_image = Image.new("RGB", canvas_tuple_fin, color=0) + combined_image.load() + for i in range(len(cropped_images)): + combined_image.paste(cropped_images[i], DISPLAY_OFFSET_ARRAY[i]) + # Saving combined image + outputfile = TEMP_PATH + profile.name + "-a.png" + if os.path.isfile(outputfile): + outputfile_old = outputfile + outputfile = TEMP_PATH + profile.name + "-b.png" + else: + outputfile_old = TEMP_PATH + profile.name + "-b.png" + combined_image.save(outputfile, "PNG") + set_wallpaper(outputfile) + if os.path.exists(outputfile_old): + os.remove(outputfile_old) + return 0 + + +def set_multi_image_wallpaper(profile): + """Sets a distinct image on each monitor. + + Since most platforms only support setting a single image + as the wallpaper this has to be accomplished by creating a + composite image based on the monitor offsets and then setting + the resulting image as the wallpaper. + """ + files = profile.next_wallpaper_files() + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info(str(files)) + img_resized = [] + for file, res in zip(files, RESOLUTION_ARRAY): + image = Image.open(file) + img_resized.append(resize_to_fill(image, res)) + canvas_tuple = tuple(compute_canvas(RESOLUTION_ARRAY, DISPLAY_OFFSET_ARRAY)) + combined_image = Image.new("RGB", canvas_tuple, color=0) + combined_image.load() + for i in range(len(files)): + combined_image.paste(img_resized[i], DISPLAY_OFFSET_ARRAY[i]) + outputfile = TEMP_PATH + profile.name + "-a.png" + if os.path.isfile(outputfile): + outputfile_old = outputfile + outputfile = TEMP_PATH + profile.name + "-b.png" + else: + outputfile_old = TEMP_PATH + profile.name + "-b.png" + combined_image.save(outputfile, "PNG") + set_wallpaper(outputfile) + if os.path.exists(outputfile_old): + os.remove(outputfile_old) + return 0 + + +def errcheck(result, func, args): + """Error getter for Windows.""" + if not result: + raise ctypes.WinError(ctypes.get_last_error()) + + +def set_wallpaper(outputfile): + """ + Master method to set the composed image as wallpaper. + + After the final background image is created, this method + is called to communicate with the host system to set the + desktop background. For Linux hosts there is a separate method. + """ + pltform = platform.system() + if pltform == "Windows": + spi_setdeskwallpaper = 20 + spif_update_ini_file = 1 + spif_send_change = 2 + user32 = ctypes.WinDLL('user32', use_last_error=True) + spiw = user32.SystemParametersInfoW + spiw.argtypes = [ + ctypes.c_uint, + ctypes.c_uint, + ctypes.c_void_p, + ctypes.c_uint] + spiw.restype = ctypes.c_int + spiw.errcheck = errcheck + spi_success = spiw( + spi_setdeskwallpaper, + 0, + outputfile, + spif_update_ini_file | spif_send_change) + if spi_success == 0: + sp_logging.G_LOGGER.info("SystemParametersInfo wallpaper set failed with \ +spi_success: '%s'", spi_success) + elif pltform == "Linux": + set_wallpaper_linux(outputfile) + elif pltform == "Darwin": + script = """/usr/bin/osascript< Date: Sun, 17 Nov 2019 22:57:09 +0200 Subject: [PATCH 02/31] Add gitignore for __pycache__ --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b79bac4 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +superpaper/__pycache__ From 243eb7c1291bc6020575b3f90d6b4769e7fa7dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Sun, 17 Nov 2019 23:15:20 +0200 Subject: [PATCH 03/31] Add support for Kubuntu and any other KDE system reporting DESKTOP_SESSION as 'plasma' --- superpaper/wallpaper_processing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/superpaper/wallpaper_processing.py b/superpaper/wallpaper_processing.py index 13f1fec..efb50a4 100644 --- a/superpaper/wallpaper_processing.py +++ b/superpaper/wallpaper_processing.py @@ -25,7 +25,7 @@ import ctypes elif platform.system() == "Linux": # KDE has special needs - if os.environ.get("DESKTOP_SESSION") == "/usr/share/xsessions/plasma": + if os.environ.get("DESKTOP_SESSION") in ["/usr/share/xsessions/plasma", "plasma"]: import dbus @@ -565,7 +565,7 @@ def set_wallpaper_linux(outputfile): sp_logging.G_LOGGER.info("Exception: failure to find either command \ 'pcmanfm' or 'pcmanfm-qt'. Exiting.") sys.exit(1) - elif desk_env in ["/usr/share/xsessions/plasma"]: + elif desk_env in ["/usr/share/xsessions/plasma", "plasma"]: kdeplasma_actions(outputfile) elif "i3" in desk_env or desk_env in ["/usr/share/xsessions/bspwm"]: subprocess.run(["feh", "--bg-scale", "--no-xinerama", outputfile]) From 48ef997e6133a53b81c4f12c5d3b0b64c745935c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Sun, 17 Nov 2019 23:23:22 +0200 Subject: [PATCH 04/31] Sanitize path handling for temp output images. --- superpaper/wallpaper_processing.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/superpaper/wallpaper_processing.py b/superpaper/wallpaper_processing.py index efb50a4..bcac62c 100644 --- a/superpaper/wallpaper_processing.py +++ b/superpaper/wallpaper_processing.py @@ -353,12 +353,12 @@ def span_single_image_simple(profile): img = Image.open(file) canvas_tuple = tuple(compute_canvas(RESOLUTION_ARRAY, DISPLAY_OFFSET_ARRAY)) img_resize = resize_to_fill(img, canvas_tuple) - outputfile = TEMP_PATH + profile.name + "-a.png" + outputfile = os.path.join(TEMP_PATH, profile.name + "-a.png") if os.path.isfile(outputfile): outputfile_old = outputfile - outputfile = TEMP_PATH + profile.name + "-b.png" + outputfile = os.path.join(TEMP_PATH, profile.name + "-b.png") else: - outputfile_old = TEMP_PATH + profile.name + "-b.png" + outputfile_old = os.path.join(TEMP_PATH, profile.name + "-b.png") img_resize.save(outputfile, "PNG") set_wallpaper(outputfile) if os.path.exists(outputfile_old): @@ -410,12 +410,12 @@ def span_single_image_advanced(profile): for i in range(len(cropped_images)): combined_image.paste(cropped_images[i], DISPLAY_OFFSET_ARRAY[i]) # Saving combined image - outputfile = TEMP_PATH + profile.name + "-a.png" + outputfile = os.path.join(TEMP_PATH, profile.name + "-a.png") if os.path.isfile(outputfile): outputfile_old = outputfile - outputfile = TEMP_PATH + profile.name + "-b.png" + outputfile = os.path.join(TEMP_PATH, profile.name + "-b.png") else: - outputfile_old = TEMP_PATH + profile.name + "-b.png" + outputfile_old = os.path.join(TEMP_PATH, profile.name + "-b.png") combined_image.save(outputfile, "PNG") set_wallpaper(outputfile) if os.path.exists(outputfile_old): @@ -443,12 +443,12 @@ def set_multi_image_wallpaper(profile): combined_image.load() for i in range(len(files)): combined_image.paste(img_resized[i], DISPLAY_OFFSET_ARRAY[i]) - outputfile = TEMP_PATH + profile.name + "-a.png" + outputfile = os.path.join(TEMP_PATH, profile.name + "-a.png") if os.path.isfile(outputfile): outputfile_old = outputfile - outputfile = TEMP_PATH + profile.name + "-b.png" + outputfile = os.path.join(TEMP_PATH, profile.name + "-b.png") else: - outputfile_old = TEMP_PATH + profile.name + "-b.png" + outputfile_old = os.path.join(TEMP_PATH, profile.name + "-b.png") combined_image.save(outputfile, "PNG") set_wallpaper(outputfile) if os.path.exists(outputfile_old): From 12aac36c6564397927ba1380fb178a2c22a81afa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Sun, 17 Nov 2019 23:55:31 +0200 Subject: [PATCH 05/31] On linux move config and temp file paths into XDG_CONFIG_HOME and XDG_CACHE_HOME. --- superpaper/configuration_dialogs.py | 4 +- superpaper/data.py | 20 ++++---- superpaper/sp_logging.py | 5 +- superpaper/sp_paths.py | 75 ++++++++++++++++++++++++++++- superpaper/tray.py | 2 +- 5 files changed, 89 insertions(+), 17 deletions(-) diff --git a/superpaper/configuration_dialogs.py b/superpaper/configuration_dialogs.py index 0c7ef9f..c7be5ad 100644 --- a/superpaper/configuration_dialogs.py +++ b/superpaper/configuration_dialogs.py @@ -7,7 +7,7 @@ from data import GeneralSettingsData, ProfileData, TempProfileData, CLIProfileData, list_profiles from message_dialog import show_message_dialog from wallpaper_processing import NUM_DISPLAYS, get_display_data, change_wallpaper_job -from sp_paths import PATH, PROFILES_PATH +from sp_paths import PATH, CONFIG_PATH, PROFILES_PATH try: import wx @@ -606,7 +606,7 @@ def onSave(self, event): current_settings = GeneralSettingsData() show_help = current_settings.show_help - fname = os.path.join(PATH, "general_settings") + fname = os.path.join(CONFIG_PATH, "general_settings") general_settings_file = open(fname, "w") if self.cb_logging.GetValue(): general_settings_file.write("logging=true\n") diff --git a/superpaper/data.py b/superpaper/data.py index ec80bb4..06210bb 100644 --- a/superpaper/data.py +++ b/superpaper/data.py @@ -14,7 +14,7 @@ from message_dialog import show_message_dialog import wallpaper_processing as wpproc import sp_paths -from sp_paths import (PATH, PROFILES_PATH) +from sp_paths import (PATH, CONFIG_PATH, PROFILES_PATH, TEMP_PATH) @@ -25,7 +25,7 @@ def list_profiles(): profile_list = [] for i in range(len(files)): try: - profile_list.append(ProfileData(sp_paths.PROFILES_PATH + files[i])) + profile_list.append(ProfileData(os.path.join(sp_paths.PROFILES_PATH, files[i]))) except Exception as exep: # TODO implement proper error catching for ProfileData init msg = "There was an error when loading profile '{}'. Exiting.".format(files[i]) sp_logging.G_LOGGER.info(msg) @@ -38,7 +38,7 @@ def list_profiles(): def read_active_profile(): """Reads last active profile from file at startup.""" - fname = sp_paths.TEMP_PATH + "running_profile" + fname = os.path.join(sp_paths.TEMP_PATH, "running_profile") profname = "" profile = None if os.path.isfile(fname): @@ -50,7 +50,7 @@ def read_active_profile(): if sp_logging.DEBUG: sp_logging.G_LOGGER.info("read profile name from 'running_profile': %s", profname) - prof_file = sp_paths.PROFILES_PATH + profname + ".profile" + prof_file = os.path.join(sp_paths.PROFILES_PATH, profname + ".profile") if os.path.isfile(prof_file): profile = ProfileData(prof_file) else: @@ -68,7 +68,7 @@ def read_active_profile(): def write_active_profile(profname): """Writes active profile name to file after profile has changed.""" - fname = sp_paths.TEMP_PATH + "running_profile" + fname = os.path.join(sp_paths.TEMP_PATH, "running_profile") rp_file = open(fname, "w") rp_file.write(profname) rp_file.close() @@ -89,7 +89,7 @@ def __init__(self): def parse_settings(self): """Parse general_settings file. Create it if it doesn't exists.""" - fname = os.path.join(PATH, "general_settings") + fname = os.path.join(CONFIG_PATH, "general_settings") if os.path.isfile(fname): general_settings_file = open(fname, "r") try: @@ -106,7 +106,7 @@ def parse_settings(self): # Install exception handler sys.excepthook = sp_logging.custom_exception_handler sp_logging.FILE_HANDLER = logging.FileHandler( - "{0}/{1}.log".format(PATH, "log"), + os.path.join(TEMP_PATH, "log"), mode="w") sp_logging.G_LOGGER.addHandler(sp_logging.FILE_HANDLER) sp_logging.CONSOLE_HANDLER = logging.StreamHandler() @@ -161,7 +161,7 @@ def parse_settings(self): def save_settings(self): """Save the current state of the general settings object.""" - fname = os.path.join(PATH, "general_settings") + fname = os.path.join(CONFIG_PATH, "general_settings") general_settings_file = open(fname, "w") if self.logging: @@ -581,7 +581,7 @@ def __init__(self): def save(self): """Saves the TempProfile into a file.""" if self.name is not None: - fname = PROFILES_PATH + self.name + ".profile" + fname = os.path.join(PROFILES_PATH, self.name + ".profile") try: tpfile = open(fname, "w") except IOError: @@ -620,7 +620,7 @@ def test_save(self): """Tests whether the user input for profile settings is valid.""" valid_profile = False if self.name is not None and self.name.strip() is not "": - fname = PROFILES_PATH + self.name + ".deleteme" + fname = os.path.join(PROFILES_PATH, self.name + ".deleteme") try: testfile = open(fname, "w") testfile.close() diff --git a/superpaper/sp_logging.py b/superpaper/sp_logging.py index cb03b30..54bd094 100644 --- a/superpaper/sp_logging.py +++ b/superpaper/sp_logging.py @@ -1,8 +1,9 @@ """Logging tools for Superpaper.""" import logging +import os -import sp_paths +from sp_paths import TEMP_PATH DEBUG = False VERBOSE = False @@ -16,7 +17,7 @@ elif LOGGING: DEBUG = True G_LOGGER.setLevel(logging.INFO) - FILE_HANDLER = logging.FileHandler("{0}/{1}.log".format(sp_paths.PATH, "log"), + FILE_HANDLER = logging.FileHandler(os.path.join(TEMP_PATH, "log"), mode="w") G_LOGGER.addHandler(FILE_HANDLER) CONSOLE_HANDLER = logging.StreamHandler() diff --git a/superpaper/sp_paths.py b/superpaper/sp_paths.py index 9ae156c..d77894e 100644 --- a/superpaper/sp_paths.py +++ b/superpaper/sp_paths.py @@ -1,6 +1,7 @@ """Define paths used by Superpaper.""" import os +import platform import sys # Set path to binary / script @@ -9,8 +10,78 @@ else: PATH = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +def setup_config_path(): + """Sets up config path for settings and profiles. + + On Linux systems use XDG_CONFIG_HOME standard, i.e. + $HOME/.config/superpaper by default. + On Windows and Mac use executable portable path for now. + """ + # from sp_logging import DEBUG, G_LOGGER + + if platform.system() == "Linux": + config_path = xdg_path_setup("XDG_CONFIG_HOME", + os.path.join(os.path.expanduser("~"), + ".config") + ) + # if DEBUG: G_LOGGER.info("config path: %s", config_path) + return config_path + else: + # Windows and Mac keep the old portable config behavior for now. + config_path = PATH + return config_path + + +def setup_cache_path(): + """Sets up temp wallpaper path. + + On Linux systems use XDG_CACHE_HOME standard. + On Windows and Mac use executable portable path (PATH/temp) for now. + """ + # from sp_logging import DEBUG, G_LOGGER + + if platform.system() == "Linux": + cache_path = xdg_path_setup("XDG_CACHE_HOME", + os.path.join(os.path.expanduser("~"), + ".cache") + ) + temp_path = os.path.join(cache_path, "temp") + # if DEBUG: G_LOGGER.info("temp path: %s", temp_path) + return temp_path + else: + # Windows and Mac keep the old portable config behavior for now. + temp_path = os.path.join(PATH, "temp") + return temp_path + + +def xdg_path_setup(xdg_var, fallback_path): + """Sets up superpaper folders in the appropriate XDG paths: + + XDG_CONFIG_HOME, or fallback ~/.config/superpaper + XDG_CACHE_HOME, or fallback ~/.cache/superpaper + """ + + xdg_home = os.environ.get(xdg_var) + if xdg_home and os.path.isdir(xdg_home): + xdg_path = os.path.join(xdg_home, "superpaper") + else: + xdg_path = os.path.join(fallback_path, "superpaper") + # Check that the path exists and otherwise make it. + if os.path.isdir(xdg_path): + return xdg_path + else: + # default path didn't exist + os.mkdir(xdg_path) + return xdg_path + + # Derivative paths -TEMP_PATH = PATH + "/temp/" +TEMP_PATH = setup_cache_path() # Save adjusted wallpapers in here. if not os.path.isdir(TEMP_PATH): os.mkdir(TEMP_PATH) -PROFILES_PATH = PATH + "/profiles/" +CONFIG_PATH = setup_config_path() # Save profiles and settings here. +print(CONFIG_PATH) +PROFILES_PATH = os.path.join(CONFIG_PATH, "profiles") +print(PROFILES_PATH) +if not os.path.isdir(PROFILES_PATH): + os.mkdir(PROFILES_PATH) diff --git a/superpaper/tray.py b/superpaper/tray.py index 1dfb24c..03b4519 100644 --- a/superpaper/tray.py +++ b/superpaper/tray.py @@ -31,7 +31,7 @@ # Constants TRAY_TOOLTIP = "Superpaper" -TRAY_ICON = sp_paths.PATH + "/resources/default.png" +TRAY_ICON = os.path.join(sp_paths.PATH, "resources/default.png") From 62dfde5212d55976b36cc8febb3d1f7cf441b947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Sun, 1 Dec 2019 15:51:38 +0200 Subject: [PATCH 06/31] Improve Quick Profile Switch on KDE by using old cropped images. XFCE implementation is still WIP. Fix a bug with removing old temp files. --- superpaper/wallpaper_processing.py | 91 ++++++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 11 deletions(-) diff --git a/superpaper/wallpaper_processing.py b/superpaper/wallpaper_processing.py index bcac62c..7537455 100644 --- a/superpaper/wallpaper_processing.py +++ b/superpaper/wallpaper_processing.py @@ -584,6 +584,29 @@ def set_wallpaper_linux(outputfile): attempting to use feh to set the wallpaper.") subprocess.run(["feh", "--bg-scale", "--no-xinerama", outputfile]) +def set_wallpaper_piecewise(image_piece_list): + """ + Wallpaper setter that takes already cropped images and sets them + directly to corresponding monitors on systems where wallpapers + are set on a monitor by monitor basis. + + This is used when the quick wallpaper change conditions are met, + see quick_profile_job method, to improve performance on these + systems. + + Currently supported such systems are KDE Plasma and XFCE. + """ + pltform = platform.system() + if pltform == "Linux": + desk_env = os.environ.get("DESKTOP_SESSION") + if desk_env in ["/usr/share/xsessions/plasma", "plasma"]: + kdeplasma_actions(None, image_piece_list) + elif desk_env in ["xfce", "xubuntu"]: + # xfce_actions(image_piece_list) + print("todo xfce quick change.") + else: + pass + def special_image_cropper(outputfile): """ @@ -620,28 +643,38 @@ def remove_old_temp_files(outputfile): """ opbase = os.path.basename(outputfile) opname = os.path.splitext(opbase)[0] - # print(opname) + print(opname) oldfileid = "" if "-a" in opname: + newfileid = "-a" oldfileid = "-b" # print(oldfileid) elif "-b" in opname: + newfileid = "-b" oldfileid = "-a" # print(oldfileid) else: pass if oldfileid: # Must take care than only temps of current profile are deleted. - match_string = oldfileid + "-crop" + profilename = opname.replace(newfileid, "").strip() + match_string = profilename + oldfileid + "-crop" + match_string = match_string.strip() + print("Matching images with: '{}'".format(match_string)) for temp_file in os.listdir(TEMP_PATH): if match_string in temp_file: # print(temp_file) os.remove(os.path.join(TEMP_PATH, temp_file)) -def kdeplasma_actions(outputfile): +def kdeplasma_actions(outputfile, image_piece_list = None): """ Sets the multi monitor wallpaper on KDE. + Arguments are path to an image and an optional image piece + list when one can set the wallpaper from existing cropped + images. IF image pieces are to be used, call this method + with outputfile == None. + This is needed since KDE uses its own scripting language to set the desktop background which sets a single image on every monitor. This means that the composed image must be cut into @@ -655,7 +688,13 @@ def kdeplasma_actions(outputfile): d.currentConfigGroup = Array("Wallpaper", "org.kde.image", "General"); d.writeConfig("Image", "file://{filename}") """ - img_names = special_image_cropper(outputfile) + if outputfile: + img_names = special_image_cropper(outputfile) + elif not outputfile and image_piece_list: + print("KDE: Using image piece list!") + img_names = image_piece_list + else: + print("Huge error! KDE actions called without arguments!") sessionb = dbus.SessionBus() plasma_interface = dbus.Interface( @@ -665,13 +704,15 @@ def kdeplasma_actions(outputfile): dbus_interface="org.kde.PlasmaShell") for fname, idx in zip(img_names, range(len(img_names))): plasma_interface.evaluateScript( - script.format(index=idx, filename=fname)) + script.format(index=idx, filename=fname) + ) # Delete old images after new ones are set - remove_old_temp_files(outputfile) + if outputfile: + remove_old_temp_files(outputfile) -def xfce_actions(outputfile): +def xfce_actions(outputfile, image_piece_list = None): """ Sets the multi monitor wallpaper on XFCE. @@ -767,11 +808,39 @@ def quick_profile_job(profile): if sp_logging.DEBUG: sp_logging.G_LOGGER.info("quickswitch file lookup: %s", files) if files: - thrd = Thread(target=set_wallpaper, - args=(os.path.join(TEMP_PATH, files[0]),), - daemon=True) - thrd.start() + image_pieces = [os.path.join(TEMP_PATH, i) for i in files + if "-crop-" in i] + image_pieces.sort() + print("image pieces: ", image_pieces) + if use_image_pieces() and image_pieces: + print("use pieces, ", image_pieces) + thrd = Thread(target=set_wallpaper_piecewise, + args=(image_pieces ,), + daemon=True) + thrd.start() + else: + thrd = Thread(target=set_wallpaper, + args=(os.path.join(TEMP_PATH, files[0]),), + daemon=True) + thrd.start() else: if sp_logging.DEBUG: sp_logging.G_LOGGER.info("Old file for quickswitch was not found. %s", files) + +def use_image_pieces(): + """Determine if it improves perfomance to use existing image pieces. + + Systems that use image pieces are: KDE, XFCE. + """ + pltform = platform.system() + if pltform == "Linux": + desk_env = os.environ.get("DESKTOP_SESSION") + if desk_env in ["/usr/share/xsessions/plasma", "plasma"]: + return True + elif desk_env in ["xfce", "xubuntu"]: + return True + else: + return False + else: + return False \ No newline at end of file From 622d223ad7dd4cdfd3487eebca72950304610d2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Sun, 1 Dec 2019 22:32:10 +0200 Subject: [PATCH 07/31] Update XFCE support for piecewise quick switch. --- superpaper/wallpaper_processing.py | 33 ++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/superpaper/wallpaper_processing.py b/superpaper/wallpaper_processing.py index 7537455..ef78e08 100644 --- a/superpaper/wallpaper_processing.py +++ b/superpaper/wallpaper_processing.py @@ -643,7 +643,7 @@ def remove_old_temp_files(outputfile): """ opbase = os.path.basename(outputfile) opname = os.path.splitext(opbase)[0] - print(opname) + # print(opname) oldfileid = "" if "-a" in opname: newfileid = "-a" @@ -660,7 +660,9 @@ def remove_old_temp_files(outputfile): profilename = opname.replace(newfileid, "").strip() match_string = profilename + oldfileid + "-crop" match_string = match_string.strip() - print("Matching images with: '{}'".format(match_string)) + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Removing images matching with: '%s'", + match_string) for temp_file in os.listdir(TEMP_PATH): if match_string in temp_file: # print(temp_file) @@ -691,10 +693,12 @@ def kdeplasma_actions(outputfile, image_piece_list = None): if outputfile: img_names = special_image_cropper(outputfile) elif not outputfile and image_piece_list: - print("KDE: Using image piece list!") + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("KDE: Using image piece list!") img_names = image_piece_list else: - print("Huge error! KDE actions called without arguments!") + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Error! KDE actions called without arguments!") sessionb = dbus.SessionBus() plasma_interface = dbus.Interface( @@ -721,10 +725,19 @@ def xfce_actions(outputfile, image_piece_list = None): monitor. This means that the composed image must be cut into correct pieces that then are set to their respective displays. """ + if outputfile: + img_names = special_image_cropper(outputfile) + elif not outputfile and image_piece_list: + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("XFCE: Using image piece list!") + img_names = image_piece_list + else: + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Error! XFCE actions called without arguments!") + monitors = [] for mon_index in range(NUM_DISPLAYS): monitors.append("monitor" + str(mon_index)) - img_names = special_image_cropper(outputfile) read_prop = subprocess.Popen(["xfconf-query", "-c", @@ -752,7 +765,8 @@ def xfce_actions(outputfile, image_piece_list = None): + prop + " -s 'true'") # Delete old images after new ones are set - remove_old_temp_files(outputfile) + if outputfile: + remove_old_temp_files(outputfile) def change_wallpaper_job(profile): @@ -810,10 +824,11 @@ def quick_profile_job(profile): if files: image_pieces = [os.path.join(TEMP_PATH, i) for i in files if "-crop-" in i] - image_pieces.sort() - print("image pieces: ", image_pieces) if use_image_pieces() and image_pieces: - print("use pieces, ", image_pieces) + image_pieces.sort() + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info("Use wallpaper crop pieces: %s", + image_pieces) thrd = Thread(target=set_wallpaper_piecewise, args=(image_pieces ,), daemon=True) From 5e55463b758469a6cedc9e9cc0209d4f4990e3ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Mon, 2 Dec 2019 15:35:54 +0200 Subject: [PATCH 08/31] Make temp crop filename removal matching more exact. --- superpaper/wallpaper_processing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/superpaper/wallpaper_processing.py b/superpaper/wallpaper_processing.py index ef78e08..6755235 100644 --- a/superpaper/wallpaper_processing.py +++ b/superpaper/wallpaper_processing.py @@ -657,7 +657,7 @@ def remove_old_temp_files(outputfile): pass if oldfileid: # Must take care than only temps of current profile are deleted. - profilename = opname.replace(newfileid, "").strip() + profilename = opname.strip()[:-2] match_string = profilename + oldfileid + "-crop" match_string = match_string.strip() if sp_logging.DEBUG: From 219f00aec19a4f0697e37663875eccbfa19b502b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Mon, 2 Dec 2019 22:40:04 +0200 Subject: [PATCH 09/31] KDE scripting improvements: reduce script calls to one by looping over the files in the script. Filter out non-physical displays. Sort filtered KDE reported displays horizontally to match with the bookkeeping of the python side. --- superpaper/wallpaper_processing.py | 53 ++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/superpaper/wallpaper_processing.py b/superpaper/wallpaper_processing.py index 6755235..d7745e5 100644 --- a/superpaper/wallpaper_processing.py +++ b/superpaper/wallpaper_processing.py @@ -682,13 +682,41 @@ def kdeplasma_actions(outputfile, image_piece_list = None): monitor. This means that the composed image must be cut into correct pieces that then are set to their respective displays. """ + script = """ -var listDesktops = desktops(); -print(listDesktops); -d = listDesktops[{index}]; -d.wallpaperPlugin = "org.kde.image"; -d.currentConfigGroup = Array("Wallpaper", "org.kde.image", "General"); -d.writeConfig("Image", "file://{filename}") +// make an array of all desktops with a valid screen +var desktopArray = []; +for(var desktopIndex in desktops()) {{ + var desktop = desktops()[desktopIndex]; + if(desktop.screen != -1) {{ + desktopArray.push(desktop); + }} +}} + +// sort the array based on the (horizontal) desktop position +var i = 1; +while(i < desktopArray.length) {{ + var j = i; + while(j > 0 && screenGeometry(desktopArray[j-1].screen).left > screenGeometry(desktopArray[j].screen).left) {{ + var temp = desktopArray[j]; + desktopArray[j] = desktopArray[j-1]; + desktopArray[j-1] = temp; + j = j-1; + }} + i = i+1; +}} + +var imageFileArray = Array({imagelist}); + +// set the desired wallpaper +var k = 0; +while(k < desktopArray.length) {{ + var desktop = desktopArray[k]; + desktop.wallpaperPlugin = "org.kde.image"; + desktop.currentConfigGroup = Array("Wallpaper", "org.kde.image", "General"); + desktop.writeConfig("Image", imageFileArray[k]); + k = k+1; +}} """ if outputfile: img_names = special_image_cropper(outputfile) @@ -700,16 +728,21 @@ def kdeplasma_actions(outputfile, image_piece_list = None): if sp_logging.DEBUG: sp_logging.G_LOGGER.info("Error! KDE actions called without arguments!") + filess_img_names = [] + for fname in img_names: + filess_img_names.append("file://" + fname) + filess_img_names_str = ', '.join('"' + item + '"' for item in filess_img_names) + # print(script.format(imagelist=filess_img_names_str)) + sessionb = dbus.SessionBus() plasma_interface = dbus.Interface( sessionb.get_object( "org.kde.plasmashell", "/PlasmaShell"), dbus_interface="org.kde.PlasmaShell") - for fname, idx in zip(img_names, range(len(img_names))): - plasma_interface.evaluateScript( - script.format(index=idx, filename=fname) - ) + plasma_interface.evaluateScript( + script.format(imagelist=filess_img_names_str) + ) # Delete old images after new ones are set if outputfile: From 88ec19c09cb7f5ce132fd3d6ce5f4c7078a9a03f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Wed, 4 Dec 2019 22:45:33 +0200 Subject: [PATCH 10/31] Add requirements files. --- requirements_cli.txt | 2 ++ requirements_full_linux.txt | 5 +++++ requirements_full_windows.txt | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 requirements_cli.txt create mode 100644 requirements_full_linux.txt create mode 100644 requirements_full_windows.txt diff --git a/requirements_cli.txt b/requirements_cli.txt new file mode 100644 index 0000000..545d7f9 --- /dev/null +++ b/requirements_cli.txt @@ -0,0 +1,2 @@ +Pillow>=6.0.0 +screeninfo>=0.6.1 diff --git a/requirements_full_linux.txt b/requirements_full_linux.txt new file mode 100644 index 0000000..a638874 --- /dev/null +++ b/requirements_full_linux.txt @@ -0,0 +1,5 @@ +Pillow>=6.0.0 +screeninfo>=0.6.1 +system_hotkey>=1.0.3 +xcffib>=0.8.0 +xpybutil>=0.0.5 diff --git a/requirements_full_windows.txt b/requirements_full_windows.txt new file mode 100644 index 0000000..691e39b --- /dev/null +++ b/requirements_full_windows.txt @@ -0,0 +1,5 @@ +Pillow>=6.0.0 +screeninfo>=0.6.1 +wxPython>=4.0.4 +system_hotkey>=1.0.3 +pywin32 From 2c3b69e4027c795fb75c746c2568819d937b75ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Wed, 4 Dec 2019 23:07:18 +0200 Subject: [PATCH 11/31] Add .desktop file --- superpaper.desktop | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 superpaper.desktop diff --git a/superpaper.desktop b/superpaper.desktop new file mode 100644 index 0000000..41a89df --- /dev/null +++ b/superpaper.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Encoding=UTF-8 +Type=Application +Version=1.0 +Name=Superpaper +GenericName=Multi-monitor wallpaper manager +Exec=superpaper +Icon=superpaper +Terminal=False +Categories=Utility; From 8c951c5ac6abb03f9fecb353a91deed1729d091d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Sat, 7 Dec 2019 23:31:25 +0200 Subject: [PATCH 12/31] Move .desktop and create icon for installation. --- .../superpaper.desktop | 0 resources/superpaper.png | Bin 0 -> 1444 bytes 2 files changed, 0 insertions(+), 0 deletions(-) rename superpaper.desktop => resources/superpaper.desktop (100%) create mode 100644 resources/superpaper.png diff --git a/superpaper.desktop b/resources/superpaper.desktop similarity index 100% rename from superpaper.desktop rename to resources/superpaper.desktop diff --git a/resources/superpaper.png b/resources/superpaper.png new file mode 100644 index 0000000000000000000000000000000000000000..37f729e134c48e998ec9fa80d275d490eb5be342 GIT binary patch literal 1444 zcmZWpcQo4x9RDRDB&xQmae{cLn9-t2sTc`o)`)7)jHdPsSG|U|Mrje{jA*NrDz!!0 zGg2*T)u<5>quZuxo^|Pa_wMe!`@Zkz`}wXvKkt)lXJf$+mxKcVz>l@WIB?kG2Y8{J z`XPs;$04rpbJ+8|oQUQ1N#e|~5KDYG0C2DT0Eq0>Oyw+(N0_-rI0h3UNS&b7|L=# zYtiFK;>ex~(QZf_J1Mlb#EXpd3)IeO7(5)VYk*SwKly+qphggA9E9Mk>!F0`A~!Gy zGuVd$Phui9>+lnFF;Gdk^tP}BxJ9$hil5cMP00pqfqZf&c|xit-wO0p-%9YKsz+n4 zOxJq_eTp+k03;RH8MlzXk%1%6QNd9zN6`kNFa08rli16viFMvFla{bBd`S~fk=>j25u&gS<`*OxX+HE!N9g@d^x|l<1)h)0;BV$f{u|} zdjn8Cp`dDB`cYTFoeu^5l^is4s%^aK%x+~O^h%nE0c;>+ScD;4A^Z`H?BKRc)h(~}3BZUyvw00|ZiJMkxMI@ZICvWvGewUmMhP31__{`hWjAl^pkF>~@ z?&~tl(d9AEGwOx)094mpE0Og~AQ~18r&C{TB3Bo!?+B##y^YrQ3nC7%znM7oy5!f( zWY$+E=S6kcww_v&XV_B2e@@x87ul>jiGSySwcsoBkz7JBa*A6dLyzHDW8{4${RKA- zV$++p;!VMs4ZPQurDQ>S6vngLV~Rx+zvw>2ewvA*$c^N#Rfd$@9jP{~(u}OmV!c2v zsns%^*UFK2(N^q>hL_S;BD?z=+}&3vRp;F8%{nZrmG1{>r_I~!>}T`k&+qO$x}BQg zE)<)ikcKO5vT#_}r=FM{t-r(;wQcJppCm*T;!Uy549td}Eefdy`3=!4b;o@COTr~6 zv^U^7=D}ck{ZCBpi1sx+tuU#vhqawf7>-cC4*CU>mN$+;UcZGXOQ}xxK_s-+y2l>? z%UX4rLa|~tqhm)PRu?v$vaJTPqlmEp+5p-_IW2(b4xH^Q^M8{f&(0f7v0qo5)vTsn z{JjF_hD*^{ciaRWf~9%`Y|-ZMg=S|k2`GoPPv4W#h4c`#WO?5Dbj~fV^pA%s_xsun zDct-_d#*mF!p$TQf=9nK3%XjkK4x0DUTcxed4MJusslW*D#b*YDtbXZH)~ye)%-rW zjEiX&t!wZ(8*T81EVAO%G*?9-y(8#e3S;Z0*n#OVGt5MdomV2r(Lwh2n{9UCvYGrw z#;!JQgtvk=51Sc{KJm(5pAq4pDX=+=zXe3E!S2>-;c^1CFtazNh#hp4fR~G!Z;W6# zf$@LkCT}jI!7|T19St1}lVlVF9G#ne$34+f8i-LbWov(Y`>R+7$hVPwV7@pH8?c Date: Sun, 8 Dec 2019 00:39:53 +0200 Subject: [PATCH 13/31] Update .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b79bac4..3bbbf57 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ superpaper/__pycache__ +build/ +dist/ +superpaper.egg-info/ From c5808fa9be91c7212239e85d48e2d7d9605a1605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Sun, 8 Dec 2019 23:11:02 +0200 Subject: [PATCH 14/31] Relocate resources for installation availability. --- {resources => superpaper/resources}/default.png | Bin .../resources}/icons8-image-gallery-96-white.png | Bin .../resources}/icons8-image-gallery-96.png | Bin superpaper/resources/icons8-panorama-48-white.png | Bin 0 -> 670 bytes .../icons8-panorama-filled-96-lightgrey.png | Bin .../icons8-panorama-filled-96-white.png | Bin .../resources}/icons8-panorama-filled-96.png | Bin .../resources}/superpaper.desktop | 0 .../resources}/superpaper.png | Bin {resources => superpaper/resources}/test.png | Bin 10 files changed, 0 insertions(+), 0 deletions(-) rename {resources => superpaper/resources}/default.png (100%) rename {resources => superpaper/resources}/icons8-image-gallery-96-white.png (100%) rename {resources => superpaper/resources}/icons8-image-gallery-96.png (100%) create mode 100644 superpaper/resources/icons8-panorama-48-white.png rename {resources => superpaper/resources}/icons8-panorama-filled-96-lightgrey.png (100%) rename {resources => superpaper/resources}/icons8-panorama-filled-96-white.png (100%) rename {resources => superpaper/resources}/icons8-panorama-filled-96.png (100%) rename {resources => superpaper/resources}/superpaper.desktop (100%) rename {resources => superpaper/resources}/superpaper.png (100%) rename {resources => superpaper/resources}/test.png (100%) diff --git a/resources/default.png b/superpaper/resources/default.png similarity index 100% rename from resources/default.png rename to superpaper/resources/default.png diff --git a/resources/icons8-image-gallery-96-white.png b/superpaper/resources/icons8-image-gallery-96-white.png similarity index 100% rename from resources/icons8-image-gallery-96-white.png rename to superpaper/resources/icons8-image-gallery-96-white.png diff --git a/resources/icons8-image-gallery-96.png b/superpaper/resources/icons8-image-gallery-96.png similarity index 100% rename from resources/icons8-image-gallery-96.png rename to superpaper/resources/icons8-image-gallery-96.png diff --git a/superpaper/resources/icons8-panorama-48-white.png b/superpaper/resources/icons8-panorama-48-white.png new file mode 100644 index 0000000000000000000000000000000000000000..01664002ae242e6783ff44d5596399ba2bdda7b6 GIT binary patch literal 670 zcmV;P0%84$P)@Y)x_80-jFL~*4gZQtZ*MV%wcZkp7%}2 z%*hEvB9TZW3na}*+9%1%){=xI^BQOR=PX#1PnUpKzzd)ed@4z`=Ig7$s8Mq2uN>~$c!OULwPng+L;BrQ_eU;I9B&p)}xHV+`T0%XObZ^w6 zSsC~yX@9UsRnp^N{h?fIob;wmN_tnOf%8N5*)6FhXd3|!Aze4yc!q~CcOcrK}$mjSQi zMqUOwl8)pl-s`AHn$5$&#k|D_TJ}m>N@E~xGj|_23+!Sr`k3~44Vc7@NhX1<{+cSV z+2?Hrz0X%*A&3@kO!FQ$rfK@)8Q@Nmpw*65I)Rv?XaPG&=-1+0XuS{(lucr*JA|LO zfo_w(hr4sY7u>I|1%DLKF|!}w+HBlSz!==ECU9TUI)mYE|1MVtHl`7kG+u82FMTv{ z@)?0<)k&!fx8?PVokvLutFZwV4;D|&bk;t0(1B$Yww6c`mPXGV_07*qoM6N<$ Eg5Q8JC;$Ke literal 0 HcmV?d00001 diff --git a/resources/icons8-panorama-filled-96-lightgrey.png b/superpaper/resources/icons8-panorama-filled-96-lightgrey.png similarity index 100% rename from resources/icons8-panorama-filled-96-lightgrey.png rename to superpaper/resources/icons8-panorama-filled-96-lightgrey.png diff --git a/resources/icons8-panorama-filled-96-white.png b/superpaper/resources/icons8-panorama-filled-96-white.png similarity index 100% rename from resources/icons8-panorama-filled-96-white.png rename to superpaper/resources/icons8-panorama-filled-96-white.png diff --git a/resources/icons8-panorama-filled-96.png b/superpaper/resources/icons8-panorama-filled-96.png similarity index 100% rename from resources/icons8-panorama-filled-96.png rename to superpaper/resources/icons8-panorama-filled-96.png diff --git a/resources/superpaper.desktop b/superpaper/resources/superpaper.desktop similarity index 100% rename from resources/superpaper.desktop rename to superpaper/resources/superpaper.desktop diff --git a/resources/superpaper.png b/superpaper/resources/superpaper.png similarity index 100% rename from resources/superpaper.png rename to superpaper/resources/superpaper.png diff --git a/resources/test.png b/superpaper/resources/test.png similarity index 100% rename from resources/test.png rename to superpaper/resources/test.png From 88148a792e9391634df0b4c43b9a9e2b53749ac0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Sun, 8 Dec 2019 23:11:51 +0200 Subject: [PATCH 15/31] Add MANIFEST.in --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) create mode 100644 MANIFEST.in diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..96a0955 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include resources/superpaper.png \ No newline at end of file From 7b8651f29533da7c8cf6983f64f5d4f92d6602e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Sun, 8 Dec 2019 23:15:27 +0200 Subject: [PATCH 16/31] Make local module imports explicit. Bump version. --- superpaper/__version__.py | 2 +- superpaper/cli.py | 10 +++++----- superpaper/configuration_dialogs.py | 10 +++++----- superpaper/data.py | 10 +++++----- superpaper/sp_logging.py | 2 +- superpaper/superpaper.py | 4 ++-- superpaper/tray.py | 16 ++++++++-------- superpaper/wallpaper_processing.py | 6 +++--- 8 files changed, 30 insertions(+), 30 deletions(-) mode change 100644 => 100755 superpaper/superpaper.py diff --git a/superpaper/__version__.py b/superpaper/__version__.py index 7df837e..4b2d7e0 100644 --- a/superpaper/__version__.py +++ b/superpaper/__version__.py @@ -1,3 +1,3 @@ """Version string for Superpaper.""" -__version__ = "1.1.3-alpha1" +__version__ = "1.2a1" diff --git a/superpaper/cli.py b/superpaper/cli.py index 2afc76a..2472396 100644 --- a/superpaper/cli.py +++ b/superpaper/cli.py @@ -4,11 +4,11 @@ import os import sys -import sp_logging -from data import CLIProfileData -import wallpaper_processing as wpproc -from wallpaper_processing import get_display_data, change_wallpaper_job -from tray import tray_loop +import superpaper.sp_logging as sp_logging +from superpaper.data import CLIProfileData +import superpaper.wallpaper_processing as wpproc +from superpaper.wallpaper_processing import get_display_data, change_wallpaper_job +from superpaper.tray import tray_loop def cli_logic(): diff --git a/superpaper/configuration_dialogs.py b/superpaper/configuration_dialogs.py index c7be5ad..d4fb0e3 100644 --- a/superpaper/configuration_dialogs.py +++ b/superpaper/configuration_dialogs.py @@ -3,11 +3,11 @@ """ import os -import sp_logging -from data import GeneralSettingsData, ProfileData, TempProfileData, CLIProfileData, list_profiles -from message_dialog import show_message_dialog -from wallpaper_processing import NUM_DISPLAYS, get_display_data, change_wallpaper_job -from sp_paths import PATH, CONFIG_PATH, PROFILES_PATH +import superpaper.sp_logging as sp_logging +from superpaper.data import GeneralSettingsData, ProfileData, TempProfileData, CLIProfileData, list_profiles +from superpaper.message_dialog import show_message_dialog +from superpaper.wallpaper_processing import NUM_DISPLAYS, get_display_data, change_wallpaper_job +from superpaper.sp_paths import PATH, CONFIG_PATH, PROFILES_PATH try: import wx diff --git a/superpaper/data.py b/superpaper/data.py index 06210bb..64b3fb2 100644 --- a/superpaper/data.py +++ b/superpaper/data.py @@ -10,11 +10,11 @@ import random import sys -import sp_logging -from message_dialog import show_message_dialog -import wallpaper_processing as wpproc -import sp_paths -from sp_paths import (PATH, CONFIG_PATH, PROFILES_PATH, TEMP_PATH) +import superpaper.sp_logging as sp_logging +from superpaper.message_dialog import show_message_dialog +import superpaper.wallpaper_processing as wpproc +import superpaper.sp_paths as sp_paths +from superpaper.sp_paths import (PATH, CONFIG_PATH, PROFILES_PATH, TEMP_PATH) diff --git a/superpaper/sp_logging.py b/superpaper/sp_logging.py index 54bd094..edb8a6b 100644 --- a/superpaper/sp_logging.py +++ b/superpaper/sp_logging.py @@ -3,7 +3,7 @@ import logging import os -from sp_paths import TEMP_PATH +from superpaper.sp_paths import TEMP_PATH DEBUG = False VERBOSE = False diff --git a/superpaper/superpaper.py b/superpaper/superpaper.py old mode 100644 new mode 100755 index fac4bc8..c2e74b0 --- a/superpaper/superpaper.py +++ b/superpaper/superpaper.py @@ -10,8 +10,8 @@ import sys -from cli import cli_logic -from tray import tray_loop +from superpaper.cli import cli_logic +from superpaper.tray import tray_loop def main(): diff --git a/superpaper/tray.py b/superpaper/tray.py index 03b4519..265ad49 100644 --- a/superpaper/tray.py +++ b/superpaper/tray.py @@ -7,14 +7,14 @@ import sys from threading import Lock -from __version__ import __version__ -import sp_logging -import sp_paths -from configuration_dialogs import ConfigFrame, SettingsFrame, HelpFrame -from message_dialog import show_message_dialog -from data import (GeneralSettingsData, +from superpaper.__version__ import __version__ +import superpaper.sp_logging as sp_logging +import superpaper.sp_paths as sp_paths +from superpaper.configuration_dialogs import ConfigFrame, SettingsFrame, HelpFrame +from superpaper.message_dialog import show_message_dialog +from superpaper.data import (GeneralSettingsData, list_profiles, read_active_profile, write_active_profile) -from wallpaper_processing import (get_display_data, +from superpaper.wallpaper_processing import (get_display_data, run_profile_job, quick_profile_job, change_wallpaper_job ) @@ -31,7 +31,7 @@ # Constants TRAY_TOOLTIP = "Superpaper" -TRAY_ICON = os.path.join(sp_paths.PATH, "resources/default.png") +TRAY_ICON = os.path.join(sp_paths.PATH, "superpaper/resources/superpaper.png") diff --git a/superpaper/wallpaper_processing.py b/superpaper/wallpaper_processing.py index d7745e5..072316b 100644 --- a/superpaper/wallpaper_processing.py +++ b/superpaper/wallpaper_processing.py @@ -17,9 +17,9 @@ from PIL import Image from screeninfo import get_monitors -import sp_logging -from message_dialog import show_message_dialog -from sp_paths import TEMP_PATH +import superpaper.sp_logging as sp_logging +from superpaper.message_dialog import show_message_dialog +from superpaper.sp_paths import TEMP_PATH if platform.system() == "Windows": import ctypes From 62c8f3cff31b9e76350a6805226b12e16ac6eb82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Sun, 8 Dec 2019 23:17:08 +0200 Subject: [PATCH 17/31] Add setup.py. Beta-stage, only linux compatible. --- setup.py | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 setup.py diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..1f5248a --- /dev/null +++ b/setup.py @@ -0,0 +1,113 @@ +import os +import platform +import sys +from setuptools import setup + + +def read_version(): + with open("superpaper/__version__.py") as verfile: + verlines = verfile.readlines() + for line in verlines: + if "__version__" in line: + ver_str = line.split("=")[1].strip().replace('"',"") + print(ver_str) + return ver_str + print("Version not found, exitting install.") + sys.exit(1) + +def establish_config_dir(): + """Sets up config path for settings and profiles. + + On Linux systems use XDG_CONFIG_HOME standard, i.e. + $HOME/.config/superpaper by default. + On Windows and Mac use executable portable path for now. + """ + if platform.system() == "Linux": + config_path = xdg_path_setup("XDG_CONFIG_HOME", + os.path.join(os.path.expanduser("~"), + ".config") + ) + return config_path + else: + print("This setup.py has been designed for Linux only. Apologies for any inconvenience.") + sys.exit(1) + +def xdg_path_setup(xdg_var, fallback_path): + """Sets up superpaper folders in the appropriate XDG paths: + + XDG_CONFIG_HOME, or fallback ~/.config/superpaper + XDG_CACHE_HOME, or fallback ~/.cache/superpaper + """ + + xdg_home = os.environ.get(xdg_var) + if xdg_home and os.path.isdir(xdg_home): + xdg_path = os.path.join(xdg_home, "superpaper") + else: + xdg_path = os.path.join(fallback_path, "superpaper") + # Check that the path exists and otherwise make it. + if os.path.isdir(xdg_path): + return xdg_path + else: + # default path didn't exist + os.mkdir(xdg_path) + return xdg_path + + +def test_import(packaname, humanname): + try: + __import__(packaname) + except ImportError: + print("{} import failed; refer to the install instructions.".format(humanname)) + sys.exit(1) + +if __name__ == "__main__": + test_import("wx", "wxPython") + # read_version() + # print(establish_config_dir()) + # sys.exit(0) + + setup( + name="superpaper", + version=read_version(), + author="Henri Hänninen", + description="Cross-platform wallpaper manager that focuses on " + "multi-monitor support. Features include ppi corrections, " + "keyboard shortcuts, slideshow.", + long_description="todo", + url="https://github.com/hhannine/superpaper", + + classifiers=[ + "Development Status :: 4 - Beta", + "Environment :: X11 Applications", + # "Environment :: Win32", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: MIT Licence", + "Natural Language :: English", + "Operating System :: POSIX :: Linux", + # "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3.5", + "Topic :: Utilities", + ], + keywords="dual-monitor multi-monitor wallpaper background manager", + license="MIT", + + install_requires=[ + "Pillow>=6.0.0", + "screeninfo>=0.6.1", + ], + packages=["superpaper"], + entry_points={ + "console_scripts": ["superpaper = superpaper.superpaper:main"] + }, # On windows create a 'gui_scripts' entry + # include_package_data=True, + package_data={ + "superpaper": ["resources/superpaper.png"] + }, + data_files=[ + ("share/applications", ["superpaper/resources/superpaper.desktop"]), + ("share/icons/hicolor/256x256/apps", ["superpaper/resources/superpaper.png"]), + # ("resources", ["resources/superpaper.png"]), + (os.path.join(establish_config_dir(),"profiles"), ["profiles/example.profile", "profiles/example_multi.profile"]) + ] + + ) From c037b2d67e38fffce102e1e151c4a312432abd1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Mon, 9 Dec 2019 20:55:27 +0200 Subject: [PATCH 18/31] Fix full requirements --- requirements_full_linux.txt | 2 +- requirements_full_windows.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_full_linux.txt b/requirements_full_linux.txt index a638874..edc9b08 100644 --- a/requirements_full_linux.txt +++ b/requirements_full_linux.txt @@ -1,5 +1,5 @@ Pillow>=6.0.0 screeninfo>=0.6.1 -system_hotkey>=1.0.3 +system_hotkey>=1.0 xcffib>=0.8.0 xpybutil>=0.0.5 diff --git a/requirements_full_windows.txt b/requirements_full_windows.txt index 691e39b..bb240dd 100644 --- a/requirements_full_windows.txt +++ b/requirements_full_windows.txt @@ -1,5 +1,5 @@ Pillow>=6.0.0 screeninfo>=0.6.1 wxPython>=4.0.4 -system_hotkey>=1.0.3 +system_hotkey>=1.0 pywin32 From aece35a6c301acd3de14c938dc7771b91d9ad2d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Mon, 9 Dec 2019 23:05:46 +0200 Subject: [PATCH 19/31] Fix example profile installation --- MANIFEST.in | 4 +- setup.py | 43 +------------------ .../profiles}/example.profile | 2 +- .../profiles}/example_multi.profile | 2 +- superpaper/sp_paths.py | 8 ++++ 5 files changed, 15 insertions(+), 44 deletions(-) rename {profiles => superpaper/profiles}/example.profile (83%) rename {profiles => superpaper/profiles}/example_multi.profile (80%) diff --git a/MANIFEST.in b/MANIFEST.in index 96a0955..94d423e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,3 @@ -include resources/superpaper.png \ No newline at end of file +include resources/superpaper.png +include profiles/* + diff --git a/setup.py b/setup.py index 1f5248a..a627add 100644 --- a/setup.py +++ b/setup.py @@ -15,43 +15,6 @@ def read_version(): print("Version not found, exitting install.") sys.exit(1) -def establish_config_dir(): - """Sets up config path for settings and profiles. - - On Linux systems use XDG_CONFIG_HOME standard, i.e. - $HOME/.config/superpaper by default. - On Windows and Mac use executable portable path for now. - """ - if platform.system() == "Linux": - config_path = xdg_path_setup("XDG_CONFIG_HOME", - os.path.join(os.path.expanduser("~"), - ".config") - ) - return config_path - else: - print("This setup.py has been designed for Linux only. Apologies for any inconvenience.") - sys.exit(1) - -def xdg_path_setup(xdg_var, fallback_path): - """Sets up superpaper folders in the appropriate XDG paths: - - XDG_CONFIG_HOME, or fallback ~/.config/superpaper - XDG_CACHE_HOME, or fallback ~/.cache/superpaper - """ - - xdg_home = os.environ.get(xdg_var) - if xdg_home and os.path.isdir(xdg_home): - xdg_path = os.path.join(xdg_home, "superpaper") - else: - xdg_path = os.path.join(fallback_path, "superpaper") - # Check that the path exists and otherwise make it. - if os.path.isdir(xdg_path): - return xdg_path - else: - # default path didn't exist - os.mkdir(xdg_path) - return xdg_path - def test_import(packaname, humanname): try: @@ -91,6 +54,7 @@ def test_import(packaname, humanname): keywords="dual-monitor multi-monitor wallpaper background manager", license="MIT", + # python_requires="~=3.5", install_requires=[ "Pillow>=6.0.0", "screeninfo>=0.6.1", @@ -99,15 +63,12 @@ def test_import(packaname, humanname): entry_points={ "console_scripts": ["superpaper = superpaper.superpaper:main"] }, # On windows create a 'gui_scripts' entry - # include_package_data=True, package_data={ - "superpaper": ["resources/superpaper.png"] + "superpaper": ["resources/superpaper.png", "profiles/example.profile", "profiles/example_multi.profile"] }, data_files=[ ("share/applications", ["superpaper/resources/superpaper.desktop"]), ("share/icons/hicolor/256x256/apps", ["superpaper/resources/superpaper.png"]), - # ("resources", ["resources/superpaper.png"]), - (os.path.join(establish_config_dir(),"profiles"), ["profiles/example.profile", "profiles/example_multi.profile"]) ] ) diff --git a/profiles/example.profile b/superpaper/profiles/example.profile similarity index 83% rename from profiles/example.profile rename to superpaper/profiles/example.profile index 65096c0..62b34bf 100644 --- a/profiles/example.profile +++ b/superpaper/profiles/example.profile @@ -6,5 +6,5 @@ sortmode=shuffle offsets=0,0;0,0 bezels=9.5;7 diagonal_inches=27;25 -hotkey=control+super+shift+x +hotkey=control+super+shift+h display1paths=/home;/ \ No newline at end of file diff --git a/profiles/example_multi.profile b/superpaper/profiles/example_multi.profile similarity index 80% rename from profiles/example_multi.profile rename to superpaper/profiles/example_multi.profile index 4d81016..0c522bf 100644 --- a/profiles/example_multi.profile +++ b/superpaper/profiles/example_multi.profile @@ -3,6 +3,6 @@ spanmode=multi slideshow=true delay=120 sortmode=shuffle -hotkey=control+super+shift+z +hotkey=control+super+shift+g display1paths=/home;/ display2paths=/home \ No newline at end of file diff --git a/superpaper/sp_paths.py b/superpaper/sp_paths.py index d77894e..7e6b42c 100644 --- a/superpaper/sp_paths.py +++ b/superpaper/sp_paths.py @@ -2,6 +2,7 @@ import os import platform +import shutil import sys # Set path to binary / script @@ -9,6 +10,7 @@ PATH = os.path.dirname(os.path.dirname(os.path.realpath(sys.executable))) else: PATH = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) +print(PATH) def setup_config_path(): """Sets up config path for settings and profiles. @@ -84,4 +86,10 @@ def xdg_path_setup(xdg_var, fallback_path): PROFILES_PATH = os.path.join(CONFIG_PATH, "profiles") print(PROFILES_PATH) if not os.path.isdir(PROFILES_PATH): + # Profiles folder didn't exist, so create it and copy example + # profiles in there assuming it's a first time run. os.mkdir(PROFILES_PATH) + example_src = os.path.join(PATH, "superpaper/profiles") + if os.path.isdir(example_src): + for example_file in os.listdir(example_src): + shutil.copy(os.path.join(example_src, example_file), PROFILES_PATH) From 7e98623c32c80fd3d9045ff3c38a018957c0478d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Tue, 10 Dec 2019 12:39:11 +0200 Subject: [PATCH 20/31] Rework bezel correction feature. - Use maximal PPI to compute correct px sizes of bezels. - Input is now the combined thickness of the two adjacent bezels and a possible gap in between. So for 2 displays give 1 value, 3 displays 2 values etc. --- superpaper/__version__.py | 2 +- superpaper/data.py | 48 ++++++++++++++++++++++++++++----------- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/superpaper/__version__.py b/superpaper/__version__.py index 4b2d7e0..565da02 100644 --- a/superpaper/__version__.py +++ b/superpaper/__version__.py @@ -1,3 +1,3 @@ """Version string for Superpaper.""" -__version__ = "1.2a1" +__version__ = "1.2a2" diff --git a/superpaper/data.py b/superpaper/data.py index 64b3fb2..de6c35a 100644 --- a/superpaper/data.py +++ b/superpaper/data.py @@ -366,9 +366,6 @@ def compute_relative_densities(self): if self.ppi_array: max_density = max(self.ppi_array) else: - print("Can't pick a max from empty list.") - print("ppi_array:", self.ppi_array) - print("RES_ARR", wpproc.RESOLUTION_ARRAY) sp_logging.G_LOGGER("Couldn't compute relative densities: %s, %s", self.name, self.file) return 1 for ppi in self.ppi_array: @@ -379,24 +376,49 @@ def compute_relative_densities(self): def compute_bezel_px_offsets(self): """Computes bezel sizes in pixels based on display PPIs.""" + if self.ppi_array: + max_ppi = max(self.ppi_array) + else: + sp_logging.G_LOGGER("Couldn't compute relative densities: %s, %s", self.name, self.file) + return 1 + + bez_px_offs=[0] # never offset 1st disp, anchor to it. inch_per_mm = 1.0 / 25.4 - for bez_mm, ppi in zip(self.bezels, self.ppi_array): - self.bezel_px_offsets.append( - round(float(ppi) * inch_per_mm * bez_mm)) + for bez_mm in self.bezels: + bez_px_offs.append( + round(float(max_ppi) * inch_per_mm * bez_mm) + ) if sp_logging.DEBUG: sp_logging.G_LOGGER.info( "Bezel px calculation: initial manual offset: %s, \ and bezel pixels: %s", self.manual_offsets, - self.bezel_px_offsets) + bez_px_offs) + if len(bez_px_offs) < wpproc.NUM_DISPLAYS: + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info( + "Bezel px calculation: Too few bezel mm values given! " + "Appending zeros." + ) + while (len(bez_px_offs) < wpproc.NUM_DISPLAYS): + bez_px_offs.append(0) + elif len(bez_px_offs) > wpproc.NUM_DISPLAYS: + if sp_logging.DEBUG: + sp_logging.G_LOGGER.info( + "Bezel px calculation: Got more bezel mm values than expected!" + ) + # Currently ignore list tail if there are too many bezel values # Add these horizontal offsets to manual_offsets: # Avoid offsetting the leftmost anchored display i==0 - # -1 since last display doesn't have a next display. - for i in range(len(self.bezel_px_offsets) - 1): - self.manual_offsets[i + 1] = (self.manual_offsets[i + 1][0] + - self.bezel_px_offsets[i + 1] + - self.bezel_px_offsets[i], - self.manual_offsets[i + 1][1]) + for i in range(1, min(len(bez_px_offs), wpproc.NUM_DISPLAYS)): + # Add previous offsets to ones further away to the right. + # Each display needs to be offset by the given bezel relative to + # the display to its left, which can be shifted relative to + # the anchor. + bez_px_offs[i]+=bez_px_offs[i-1] + self.manual_offsets[i] = (self.manual_offsets[i][0] + bez_px_offs[i], + self.manual_offsets[i][1]) + self.bezel_px_offsets = bez_px_offs if sp_logging.DEBUG: sp_logging.G_LOGGER.info( "Bezel px calculation: resulting combined manual offset: %s", From 2a34b81a137218a6e402b8a5818d4891f073bf4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Tue, 10 Dec 2019 21:32:29 +0200 Subject: [PATCH 21/31] Update setup.py, fix paths. --- MANIFEST.in | 1 + setup.py | 21 +++++++++++++++------ superpaper/configuration_dialogs.py | 2 +- superpaper/tray.py | 8 ++++---- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 94d423e..83ea7d4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ include resources/superpaper.png +include resources/test.png include profiles/* diff --git a/setup.py b/setup.py index a627add..6841319 100644 --- a/setup.py +++ b/setup.py @@ -25,9 +25,10 @@ def test_import(packaname, humanname): if __name__ == "__main__": test_import("wx", "wxPython") - # read_version() - # print(establish_config_dir()) - # sys.exit(0) + + with open(os.path.join(os.path.abspath(os.path.dirname(__file__)), 'README.md'), + encoding='utf-8') as f: + long_description = f.read() setup( name="superpaper", @@ -36,7 +37,7 @@ def test_import(packaname, humanname): description="Cross-platform wallpaper manager that focuses on " "multi-monitor support. Features include ppi corrections, " "keyboard shortcuts, slideshow.", - long_description="todo", + long_description=long_description, url="https://github.com/hhannine/superpaper", classifiers=[ @@ -58,13 +59,21 @@ def test_import(packaname, humanname): install_requires=[ "Pillow>=6.0.0", "screeninfo>=0.6.1", + "system_hotkey>=1.0.3", + "xcffib>=0.8.0", + "xpybutil>=0.0.5" ], packages=["superpaper"], entry_points={ "console_scripts": ["superpaper = superpaper.superpaper:main"] - }, # On windows create a 'gui_scripts' entry + # "gui_scripts": ["superpaper = superpaper.superpaper:main"] # for possible future windows install support. + }, package_data={ - "superpaper": ["resources/superpaper.png", "profiles/example.profile", "profiles/example_multi.profile"] + "superpaper": ["resources/superpaper.png", + "resources/test.png", + "profiles/example.profile", + "profiles/example_multi.profile" + ] }, data_files=[ ("share/applications", ["superpaper/resources/superpaper.desktop"]), diff --git a/superpaper/configuration_dialogs.py b/superpaper/configuration_dialogs.py index d4fb0e3..e66d61d 100644 --- a/superpaper/configuration_dialogs.py +++ b/superpaper/configuration_dialogs.py @@ -406,7 +406,7 @@ def onSave(self, event): def onAlignTest(self, event): """Align test, takes alignment settings from open profile and sets a test image wp.""" # Use the settings currently written out in the fields! - testimage = [os.path.join(PATH, "resources/test.png")] + testimage = [os.path.join(PATH, "superpaper/resources/test.png")] if not os.path.isfile(testimage[0]): print(testimage) msg = "Test image not found in {}.".format(testimage) diff --git a/superpaper/tray.py b/superpaper/tray.py index 265ad49..6978246 100644 --- a/superpaper/tray.py +++ b/superpaper/tray.py @@ -266,20 +266,20 @@ def on_left_down(self, *event): sp_logging.G_LOGGER.info('Tray icon was left-clicked.') def open_config(self, event): - """Opens Superpaper base folder, PATH.""" + """Opens Superpaper config folder, CONFIG_PATH.""" if platform.system() == "Windows": try: - os.startfile(sp_paths.PATH) + os.startfile(sp_paths.CONFIG_PATH) except BaseException: show_message_dialog("There was an error trying to open the config folder.") elif platform.system() == "Darwin": try: - subprocess.check_call(["open", sp_paths.PATH]) + subprocess.check_call(["open", sp_paths.CONFIG_PATH]) except subprocess.CalledProcessError: show_message_dialog("There was an error trying to open the config folder.") else: try: - subprocess.check_call(['xdg-open', sp_paths.PATH]) + subprocess.check_call(['xdg-open', sp_paths.CONFIG_PATH]) except subprocess.CalledProcessError: show_message_dialog("There was an error trying to open the config folder.") From 82bae5a10018d22d604deffc796c8029ceebff83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Tue, 10 Dec 2019 22:22:22 +0200 Subject: [PATCH 22/31] Update help, readme for bezel feature change. --- README.md | 4 ++-- superpaper/configuration_dialogs.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4eef715..31daf5f 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,8 @@ Accepted values and their explanations are: - offsets - horizontal,vertical pixel offsets with offsets of different monitors separated by ";" - bezels - - List of monitor bezel thicknesses in millimeters, floats are accepted, values separated by ";". - - Measure at the edges where monitor sides meet. A possible gap can be included in the values given. + - List of adjacent monitor bezel pairs' thicknesses in millimeters, i.e. "bezel+gap+bezel", floats are accepted, values separated by ";". + - Measure adjacent bezels and add them together with a possible gap to get a combined thickness. One value for 2 monitors, two values for 3 and so on. - diagonal_inches - List of monitor diagonals in inches. Used for computing display pixel densities (PPI). - hotkey diff --git a/superpaper/configuration_dialogs.py b/superpaper/configuration_dialogs.py index e66d61d..8d265d6 100644 --- a/superpaper/configuration_dialogs.py +++ b/superpaper/configuration_dialogs.py @@ -707,7 +707,9 @@ def __init__(self, parent): "Bezels": Bezel correction for "Single" spanmode wallpaper. Use this if you want the image to continue behind the bezels, - like a scenery does behind a window frame. + like a scenery does behind a window frame. The expected + values are the combined widths in millimeters of the + two adjacent monitor bezels including a possible gap. "Hotkey": An optional key combination to apply/start the profile. Supports up to 3 modifiers and a key. Valid modifiers From fec5463cf4c2ac1f740b685a0b2596f68c865872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= <47873564+hhannine@users.noreply.github.com> Date: Sun, 15 Dec 2019 13:59:29 +0200 Subject: [PATCH 23/31] Add tooling and resources for Windows builds. -wrapper for pyinstaller -.ico for the executable --- .gitignore | 2 ++ pyinstaller_wrapper.py | 9 +++++++ superpaper/resources/superpaper.ico | Bin 0 -> 28862 bytes windows-tooling/make-pyinstaller-build.py | 28 ++++++++++++++++++++++ 4 files changed, 39 insertions(+) create mode 100644 pyinstaller_wrapper.py create mode 100644 superpaper/resources/superpaper.ico create mode 100644 windows-tooling/make-pyinstaller-build.py diff --git a/.gitignore b/.gitignore index 3bbbf57..7ddc2e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ +__pycache__/ superpaper/__pycache__ build/ dist/ superpaper.egg-info/ +*.spec diff --git a/pyinstaller_wrapper.py b/pyinstaller_wrapper.py new file mode 100644 index 0000000..44c264b --- /dev/null +++ b/pyinstaller_wrapper.py @@ -0,0 +1,9 @@ +""" +Simple wrapper that makes the module structure of Superpaper +compatible with PyInstaller builds. +""" + +from superpaper.superpaper import main + +if __name__ == '__main__': + main() diff --git a/superpaper/resources/superpaper.ico b/superpaper/resources/superpaper.ico new file mode 100644 index 0000000000000000000000000000000000000000..005e7af17aa905381fa0062f7d8e34bdb480870d GIT binary patch literal 28862 zcmeHPO-L169G}}Q6br?T8XDH3o>H4|QQ=Enw386&V-rM+&_!4ek%FYBk1*V6t`ro+ zq@6|f3e1*`(;P~h3KXYK9n6%`fHXfz%5H=-t}fgosttZBX2 z+V-MmhB%rDnoy3S0_q+bn+djB4RJIRG@%?t1=Kw>HWO^M z8scasXhJ!P3aEQ%Y$n)hHN??O(1dan6;SukSebq0$`xD@<2&2?_wQvs>FUSY1%Lq_ zKU0q$>+}wQQ#tp1trbMn!pFZ8cf4{4%>-6c<>V<-9 z*RJWakWrBJ>t_b*!-o&c^3Z}fI{MWBv8-e7U zWtnCDT)1fyU;kv8X+OSTKYH|Na&j`s8$doLbdC{4rDJp9KAQFQ_3IWI7#LuguV25u zuCvU+--OOFk|me*bKyRkA3l62E-sekM}JdO6HR;aV7PD{g<<(Kty;r^O; z@7|S7_c)z$&r#sQ6E_59coOy}Gof>g66RNQa^b$47cX8Et6cn%hEbZ*4b$8om_bGO{@@N#@Ek-MN~{rB0@Z< z_T|eL{XCkUVj*>pX4*jAIyyQykSUzdIpkcSb(>sxTAEluuC1-5P*XUebBt&e9h(bJ zR}+V%clDau37uo4qY3rl!qeEqeKdS!O8JD&F{0dcY%V;#P24`ijnP0c=x9QH0tFMo z*r~oW}xU@%%RrkU zJ)mNmFogb~cIeO{&;;2K!n2U90#*U5fK|XMU=^?mSOu&CRspL(Mk(N3z-Ee(w?-5{ zI}us4@!p|R%^J%N>?pG*T-#}L8>>#-&T{P;W;>;^*mvwK?B$k7ySv{o+qE{t{{7>e zHE!L=j9WCTJ#GUo0K{!4w&VaRnjDulm&Q&yKiuJL`91|F|jQd7L)KEz9h`oi@+@(`f?))?eaOd)gK`rJfB)fWv&ic81s! y|JzYw$3K{Oag4iL_-(Q;;lo(tPBm|HKO^?p(R~%(?)DuV>Ap?(dFBzC|NjGL-XsA4 literal 0 HcmV?d00001 diff --git a/windows-tooling/make-pyinstaller-build.py b/windows-tooling/make-pyinstaller-build.py new file mode 100644 index 0000000..43343e3 --- /dev/null +++ b/windows-tooling/make-pyinstaller-build.py @@ -0,0 +1,28 @@ +import os +import sys + +def wrap_run(arg_list): + print(" ".join(arg_list)) + os.system(" ".join(arg_list)) + print("Wrapper: Build finished.") + +def main(): + if len(sys.argv) == 2: + if sys.argv[1] == "testing": + cmd = ["pyinstaller", "--onefile", + "--name", "superpaper", + "-i", r".\superpaper\resources\superpaper.ico", + r".\pyinstaller_wrapper.py"] + wrap_run(cmd) + elif sys.argv[1] == "dist": + cmd = ["pyinstaller", "--onefile", "--noconsole", + "--name", "superpaper", + "-i", r".\superpaper\resources\superpaper.ico", + r".\pyinstaller_wrapper.py"] + wrap_run(cmd) + else: + print("A type of build must be passed as the only argument: 'testing' or 'dist'.") + + +if __name__ == '__main__': + main() From 3ddafb09cadd4e0db820a88e9f45835e00223d32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= <47873564+hhannine@users.noreply.github.com> Date: Tue, 17 Dec 2019 22:44:10 +0200 Subject: [PATCH 24/31] Start work on Windows release tooling WIP --- .gitignore | 1 + windows-tooling/inno-setup-script.iss | 53 ++++++++++++++++ windows-tooling/make-windows-release.py | 80 +++++++++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 windows-tooling/inno-setup-script.iss create mode 100644 windows-tooling/make-windows-release.py diff --git a/.gitignore b/.gitignore index 7ddc2e5..eea19f5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ __pycache__/ superpaper/__pycache__ build/ dist/ +releases/ superpaper.egg-info/ *.spec diff --git a/windows-tooling/inno-setup-script.iss b/windows-tooling/inno-setup-script.iss new file mode 100644 index 0000000..5ac7c30 --- /dev/null +++ b/windows-tooling/inno-setup-script.iss @@ -0,0 +1,53 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define MyAppName "Superpaper" +#define MyAppVersion "1.2.0" +#define MyAppPublisher "Henri Hänninen" +#define MyAppURL "https://github.com/hhannine/superpaper/" +#define MyAppExeName "superpaper.exe" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{3E097BBB-045C-4E42-AF0C-918C5AB359BB} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +;AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppName} +AllowNoIcons=yes +LicenseFile=C:\Users\hana_\Documents\GitHub\Superpaper\LICENSE +; Uncomment the following line to run in non administrative install mode (install for current user only.) +;PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog +OutputDir=C:\Users\hana_\Documents\GitHub\Superpaper\releases\1.2a2 +OutputBaseFilename=superpaper_win_installer +SetupIconFile=C:\Users\hana_\Documents\GitHub\Superpaper\superpaper\resources\superpaper.ico +Compression=lzma +SolidCompression=yes +WizardStyle=modern + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +Source: "C:\Users\hana_\Documents\GitHub\Superpaper\dist\superpaper.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "C:\Users\hana_\Documents\GitHub\Superpaper\releases\1.2a2\superpaper-portable\profiles\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "C:\Users\hana_\Documents\GitHub\Superpaper\releases\1.2a2\superpaper-portable\superpaper\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + diff --git a/windows-tooling/make-windows-release.py b/windows-tooling/make-windows-release.py new file mode 100644 index 0000000..2db3f98 --- /dev/null +++ b/windows-tooling/make-windows-release.py @@ -0,0 +1,80 @@ +import os +import shutil +import subprocess +import sys + +from distutils.dir_util import copy_tree + +SRCPATH = os.path.realpath("./superpaper") +DISTPATH = os.path.realpath("./releases/") + +def read_version(): + with open("superpaper/__version__.py") as verfile: + verlines = verfile.readlines() + for line in verlines: + if "__version__" in line: + ver_str = line.split("=")[1].strip().replace('"',"") + print("Found version: %s" % ver_str) + return ver_str + print("Version not found, exitting install.") + sys.exit(1) + +def make_portable(dst_path): + portpath = os.path.join(dst_path, "superpaper-portable") + portres = os.path.join(portpath, "superpaper/resources") + portprof = os.path.join(portpath, "profiles") + portexec = os.path.join(portpath, "superpaper") + # copy resources + copy_tree(os.path.join(SRCPATH, "resources"), portres) + # copy profiles + copy_tree(os.path.join(SRCPATH, "profiles"), portprof) + # copy executable + shutil.copy2("./dist/superpaper.exe", portexec) + # zip it + shutil.make_archive(os.path.join(dst_path, "superpaper-portable"), 'zip', portpath) + +def update_inno_script(version_str, output_path): + # update both VERSTION_STR and OUTPUT PATH + return 0 + +def run_inno_script(): + inno_cmd = "iscc scriptfile.iss" + os.system(inno_cmd) + +def main(): + if not os.path.isdir(DISTPATH): + os.mkdir(DISTPATH) + print("Made dir %s" % DISTPATH) + version = read_version() + dist_path = os.path.join(DISTPATH, version) + if not os.path.isdir(dist_path): + os.mkdir(dist_path) + print("Made dir %s" % dist_path) + + # run pyinstaller build + # os.system("python make-pyinstaller-build.py dist") + try: + subprocess.call(["python", "./windows-tooling/make-pyinstaller-build.py", "dist"]) + except: + print("\nPyinstaller build FAILED.\n") + exit(0) + print("\nPyinstaller build done.\n") + + # copy binary, resources and examples into package structure + make_portable(dist_path) + + print("Portable package build done.") + exit(0) + + # update inno script + update_inno_script(version, dist_path) + + # run inno installer compilation + run_inno_script() + + # done + print("Release built and packaged.") + + +if __name__ == '__main__': + main() From 7962f9eb603de51581f2db984dc817dab1d26708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= <47873564+hhannine@users.noreply.github.com> Date: Wed, 18 Dec 2019 22:05:48 +0200 Subject: [PATCH 25/31] Make Inno Setup script paths relative, add option to start installation on startup --- windows-tooling/inno-setup-script.iss | 28 +++++++++++++++---------- windows-tooling/make-windows-release.py | 4 ++++ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/windows-tooling/inno-setup-script.iss b/windows-tooling/inno-setup-script.iss index 5ac7c30..c3efba4 100644 --- a/windows-tooling/inno-setup-script.iss +++ b/windows-tooling/inno-setup-script.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "Superpaper" -#define MyAppVersion "1.2.0" +#define MyAppVersion "1.2a2" #define MyAppPublisher "Henri Hänninen" #define MyAppURL "https://github.com/hhannine/superpaper/" #define MyAppExeName "superpaper.exe" @@ -21,13 +21,13 @@ AppUpdatesURL={#MyAppURL} DefaultDirName={autopf}\{#MyAppName} DefaultGroupName={#MyAppName} AllowNoIcons=yes -LicenseFile=C:\Users\hana_\Documents\GitHub\Superpaper\LICENSE +LicenseFile=..\LICENSE ; Uncomment the following line to run in non administrative install mode (install for current user only.) -;PrivilegesRequired=lowest -PrivilegesRequiredOverridesAllowed=dialog -OutputDir=C:\Users\hana_\Documents\GitHub\Superpaper\releases\1.2a2 +PrivilegesRequired=admin +;PrivilegesRequiredOverridesAllowed=dialog +OutputDir=..\releases\{#MyAppVersion} OutputBaseFilename=superpaper_win_installer -SetupIconFile=C:\Users\hana_\Documents\GitHub\Superpaper\superpaper\resources\superpaper.ico +SetupIconFile=..\superpaper\resources\superpaper.ico Compression=lzma SolidCompression=yes WizardStyle=modern @@ -37,16 +37,22 @@ Name: "english"; MessagesFile: "compiler:Default.isl" [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +Name: "startupicon"; Description: "Start application with Windows"; GroupDescription: "{cm:AdditionalIcons}" [Files] -Source: "C:\Users\hana_\Documents\GitHub\Superpaper\dist\superpaper.exe"; DestDir: "{app}"; Flags: ignoreversion -Source: "C:\Users\hana_\Documents\GitHub\Superpaper\releases\1.2a2\superpaper-portable\profiles\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs -Source: "C:\Users\hana_\Documents\GitHub\Superpaper\releases\1.2a2\superpaper-portable\superpaper\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "..\dist\superpaper.exe"; DestDir: "{app}\superpaper"; Flags: ignoreversion +Source: "..\releases\innostub\profiles\*"; DestDir: "{app}\profiles"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "..\releases\innostub\superpaper\*"; DestDir: "{app}\superpaper"; Flags: ignoreversion recursesubdirs createallsubdirs ; NOTE: Don't use "Flags: ignoreversion" on any shared system files [Icons] -Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" -Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon +Name: "{group}\{#MyAppName}"; Filename: "{app}\superpaper\{#MyAppExeName}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\superpaper\{#MyAppExeName}"; Tasks: desktopicon +;Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\superpaper\{#MyAppExeName}"; Tasks: startupicon +;Name: "{userstartup}\{#MyAppName}"; Filename: "{app}\superpaper\{#MyAppExeName}" ; system-wide startup + +[Registry] +Root: HKLM; Subkey: "SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\superpaper\{#MyAppExeName}"""; Flags: uninsdeletevalue; Tasks: startupicon [Run] Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent diff --git a/windows-tooling/make-windows-release.py b/windows-tooling/make-windows-release.py index 2db3f98..2c6dc7e 100644 --- a/windows-tooling/make-windows-release.py +++ b/windows-tooling/make-windows-release.py @@ -7,6 +7,7 @@ SRCPATH = os.path.realpath("./superpaper") DISTPATH = os.path.realpath("./releases/") +INNO_STUB = os.path.realpath("./releases/innostub") def read_version(): with open("superpaper/__version__.py") as verfile: @@ -28,6 +29,9 @@ def make_portable(dst_path): copy_tree(os.path.join(SRCPATH, "resources"), portres) # copy profiles copy_tree(os.path.join(SRCPATH, "profiles"), portprof) + # copy exe-less structure to be used by innosetup + copy_tree(portpath, INNO_STUB) + # copy executable shutil.copy2("./dist/superpaper.exe", portexec) # zip it From c1dae35e724f681273c7e693308313e245c90c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= <47873564+hhannine@users.noreply.github.com> Date: Thu, 19 Dec 2019 10:31:30 +0200 Subject: [PATCH 26/31] Fix working directory paths on windows installation. Update examples. -Add Windows specific example profiles to installation resources. -Update example profiles to reflect the bezel correction feature changes. --- superpaper/__version__.py | 2 +- superpaper/profiles-win/example.profile | 10 +++++ superpaper/profiles-win/example_multi.profile | 8 ++++ superpaper/profiles/example.profile | 4 +- superpaper/sp_paths.py | 39 +++++++++++++++++-- windows-tooling/inno-setup-script.iss | 6 +-- windows-tooling/make-windows-release.py | 2 +- 7 files changed, 60 insertions(+), 11 deletions(-) create mode 100644 superpaper/profiles-win/example.profile create mode 100644 superpaper/profiles-win/example_multi.profile diff --git a/superpaper/__version__.py b/superpaper/__version__.py index 565da02..bd312c7 100644 --- a/superpaper/__version__.py +++ b/superpaper/__version__.py @@ -1,3 +1,3 @@ """Version string for Superpaper.""" -__version__ = "1.2a2" +__version__ = "1.2a3" diff --git a/superpaper/profiles-win/example.profile b/superpaper/profiles-win/example.profile new file mode 100644 index 0000000..9e479b4 --- /dev/null +++ b/superpaper/profiles-win/example.profile @@ -0,0 +1,10 @@ +name=example +spanmode=single +slideshow=true +delay=120 +sortmode=shuffle +offsets=0,0;0,0 +bezels=19;20 +diagonal_inches=27;25;24 +hotkey=control+super+shift+h +display1paths=C:\Users;C:\ \ No newline at end of file diff --git a/superpaper/profiles-win/example_multi.profile b/superpaper/profiles-win/example_multi.profile new file mode 100644 index 0000000..04f1b45 --- /dev/null +++ b/superpaper/profiles-win/example_multi.profile @@ -0,0 +1,8 @@ +name=example_multi +spanmode=multi +slideshow=true +delay=120 +sortmode=shuffle +hotkey=control+super+shift+g +display1paths=C:\Users;C:\ +display2paths=C:\Users \ No newline at end of file diff --git a/superpaper/profiles/example.profile b/superpaper/profiles/example.profile index 62b34bf..5525a24 100644 --- a/superpaper/profiles/example.profile +++ b/superpaper/profiles/example.profile @@ -4,7 +4,7 @@ slideshow=true delay=120 sortmode=shuffle offsets=0,0;0,0 -bezels=9.5;7 -diagonal_inches=27;25 +bezels=19;20 +diagonal_inches=27;25;24 hotkey=control+super+shift+h display1paths=/home;/ \ No newline at end of file diff --git a/superpaper/sp_paths.py b/superpaper/sp_paths.py index 7e6b42c..29377f7 100644 --- a/superpaper/sp_paths.py +++ b/superpaper/sp_paths.py @@ -28,8 +28,18 @@ def setup_config_path(): ) # if DEBUG: G_LOGGER.info("config path: %s", config_path) return config_path + elif platform.system() == "Windows": + # Windows and Mac default to the old portable config behavior + config_path = PATH + # and test if it is writable: + if not test_full_write_access(config_path): + # if it is not writable, use %LOCALAPPDATA%\Superpaper + config_path = os.path.join(os.getenv("LOCALAPPDATA"), "Superpaper") + if not os.path.isdir(config_path): + os.mkdir(config_path) + return config_path else: - # Windows and Mac keep the old portable config behavior for now. + # Mac & other default to the old portable config behavior config_path = PATH return config_path @@ -50,8 +60,19 @@ def setup_cache_path(): temp_path = os.path.join(cache_path, "temp") # if DEBUG: G_LOGGER.info("temp path: %s", temp_path) return temp_path + elif platform.system() == "Windows": + # Windows and Mac default to the old portable temp behavior + parent_path = PATH + temp_path = os.path.join(parent_path, "temp") + # and test if it is writable: + if not test_full_write_access(parent_path): + # if it is not writable, use %LOCALAPPDATA%\Superpaper\temp + temp_path = os.path.join(os.getenv("LOCALAPPDATA"), os.path.join("Superpaper", "temp")) + if not os.path.isdir(temp_path): + os.mkdir(temp_path) + return temp_path else: - # Windows and Mac keep the old portable config behavior for now. + # Mac & other keep the old portable config behavior for now. temp_path = os.path.join(PATH, "temp") return temp_path @@ -76,13 +97,23 @@ def xdg_path_setup(xdg_var, fallback_path): os.mkdir(xdg_path) return xdg_path +def test_full_write_access(path): + try: + testdir = os.path.join(path, "test_write_access") + os.mkdir(testdir) + os.rmdir(testdir) + return True + except PermissionError: + # There is no access to create folders in path: + return False + # Derivative paths +CONFIG_PATH = setup_config_path() # Save profiles and settings here. +print(CONFIG_PATH) TEMP_PATH = setup_cache_path() # Save adjusted wallpapers in here. if not os.path.isdir(TEMP_PATH): os.mkdir(TEMP_PATH) -CONFIG_PATH = setup_config_path() # Save profiles and settings here. -print(CONFIG_PATH) PROFILES_PATH = os.path.join(CONFIG_PATH, "profiles") print(PROFILES_PATH) if not os.path.isdir(PROFILES_PATH): diff --git a/windows-tooling/inno-setup-script.iss b/windows-tooling/inno-setup-script.iss index c3efba4..3d8e120 100644 --- a/windows-tooling/inno-setup-script.iss +++ b/windows-tooling/inno-setup-script.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "Superpaper" -#define MyAppVersion "1.2a2" +#define MyAppVersion "1.2a3" #define MyAppPublisher "Henri Hänninen" #define MyAppURL "https://github.com/hhannine/superpaper/" #define MyAppExeName "superpaper.exe" @@ -41,7 +41,7 @@ Name: "startupicon"; Description: "Start application with Windows"; GroupDescrip [Files] Source: "..\dist\superpaper.exe"; DestDir: "{app}\superpaper"; Flags: ignoreversion -Source: "..\releases\innostub\profiles\*"; DestDir: "{app}\profiles"; Flags: ignoreversion recursesubdirs createallsubdirs +Source: "..\releases\innostub\profiles\*"; DestDir: "{app}\superpaper\profiles"; Flags: ignoreversion recursesubdirs createallsubdirs Source: "..\releases\innostub\superpaper\*"; DestDir: "{app}\superpaper"; Flags: ignoreversion recursesubdirs createallsubdirs ; NOTE: Don't use "Flags: ignoreversion" on any shared system files @@ -55,5 +55,5 @@ Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\superpaper\{#MyAppExeName}" Root: HKLM; Subkey: "SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "{#MyAppName}"; ValueData: """{app}\superpaper\{#MyAppExeName}"""; Flags: uninsdeletevalue; Tasks: startupicon [Run] -Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent +Filename: "{app}\superpaper\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent diff --git a/windows-tooling/make-windows-release.py b/windows-tooling/make-windows-release.py index 2c6dc7e..29da6e2 100644 --- a/windows-tooling/make-windows-release.py +++ b/windows-tooling/make-windows-release.py @@ -28,7 +28,7 @@ def make_portable(dst_path): # copy resources copy_tree(os.path.join(SRCPATH, "resources"), portres) # copy profiles - copy_tree(os.path.join(SRCPATH, "profiles"), portprof) + copy_tree(os.path.join(SRCPATH, "profiles-win"), portprof) # copy exe-less structure to be used by innosetup copy_tree(portpath, INNO_STUB) From 0994f59866039a786d851a76d9596f3ed542f6bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= <47873564+hhannine@users.noreply.github.com> Date: Thu, 19 Dec 2019 23:31:18 +0200 Subject: [PATCH 27/31] Pass version string from python to inno setup --- windows-tooling/inno-setup-script.iss | 2 +- windows-tooling/make-windows-release.py | 17 ++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/windows-tooling/inno-setup-script.iss b/windows-tooling/inno-setup-script.iss index 3d8e120..58be84d 100644 --- a/windows-tooling/inno-setup-script.iss +++ b/windows-tooling/inno-setup-script.iss @@ -2,7 +2,7 @@ ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! #define MyAppName "Superpaper" -#define MyAppVersion "1.2a3" +; #define MyAppVersion "null" ; Set version via a command line argument #define MyAppPublisher "Henri Hänninen" #define MyAppURL "https://github.com/hhannine/superpaper/" #define MyAppExeName "superpaper.exe" diff --git a/windows-tooling/make-windows-release.py b/windows-tooling/make-windows-release.py index 29da6e2..693f867 100644 --- a/windows-tooling/make-windows-release.py +++ b/windows-tooling/make-windows-release.py @@ -37,14 +37,13 @@ def make_portable(dst_path): # zip it shutil.make_archive(os.path.join(dst_path, "superpaper-portable"), 'zip', portpath) -def update_inno_script(version_str, output_path): - # update both VERSTION_STR and OUTPUT PATH - return 0 - -def run_inno_script(): - inno_cmd = "iscc scriptfile.iss" +def run_inno_script(version_str): + inno_cmd = "iscc ./windows-tooling/inno-setup-script.iss /DMyAppVersion={}".format(version_str) os.system(inno_cmd) + + + def main(): if not os.path.isdir(DISTPATH): os.mkdir(DISTPATH) @@ -68,13 +67,9 @@ def main(): make_portable(dist_path) print("Portable package build done.") - exit(0) - - # update inno script - update_inno_script(version, dist_path) # run inno installer compilation - run_inno_script() + run_inno_script(version) # done print("Release built and packaged.") From b75266ba6f64f425409c538327854a609be60839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Fri, 20 Dec 2019 10:35:46 +0200 Subject: [PATCH 28/31] Fix setup.py typo, add a note on setup.py builds and pypi --- readme-setuppy.md | 11 +++++++++++ setup.py | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 readme-setuppy.md diff --git a/readme-setuppy.md b/readme-setuppy.md new file mode 100644 index 0000000..8e60ccb --- /dev/null +++ b/readme-setuppy.md @@ -0,0 +1,11 @@ +# Some notes on building and uploading PyPI packages + +## Building a wheel / sdist +python3 setup.py sdist bdist_wheel + +## Checking that twine (pypi tool) passes the built package +twine check dist/* + +## Uploading to PyPI +python3 -m twine upload dist/* + diff --git a/setup.py b/setup.py index 6841319..164c063 100644 --- a/setup.py +++ b/setup.py @@ -38,6 +38,7 @@ def test_import(packaname, humanname): "multi-monitor support. Features include ppi corrections, " "keyboard shortcuts, slideshow.", long_description=long_description, + long_description_content_type="text/markdown", url="https://github.com/hhannine/superpaper", classifiers=[ @@ -45,7 +46,7 @@ def test_import(packaname, humanname): "Environment :: X11 Applications", # "Environment :: Win32", "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: MIT Licence", + "License :: OSI Approved :: MIT License", "Natural Language :: English", "Operating System :: POSIX :: Linux", # "Operating System :: Microsoft :: Windows", From 4be3efe00c077798b72562ec638fd5e76e18444d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Fri, 20 Dec 2019 15:17:36 +0200 Subject: [PATCH 29/31] Update install instructions. --- README.md | 75 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 53 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 31daf5f..c6e67a3 100644 --- a/README.md +++ b/README.md @@ -48,44 +48,75 @@ using the manual offset. In the above banner photo you can see the PPI and bezel corrections in action. The left one is a 27" 4K display, and the right one is a 25" 1440p display. ## Support -If you find Superpaper useful please consider supporting its development: [Support via PayPal][paypal-superpaper]. +If you find Superpaper useful please consider supporting its development: [Support via PayPal][paypal-superpaper] or [Support via Github Sponsors][github-sponsors]. Github matches your donations done through the sponsorship program! [paypal-superpaper]: https://www.paypal.me/superpaper/5 +[github-sponsors]: https://github.com/sponsors/hhannine ## Installation -### A. Portable releases -For Linux and Windows there are portable stand-alone binary packages available under [releases](https://github.com/hhannine/Superpaper/releases). -These work on a download-and-run basis without additional requirements. Look for the executable "superpaper.exe", or "superpaper" on Linux. - -Standalone package for Mac OS X is unfortunately unavailable at this time, but you may look at the alternative way to run Superpaper. - +### Linux -### B. Run the script +Superpaper is available from PyPI, [here][sp-on-pypi]. -You may either clone the repository or download the packaged script under releases. You will need to take care of its dependencies, which are: +[sp-on-pypi]: https://pypi.org/project/superpaper #### Requirements - Python 3.5+ -- Pillow (or the slower PIL should also work, or on Linux the faster Pillow-SIMD) +- Pillow - screeninfo -- wxpython (tray applet & slideshow, optional) +- wxpython (tray applet, GUI & slideshow, optional) - system_hotkey (hotkeys, optional) +- xcffib (dep for system_hotkey) +- xpybutil (dep for system_hotkey) -If you are going to run only in CLI mode you will need to install the first three in the list. For full functionality you will of course need to install all of them. +If you install Superpaper from PyPI, pip will handle everything else other than _wxPython_. It will be easiest to install wxPython from your distribution specific package repository: +#### Arch / Manjaro +``` +sudo pacman -S python-wxpython +``` +#### Debian / Ubuntu and derivatives +``` +sudo apt install python3-wxgtk4.0 +``` +#### Fedora +``` +sudo dnf install python3-wxpython4 +``` +#### Python wheels (if wxPython4 is not in your standard repositories) +For a few CentOS, Debian, Fedora and Ubuntu flavors there are pre-built wheels so on those you can look at the [instructions](https://wxpython.org/pages/downloads/) to install wxpython through pip, without having to build it as you do when installing directly from PyPI. +#### Installing other dependencies manually: +Install easily via pip3: +``` +pip3 install -U Pillow screeninfo system_hotkey xcffib xpybutil +``` +#### Running CLI only +If you are going to run only in CLI mode you will need to install the first two modules in the list: +``` +pip3 install -U Pillow screeninfo +``` +### Installing Superpaper from PyPI +Once wxPython4 is installed, you can just run: +``` +pip3 install -U superpaper +``` + +### Windows +For Windows an installer and a portable package are available under [releases](https://github.com/hhannine/Superpaper/releases). +These work on a download-and-run basis without additional requirements. In the portable package, look for the executable "superpaper.exe" in the subfolder "superpaper". -One can install these easily via pip3: +If you want to run the cloned repo on Windows, on top of the requirements listed in the Linux section, you will need the "pywin32" module (instead of the xcffib and xpybutil modules). Installation through pip: +``` +pip3 install -U Pillow screeninfo wxpython system_hotkey pywin32 ``` -pip3 install Pillow -pip3 install screeninfo -pip3 install wxpython -pip3 install system_hotkey + +### Max OS X +Basic support for OS X has been kept in the script but it has not been tested. If you want to try to run it, best bet would be to install the dependencies through pip: ``` -System_hotkey has dependencies: on Linux it needs "xcffib" and "xpybutil" modules, and on Windows it needs "pywin32". -Note that on Linux wxpython needs to be built and even though it is automated via pip, it has its own [dependencies](https://wxpython.org/blog/2017-08-17-builds-for-linux-with-pip/index.html), -and the build may take some time. -However for a few CentOS, Debian, Fedora and Ubuntu flavors there are pre-built wheels so on those you can look at the [instructions](https://wxpython.org/pages/downloads/) to install wxpython -without having to build it yourself. +pip install -U wxpython Pillow screeninfo +``` +and then clone the repository. + ## Usage From 8c05622f8d23c80564e59b8b6fa5c716ec71299f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Fri, 20 Dec 2019 15:28:04 +0200 Subject: [PATCH 30/31] Small README changes. --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c6e67a3..77b692b 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,13 @@ using the manual offset. In the above banner photo you can see the PPI and bezel corrections in action. The left one is a 27" 4K display, and the right one is a 25" 1440p display. -## Support +### Support If you find Superpaper useful please consider supporting its development: [Support via PayPal][paypal-superpaper] or [Support via Github Sponsors][github-sponsors]. Github matches your donations done through the sponsorship program! [paypal-superpaper]: https://www.paypal.me/superpaper/5 [github-sponsors]: https://github.com/sponsors/hhannine + ## Installation ### Linux @@ -86,7 +87,7 @@ sudo dnf install python3-wxpython4 #### Python wheels (if wxPython4 is not in your standard repositories) For a few CentOS, Debian, Fedora and Ubuntu flavors there are pre-built wheels so on those you can look at the [instructions](https://wxpython.org/pages/downloads/) to install wxpython through pip, without having to build it as you do when installing directly from PyPI. #### Installing other dependencies manually: -Install easily via pip3: +Install via pip3: ``` pip3 install -U Pillow screeninfo system_hotkey xcffib xpybutil ``` @@ -100,9 +101,10 @@ Once wxPython4 is installed, you can just run: ``` pip3 install -U superpaper ``` +This will install an icon and .desktop file for menu entries. ### Windows -For Windows an installer and a portable package are available under [releases](https://github.com/hhannine/Superpaper/releases). +For Windows an installer and a portable package are available under [releases](https://github.com/hhannine/superpaper/releases). These work on a download-and-run basis without additional requirements. In the portable package, look for the executable "superpaper.exe" in the subfolder "superpaper". If you want to run the cloned repo on Windows, on top of the requirements listed in the Linux section, you will need the "pywin32" module (instead of the xcffib and xpybutil modules). Installation through pip: From 24369ac240ad86d6c38470fa5f25f37c9dc14d88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henri=20H=C3=A4nninen?= Date: Fri, 20 Dec 2019 15:42:30 +0200 Subject: [PATCH 31/31] Update version and status classifier --- setup.py | 3 ++- superpaper/__version__.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 164c063..692e629 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,8 @@ def test_import(packaname, humanname): url="https://github.com/hhannine/superpaper", classifiers=[ - "Development Status :: 4 - Beta", + # "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Environment :: X11 Applications", # "Environment :: Win32", "Intended Audience :: End Users/Desktop", diff --git a/superpaper/__version__.py b/superpaper/__version__.py index bd312c7..b24cb81 100644 --- a/superpaper/__version__.py +++ b/superpaper/__version__.py @@ -1,3 +1,3 @@ """Version string for Superpaper.""" -__version__ = "1.2a3" +__version__ = "1.2.0"