prompt-toolkit / prompt-toolkit/python-prompt-toolkit
asyncio concepts and prompt toolkit for UI updates
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 10.6k
- Forks
- 815
- PR merge metrics
- No merged PRs in 30d
Description
I am currently struggling with asyncio concepts and prompt toolkit. I want to be able to update the UI when I have long running tasks in the background. In a regular UI (non-terminal application) the task would be spun up in a separate thread and when there is data available it could send... say an event or message (depending on the framework) with the data to the UI thread for it to update itself.
Again, I am struggling to try to get anything similar to that pattern with asyncio and prompt toolkit. I included a sample application that has a "ListView." Pressing "i" would run a long-running task that populates the list view. If I remove all of the asyncio stuff and run everything on the main thread, then everything works fine, but when I run it as-is, it only prints the first item. It does nothing after the first asyncio.sleep() call.
Also, I realize that the UI updates are happening in the "background" here (or at least I think they are not--I don't understand asyncio enough yet to know if it's sharing the main event loop?). Do you have any suggestion on how I can send events or messages to the main thread so all the UI work happens on one thread? (sample code?) Or is that not required for prompt-toolkit?
Any and all help and suggestions would be appreciated.
Just fyi. This is obviously sample code but in my real application the list view would receive updates from http calls.
# -*- coding: utf-8 -*-
"""
main.py
Part of the listupdate Project
Created: 2024-02-06T16:03:56
"""
import asyncio
import os
import random
import threading
from prompt_toolkit import Application
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.layout import FormattedTextControl, Window, Layout
from prompt_toolkit.styles import Style
LIST_DATA_HEADER_STYLE: str = 'class:list-data-header'
LIST_DATA_DATA_STYLE: str = 'class:list-data-data'
TAB_DATA: str = ' '
HDR_SEP: str = '|'
COL_SEP: str = ' ' # ' ' if you do not want any or '|' if you want lines
GREEK_ALPHABET = [
'Alpha',
'Beta',
'Gamma',
'Delta',
'Epsilon',
'Zeta',
'Eta',
'Theta',
'Iota',
'Kappa',
'Lambda',
'Mu',
'Nu',
'Xi',
'Omicron',
'Pi',
'Rho',
'Sigma',
'Tau',
'Upsilon',
'Phi',
'Chi',
'Psi',
'Omega',
]
MILITARY_ALPHABET = [
'Alpha',
'Bravo',
'Charlie',
'Delta',
'Echo',
'Foxtrot',
'Golf',
'Hotel',
'India',
'Juliet',
'Kilo',
'Lima',
'Mike',
'November',
'Oscar',
'Papa',
'Quebec',
'Romeo',
'Sierra',
'Tango',
'Uniform',
'Victor',
'Whiskey',
'X-ray',
'Yankee',
'Zulu',
]
def get_max_item_size(items: list[str]) -> int:
max_size = 0
for i in items:
s = len(i)
if s > max_size:
max_size = s
return max_size
COL_1_SIZE = 3
COL_2_SIZE = get_max_item_size(GREEK_ALPHABET) + 1 + get_max_item_size(MILITARY_ALPHABET)
COL_3_SIZE = len('desc')
class ListUpdate:
def __init__(self):
self._list = FormattedTextControl()
self._root_container = self._build_ui()
self._layout = Layout(self._root_container)
self._key_bindings = self._make_key_bindings()
self._style = self._make_style()
self._app = Application(
layout=self._layout,
key_bindings=self._key_bindings,
mouse_support=True,
style=self._style,
full_screen=True
)
def run(self):
self._app.run()
def _build_ui(self):
term_width = os.get_terminal_size().columns
col_4_width = term_width - (COL_1_SIZE + COL_2_SIZE + COL_3_SIZE + 9)
data = [
(LIST_DATA_HEADER_STYLE, f' {"ID": >{COL_1_SIZE}} '),
(LIST_DATA_HEADER_STYLE, HDR_SEP),
(LIST_DATA_HEADER_STYLE, f' {"NAME": <{COL_2_SIZE}} '),
(LIST_DATA_HEADER_STYLE, HDR_SEP),
(LIST_DATA_HEADER_STYLE, f' {"DESCRIPTION": <{COL_3_SIZE}} '),
(LIST_DATA_HEADER_STYLE, HDR_SEP),
(LIST_DATA_HEADER_STYLE, ' ' * col_4_width),
(LIST_DATA_HEADER_STYLE, '\n'),
]
self._list.text = data
return Window(content=self._list)
def _make_key_bindings(self):
kb = KeyBindings()
@kb.add("i")
def _(event):
self.on_add_list_data(event)
@kb.add("c-q", eager=True)
def _(event):
self.on_exit_app(event)
return kb
def _make_style(self):
return Style.from_dict({
'list-data-header': 'bg:#47ddaa #000000 bold',
'list-data-data': ''
})
def add_list_item(self, id_, name, description):
data: list = self._list.text
data.append((LIST_DATA_DATA_STYLE, f' {id_: >{COL_1_SIZE}} '))
data.append((LIST_DATA_DATA_STYLE, COL_SEP))
data.append((LIST_DATA_DATA_STYLE, f' {name: <{COL_2_SIZE}} '))
data.append((LIST_DATA_DATA_STYLE, COL_SEP))
data.append((LIST_DATA_DATA_STYLE, f' {description: <{COL_3_SIZE}} '))
data.append((LIST_DATA_DATA_STYLE, '\n'))
async def long_running_method(self, event):
all_names: list = []
for idx in range(len(GREEK_ALPHABET)):
for jdx in range(len(MILITARY_ALPHABET)):
all_names.append((GREEK_ALPHABET[idx], MILITARY_ALPHABET[jdx]))
random.shuffle(all_names)
# just show the first 40 because we haven't handled
# scrolling in our "ListView"
for idx, names in enumerate(all_names[0:40]):
self.add_list_item(idx + 1, f'{names[0]}-{names[1]}', 'desc')
await asyncio.sleep(1)
def on_add_list_data(self, event):
asyncio.create_task(self.long_running_method(event))
def on_exit_app(self, event):
self._app.exit()
def main():
lu: ListUpdate = ListUpdate()
lu.run()
if __name__ == '__main__':
main()
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by running the sample in main.py and tracing ListUpdate.run, on_add_list_data, and long_running_method. Inspect how the prompt-toolkit Application and asyncio task interact, then reproduce the first-item-only behavior; done means providing a confirmed explanation and guidance for updating the ListView during the long-running operation.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- cli
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 20/100