PHP-TUI Progress

Last modified 2024/12/07 11:48

Update 30/11/2023: I continued to work on it and versions have been tagged and you can visit the docs and view a follow up blog post about Term).


TL;DR I’ve been working on a TUI framework for PHP. It’s not finished yet. Give it a star.

phptui logo PHP-TUI lnogo

Since being made redundant 3 weeks ago I have been using my unexpected free time to start a project that has been in my minds eye for some time: a TUI framework for PHP.

What is a TUI framework?

TUI stands for Terminal User Interface. It’s generally for a full screen application that runs in the terminal and is controlled with the keyboard, although it’s also possible for TUIs to use the mouse!

This blog post documents some of my journey up until now.

Background

For the past 14 years or more the terminal has been my environment, I use neovim, tmux, ncmcpp, and a wide variety of other tools. I only use graphical applications if there is no other choice, or if it makes sense (e.g. GIMP).

I appreciate the succintness of textual, keyboard driven interfaces and have always wanted to be able to write advanced CLI applications, and I did so with PHPBench - which can render graphs and even complete reports to the console!

phpbench One of PHPBench’s graphical novelties

But this was lots of work and it was obvious to me that there was a better way, then I saw libraries in Rust and Go (for example the original Rust TUI which has since been supplanted by Ratatui)

rust tui screenshot Original Rust TUI screenshot

These libraries supported “drawing” in Braille, and could organise widgets in a layout.

I made a few abortive attempts to write a framework in PHP but always stopped when I realised how complicated it was - and I had hardly any experience of writing event driven interfaces…

Learning Rust

A few years ago I was learning Golang (see my post about it), and was interested in learning Rust.

Whenever I want to learn a new language I start thinking about a project to work on - something that I would find useful. My first project was a Plain Text Time Logger.

It’s an application that parses and analyses plain text timesheets:

strava rs screenshot

My second large project was Strava RS - a TUI client for Strava - which taught me much more about Rust and the TUI framework.

strava rs screenshot

After a few years of on-and-off Rust time I had a better idea of what was required to build a TUI framework.

Redundancy

It’s a horrible word, and it’s a word that grows heavier the longer I don’t have a job. But the good thing is that I now had plenty of spare time.

I did what most people do in this situation and decided to port Ratatui to PHP.

Initial Steps

I started with a failing acceptance test in PHPUnit which (basically) just ran the following code and asserted the “output”:

$display = Display::fromBackend(SymfonyBackend::new());
$display->draw(function (Buffer $buffer) {
    Paragraph::fromString('Hello World!')->render($buffer->area(), $buffer);
});

The test failed of course as none of the classes existed.

All I had to do was create the classes and implement all the dependencies! Given I was now familiar with Rust this was relatively easy.

Cassowary

The first stumbling block came when implementing the Layout class. The layout class is responsible for organising the widgets in the available space in the terminal, a layout might look like this:

+------------------------------------+
| Header                             |
+-----------------------+------------+
| Main content          | Side       |
|                       | Panel      |
|                       |            |
|                       |            |
|                       |            |
|                       |            |
|                       |            |
+------------------------------------+
| Footer                             |
+------------------------------------+

It typically needs to fill the entire terminal and the sections need to scale with it and collapse if necessary, it’s similar to a modern Grid system in CSS.

Easy you say! Just make the left column use 70% of the width, the header must be exactly 4 rows high, the footer can collapse to zero if there’s not enough space, and it would be nice if the side panel had 10 columns, but if it was a choice between that and showing the main content, then the main content should win…

It turns out the logic for this is not trivial at all Ratatui uses a library called cassowary rs to do this.

Cassowary is the algorithm that probably arranges the user interfaces in your desktop environment, whether it be Windows, Mac or Linux. It has been ported to just about every other programming languages except PHP.

Most of the ports are based on kiwi and my port is based on the Rust version, I copied it line-for-line and spent many, many (many) ((MANY)) hours debugging it.

But I got there in the end (and published the package) without really understanding how it works (you can read the paper).

Talking to the Terminal

Until this point I had been using Symfony console as the “backend”.

Ratatui is abstracted from the dirty job of writing to the terminal, it uses either crossterm or termion for this purpose. PHP-TUI is abstracted in the same way.

The Symfony backend interprets the “updates” from PHP-TUI and writes them to the console with the necessary formatting:

// this is _essentially_ what it DID but also nothing like it at all.
foreach ($updates as $update) {
    SymfonyTerminal::moveCursor($update->x, $update->y);
    $symfonyConsoleOutput->write(sprintf(
        '<fg=%s bg=%s>%s</>',
        $update->fgColor,
        $update->bgColor,
        $update->char
    ));
}

But Symfony Console only goes so far, it does not offer advanced support for the terminal, and it does nothing about reading events from the terminal.

So, I was becoming good at porting Rust libraries to PHP, why not port another?. I started to port crossterm under the name PHP-Term

PHP-Term

PHP-Term is heavily inspired by crossterm. It’s not a 100% faithful port as Crossterm, being a Rust library, is able to make low level calls with the termios API to fulfil some requests, which PHP is unable to do practically.

Instead I am able to use tools such as stty to infer the necessary information.

PHP-Term is capable of reading user events:

// reading user events
while (null !== $event = $terminal->events()->next()) {
    if ($event instanceof CodedKeyEvent) {
        if ($event->code === KeyCode::Left) {
            // user pressed left
        }
    }
}

And executing actions on the terminal:

// raw mode is that thing that breaks your terminal when programs crash
$terminal->enableRawMode();

// hide cursor directly
$terminal->execute(Actions::cursorHide());

// queue and then flush actions
$terminal->queue(Actions::printString("Hello"));
$terminal->queue(Actions::setRgbBackgroundColor(255, 255, 255));
$terminal->flush()

// disable raw mode again!
$terminal->disableRawMode();

At time of writing it’s packaged in the main php-tui repository (under lib/) but I’ll split it out when the time comes.

Implementing Widgets

By this point I had a pretty good foundation and could start implementing widgets:

braille Map of the world rendered on a canvas in Braille

chart The chart widget

block Blocks

In addition the Paragraph, Table and List widgets were ported.

One of the nicest features of Ratatui is the Canvas widget, which offers a canvas with a given resolution which can be draw upon. Cells can be filled with one of the following characters:

  • Block: Each cell a full block or empty . The resolution is the number of cells in the terminal area.
  • Half Block: Each cell is upper half-block , lower half block , full block or an empty block. This doubles the resolution in the vertical direction.
  • Braille: UTF-8 has a full set of braille characters, (, , , …) which quadruples the vertical resolution and doubles the horizontal resolution. The only down side is that you can only have one color per terminal cell.

Essentially Braille can be used to render relatively detailed things in but with only one color per cell, while Half Block can be used to provide increased resolution while preserving colors.

Nyan Elephant

Then I thought:

What if you could make a Nyan Elephant?

Originally I thought I could make an elephant that flew threw space and pooped rainbow bombs.

Ratatui didn’t support this, so I rolled a new Widget called Sprite:

nyan elephants

Then I thought, what if you could have scrolling, bouncy text like in the 90s)?

Font Parsing

To render big 90s demo-scene scrolling text I would need to cast my mind back to the time before TrueType fonts.

Before TrueType fonts were encoded as a bitmaps - essentially the letters were represented as pixel grids:

6     
5 ███ 
4█   █
3█████
2█    
1 ███ 
0123456

It turns out that these can be stored in BDF files and BDF is a plain text format, the following is the relevant data for the letter e:

STARTCHAR e
ENCODING 101
SWIDTH 576 0
DWIDTH 6 0
BBX 6 10 0 -2
BITMAP
00
00
00
70
88
F8
80
70
00
00
ENDCHAR

Rust has a bdf-parser by the Rust embedded graphics project. I could port ANOTHER RUST LIBRARY!

The Rust BDF parser used parser combinators, and although PHP has the great Parsica library, I didn’t want to add more dependencies, and as the format is actually very simple, I ended up rolling my own parser, and lo! it SPEAKS:

text Slide splash screen

A Picture is Worth £5

At this point I was thinking, what else could be done? I remember seeing the Golang tview TUI project and being impressed that he was able to render his own likeness in the terminal:

tview screenshot TView Screenshot

I’m not familiar with image formats, I immediately thought of BMP files - after my recent experience with bitmaps in BDF files, coiuld a BMP be the same thing for images? Could I parse a BMP file or somethign similar? The answer was NO, or at least I didn’t want to.

Tview used Golang’s standard image module. PHP has two main graphics extensions GD and ImageMagick and it turns out that it was exceedingly simple to render images to the console now:

class ImageShape implements Shape
{
    // ...

    public function draw(Painter $painter): void
    {
        $geo = $this->image->getImageGeometry();

        /** @var ImagickPixel[] $pixels */
        foreach ($this->image->getPixelIterator() as $y => $pixels) {
            foreach ($pixels as $x => $pixel) {
                $point = $painter->getPoint(
                    FloatPosition::at(
                        $this->position->x + $x,
                        $this->position->y + $geo['height'] - intval($y) - 1
                    )
                );
                if (null === $point) {
                    continue;
                }
                $rgb = $pixel->getColor();
                $painter->paint($point, RgbColor::fromRgb(
                    $rgb['r'],
                    $rgb['g'],
                    $rgb['b']
                ));
            }
        }

    }
}

Burrrr - that’s it. That’s the image rendering code:

image Photo gallery in the demo app

Documentation

If you build it they will come. Well, if people do end up using this TUI for some reason they might appreciate some documentation.

One of the things I learnt from PHPBench development is that documentation is MUCH better when it is generated. Generated documentation is more accurate and also you can set it up in such a way that the code examples are executed by PHPUnit.

The following is the standalone blocks code example:

require 'vendor/autoload.php';

$display = Display::fullscreen(PhpTermBackend::new());
$display->draw(function (Buffer $buffer): void {
    Block::default()
        ->borders(Borders::ALL)
        ->title(Title::fromString('Hello World'))
        ->borderType(BorderType::Rounded)
        ->widget(Paragraph::new(Text::raw('This is a block example')))
        ->render($buffer->area(), $buffer);
});
$display->flush();

The COOL thing is that PHPUnit executes this script as a process and captures it’s output. The REALLY COOL thing is that PHP-Term will read the raw ANSI codes and render the output to HTML!

image rendered in documentation Terminal image rendered in HTML from the docs

A SLIGHTLY LESS COOL BUT STILL COOL thing is that the output is stored as a snapshot and if the output changes the test will fail.

So code examples:

  • Are fully independent scripts that can be executed independently.
  • Are imported into the docs
  • Are executed in CI
  • Output is parsed and rendered to HTML

Work in Progress

That takes me to about where I am now, and the next steps include:

  • Publish an alpha package!
  • Implementing the rest of the Ratatui widgets.
  • Maybe implementing some other widgets from bubbletea
  • Refining and improving the public API
  • Writing a full slide deck (maybe a terminal presentation framework?)

If you want me to stop give me a job! preferably one which involves TUIs but I’m open to anything.