Skip to content
Open
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
1673d57
make reformat_text private
jcollins1983 Aug 14, 2024
ba6066a
beginnings of (hopefully) improving the UI logic
jcollins1983 Aug 18, 2024
55af830
switch to the more descriptive UserInputError from the Fixate excepti…
jcollins1983 Aug 18, 2024
844682c
switch to new user_serial from _ui.py
jcollins1983 Aug 18, 2024
43c0b69
add user_yes_no and _user_abort_retry
jcollins1983 Aug 18, 2024
8cdc998
add _user_choices_
jcollins1983 Aug 18, 2024
aac14da
add space after choices string
jcollins1983 Aug 18, 2024
bfa1c55
make get_user_input private
jcollins1983 Aug 19, 2024
02b228f
gui for user choices done
jcollins1983 Aug 19, 2024
b56c71a
add some colour to the user_info_important call
jcollins1983 Aug 19, 2024
f4bfb2b
make a bit easier to follow
jcollins1983 Aug 19, 2024
0bef7a3
add user_info and user_info_important
jcollins1983 Aug 19, 2024
0ae712a
add user_ok
jcollins1983 Aug 19, 2024
a9833b8
add user_action
jcollins1983 Aug 23, 2024
7ed5d93
add some missing type hints
jcollins1983 Aug 23, 2024
7da8de8
add _user_image so indicate that an image would have been displayed
jcollins1983 Aug 25, 2024
97affa8
make information standout more
jcollins1983 Aug 25, 2024
3c790bc
fix typo
jcollins1983 Aug 25, 2024
aca4d91
add user image and gif functionality
jcollins1983 Aug 25, 2024
1e0247f
add doc strings
jcollins1983 Aug 26, 2024
b4d2b5e
include message RE use of GIFs in ccommand line
jcollins1983 Aug 26, 2024
2adc228
add post sequence display functions
jcollins1983 Aug 26, 2024
62f7355
move logic out of cmd and qt UIs into ui.py
jcollins1983 Aug 27, 2024
c08f417
adjust tests to account for movement of logic from ui to ui controlle…
jcollins1983 Aug 30, 2024
5aef5e7
fix a whoopsie
jcollins1983 Aug 30, 2024
5eca4af
fix docstring
jcollins1983 Aug 30, 2024
12301bd
probably don't need anything other than int or string for serial numbers
jcollins1983 Aug 31, 2024
382f2b5
add tests for new _ui.py
jcollins1983 Aug 31, 2024
4e7428c
fix tests
jcollins1983 Aug 31, 2024
46f3f47
add a bit more flexibility for the user_info_important colours in the…
jcollins1983 Sep 1, 2024
5c92d03
add test to ensure Issue #213 is not repeated
jcollins1983 Sep 1, 2024
cc7fe83
Merge branch 'PyFixate:main' into revamp-ui
jcollins1983 Oct 24, 2024
42ffa0d
fix typo in comment
jcollins1983 Oct 24, 2024
89631c3
Merge branch 'main' into revamp-ui
jcollins1983 Mar 1, 2026
a5db621
Merge branch 'main' into revamp-ui
jcollins1983 Mar 14, 2026
6ef77b5
remove the 'Result' part of the q.put
jcollins1983 Mar 14, 2026
8bfefc7
fix the things that were getting in the way of the UI tests
jcollins1983 Mar 21, 2026
5c0a2db
make mypy happy
jcollins1983 Mar 22, 2026
e4b9c22
remove unused import
jcollins1983 Mar 22, 2026
5115025
switch to consistent use of f-strings
jcollins1983 Mar 22, 2026
45f2e84
remove unused import
jcollins1983 Mar 22, 2026
3f65a18
addressing review comments
jcollins1983 Mar 22, 2026
70bfe63
add multiplier to account for width of !
jcollins1983 Mar 27, 2026
5cc7272
add manual tests for UI
jcollins1983 Mar 27, 2026
783ec7f
add file used in my setup
jcollins1983 Mar 27, 2026
2096a71
update release notes
jcollins1983 Mar 27, 2026
a72ad7a
one day I will remember all of these things together... bump version
jcollins1983 Mar 27, 2026
a6bb641
Merge branch 'main' into revamp-ui
jcollins1983 Mar 28, 2026
5b088e6
address review comments round 2 and bump black version
jcollins1983 May 17, 2026
b8ada04
sync black version to pre-commit...
jcollins1983 May 17, 2026
e131436
remove black from tox
jcollins1983 May 24, 2026
a1f0c65
forgot the envlist entry for removing black
jcollins1983 May 24, 2026
92be7d5
update mypy version to latest
jcollins1983 May 24, 2026
eca5ab7
remove tox -e black from workflows
jcollins1983 May 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/fixate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@
generate_relay_matrix_pin_list as generate_relay_matrix_pin_list,
)

from fixate._ui import (
Validator as Validator,
UiColour as UiColour,
user_input as user_input,
user_input_float as user_input_float,
user_serial as user_serial,
user_yes_no as user_yes_no,
user_info as user_info,
user_info_important as user_info_important,
user_ok as user_ok,
user_action as user_action,
user_image as user_image,
user_image_clear as user_image_clear,
user_gif as user_gif,
user_post_sequence_info_pass as user_post_sequence_info_pass,
user_post_sequence_info_fail as user_post_sequence_info_fail,
user_post_sequence_info as user_post_sequence_info,
)

from fixate.main import run_main_program as run

__version__ = "0.6.3"
341 changes: 341 additions & 0 deletions src/fixate/_ui.py
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
Comment thread
jcollins1983 marked this conversation as resolved.
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 thread
jcollins1983 marked this conversation as resolved.
Outdated


class UiColour(StrEnum):
Comment thread
jcollins1983 marked this conversation as resolved.
RED = "red"
GREEN = "green"
BLUE = "blue"
YELLOW = "yellow"
WHITE = "white"
BLACK = "black"
CYAN = "cyan"
MAGENTA = "magenta"
GREY = "grey"


def _user_request_input(msg: str):
Comment thread
jcollins1983 marked this conversation as resolved.
Outdated
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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it intentional that the retry logic has been removed from this? In the old ui user_input and user_input_float are the same thing with a different validator

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It hasn't, user_input from the original ui module has no retry logic. It's just used to get a raw input from the user.

In general, I've made anything that gets user input just that, a mechanism for getting input. If a more specific function uses the function to get input and there is any processing to be done, this is done by the caller, including any retry logic.

"""
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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make more sense to move resp into the start of the loop and remove from the except

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this will fix the bug that I mentioned earlier in _user_choices if attempts = 1.

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,
Comment thread
jcollins1983 marked this conversation as resolved.
Outdated
attempts: int = 5,
) -> Any:
Comment thread
jcollins1983 marked this conversation as resolved.
Outdated
"""
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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto.

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):
Comment thread
jcollins1983 marked this conversation as resolved.
Outdated
# TODO - do we really need this check since this is a private function and any callers should be calling correctly
if len(choices) < 2:
Comment thread
jcollins1983 marked this conversation as resolved.
Outdated
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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this have the same bug here #213

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, _user_req_choices only gets a value from the user (in the GUI shows buttons to press and gets the value for the button that was pressed). It's up to the caller to process that response. Currently the only caller is _user_choices which will either get a valid choice from the input or return False (which is what would happen if the user entered 0 or any other falsey value), in which case either another attempt is allowed or an exception is raised.

Currently there is no "gerneric" form of user_choices (the public version of this was removed in 0.5.2 - see line 204 of the release-notes), so there is currently no mechanism for a script to introduce a potentially problematic set of choices, though I have tested with a use of the private _user_choices and it happily accepts any string including an empty string and will return that as the choice if that is entered. It currently doesn't accept any numeric values.

Also, I made a point of addressing #213 in this PR.

pub.sendMessage("UI_block_end")
return resp


def _choice_from_response(choices: tuple, resp: str) -> str | bool:
Comment thread
jcollins1983 marked this conversation as resolved.
Outdated
for choice in choices:
if resp.startswith(choice[0]):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

Is this intentionally different to the old code?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, not sure what I was thinking when I did this.
With what used to be, you'd get a true response if you responded with Y, YE or YES if the choice was YES, but with what I did you'd get a true response if you responded with the same set but also with any other response starting with Y, like YASS, not really what we want. As I said, not really sure what I was thinking when I did this.

In both cases, if there were multiple choices that started with the same letter, you'd get a hit on the first choice that begins with the entered letter if only one letter was entered, this is only an issue in the CLI, with the GUI you get then entire word for the response. With that in mind, I'll go back to the original logic.

Whilst testing this I found an issue with the retry logic where if an invalid response is entered the user is asked again to input, but if attempts is not overridden to more than 1 this shouldn't happen etc. I've fixed this now too.

return choice
return False


def _user_choices(msg: str, choices: tuple, attempts: int = 5) -> str:
resp = _user_req_choices(msg, choices).upper()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, found it before I go to this comment. I would much prefer GH comments to be linear with respect to the code instead of temporally linear (they probably would be in a different view... 🤷‍♂️)

for _ in range(attempts):
choice = _choice_from_response(choices, resp)
if choice:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume empty string should not count as a choice

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, I don't think it would make sense to have an empty string as an option.

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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be private? It isn't used here but is imported in other modules. This highlights a different colour in vs code to show it isn't used and makes me want to delete it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's private in the sense that we don't want to expose it as part of the public interface. Why Pylance doesn't highlight it as used I don't know, it knows where it's used if you ctrl+click on in or show references though.

I have the same tendency to want to delete things that aren't used, since the mechanism used in __init__.py is to expose the methods that are part of the public interface, I've removed the _ so that Pylance treats it properly, and updated the comment to indicate that it should not be included in the public interface. You still have to explicitly import it from a private module to get to it. I think I'm happy enough with that approach.

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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I follow. This is defined in the common module that both cmd and gui use.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think what I meant was in _topic_UI_action and _user_action to add some typing info on what callback_obj is.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In all cases it should be a UserActionCallback, but this is defined within the user_action method, I don't see an easy way of typing this in the calling functions. I have noticed though that the docstrings for the cmdline was incorrect, so I've updated that.

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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mypy gives error for calling get_nowait on None

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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not getting an error when I run mypy...

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this setting isn't turned on for this file. I might've had everything at strict when I wrote this.

I think for this PR I would keep as is, but we probably need to make it more obvious in the project readme how to set these sorts of things up when writing new modules.
The general intent is over time we fix up errors and add in stricter options.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, agree. I've added an assert as the developer making tweaks or additions to the UI should be looking after the assignment of user_cancel_queue and target_finished_callback. Also added typing for these.

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")
Loading