Skip to content

Commit

Permalink
feat: generic prompts
Browse files Browse the repository at this point in the history
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 desirable 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)
  • Loading branch information
ctrlaltf24 committed Oct 24, 2024
1 parent 0def2e5 commit 82d0c94
Show file tree
Hide file tree
Showing 6 changed files with 243 additions and 80 deletions.
59 changes: 59 additions & 0 deletions ou_dedetai/app.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 20 additions & 2 deletions ou_dedetai/cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import queue
import threading
from typing import Optional

from ou_dedetai.app import App

from . import control
from . import installer
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions ou_dedetai/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
117 changes: 97 additions & 20 deletions ou_dedetai/gui_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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(
"<Return>",
self.on_confirm_choice
)
self.bind(
"<Escape>",
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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
36 changes: 10 additions & 26 deletions ou_dedetai/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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=}")

Expand Down
Loading

0 comments on commit 82d0c94

Please sign in to comment.