a tui in a day
what is a TUI
A TUI is a Text-based User Interface, which provides another way of interacting within the terminal besides writing plain text commands. There are several frameworks to build such TUIs, e.g. ratatui for Rust or textual for Python. In the following article, I will describe the development of a TUI using the textual framework.
the issue
When switching from my phone to my MacBook, my airpods would not automatically connect to the MacBook. Since opening the Bluetooth settings and manually connecting them each time was quite annoying, I looked for possibilities to control the Bluetooth directly from terminal. After some research I stumbled over bluetui, which is only for linux, and blueutil, which is macos only. blueutil offered all the functionalities I wanted, but I did not like to remember lengthy commands or define aliases for them.
the idea
Since I’ve worked with the textual framework quite a lot for my recent projects, I decided to build a simple TUI, which acts as a wrapper around some blueutil commands. Here are the features I wanted, and how I decided to implement them:
- Feature 1: The app shall have an easy overview to show my paired devices and their status
- How: DataTable Widget with a single row per device. A clear label to identify devices, as well as red and green circles to display connection and pairing status
- Feature 2: The app shall support fast and easy navigation and controls without needing the mouse
- How: Let the app start in inline mode (not supported on windows currently) to open the tui directly under the prompt. Also, overwrite/add additional bindings to DataTable Widget for Vim-like navigation with j/k and other functionalities. Also utilize workers to not block the apps main event loop
- Feature 3: The app shall support unpairing/pairing,
disconnecting/connecting of devices and searching for new unpaired devices
- How: Define the necessary functions, bindings to trigger them and table cell updates
- Feature 4: The app should indicate, that it is currently searching for new devices
- How: Add a label to indicate that search is running. Including timer to show how much time is left
building the app
Based on the features described above I went for a pretty minimal app, which is composed from three widgets.
First a Header
to display the app title and the current installed version of blueutil. Then the
main part of the app, the DeviceTable
, which is just a custom class, that inherits from textuals
DataTable
widget and finally the Footer
to show the keybindings to control the app.
class BlueUtilApp(App):
def on_mount(self):
self.get_blueutil_version()
def compose(self) -> ComposeResult:
self.screen.title = "blueutil-tui"
yield Header(icon="")
yield DeviceTable()
yield Footer()
return super().compose()
@work(thread=True)
def get_blueutil_version(self):
version = get_blueutil_version()
self.screen.title += f" using blueutil v{version}"
Before starting with the DeviceTable
definition I had to get the information
about the bluetooth devices from blueutil and then use that information to
display it inside a textual DataTable. blueutil provides the option to display the device data in .json-format.
$ blueutil --paired --format json-pretty
[
{
"address" : "f0-04-e1-db-ea-42",
"recentAccessDate" : "2025-02-20T14:28:06+01:00",
"paired" : true,
"RSSI" : 0,
"rawRSSI" : 0,
"favourite" : false,
"connected" : true,
"name" : "AirPods Pro",
"slave" : false
},
{
...
}
]
So I could use subprocess.run
to execute the command
and capture the string output and parse it with json.loads
into a list of dictionaries.
def get_paired_devices() -> list[dict[str, str | bool]] | None:
command = subprocess.run(
["blueutil", "--paired", "--format", "json"],
capture_output=True,
text=True,
timeout=TIMEOUT,
)
handle_returncodes(errorcode=command.returncode)
if command.stdout:
devices = command.stdout
formatted_devices = format_device_string(device_string=devices)
return formatted_devices
def format_device_string(device_string: str) -> list[dict[str, str | bool]]:
json_dict = json.loads(device_string)
return json_dict
Then I could define the function that updates the DataTable widget. It clears the table
then iterates over the list of devices to insert the
address
, name
, recentAccessDate
and pairing
and connection
status as
new rows into the textual DataTable Widget.
class DeviceTable(DataTable):
...
def action_update_devices(self):
self.clear()
devices = get_paired_devices()
for device in devices:
self.add_row(
":green_circle:" if device["connected"] else ":red_circle:",
":green_circle:" if device["paired"] else ":red_circle:",
device["recentAccessDate"],
device["address"],
key=device["address"],
label=f"[blue]{device['name']}[/]",
)
To enable Vim-like navigation, I had to redefine some of the bindings for the DataTable widget.
Since there already are cursor_down
, cursor_up
and select_cursor
actions defined, I did not have to create a custom
action for the navigations.
class DeviceTable(DataTable):
BINDINGS = [
Binding("j, down", "cursor_down", "down", key_display="j/↓"),
Binding("k, up", "cursor_up", "up", key_display="k/↑"),
Binding("space, enter", "select_cursor", "dis/connect", key_display="space/enter"),
Binding("r", "update_devices", "refresh"),
Binding("s", "display_new_devices", "search"),
Binding("p", "toggle_pair_device", "un/pair"),
]
The last three bindings, which handle the interaction with blueutil required the definition
of new actions though. For example the binding to refresh the table is triggered when pressing
r
, calls the action_update_devices
function from above and is displayed as refresh
in the Footer.
One of the harder parts was updating the device status and updating the app view accordingly without blocking the main event loop in which the app runs. This is possible in textual using the worker API (see docs).
class DeviceTable(DataTable):
...
@on(DataTable.RowSelected)
@work(thread=True)
async def toggle_connection(self, event: DataTable.RowSelected):
selected_address = event.row_key.value
self.app.call_from_thread(
lambda: self.update_cell(
row_key=selected_address,
column_key="connection",
value="updating...",
)
)
# disconnecting
if await device_is_connected(device_address=selected_address):
success = await disconnect_device(device_address=selected_address)
new_status = ":red_circle:" if success == 0 else ":green_circle:"
if success == 0:
message = f"[blue]{self.rows[selected_address].label}[/] disconnected"
else:
message = f"Please check [blue]{self.rows[selected_address].label}[/] if the device is nearby"
# connecting
else:
success = await connect_device(device_address=selected_address)
new_status = ":green_circle:" if success == 0 else ":red_circle:"
if success == 0:
message = f"[blue]{self.rows[selected_address].label}[/] connected"
else:
message = f"Please check [blue]{self.rows[selected_address].label}[/] if the device is nearby"
self.app.call_from_thread(
lambda: self.update_cell(
row_key=selected_address,
column_key="connection",
value=new_status,
)
)
self.notify(
title="Error" if "nearby" in message else "Success",
message=message,
timeout=1.5,
)
This function is triggered, when a row/device is selected in the DeviceTable.
It updates the corresponding field in the DeviceTable to updating…,
checks the device’s connection status, then either
connects or disconnects it, and updates the table entry accordingly.
If the return value of the disconnect_device
/ connect_device
function is non-zero, i.e. if an error occurred,
it updates the DeviceTable field to the previous value and displays an error notification.
The same functionality is also implemented for pairing devices,
which can be triggered pressing p
on the corresponding device row in the table.
The final missing feature was the functionality to search for unpaired devices. I implemented another worker function to look for new unpaired devices in the background to keep the app responsive. The existing table is then updated with new found devices, ready to be paired.
To indicate that a search was initiated a Label widget with a countdown timer, is mounted. The label indicates for how long the search will last and gets automatically unmounted once the timer expires.
the result
Putting everything together, here’s the final application:
The initial version of blueutil-tui was built in a single day. Over the next few days, I fixed minor bugs related to the search function and fine-tuned the countdown timer behavior.
This example demonstrates, how quickly you can build with textual, thanks to its intuitive API and great documentation. As a bonus blueutil-tui is now also featured in the original blueutil README under the Alternative Interface section.