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: image

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.