A helper class to quickly create and thread tkinter GUIs, with an optional feature for handling basic user input.
A ThreadedGUI instance runs the tkinter GUI in the main thread, with the "app logic" running in a background thread. This allows the GUI to remain responsive while the app code manages its own flow.
Tkinter can be used as normal, and the UI provides a method to pass updates from the app code to the main thread (run_on_ui_thread()). Optionally, there is also a pre-built "prompt mechanism" to handle the display, waiting and getting of user input into the app thread.
pip install -e .
The following snippet shows how a ThreadedGUI UI can be used to create a responsive GUI. It uses both direct tkinter and the optional "prompt mechanism" to modify the UI and capture user input.
import tkinter as tk
import quick_tk_gui as qtkgui
def build_ui(gui):
"""Create an initial UI layout (this runs on the main thread during init)."""
# Use tkinter directly to add a header
gui.label = tk.Label(gui.root, text="User Response: waiting...", font=("Arial", 14))
gui.label.pack(pady=(50, 0))
# Make a frame to hold the prompt
gui.prompt_frame = tk.Frame(gui.root)
gui.prompt_frame.pack(expand=True, fill="both")
# Add a button array prompt to the GUI using the n_button preset UI
gui.prompt = gui.add_prompt(
setup_func=qtkgui.presets.n_button_prompt,
parent_frame=gui.prompt_frame,
label="Choose an option:",
buttons=[
{"label": "A", "value": "A", "keybindings": ["A", "a"]},
{"label": "B", "value": "B", "keybindings": ["B", "b"]},
],
)
def app_logic(gui):
"""Run the app logic in a background thread to keep the UI responsive."""
# Get user input from the buttons
value, timestamp = gui.prompt.wait_for_response()
print(f"User input: '{value}' at {timestamp}")
# Remove the prompt from the screen (empty the frame)
gui.remove_prompt(gui.prompt)
# Update the header with the user response
gui.run_on_ui_thread(
lambda: gui.label.config(text=f"User Response: '{value}' at t={timestamp}")
)
qtkgui.ThreadedGUI(name="Example GUI", build_ui=build_ui, app_logic=app_logic)Many factors impact the timing of visual updates and user input capture.
If using the "prompt mechanism", a timestamp is taken as close to the visual render as possible and saved to the presentation_timestamp property of the prompt instance. Similarly, the prompt wait_for_response() method returns a timestamp taken as close to the user input as possible.
Timestamps are taken from the ThreadedGUI clock. The now property returns the current time.
By default, the clock function uses time.time(), but this can be changed using set_clock(). For example, this is necessary when comparing UI timestamps to events from other sources such as a sounddevice or dipstream stream.
For example, to use the sounddevice Stream time as the clock:
gui.set_clock(lambda: stream.time)ThreadedGUI uses the ttk for styles and themes. The window theme and default font size can be set during init using the theme and default_font_size arguments.
Styles can be modified from using the style attribute. For example, to make all ttk.Buttons blue:
gui.style.configure(
"TButton",
background="#007acc"
)The presets module uses ttk widgets which can be individually styled by defining a style with an individual name, for example:
gui.style.configure(
"MyStyle.TButton",
background="#007acc"
)
prompt = gui.add_prompt(
setup_func=qtkgui.presets.n_button,
...
button_style="MyStyle.TButton"
)Instantiate using ThreadedGUI(name: str, build_ui: Callable, app_logic: Callable, theme: str, default_font_size: int), where build_ui is a function which runs on the main thread during init and lays out the initial UI, and app_logic is a function which runs on a background thread and handles the ongoing app and interactivity. The build_ui function is optional if it acceptable to draw the initial UI after the GUI window opens. Optionally, to apply ttk styling using the theme and default_font_size arguments.
Methods:
run_on_ui_thread(function: Callable, *args, **kwargs)schedules a function to run on the main (UI) thread. Additional augments are passed to the scheduled function and any returns are returned. Blocks until the scheduled function completes. Deep-copies arguments where possible for thread safety.close()closes the UI window.set_clock(clock_func: Callable)sets the clock function.add_prompt(setup_func: Callable, parent_frame: tk.Widget | ttk.Widget, *args, **kwargs)adds a new prompt to the UI by calling the setup function with any provided arguments.- The setup function could be a
presetor a custom function (see the example in "Prompt Mechanism" below).
- The setup function could be a
remove_prompt(prompt: _Prompt)removes a prompt from the UI (destroys the UI elements and unbinds events).clear_prompts()removes all prompts from the UI (destroys the UI elements and unbinds events).
Properties:
rootis thetk.Tk()instance.styleis thettk.Style(self.root)instance.nowis the current time from the clock function.
Prompts should not be instantiated directly, but by using the add_prompt() method above.
Methods:
enable()enables the interactive widgets (eg buttons) in the prompt.disable()disables the interactive widgets (eg buttons) in the prompt.wait_for_response()blocks until a user input is captured, and returns the value and timestamp.reset()resets the state of the prompt so that another user input can be captured without removing and re-creating it.
The following methods should only be used in the prompt setup function:
set_return_type(type: Any)sets the type of data that the prompt will return.submit(value: Any)a callback for binding to widgets or keys which sets the timestamp and value of the user input.track_interactive_widgets(widget: tk.Widget | ttk.Widget)adds a widget to the set that can be enabled and disabled.track_root_keybindings(key: str)adds a key name to the set that can be unbound from the root when the prompt is removed.
Properties (readonly):
is_enabledis True if the interactive widgets in the prompt are enabled.presentation_timestampis a timestamp taken as close to the visual render as possible.
The following properties should only be used in the prompt setup function:
rootis the Tk() root for binding keypresses to.
ThreadedGUI can be used as a base for directly using tkinter for UI layout and interactivity. For convenience, the "prompt mechanism" provides a basic way of setting up user input.
Additionally, the presets module provides some basic UI elements and pre-configured prompts, such as: a text label, button input or text input.
The examples and API description above provides an overview of using the preset prompts.
The presets module provides functions to create some basic UI elements and some pre-configured setup functions for creating prompts.
The parent_frame argument of a preset is the tk.Widget | ttk.Widget that is will be placed inside. The preset UIs are placed inside a centred_frame.
Presets use ttk, and can be styled using the ..._style arguments.
centred_frame(parent_frame)returns a frame who's content will be horizontally and vertically centred inside theparent_frame.
The pre-configured setup functions below add a prompt to the UI and return a prompt instance.
label(parent_frame, label: str, label_style: str)adds a text label. It has no user input interactivity, but can be added and removed like a prompt.n_button_prompt(parent_frame, label: str, buttons: list[dict], label_style: str, button_style: str, vertical_spacing: int, button_spacing: int, max_buttons_in_row: int)adds a row or grid of n buttons with a label above. Response return value is based on the button "value" type.- the
buttonsargument must be a list of dicts defining the buttons, where the format is like{"label": str, "value": Any, "keybindings": list[str]}. All the buttons must have the same "value" type.
- the
text_entry_prompt(parent_frame, label: str, button: dict, entry_prefill: str, label_style: str, entry_style: str, button_style: str, vertical_spacing: int)adds a text entry field (optionally prefilled with text), with a label above and a submit button below. Response return value is str.- the
buttonargument must be a dict of format like{"label": str, "keybindings": list[str]}
- the
dropdown_prompt(parent_frame, label: str, options: list[str], button: dict, label_style: str, dropdown_style: str, button_style: str, vertical_spacing: int)adds a dropdown entry field displaying theoptionslist, with a label above and a submit button below. Response return value is one of theoptionsstr.- the
buttonargument must be a dict of format like{"label": str, "keybindings": list[str]}
- the
file_select_prompt(parent_frame, label: str, button: dict, filetypes: list[tuple[str, str]], label_style: str, button_style: str, vertical_spacing: int)adds a button which opens a file selection dialogue, with a label above. Response return value is a str filepath.- the
buttonargument must be a dict of format like{"label": str, "keybindings": list[str]}
- the
The setup functions above also accept a prompt instance as their first argument, but this is handled internally during gui.add(...).
Use gui.add_prompt(setup_func, parent_frame, *args, **kwargs) to add a preset prompt to the UI.
It's possible to create user input from scratch without using the prompt mechanism. However, custom prompts can be connected to the underlying mechanism which could simplify the process.
When creating a custom prompt the setup function must:
- Accept an instance of the prompt as an argument (the prompt instance is created internally during
gui.add(...)). - Set the return type of the prompt using
set_return_type(type). - Bind user input events (buttons, keypresses, etc) to the
submit(value)callback, where the value argument is of the correct return type. - Keep track of all interactive widgets (for enabling and disabling the prompt) using
track_interactive_widget(widget). - Keep track of all root keybinding (for unbinding when the prompt is removed) using
track_root_keybinding(key).- Keys should be bound to the root using the
rootproperty.
- Keys should be bound to the root using the
The following example uses both a preset and a custom prompt.
import time
import tkinter as tk
import quick_tk_gui as qtkgui
def preset_prompt(prompt, parent_frame: tk.Widget):
"""Create a 3 button input choice prompt using a preset."""
qtkgui.presets.n_button_prompt(
prompt,
parent_frame=parent_frame,
label="Select a number:",
buttons=[
{"label": "One", "value": 1, "keybindings": ["1"]},
{"label": "Two", "value": 2, "keybindings": ["2"]},
{"label": "Three", "value": 3, "keybindings": ["3"]},
],
)
def custom_prompt(prompt, parent_frame: tk.Widget):
"""Create a text input field prompt from scratch."""
prompt.set_return_type(str) # set the return type of the custom prompt
lbl = tk.Label(parent_frame, text="Enter some text:")
lbl.pack()
entry = tk.Entry(parent_frame)
entry.pack()
prompt.track_interactive_widget(
entry
) # track the interactive widgets in the custom prompt
b = tk.Button(
parent_frame, text="Submit", command=lambda: prompt.submit(entry.get())
)
b.pack()
prompt.track_interactive_widget(
b
) # track the interactive widgets in the custom prompt
keybinding = "<Return>"
prompt.root.bind(keybinding, lambda _: prompt.submit(entry.get()))
prompt.track_root_keybinding(
keybinding
) # track the keybindings in the custom prompt
def app_logic(gui):
"""Run the app logic in a background thread to keep the UI responsive."""
# Create a prompt using a preset and wait for user input
my_preset_prompt = gui.add_prompt(setup_func=preset_prompt, parent_frame=gui.root)
value, ts = my_preset_prompt.wait_for_response()
print("Original preset prompt:", value, ts)
time.sleep(2)
# Clear the existing prompt and use it again, then destroy (remove) it
my_preset_prompt.reset()
my_preset_prompt.enable()
value, ts = my_preset_prompt.wait_for_response()
print("Reset preset prompt:", value, ts)
gui.remove_prompt(my_preset_prompt)
time.sleep(2)
# Create a new prompt, custom defined, wait for then input the destroy it
my_custom_prompt = gui.add_prompt(setup_func=custom_prompt, parent_frame=gui.root)
value, ts = my_custom_prompt.wait_for_response()
print("Original custom prompt:", value, ts)
gui.remove_prompt(my_custom_prompt)
time.sleep(2)
print("Closing...")
gui.close()
qtkgui.ThreadedGUI(name="Prompt Example", app_logic=app_logic)