diff --git a/ou_dedetai/app.py b/ou_dedetai/app.py new file mode 100644 index 00000000..713eeb54 --- /dev/null +++ b/ou_dedetai/app.py @@ -0,0 +1,59 @@ +import abc +from typing import Optional + +from ou_dedetai import config + + +class App(abc.ABC): + def __init__(self, **kwargs) -> None: + self.conf = Config(self) + + def ask(self, question: str, options: list[str]) -> str: + """Askes the user a question with a list of supplied options + + Returns the option the user picked. + + If the internal ask function returns None, the process will exit with an error code 1 + """ + if options is not None and self._exit_option is not None: + options += [self._exit_option] + answer = self._ask(question, options) + if answer == self._exit_option: + answer = None + + if answer is None: + exit(1) + + return answer + + _exit_option: Optional[str] = "Exit" + + @abc.abstractmethod + def _ask(self, question: str, options: list[str] = None) -> Optional[str]: + """Implementation for asking a question pre-front end + + If you would otherwise return None, consider shutting down cleanly, + the calling function will exit the process with an error code of one + if this function returns None + """ + raise NotImplementedError() + + def _hook_product_update(self, product: Optional[str]): + """A hook for any changes the individual apps want to do when a platform changes""" + pass + +class Config: + def __init__(self, app: App) -> None: + self.app = app + + @property + def faithlife_product(self) -> str: + """Wrapper function that ensures that ensures the product is set + + if it's not then the user is prompted to choose one.""" + if not config.FLPRODUCT: + question = "Choose which FaithLife product the script should install: " # noqa: E501 + options = ["Logos", "Verbum"] + config.FLPRODUCT = self.app.ask(question, options) + self.app._hook_product_update(config.FLPRODUCT) + return config.FLPRODUCT \ No newline at end of file diff --git a/ou_dedetai/cli.py b/ou_dedetai/cli.py index af17cc20..e85cd994 100644 --- a/ou_dedetai/cli.py +++ b/ou_dedetai/cli.py @@ -1,5 +1,8 @@ import queue import threading +from typing import Optional + +from ou_dedetai.app import App from . import control from . import installer @@ -8,8 +11,9 @@ from . import utils -class CLI: +class CLI(App): def __init__(self): + super().__init__() self.running = True self.choice_q = queue.Queue() self.input_q = queue.Queue() @@ -88,6 +92,20 @@ def winetricks(self): import config wine.run_winetricks_cmd(*config.winetricks_args) + _exit_option: str = "Exit" + + def _ask(self, question: str, options: list[str]) -> str: + """Passes the user input to the user_input_processor thread + + The user_input_processor is running on the thread that the user's stdin/stdout is attached to + This function is being called from another thread so we need to pass the information between threads using a queue/event + """ + self.input_q.put((question, options)) + self.input_event.set() + self.choice_event.wait() + self.choice_event.clear() + return self.choice_q.get() + def user_input_processor(self, evt=None): while self.running: prompt = None @@ -111,7 +129,7 @@ def user_input_processor(self, evt=None): choice = input(f"{question}: {optstr}: ") if len(choice) == 0: choice = default - if choice is not None and choice.lower() == 'exit': + if choice is not None and choice == self._exit_option: self.running = False if choice is not None: self.choice_q.put(choice) diff --git a/ou_dedetai/gui.py b/ou_dedetai/gui.py index a370744c..80b04588 100644 --- a/ou_dedetai/gui.py +++ b/ou_dedetai/gui.py @@ -18,6 +18,37 @@ from . import utils +class ChoiceGui(Frame): + _default_prompt: str = "Choose…" + + def __init__(self, root, question: str, options: list[str], **kwargs): + super(ChoiceGui, self).__init__(root, **kwargs) + self.italic = font.Font(slant='italic') + self.config(padding=5) + self.grid(row=0, column=0, sticky='nwes') + + # Label Row + self.question_label = Label(self, text=question) + # drop-down menu + self.answer_var = StringVar(value=self._default_prompt) + self.answer_dropdown = Combobox(self, textvariable=self.answer_var) + self.answer_dropdown['values'] = options + if len(options) > 0: + self.answer_dropdown.set(options[0]) + + # Cancel/Okay buttons row. + self.cancel_button = Button(self, text="Cancel") + self.okay_button = Button(self, text="Confirm") + + # Place widgets. + row = 0 + self.question_label.grid(column=0, row=row, sticky='nws', pady=2) + self.answer_dropdown.grid(column=1, row=row, sticky='w', pady=2) + row += 1 + self.cancel_button.grid(column=3, row=row, sticky='e', pady=2) + self.okay_button.grid(column=4, row=row, sticky='e', pady=2) + + class InstallerGui(Frame): def __init__(self, root, **kwargs): super(InstallerGui, self).__init__(root, **kwargs) diff --git a/ou_dedetai/gui_app.py b/ou_dedetai/gui_app.py index 7436cd17..b5f3113f 100644 --- a/ou_dedetai/gui_app.py +++ b/ou_dedetai/gui_app.py @@ -7,11 +7,15 @@ from pathlib import Path from queue import Queue +from threading import Event from tkinter import PhotoImage from tkinter import Tk from tkinter import Toplevel from tkinter import filedialog as fd from tkinter.ttk import Style +from typing import Optional + +from ou_dedetai.app import App from . import config from . import control @@ -23,6 +27,33 @@ from . import utils from . import wine +class GuiApp(App): + """Implements the App interface for all windows""" + + _exit_option: Optional[str] = None + + def __init__(self, root: "Root", **kwargs): + super().__init__() + self.root_to_destory_on_none = root + + def _ask(self, question: str, options: list[str] = None) -> Optional[str]: + answer_q = Queue() + answer_event = Event() + def spawn_dialog(): + # Create a new popup (with it's own event loop) + pop_up = ChoicePopUp(question, options, answer_q, answer_event) + + # Run the mainloop in this thread + pop_up.mainloop() + + utils.start_thread(spawn_dialog) + + answer_event.wait() + answer = answer_q.get() + if answer is None: + self.root_to_destory_on_none.destroy() + return None + return answer class Root(Tk): def __init__(self, *args, **kwargs): @@ -82,8 +113,45 @@ def __init__(self, *args, **kwargs): self.iconphoto(False, self.pi) -class InstallerWindow(): - def __init__(self, new_win, root, **kwargs): +class ChoicePopUp(Tk): + """Creates a pop-up with a choice""" + def __init__(self, question: str, options: list[str], answer_q: Queue, answer_event: Event, **kwargs): + # Set root parameters. + super().__init__() + self.title(f"Quesiton: {question.strip().strip(':')}") + self.resizable(False, False) + self.gui = gui.ChoiceGui(self, question, options) + # Set root widget event bindings. + self.bind( + "", + self.on_confirm_choice + ) + self.bind( + "", + self.on_cancel_released + ) + self.gui.cancel_button.config(command=self.on_cancel_released) + self.gui.okay_button.config(command=self.on_confirm_choice) + self.answer_q = answer_q + self.answer_event = answer_event + + def on_confirm_choice(self, evt=None): + if self.gui.answer_dropdown.get() == gui.ChoiceGui._default_prompt: + return + answer = self.gui.answer_dropdown.get() + self.answer_q.put(answer) + self.answer_event.set() + self.destroy() + + def on_cancel_released(self, evt=None): + self.answer_q.put(None) + self.answer_event.set() + self.destroy() + + +class InstallerWindow(GuiApp): + def __init__(self, new_win, root: Root, **kwargs): + super().__init__(root) # Set root parameters. self.win = new_win self.root = root @@ -177,7 +245,29 @@ def __init__(self, new_win, root, **kwargs): # Run commands. self.get_winetricks_options() - self.start_ensure_config() + self.grey_out_others_if_faithlife_product_is_not_selected() + + def grey_out_others_if_faithlife_product_is_not_selected(self): + if not config.FLPRODUCT: + # Disable all input widgets after Version. + widgets = [ + self.gui.version_dropdown, + self.gui.release_dropdown, + self.gui.release_check_button, + self.gui.wine_dropdown, + self.gui.wine_check_button, + self.gui.okay_button, + ] + self.set_input_widgets_state('disabled', widgets=widgets) + if not self.gui.productvar.get(): + self.gui.productvar.set(self.gui.product_dropdown['values'][0]) + # This is started in a new thread because it blocks and was called form the constructor + utils.start_thread(self.set_product) + + def _hook_product_update(self, product: Optional[str]): + if product is not None: + self.gui.productvar.set(product) + self.gui.product_dropdown.set(product) def start_ensure_config(self): # Ensure progress counter is reset. @@ -222,21 +312,7 @@ def todo(self, evt=None, task=None): else: return self.set_input_widgets_state('enabled') - if task == 'FLPRODUCT': - # Disable all input widgets after Version. - widgets = [ - self.gui.version_dropdown, - self.gui.release_dropdown, - self.gui.release_check_button, - self.gui.wine_dropdown, - self.gui.wine_check_button, - self.gui.okay_button, - ] - self.set_input_widgets_state('disabled', widgets=widgets) - if not self.gui.productvar.get(): - self.gui.productvar.set(self.gui.product_dropdown['values'][0]) - self.set_product() - elif task == 'TARGETVERSION': + if task == 'TARGETVERSION': # Disable all input widgets after Version. widgets = [ self.gui.release_dropdown, @@ -290,7 +366,7 @@ def set_product(self, evt=None): self.gui.product_dropdown.selection_clear() if evt: # manual override; reset dependent variables logging.debug(f"User changed FLPRODUCT to '{self.gui.flproduct}'") - config.FLPRODUCT = None + config.FLPRODUCT = self.gui.flproduct config.FLPRODUCTi = None config.VERBUM_PATH = None @@ -556,8 +632,9 @@ def update_install_progress(self, evt=None): return 0 -class ControlWindow(): +class ControlWindow(GuiApp): def __init__(self, root, *args, **kwargs): + super().__init__(root) # Set root parameters. self.root = root self.root.title(f"{config.name_app} Control Panel") diff --git a/ou_dedetai/installer.py b/ou_dedetai/installer.py index 34a4eb1c..6107fe0d 100644 --- a/ou_dedetai/installer.py +++ b/ou_dedetai/installer.py @@ -4,6 +4,8 @@ import sys from pathlib import Path +from ou_dedetai.app import App + from . import config from . import msg from . import network @@ -12,41 +14,23 @@ from . import wine -def ensure_product_choice(app=None): +def ensure_product_choice(app: App): config.INSTALL_STEPS_COUNT += 1 update_install_feedback("Choose product…", app=app) logging.debug('- config.FLPRODUCT') logging.debug('- config.FLPRODUCTi') logging.debug('- config.VERBUM_PATH') - if not config.FLPRODUCT: - if config.DIALOG == 'cli': - app.input_q.put( - ( - "Choose which FaithLife product the script should install: ", # noqa: E501 - ["Logos", "Verbum", "Exit"] - ) - ) - app.input_event.set() - app.choice_event.wait() - app.choice_event.clear() - config.FLPRODUCT = app.choice_q.get() - else: - utils.send_task(app, 'FLPRODUCT') - if config.DIALOG == 'curses': - app.product_e.wait() - config.FLPRODUCT = app.product_q.get() - else: - if config.DIALOG == 'curses' and app: - app.set_product(config.FLPRODUCT) - - config.FLPRODUCTi = get_flproducti_name(config.FLPRODUCT) - if config.FLPRODUCT == 'Logos': + # accessing app.conf.faithlife_product ensures the product is selected + # Eventually we'd migrate all of these kind of variables in config to this pattern + # That require a user selection if they are found to be None + config.FLPRODUCTi = get_flproducti_name(app.conf.faithlife_product) + if app.conf.faithlife_product == 'Logos': config.VERBUM_PATH = "/" - elif config.FLPRODUCT == 'Verbum': + elif app.conf.faithlife_product == 'Verbum': config.VERBUM_PATH = "/Verbum/" - logging.debug(f"> {config.FLPRODUCT=}") + logging.debug(f"> {app.conf.faithlife_product=}") logging.debug(f"> {config.FLPRODUCTi=}") logging.debug(f"> {config.VERBUM_PATH=}") diff --git a/ou_dedetai/tui_app.py b/ou_dedetai/tui_app.py index 14d32d81..1d6d02bc 100644 --- a/ou_dedetai/tui_app.py +++ b/ou_dedetai/tui_app.py @@ -6,6 +6,9 @@ import curses from pathlib import Path from queue import Queue +from typing import Optional + +from ou_dedetai.app import App from . import config from . import control @@ -23,8 +26,9 @@ # TODO: Fix hitting cancel in Dialog Screens; currently crashes program. -class TUI: +class TUI(App): def __init__(self, stdscr): + super().__init__() self.stdscr = stdscr # if config.current_logos_version is not None: self.title = f"Welcome to {config.name_app} {config.LLI_CURRENT_VERSION} ({config.lli_release_channel})" # noqa: E501 @@ -37,6 +41,10 @@ def __init__(self, stdscr): self.logos = logos.LogosManager(app=self) self.tmp = "" + # Generic ask/response events/threads + self.ask_answer_queue = Queue() + self.ask_answer_event = threading.Event() + # Queues self.main_thread = threading.Thread() self.get_q = Queue() @@ -54,8 +62,6 @@ def __init__(self, stdscr): self.switch_q = Queue() # Install and Options - self.product_q = Queue() - self.product_e = threading.Event() self.version_q = Queue() self.version_e = threading.Event() self.releases_q = Queue() @@ -350,9 +356,7 @@ def run(self): signal.signal(signal.SIGINT, self.end) def task_processor(self, evt=None, task=None): - if task == 'FLPRODUCT': - utils.start_thread(self.get_product, config.use_python_dialog) - elif task == 'TARGETVERSION': + if task == 'TARGETVERSION': utils.start_thread(self.get_version, config.use_python_dialog) elif task == 'TARGET_RELEASE_VERSION': utils.start_thread(self.get_release, config.use_python_dialog) @@ -377,7 +381,7 @@ def choice_processor(self, stdscr, screen_id, choice): screen_actions = { 0: self.main_menu_select, 1: self.custom_appimage_select, - 2: self.product_select, + 2: self.handle_ask_response, 3: self.version_select, 4: self.release_select, 5: self.installdir_select, @@ -585,16 +589,6 @@ def custom_appimage_select(self, choice): self.appimage_q.put(config.SELECTED_APPIMAGE_FILENAME) self.appimage_e.set() - def product_select(self, choice): - if choice: - if str(choice).startswith("Logos"): - config.FLPRODUCT = "Logos" - elif str(choice).startswith("Verbum"): - config.FLPRODUCT = "Verbum" - self.menu_screen.choice = "Processing" - self.product_q.put(config.FLPRODUCT) - self.product_e.set() - def version_select(self, choice): if choice: if "10" in choice: @@ -719,25 +713,25 @@ def switch_screen(self, dialog): if isinstance(self.active_screen, tui_screen.CursesScreen): self.clear() - def get_product(self, dialog): - question = "Choose which FaithLife product the script should install:" # noqa: E501 - labels = ["Logos", "Verbum", "Return to Main Menu"] - options = self.which_dialog_options(labels, dialog) + _exit_option = "Return to Main Menu" + + def _ask(self, question: str, options: list[str]) -> Optional[str]: + options = self.which_dialog_options(options, config.use_python_dialog) self.menu_options = options - self.screen_q.put(self.stack_menu(2, self.product_q, self.product_e, question, options, dialog=dialog)) + self.screen_q.put(self.stack_menu(2, Queue(), threading.Event(), question, options, dialog=config.use_python_dialog)) - def set_product(self, choice): - if str(choice).startswith("Logos"): - config.FLPRODUCT = "Logos" - elif str(choice).startswith("Verbum"): - config.FLPRODUCT = "Verbum" - self.menu_screen.choice = "Processing" - self.product_q.put(config.FLPRODUCT) - self.product_e.set() + # Now wait for it to complete + self.ask_answer_event.wait() + return self.ask_answer_queue.get() + + def handle_ask_response(self, choice: Optional[str]): + if choice is not None: + self.ask_answer_queue.put(choice) + self.ask_answer_event.set() + self.switch_screen(config.use_python_dialog) def get_version(self, dialog): - self.product_e.wait() - question = f"Which version of {config.FLPRODUCT} should the script install?" # noqa: E501 + question = f"Which version of {self.conf.faithlife_product} should the script install?" # noqa: E501 labels = ["10", "9", "Return to Main Menu"] options = self.which_dialog_options(labels, dialog) self.menu_options = options