Source code for mytk.app

"""app.py — Main base application class using myTk framework.

This module defines the `App` class, which serves as the main controller of a
Tkinter application built with the myTk framework.

It handles window creation, menu setup, platform-specific quirks, and help/documentation integration.

Classes:
    - App: The main application object, integrating window, menu, and lifecycle management.

Typical usage:
    from mytk.app import App

    class MyApp(App):
        def save(self):
            ...

        def preferences(self):
            ...

    app = MyApp(geometry="800x600", name="MyApp")
    app.mainloop()
"""

import io
import os
import platform
import subprocess
from contextlib import redirect_stdout, suppress
from queue import Empty
from queue import Queue as TQueue
from tkinter import Menu, TclError

from .bindable import Bindable
from .dialog import Dialog
from .eventcapable import EventCapable
from .modulesmanager import ModulesManager
from .utils import is_main_thread
from .window import Window


[docs] class App(Bindable, EventCapable): """Main application class for a myTk-based GUI. This class integrates the `Window`, `Bindable`, and `EventCapable` behaviors into a single application controller. It manages the main window, menu bar, help system, and platform-specific requirements. Attributes: app (App): Class-level reference to the current App instance. name (str): Application name. help_url (str): Optional URL to the help/documentation site. window (Window): The main application window. """ app = None def __init__( self, *args, geometry=None, auto_position="center", name="myTk App", help_url=None, bring_to_front=False, no_window=False, **kwargs ): """Initializes the application, including window and menu setup. Args: geometry (str, optional): Geometry string for window size and position. auto_position (str, optional): Named screen position: "center", "top-left", "top-right", "bottom-left", or "bottom-right". Defaults to "center". name (str): The application name (used in menu and title). help_url (str, optional): URL to the documentation site. bring_to_front (bool): If True, bring the window to the front on macOS. no_window (bool): If True, create the window in a withdrawn (hidden) state. *args: Positional arguments passed to superclasses. **kwargs: Keyword arguments passed to superclasses. """ super().__init__(*args, **kwargs) self.name = name self.help_url = help_url self.window = Window(geometry=geometry, title=name, withdraw=no_window, auto_position=auto_position) self.main_queue: TQueue = TQueue() self.run_loop_delay: int = 20 self.check_requirements() self.create_menu() if bring_to_front and platform.system() == "Darwin": os.system( "osascript -e 'tell application \"System Events\" to set frontmost of" " the first process whose unix id is {} to true'".format(os.getpid()) ) App.app = self def _cancel_all_afters(event): if event.widget is self.root: with suppress(Exception): for after_id in self.root.tk.call('after', 'info'): with suppress(Exception): self.root.after_cancel(after_id) self.root.bind('<Destroy>', _cancel_all_afters) if self.is_running: self.after(self.run_loop_delay, self.run_main_queue) @property def widget(self): """Returns the root Tk widget. Returns: tk.Tk: The root Tk window. """ return self.root @property def root(self): """Returns the root window widget. Returns: tk.Tk: The application's root window. """ return self.window.widget @property def is_running(self): """Indicates whether the application is currently running. Returns: bool: True if the window exists; False otherwise. """ return self.root is not None
[docs] def check_requirements(self): """Warns the user if Python version is too old for the current macOS. Helps avoid click event issues on macOS Sonoma and older Python versions. """ mac_version = platform.mac_ver()[0] python_version = platform.python_version() if mac_version >= "14" and python_version < "3.12": Dialog.showwarning( message="It is recommended to use Python 3.12 on macOS 14 (Sonoma) with Tk. " "If not, you will need to move the mouse while holding the button to register the click." )
[docs] def mainloop(self): """Enters the Tkinter main event loop.""" self.run_main_queue() self.window.widget.mainloop()
[docs] def schedule_on_main_thread(self, fct, args=None, kwargs=None): """Schedules a function call to be executed on the main thread. Args: fct (callable): The function to call. args (list, optional): Positional arguments for the function. kwargs (dict, optional): Keyword arguments for the function. """ self.main_queue.put((fct, args, kwargs))
[docs] def run_main_queue(self): """Processes all pending tasks in the main thread queue.""" assert is_main_thread() while not self.main_queue.empty(): try: f, args, kwargs = self.main_queue.get_nowait() f(*(args or []), **(kwargs or {})) except Empty: pass except Exception as e: print( "Unable to call scheduled function {fct} with arguments {args}:", e, ) if self.is_running: self.after(self.run_loop_delay, self.run_main_queue)
[docs] def create_menu(self): """Creates the application menu bar with File, Edit, and Help items.""" root = self.window.widget menubar = Menu(root) appmenu = Menu(menubar, name="apple") menubar.add_cascade(menu=appmenu) appmenu.add_command(label=f"About {self.name}", command=self.about) appmenu.add_command(label="Preferences", command=self.preferences) appmenu.add_separator() filemenu = Menu(menubar, tearoff=0) filemenu.add_command( label="Save…", command=self.save, accelerator="Command+S" ) filemenu.add_command(label="Quit", command=self.quit) menubar.add_cascade(label="File", menu=filemenu) editmenu = Menu(menubar, tearoff=0) editmenu.add_command(label="Undo", state="disabled") editmenu.add_separator() editmenu.add_command(label="Cut", state="disabled") editmenu.add_command(label="Copy", state="disabled") editmenu.add_command(label="Paste", state="disabled") editmenu.add_command(label="Select All", state="disabled") menubar.add_cascade(label="Edit", menu=editmenu) helpmenu = Menu(menubar, tearoff=0) if self.help_url is None: helpmenu.add_command( label="No help available", command=self.help, state="disabled" ) else: helpmenu.add_command( label="Documentation web site", command=self.help ) menubar.add_cascade(label="Help", menu=helpmenu) root.config(menu=menubar) # IMPORTANT: Define the Tcl callback for the macOS quit event # If we don't, quitting with the Python menu will skip our self.quit command def on_mac_quit(): # This is called when the user clicks "Quit" from the Apple menu or presses ⌘+Q self.quit() with suppress(TclError): self.root.createcommand("tk::mac::Quit", on_mac_quit)
[docs] def reveal_path(self, path): """Reveals the file or directory path in the system's file browser. Args: path (str): The path to reveal. """ try: if platform.system() == "Windows": os.startfile(path) # pylint: disable=no-member elif platform.system() == "Darwin": subprocess.call(["open", path]) else: subprocess.call(["xdg-open", path]) except (OSError, FileNotFoundError, subprocess.SubprocessError) as e: Dialog.showerror( title=f"Unable to show {path}", message=f"An error occurred when trying to reveal {path}: {e}", )
[docs] def save(self): """Saves application data; must be overridden in subclasses.""" raise NotImplementedError("Implement save: in derived class")
[docs] def preferences(self): """Shows preferences UI; must be overridden in subclasses.""" raise NotImplementedError("Implement preferences: in derived class")
[docs] def about(self, timeout=3000): """Displays an About dialog with app information. Args: timeout (int): Optional time before auto-close in milliseconds. """ Dialog.showinfo( title="About this App", message="Created with myTk: A simple user interface framework for busy scientists.\n\nhttps://github.com/DCC-Lab/myTk", auto_click=(Dialog.Replies.Ok, timeout), )
[docs] def help(self): """Opens the documentation help URL in a browser if available. Falls back to an info dialog if no help URL has been configured. """ ModulesManager.install_and_import_modules_if_absent( {"webbrowser": "webbrowser"} ) webbrowser = ModulesManager.imported.get("webbrowser") if self.help_url is not None and webbrowser is not None: webbrowser.open(self.help_url) else: Dialog.showinfo( title="Help", message="There is no help available for this Application.", )
[docs] def quit(self): """Quits the application gracefully and destroys the root window. Cancels any scheduled tasks before teardown. """ if self.is_running: self.after_cancel_all() with suppress(TclError), redirect_stdout(io.StringIO()): self.window.widget.destroy() self.window.widget = None