From 80428177acf8bfa5bdc047ba0fca3559fa0e500e Mon Sep 17 00:00:00 2001 From: Nathan Shaaban <86252985+ctrlaltf24@users.noreply.github.com> Date: Thu, 24 Oct 2024 00:30:57 -0700 Subject: [PATCH] feat: generic prompts Works on all three UIs offers a generic function to ask a question that platform independent. If the user fails to offer a response, the installer will terminate. In the GUI this still works, however it may not be desireable to prompt the user for each question. So long as we don't attempt to access the variable before the user has had a chance to put in their preferences it will not prompt them Changed the GUI to gray out the other widgets if the product is not selected. start_ensure_config is called AFTER product is set, if it's called before it attempts to figure out which platform it's on, prompting the user with an additional dialog (not ideal, but acceptable) --- ou_dedetai/app.py | 59 ++++++++++++++++++++ ou_dedetai/cli.py | 22 +++++++- ou_dedetai/gui.py | 31 +++++++++++ ou_dedetai/gui_app.py | 117 +++++++++++++++++++++++++++++++++------- ou_dedetai/installer.py | 36 ++++--------- ou_dedetai/tui_app.py | 58 +++++++++----------- 6 files changed, 243 insertions(+), 80 deletions(-) create mode 100644 ou_dedetai/app.py 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