diff --git a/README.md b/README.md index f627709..2bebe10 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,44 @@ # konawall-py -A rewrite of konawall-rs in Python so that image manipulation is easier and cross-platform support is sort-of easier. \ No newline at end of file +A rewrite of konawall-rs in Python so that image manipulation is easier and cross-platform support is sort-of easier. + +## To-do + +- [ ] Ensure that platforms all work with single and multiple-monitor setting: + - [x] macOS + - [x] Windows + - [ ] Linux + - Current progress: need to set up VM + - [ ] GNOME + - [ ] KDE + - [ ] XFCE + - [ ] Cinnamon + - [ ] MATE + - [ ] LXDE / LXQT + - [ ] Openbox + - [ ] i3 + - [ ] sway +- [ ] Provide Nix packaging +- [ ] Provide installer packages for macOS and Windows, including on-startup functionality + - [ ] macOS + - [ ] Windows +- [ ] Sources + - [ ] Turn the current konachan source into a eneralized booru plugin + - [ ] Refactor, allow custom HTTP headers and a specified URL within config + - [ ] Test with e621 + - [ ] Test with gelbooru + - [ ] Test with konachan + - [ ] Randomized sources and wallpapers mapping + - Implementation thoughts: To do this, there would need to be an intermediary between a source instance with a set of tags and another copy of that source instance with a set of tags as far as "presets" to be randomly selected from goes. + - [ ] Allow for a system in a different source is used to fetch a wallpaper or wallpaper(s) for each monitor + - [ ] Allow for a system in which a random source can be used to fetch a wallpaper or wallpaper(s) for each instantiation + - [ ] Allow multiple tag sets to be utilized even within one source, chosen at random +- [ ] Replace logging system with a local data store for the history + - Implementation thoughts: The data store ought to keep a note of the source and tags used to request it and any reasonable data that the API returned, e.g. tags, rating, other metadata... + - [ ] Replace current file downloading system + - [ ] Provide temporary directory as an option via config + - [ ] Provide a permanent directory as an option via config + - [ ] Provide a maximum number of wallpapers / history to keep via config + - [ ] Provide tooling for browsing the history + - [ ] Proper gallery grid UI, open in browser option, ... + - [ ] CLI tooling for searching the history \ No newline at end of file diff --git a/environments/darwin.py b/environments/darwin.py index 4481723..56d0f3b 100644 --- a/environments/darwin.py +++ b/environments/darwin.py @@ -10,4 +10,5 @@ This sets wallpapers on Darwin. def set_wallpapers(files: list, displays: list): for i, file in enumerate(files): # Run osascript to set the wallpaper for each monitor - subprocess.run(["osascript", "-e", f'tell application "System Events" to set picture of desktop {i} file "{file}"']) \ No newline at end of file + command = f'tell application "System Events" to set picture of desktop {i} to "{file}"' + subprocess.run(["osascript", "-e", command]) \ No newline at end of file diff --git a/environments/win32.py b/environments/win32.py index 1ef8599..a61dec8 100644 --- a/environments/win32.py +++ b/environments/win32.py @@ -1,7 +1,6 @@ import os import ctypes import logging -import winreg from imager import combine_to_viewport from module_loader import add_environment @@ -20,6 +19,7 @@ This sets wallpapers on Windows. """ @add_environment("win32_setter") def set_wallpapers(files: list, displays: list): + import winreg if len(files) > 1: logging.debug("Several monitors detected, going the hard route") desktop = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Control Panel\\Desktop", 0, winreg.KEY_ALL_ACCESS) diff --git a/gui.py b/gui.py index 372f636..5c8d112 100644 --- a/gui.py +++ b/gui.py @@ -11,70 +11,89 @@ import subprocess from environment import set_environment_wallpapers, detect_environment from module_loader import import_dir, environment_handlers, source_handlers from custom_print import kv_print - -def create_icon(): - width = 128 - height = 128 - # Missing texture - image = Image.new('RGB', (width, height), (0, 0, 0)) - dc = ImageDraw.Draw(image) - dc.rectangle((0, 0, width//2, height//2), fill=(255, 0, 255)) - dc.rectangle((width//2, height//2, width, height), fill=(255, 0, 255)) - # Write image to temporary file - temp = tempfile.NamedTemporaryFile(suffix=".png", delete=False) - image.save(temp.name) - icon = wx.Icon() - icon.CopyFromBitmap(wx.Bitmap(temp.name)) - return icon - - -def create_menu_item(menu, label, func): - item = wx.MenuItem(menu, -1, label) - menu.Bind(wx.EVT_MENU, func, id=item.GetId()) - menu.Append(item) - return item +from humanfriendly import format_timespan class Konawall(wx.adv.TaskBarIcon): def __init__(self, file_logger): + # Prevents it from closing before it has done any work on macOS + if wx.Platform == "__WXMAC__": + self.hidden_frame = wx.Frame(None) + self.hidden_frame.Hide() + + self.wallpaper_rotation_counter = 0 self.file_logger = file_logger - #self.console_logger = console_logger - self.loaded = False - wx.adv.TaskBarIcon.__init__(self) - # Pre-setup initialization + self.loaded_before = False + + # Call the super function, make sure that the type is the statusitem for macOS + wx.adv.TaskBarIcon.__init__(self, wx.adv.TBI_CUSTOM_STATUSITEM) + + # Detect environment and timer settings self.environment = detect_environment() - self.automatic_item = None - self.automatic_timer = wx.Timer(self, wx.ID_ANY) - # Reload (actually load) the config + self.toggle_wallpaper_rotation_item = None + self.wallpaper_rotation_timer = wx.Timer(self, wx.ID_ANY) + + # Reload (actually load) the config and modules. self.reload() self.load_modules() - self.automatic_timer.Start(self.seconds * 1000) - # Set up the taskbar icon - self.SetIcon(create_icon(), "Konawall") + # Start the timer to run every second + self.wallpaper_rotation_timer.Start(1000) + + # Set up the taskbar icon, menu, bindings, ... + icon = self.generate_icon() + self.SetIcon(icon, "Konawall") self.create_menu() self.create_bindings() - def automatic_item_state(self): - if self.automatic: - return "Disable Automatic" - else: - return "Enable Automatic" + # Run the first time, manually + self.run(None) + # wxPython requires a wx.Bitmap, so we generate one from a PIL.Image + def generate_icon(self): + width = 128 + height = 128 + + # Missing texture style, magenta and black checkerboard + image = Image.new('RGB', (width, height), (0, 0, 0)) + dc = ImageDraw.Draw(image) + dc.rectangle((0, 0, width//2, height//2), fill=(255, 0, 255)) + dc.rectangle((width//2, height//2, width, height), fill=(255, 0, 255)) + if "wxMSW" in wx.PlatformInfo: + image = image.Scale(16, 16) + elif "wxGTK" in wx.PlatformInfo: + image = image.Scale(22, 22) + + # Write image to temporary file + temp = tempfile.NamedTemporaryFile(suffix=".png", delete=False) + image.save(temp.name) + + # Convert to wxPython icon + icon = wx.Icon() + icon.CopyFromBitmap(wx.Bitmap(temp.name)) + return icon + + def toggle_wallpaper_rotation_status(self): + return f"{'Dis' if self.rotate else 'En'}able Timer" + + # Load in our source and environment handlers def load_modules(self): import_dir(os.path.join(os.path.dirname(os.path.abspath( __file__ )), "sources")) kv_print("Loaded source handlers", ", ".join(source_handlers), level="debug") import_dir(os.path.join(os.path.dirname(os.path.abspath( __file__ )), "environments")) kv_print("Loaded environment handlers", ", ".join(environment_handlers), level="debug") + # Load a TOML file's key-value pairs into our class def read_config(self): - # check if config file exists if os.path.isfile("config.toml"): + # If the config file exists, load it as a dictionary into the config variable. with open("config.toml", "rb") as f: config = tomllib.load(f) + # for every key-value pair in the config variable , set the corresponding attribute of our class to it for k, v in config.items(): kv_print(f"Loaded {k}", v) setattr(self, k, v) else: + # If there is no config file, get complainy. dialog = wx.MessageDialog( None, "No config file found, using defaults.", @@ -83,17 +102,70 @@ class Konawall(wx.adv.TaskBarIcon): ) dialog.ShowModal() dialog.Destroy() - self.automatic = True - self.seconds = 10*60 + # Set some arbitrary defaults + self.rotate = True + self.interval = 10*60 self.tags = ["rating:s"] + self.logging = {} + self.logging["file"] = "INFO" + # Create the menu def create_menu(self): + # Make it easier to define menu items + def create_menu_item(menu, label, func, help="", kind=wx.ITEM_NORMAL): + item = wx.MenuItem(menu, wx.ID_ANY, label) + menu.Bind(wx.EVT_MENU, func, id=item.GetId()) + menu.Append(item) + return item + def create_separator(menu): + item = wx.MenuItem(menu, id=wx.ID_SEPARATOR, kind=wx.ITEM_SEPARATOR) + menu.Append(item) + return item + + # Create our Menu object self.menu = wx.Menu() - create_menu_item(self.menu, "Run", self.run) - self.automatic_item = create_menu_item(self.menu, self.automatic_item_state(), self.toggle_automatic) - create_menu_item(self.menu, "Edit Config", self.edit_config) - self.menu.Append(wx.ID_EXIT, "Exit") + # Time remaining for automatic wallpaper rotation + self.wallpaper_rotation_status = wx.MenuItem(self.menu, -1, "Time remaining") + self.wallpaper_rotation_status.Enable(False) + self.menu.Append(self.wallpaper_rotation_status) + + create_separator(self.menu) + + # Change wallpapers + create_menu_item( + self.menu, + "Rotate Wallpapers", + self.run, + "Fetch new wallpapers and set them as your wallpapers" + ) + + # Toggle automatic wallpaper rotation + self.toggle_wallpaper_rotation_item = create_menu_item( + self.menu, + self.toggle_wallpaper_rotation_status(), + self.toggle_wallpaper_rotation, + "Toggle the automatic wallpaper rotation timer" + ) + + create_separator(self.menu) + + # Interactive config editing + create_menu_item( + self.menu, + "Edit Config", + self.edit_config, + "Interactively edit the config file" + ) + # Exit + create_menu_item( + self.menu, + "Exit", + self.Destroy, + "Quit the application" + ) + + # Interactively edit the config file def edit_config(self, event): kv_print("User is editing", "config.toml") # Check if we're on Windows, if so use Notepad @@ -107,58 +179,82 @@ class Konawall(wx.adv.TaskBarIcon): kv_print("User has edited", "config.toml") self.reload() + # Reload the application def reload(self): - if self.loaded: - kv_print("Reloading config from", "config.toml") - else: - kv_print("Loading config from", "config.toml") + kv_print(f"{'Rel' if self.loaded_before else 'L'}oading config from", "config.toml") self.read_config() + # Handle finding the log level if "file" in self.logging: file_log_level = getattr(logging, self.logging["file"]) else: file_log_level = logging.INFO self.file_logger.setLevel(file_log_level) - #if "console" in self.logging: - # console_log_level = getattr(logging, self.logging["console"]) - #else: - # console_log_level = logging.INFO - #self.console_logger.setLevel(console_log_level) + + if self.loaded_before == True: + # If we're reloading, we need to make sure the timer and menu item reflect our current state. + self.respect_wallpaper_rotation_toggle() + # Finished loading - self.loaded = True + self.loaded_before = True + - # Handle the automatic timer - if self.automatic and self.automatic_timer.IsRunning(): - self.automatic_timer.Stop() - self.automatic_timer.Start(self.seconds * 1000) - self.menu.SetLabel(self.automatic_item.Id, self.automatic_item_state()) - elif not self.automatic and self.automatic_timer.IsRunning(): - self.automatic_timer.Stop() - self.menu.SetLabel(self.automatic_item.Id, self.automatic_item_state()) - - def toggle_automatic(self, event): - self.automatic = not self.automatic - if self.automatic: - self.automatic_timer.Start(self.seconds * 1000) - else: - self.automatic_timer.Stop() - self.menu.SetLabel(self.automatic_item.Id, self.automatic_item_state()) + # Set whether to rotate wallpapers automatically or not + def toggle_wallpaper_rotation(self, event): + self.rotate = not self.rotate + self.respect_wallpaper_rotation_toggle() - def show_menu(self, event): - self.PopupMenu(self.menu) + # Update the timer and the menu item to reflect our current state + def respect_wallpaper_rotation_toggle(self): + if self.rotate and not self.wallpaper_rotation_timer.IsRunning(): + self.wallpaper_rotation_timer.Start(1000) + elif not self.rotate and self.wallpaper_rotation_timer.IsRunning(): + self.wallpaper_rotation_timer.Stop() + # Set the time left counter to show that it is disabled + self.wallpaper_rotation_status.SetItemLabel(f"Automatic wallpaper rotation disabled") + + # Update the menu item for the toggle + self.toggle_wallpaper_rotation_item.SetItemLabel(self.toggle_wallpaper_rotation_status()) + # Update wallpaper rotation time left counter + def respect_wallpaper_rotation_status(self): + self.wallpaper_rotation_status.SetItemLabel(f"{format_timespan(self.interval - self.wallpaper_rotation_counter)} remaining") + + # Perform the purpose of the application; get new wallpaper media and set 'em. def run(self, event): displays = screeninfo.get_monitors() count = len(displays) files = source_handlers[self.source](count, self.tags) set_environment_wallpapers(self.environment, files, displays) + # For macOS + def CreatePopupMenu(self): + self.PopupMenu(self.menu) + + # For everybody else who has bindable events + def show_menu(self, event): + self.PopupMenu(self.menu) + + # Every second, check if the wallpaper rotation timer has ticked over + def handle_timer_tick(self, event): + if self.wallpaper_rotation_counter >= self.interval: + # If it has, run the fetch and set mechanism + self.run(None) + self.wallpaper_rotation_counter = 0 + else: + self.wallpaper_rotation_counter += 1 + # Update the time left counter + self.respect_wallpaper_rotation_status() + + # Bind application events def create_bindings(self): self.Bind(wx.adv.EVT_TASKBAR_LEFT_DOWN, self.run) self.Bind(wx.adv.EVT_TASKBAR_RIGHT_DOWN, self.show_menu) - self.Bind(wx.EVT_TIMER, self.run, self.automatic_timer) -if __name__ == "__main__": + # Implement the wallpaper rotation timer + self.Bind(wx.EVT_TIMER, self.handle_timer_tick, self.wallpaper_rotation_timer) + +def main(): file_logger = logging.FileHandler("app.log", mode="a") #console_logger = logging.StreamHandler() logging.basicConfig( @@ -169,6 +265,10 @@ if __name__ == "__main__": file_logger, ] ) - app = wx.App(False) + app = wx.App(redirect=False) + app.SetExitOnFrameDelete(False) Konawall(file_logger) - app.MainLoop() \ No newline at end of file + app.MainLoop() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index bebf66b..b96d67b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ Pillow screeninfo requests -termcolor \ No newline at end of file +termcolor +wxpython +humanfriendly \ No newline at end of file