-
Notifications
You must be signed in to change notification settings - Fork 20
Revamp UI interactions #214
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1673d57
ba6066a
55af830
844682c
43c0b69
8cdc998
aac14da
bfa1c55
02b228f
b56c71a
f4bfb2b
0bef7a3
0ae712a
a9833b8
7ed5d93
7da8de8
97affa8
3c790bc
aca4d91
1e0247f
b4d2b5e
2adc228
62f7355
c08f417
5aef5e7
5eca4af
12301bd
382f2b5
4e7428c
46f3f47
5c92d03
cc7fe83
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,341 @@ | ||
| """ | ||
| This module provides the user interface for fixate. It is agnostic of the | ||
| actual implementation of the UI and provides a standard set of functions used | ||
| to obtain or display information from/to the user. | ||
| """ | ||
|
|
||
| from typing import Callable, Any | ||
| from queue import Queue, Empty | ||
| from enum import StrEnum | ||
| import time | ||
| from pubsub import pub | ||
|
|
||
| # going to honour the post sequence info display from `ui.py` | ||
| from fixate.config import RESOURCES | ||
| from fixate.core.exceptions import UserInputError | ||
| from collections import OrderedDict | ||
|
|
||
|
|
||
| class Validator: | ||
| """ | ||
| Defines a validator object that can be used to validate user input. | ||
| """ | ||
|
|
||
| def __init__(self, func: Callable[[Any], bool], errror_msg: str = "Invalid input"): | ||
| """ | ||
| Args: | ||
| func (function): The function to validate the input | ||
| error_msg (str): The message to display if the input is invalid | ||
| """ | ||
| self.func = func | ||
| self.error_msg = errror_msg | ||
|
|
||
| def __call__(self, resp: Any) -> bool: | ||
| """ | ||
| Args: | ||
| resp (Any): The response to validate | ||
|
|
||
| Returns: | ||
| bool: True if the response is valid, False otherwise | ||
| """ | ||
| return self.func(resp) | ||
|
|
||
| def __str__(self) -> str: | ||
| return self.error_msg | ||
|
Comment on lines
+19
to
+44
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can use generics to improve matching types between call and init. T = TypeVar("T")
class Validator(Generic[T]):
"""
Defines a validator object that can be used to validate user input.
"""
def __init__(self, func: Callable[[T], bool], errror_msg: str = "Invalid input"):
"""
Args:
func (function): The function to validate the input
error_msg (str): The message to display if the input is invalid
"""
self.func = func
self.error_msg = errror_msg
def __call__(self, resp: T) -> bool:
"""
Args:
resp (T): The response to validate
Returns:
bool: True if the response is valid, False otherwise
"""
return self.func(resp)
def __str__(self) -> str:
return self.error_msgAn untyped callable will count as def hello(arg) ->bool:
...
v = Validator(hello)
v(1)
v("st")A typed callable will warn if the wrong type is passed in def hello(arg:int) ->bool:
...
v = Validator(hello)
v(1)
v("st") # literal not assignable to int |
||
|
|
||
|
|
||
| class UiColour(StrEnum): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See above for import error class UiColour(str, Enum):I believe there are some subtle differences betweeen this and |
||
| RED = "red" | ||
| GREEN = "green" | ||
| BLUE = "blue" | ||
| YELLOW = "yellow" | ||
| WHITE = "white" | ||
| BLACK = "black" | ||
| CYAN = "cyan" | ||
| MAGENTA = "magenta" | ||
| GREY = "grey" | ||
|
|
||
|
|
||
| def _user_request_input(msg: str): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should annotate return as |
||
| q = Queue() | ||
| pub.sendMessage("UI_block_start") | ||
| pub.sendMessage("UI_req_input", msg=msg, q=q) | ||
| resp = q.get() | ||
| pub.sendMessage("UI_block_end") | ||
| return resp | ||
|
|
||
|
|
||
| def user_input(msg: str) -> str: | ||
| """ | ||
| A blocking function that asks the UI to ask the user for raw input. | ||
|
|
||
| Args: | ||
| msg (str): A message that will be shown to the user | ||
|
|
||
| Returns: | ||
| resp (str): The user response from the UI | ||
| """ | ||
| return _user_request_input(msg) | ||
|
|
||
|
|
||
| def user_input_float(msg: str, attempts: int = 5) -> float: | ||
| """ | ||
| A blocking function that asks the UI to ask the user for input and converts the response to a float. | ||
|
|
||
| Args: | ||
| msg (str): A message that will be shown to the user | ||
| attempts (int): Number of attempts the user has to get the input right | ||
|
|
||
| Returns: | ||
| resp (float): The converted user response from the UI | ||
|
|
||
| Raises: | ||
| UserInputError: If the user fails to enter a number after the specified number of attempts | ||
| """ | ||
| resp = _user_request_input(msg) | ||
| for _ in range(attempts): | ||
| try: | ||
| return float(resp) | ||
| except ValueError: | ||
| pub.sendMessage( | ||
| "UI_display_important", msg="Invalid input, please enter a number" | ||
| ) | ||
| resp = _user_request_input(msg) | ||
| raise UserInputError("User failed to enter a number") | ||
|
|
||
|
|
||
| def _ten_digit_int_serial(serial: str) -> bool: | ||
| return len(serial) == 10 and serial.isdigit() | ||
|
|
||
|
|
||
| _ten_digit_int_serial_v = Validator( | ||
| _ten_digit_int_serial, "Please enter a 10 digit serial number" | ||
| ) | ||
|
|
||
|
|
||
| def user_serial( | ||
| msg: str, | ||
| validator: Validator = _ten_digit_int_serial_v, | ||
| return_type: int | str = int, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. breaks python 3.8 return_type: Union[Type[str], Type[int]] = int,Additionally can use typevars to match the input and the return type if you want to go overboard. Does this give much more advantage over just always returning a string and then manually converting to an int if required? Or is it more of compatibility thing |
||
| attempts: int = 5, | ||
| ) -> Any: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. return type should be |
||
| """ | ||
| A blocking function that asks the UI to ask the user for a serial number. | ||
|
|
||
| Args: | ||
| msg (str): A message that will be shown to the user | ||
| validator (Validator): An optional function to validate the serial number, | ||
| defaults to checking for a 10 digit integer. This function shall return | ||
| True if the serial number is valid, False otherwise. | ||
| return_type (int | str): The type to return the serial number as, defaults to int | ||
|
|
||
| Returns: | ||
| resp (str): The user response from the UI | ||
| """ | ||
| resp = _user_request_input(msg) | ||
| for _ in range(attempts): | ||
| if validator(resp): | ||
| return return_type(resp) | ||
| pub.sendMessage("UI_display_important", msg=f"Invalid input: {validator}") | ||
| resp = _user_request_input(msg) | ||
| raise UserInputError("User failed to enter the correct format serial number") | ||
|
|
||
|
|
||
| def _user_req_choices(msg: str, choices: tuple): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. add |
||
| # TODO - do we really need this check since this is a private function and any callers should be calling correctly | ||
| if len(choices) < 2: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe this should just be an assert that catches broken bits during unit tests, since this exception basically requires another release of fixate to fix. |
||
| raise ValueError(f"Requires at least two choices to work, {choices} provided") | ||
| q = Queue() | ||
| pub.sendMessage("UI_block_start") | ||
| pub.sendMessage("UI_req_choices", msg=msg, q=q, choices=choices) | ||
| resp = q.get() | ||
| pub.sendMessage("UI_block_end") | ||
| return resp | ||
|
|
||
|
|
||
| def _choice_from_response(choices: tuple, resp: str) -> str | bool: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. breaks python 3.8 def _choice_from_response(choices: tuple, resp: str) -> Union[str, Literal[False]]:Using Additionally can use |
||
| for choice in choices: | ||
| if resp.startswith(choice[0]): | ||
| return choice | ||
| return False | ||
|
|
||
|
|
||
| def _user_choices(msg: str, choices: tuple, attempts: int = 5) -> str: | ||
| resp = _user_req_choices(msg, choices).upper() | ||
| for _ in range(attempts): | ||
| choice = _choice_from_response(choices, resp) | ||
| if choice: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I assume empty string should not count as a choice |
||
| return choice | ||
| pub.sendMessage( | ||
| "UI_display_important", | ||
| msg="Invalid input, please enter a valid choice; first letter or full word", | ||
| ) | ||
| resp = _user_req_choices(msg, choices).upper() | ||
| raise UserInputError("User failed to enter a valid response") | ||
|
|
||
|
|
||
| def user_yes_no(msg: str, attempts: int = 1) -> str: | ||
| """ | ||
| A blocking function that asks the UI to ask the user for a yes or no response. | ||
|
|
||
| Args: | ||
| msg (str): A message that will be shown to the user | ||
|
|
||
| Returns: | ||
| resp (str): 'YES' or 'NO' | ||
| """ | ||
| CHOICES = ("YES", "NO") | ||
| return _user_choices(msg, CHOICES, attempts) | ||
|
|
||
|
|
||
| def _user_retry_abort_fail(msg: str, attempts: int = 1) -> str: | ||
| CHOICES = ("RETRY", "ABORT", "FAIL") | ||
| return _user_choices(msg, CHOICES, attempts) | ||
|
|
||
|
|
||
| def user_info(msg: str): | ||
| pub.sendMessage("UI_display", msg=msg) | ||
|
|
||
|
|
||
| def user_info_important( | ||
| msg: str, colour: UiColour = UiColour.RED, bg_colour: UiColour = UiColour.WHITE | ||
| ): | ||
| pub.sendMessage("UI_display_important", msg=msg, colour=colour, bg_colour=bg_colour) | ||
|
|
||
|
|
||
| def user_ok(msg: str): | ||
| """ | ||
| A blocking function that asks the UI to display a message and waits for the user to press OK/Enter. | ||
| """ | ||
| pub.sendMessage("UI_block_start") | ||
| pub.sendMessage("UI_req", msg=msg) | ||
| pub.sendMessage("UI_block_end") | ||
|
|
||
|
|
||
| def user_action(msg: str, action_monitor: Callable[[], bool]) -> bool: | ||
| """ | ||
| Prompts the user to complete an action. | ||
| Actively monitors the target infinitely until the event is detected or a user fail event occurs | ||
|
|
||
| Args: | ||
| msg (str): Message to display to the user | ||
| action_monitor (function): A function that will be called until the user action is cancelled. The function | ||
| should return False if it hasn't completed. If the action is finished return True. | ||
|
|
||
| Returns: | ||
| bool: True if the action is finished, False otherwise | ||
| """ | ||
| # UserActionCallback is used to handle the cancellation of the action either by the user or by the action itself | ||
| class UserActionCallback: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it worth defining this in a way that the cmdline and qt gui modules can use so we don't have to ctrl + f to find all the weird coupled definitions of these callbacks? |
||
| def __init__(self): | ||
| # The UI implementation must provide queue.Queue object. We | ||
| # monitor that object. If it is non-empty, we get the message | ||
| # in the q and cancel the target call. | ||
| self.user_cancel_queue = None | ||
|
|
||
| # In the case that the target exists the user action instead | ||
| # of the user, we need to tell the UI to do any clean up that | ||
| # might be required. (e.g. return GUI buttons to the default state | ||
| # Does not need to be implemented by the UI. | ||
| # Function takes no args and should return None. | ||
| self.target_finished_callback = lambda: None | ||
|
|
||
| def set_user_cancel_queue(self, cancel_queue): | ||
| self.user_cancel_queue = cancel_queue | ||
|
|
||
| def set_target_finished_callback(self, callback): | ||
| self.target_finished_callback = callback | ||
|
|
||
| callback_obj = UserActionCallback() | ||
| pub.sendMessage("UI_action", msg=msg, callback_obj=callback_obj) | ||
| try: | ||
| while True: | ||
| try: | ||
| callback_obj.user_cancel_queue.get_nowait() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mypy gives error for calling if assigning the queue is assumed guaranteed behaviour then possibly can add an assert to make this error go away. Otherwise do we need another exception handler? |
||
| return False | ||
| except Empty: | ||
| pass | ||
|
|
||
| if action_monitor(): | ||
| return True | ||
|
|
||
| # Yield control for other threads but don't slow down target | ||
| time.sleep(0) | ||
| finally: | ||
| # No matter what, if we exit, we want to reset the UI | ||
| callback_obj.target_finished_callback() | ||
|
|
||
|
|
||
| def user_image(path: str): | ||
| """ | ||
| Display an image to the user | ||
|
|
||
| Args: | ||
| path (str): The path to the image file. The underlying library does not take a pathlib.Path object. | ||
| """ | ||
| pub.sendMessage("UI_image", path=path) | ||
|
|
||
|
|
||
| def user_image_clear(): | ||
| """ | ||
| Clear the image canvas | ||
| """ | ||
| pub.sendMessage("UI_image_clear") | ||
|
|
||
|
|
||
| def user_gif(path: str): | ||
| """ | ||
| Display a gif to the user | ||
|
|
||
| Args: | ||
| path (str): The path to the gif file. The underlying library does not take a pathlib.Path object. | ||
| """ | ||
| pub.sendMessage("UI_gif", path=path) | ||
|
|
||
|
|
||
| def _user_post_sequence_info(msg: str, status: str): | ||
| if "_post_sequence_info" not in RESOURCES["SEQUENCER"].context_data: | ||
| RESOURCES["SEQUENCER"].context_data["_post_sequence_info"] = OrderedDict() | ||
| RESOURCES["SEQUENCER"].context_data["_post_sequence_info"][msg] = status | ||
|
|
||
|
|
||
| def user_post_sequence_info_pass(msg: str): | ||
| """ | ||
| Adds information to be displayed to the user at the end if the sequence passes | ||
| This information will be displayed in the order that this function is called. | ||
| Multiple calls with the same message will result in the previous being overwritten. | ||
|
|
||
| This is useful for providing a summary of the sequence to the user at the end. | ||
|
|
||
| Args: | ||
| msg (str): The message to display. | ||
| """ | ||
| _user_post_sequence_info(msg, "PASSED") | ||
|
|
||
|
|
||
| def user_post_sequence_info_fail(msg: str): | ||
| """ | ||
| Adds information to be displayed to the user at the end if the sequence fails. | ||
| This information will be displayed in the order that this function is called. | ||
| Multiple calls with the same message will result in the previous being overwritten. | ||
|
|
||
| This is useful for providing a summary of the sequence to the user at the end. | ||
|
|
||
| Args: | ||
| msg (str): The message to display. | ||
| """ | ||
| _user_post_sequence_info(msg, "FAILED") | ||
|
|
||
|
|
||
| def user_post_sequence_info(msg: str): | ||
| """ | ||
| Adds information to be displayed to the user at the end of the sequence. | ||
| This information will be displayed in the order that this function is called. | ||
| Multiple calls with the same message will result in the previous being overwritten. | ||
|
|
||
| This is useful for providing a summary of the sequence to the user at the end. | ||
|
|
||
| Args: | ||
| msg (str): The message to display. | ||
| """ | ||
| _user_post_sequence_info(msg, "ALL") | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
StrEnumrequires python >=3.11.