Skip to content

Build a File Browser

In this tutorial you will build a file browser you can move around with the arrow keys, open directories with, and scroll through. By the end you will have used nodes, state, bindings, refs and theming — the whole core of Phui.

Phui ships built-in components — text inputs, selects, lists, tables, progress bars and more — that cover a lot of this ground for you. The browser is built from scratch so you learn the pieces they are made of, and near the end one built-in, the keybind bar, slots straight in.

Each step adds or updates a method, named so you know where it goes. The snippets leave out use statements — your editor will add them as you go, or copy them from the complete file at the end. Run the app after each step and watch it change.

Set up

This tutorial assumes Phui is already installed — if not, follow Getting Started first. Everything you write lives in a single browser.php.

Show something

A component is a class that extends Component and returns a node from build(). Create browser.php:

php
<?php

declare(strict_types=1);

use Phui\Component;
use Phui\Nodes\Node;
use Phui\Nodes\Text;
use Phui\Screen;

require __DIR__.'/vendor/autoload.php';

final class FileBrowser extends Component
{
    private string $path;

    public function __construct(string $path = '.')
    {
        $this->path = realpath($path) ?: $path;
    }

    public function build(): Node
    {
        return Text::make($this->path)->bold();
    }
}

Screen::mount(new FileBrowser($argv[1] ?? getcwd()))->run();

Run it:

bash
php browser.php

The path appears in bold. Press Ctrl+C to quit.

List the directory

Read the directory and turn each entry into a Text node. Node::each() gives every node a key so Phui can track it between rebuilds.

Add an entries() method to the class:

php
/** @return string[] */
private function entries(): array
{
    $all = scandir($this->path) ?: [];

    return array_values(array_filter(
        $all,
        fn (string $name) => $name !== '.',
    ));
}

And update build() to stack the path above the list:

php
public function build(): Node
{
    $rows = Node::each(
        $this->entries(),
        fn (string $name) => Text::make($name),
        fn (string $name) => $name,
    );

    $path = Text::make($this->path)->bold();
    $list = Container::make()->children($rows)->axis('vertical');

    return Container::make([$path, $list])
      ->axis('vertical')
      ->grow();
}

Run it. The directory's contents are listed under the path.

Move a selection

Track which row is selected, mark it, and move it with the arrow keys.

A property marked #[State] rebuilds the component whenever you write to it. bindings() returns the keys the component responds to, and $focusable lets it receive them.

Add these two properties at the top of the class:

php
protected bool $focusable = true;

#[State]
public int $selected = 0;

Add a bindings() method:

php
public function bindings(): array
{
    return [
        Binding::keypress(
            Keypress::ARROW_DOWN,
            fn () => $this->move(1),
            description: 'Down',
        ),
        Binding::keypress(
            Keypress::ARROW_UP,
            fn () => $this->move(-1),
            description: 'Up',
        ),
    ];
}

Add a move() method:

php
private function move(int $delta): void
{
    $count = count($this->entries());

    if ($count === 0) {
        return;
    }

    $this->selected = max(0, min($count - 1, $this->selected + $delta));
}

Update build() so the selected row gets a marker — the render callback now takes the entry's index too:

php
public function build(): Node
{
    $rows = Node::each(
        $this->entries(),
        fn (string $name, int $i) => Text::make(
            ($i === $this->selected ? '> ' : '  ').$name,
        ),
        fn (string $name) => $name,
    );

    $path = Text::make($this->path)->bold();
    $list = Container::make()->children($rows)->axis('vertical');

    return Container::make([$path, $list])
      ->axis('vertical')
      ->grow();
}

Run it. The arrow keys move the marker, and it stops at both ends.

Open directories

Make $path state too, so changing it rebuilds the list. Change the property declaration near the top of the class — delete the old private string $path; line and add:

php
#[State]
public string $path = '';

Update bindings() to add Enter and Backspace:

php
public function bindings(): array
{
    return [
        Binding::keypress(
            Keypress::ARROW_DOWN,
            fn () => $this->move(1),
            description: 'Down',
        ),
        Binding::keypress(
            Keypress::ARROW_UP,
            fn () => $this->move(-1),
            description: 'Up',
        ),
        Binding::keypress(
            Keypress::ENTER,
            fn () => $this->open(),
            description: 'Open',
        ),
        Binding::keypress(
            [Keypress::BACKSPACE, Keypress::ARROW_LEFT],
            fn () => $this->leave(),
            description: 'Parent',
        ),
    ];
}

Add the three methods they call:

php
private function open(): void
{
    $entries = $this->entries();

    if (! isset($entries[$this->selected])) {
        return;
    }

    $this->enter($this->path.'/'.$entries[$this->selected]);
}

private function leave(): void
{
    $this->enter($this->path.'/..');
}

private function enter(string $target): void
{
    if (! is_dir($target)) {
        return;
    }

    $this->path = realpath($target) ?: $this->path;
    $this->selected = 0;
}

Run it. Enter moves into a directory and Backspace comes back out.

Sort the entries

Put directories first, .. at the top, and everything alphabetical within that. Update entries():

php
/** @return string[] */
private function entries(): array
{
    $all = scandir($this->path) ?: [];

    $entries = array_values(array_filter(
        $all,
        fn (string $name) => $name !== '.',
    ));

    usort($entries, function (string $a, string $b) {
        if ($a === '..' || $b === '..') {
            return $a === '..' ? -1 : 1;
        }

        $aDir = is_dir($this->path.'/'.$a);
        $bDir = is_dir($this->path.'/'.$b);

        return $aDir === $bDir
            ? strcasecmp($a, $b)
            : ($aDir ? -1 : 1);
    });

    return $entries;
}

Add a row() method so a directory can show a trailing slash:

php
private function row(string $name, int $index): Node
{
    $isDir = is_dir($this->path.'/'.$name);
    $label = $isDir && $name !== '..' ? $name.'/' : $name;

    return Text::make(
        ($index === $this->selected ? '> ' : '  ').$label,
    );
}

Update build() to use it:

php
public function build(): Node
{
    $rows = Node::each(
        $this->entries(),
        fn (string $name, int $i) => $this->row($name, $i),
        fn (string $name) => $name,
    );

    $path = Text::make($this->path)->bold();
    $list = Container::make()->children($rows)->axis('vertical');

    return Container::make([$path, $list])
      ->axis('vertical')
      ->grow();
}

Run it in a directory with subfolders. They sort to the top and end with a slash.

Scroll

Open a directory with more entries than fit on screen and the list runs off the bottom. Turn the list into a scrolling container and keep the selection in view.

A #[Ref] property is a handle on a rendered element. Attach it to a node with ref(), then use it to scroll.

Add the ref property near the top of the class:

php
#[Ref]
public ElementRef $list;

Update build() to give the list container the ref and turn on scrolling:

php
public function build(): Node
{
    $rows = Node::each(
        $this->entries(),
        fn (string $name, int $i) => $this->row($name, $i),
        fn (string $name) => $name,
    );

    $path = Text::make($this->path)->bold();

    $list = Container::make()
      ->children($rows)
      ->axis('vertical')
      ->scrollY()
      ->ref($this->list)
      ->grow();

    return Container::make([$path, $list])
      ->axis('vertical')
      ->grow();
}

Update move() to scroll the selection into view:

php
private function move(int $delta): void
{
    $count = count($this->entries());

    if ($count === 0) {
        return;
    }

    $this->selected = max(0, min($count - 1, $this->selected + $delta));
    $this->list->scroll()->intoView($this->selected);
}

Update enter() to reset the scroll when you change directory:

php
private function enter(string $target): void
{
    if (! is_dir($target)) {
        return;
    }

    $this->path = realpath($target) ?: $this->path;
    $this->selected = 0;
    $this->list->scroll()->to(0, 0);
}

Run it in a large directory. Holding the down arrow scrolls the list.

Make it look good

Colours come from the active theme's palette, so the browser follows whatever theme the app is running.

Update row() to colour each entry by what it is:

php
private function row(string $name, int $index): Node
{
    $palette = $this->themes->palette();
    $isDir = is_dir($this->path.'/'.$name);
    $selected = $index === $this->selected;

    $label = $isDir && $name !== '..' ? $name.'/' : $name;

    return Text::make(($selected ? '❯ ' : '  ').$label)
      ->colour(match (true) {
          $selected => $palette->accent,
          $isDir => $palette->info,
          default => $palette->text,
      })
      ->bold($selected)
      ->growX();
}

For the footer, use your first built-in component: KeybindBar. It reads the descriptions off the active bindings — the ones you wrote in bindings() — and lists them with their keys, so the footer stays correct as bindings change.

Update build() to add a border, a scrollbar and the keybind bar:

php
public function build(): Node
{
    $palette = $this->themes->palette();

    $rows = Node::each(
        $this->entries(),
        fn (string $name, int $i) => $this->row($name, $i),
        fn (string $name) => $name,
    );

    $list = Container::make()
      ->children($rows)
      ->axis('vertical')
      ->scrollY()
      ->scrollColour(thumb: $palette->accent, track: $palette->muted)
      ->ref($this->list)
      ->paddingX(1)
      ->grow();

    $footer = Container::make([KeybindBar::mount()])
      ->paddingX(1);

    return Container::make([$list, $footer])
      ->axis('vertical')
      ->border('rounded')
      ->borderTitle(' '.basename($this->path).' ')
      ->borderColour(line: $palette->muted)
      ->grow();
}

Run it. The browser has a rounded border titled with the current directory, directories in their own colour, a highlighted selection, a visible scrollbar and a footer listing your keybindings. The other built-in components mount the same way.

Try a different theme

The palette is the only place colours come from, so swapping the theme restyles everything. In browser.php:

php
Screen::mount(new FileBrowser($argv[1] ?? getcwd()))
    ->theme(Theme::make(Palette::nord()))
    ->run();

Import Phui\Style\Palette and Phui\Style\Theme, then run it again. Try Palette::dracula(), Palette::gruvboxDark() or Palette::tokyoNight().

The complete file

The finished browser.php, imports and all:

php
<?php

declare(strict_types=1);

use Phui\Attributes\Ref;
use Phui\Attributes\State;
use Phui\Bindings\Binding;
use Phui\Component;
use Phui\Components\KeybindBar;
use Phui\Events\Keyboard\Keypress;
use Phui\Nodes\Container;
use Phui\Nodes\Node;
use Phui\Nodes\Text;
use Phui\Refs\ElementRef;
use Phui\Screen;

require __DIR__.'/vendor/autoload.php';

final class FileBrowser extends Component
{
    protected bool $focusable = true;

    #[State]
    public string $path = '';

    #[State]
    public int $selected = 0;

    #[Ref]
    public ElementRef $list;

    public function __construct(string $path = '.')
    {
        $this->path = realpath($path) ?: $path;
    }

    public function bindings(): array
    {
        return [
            Binding::keypress(
                Keypress::ARROW_DOWN,
                fn () => $this->move(1),
                description: 'Down',
            ),
            Binding::keypress(
                Keypress::ARROW_UP,
                fn () => $this->move(-1),
                description: 'Up',
            ),
            Binding::keypress(
                Keypress::ENTER,
                fn () => $this->open(),
                description: 'Open',
            ),
            Binding::keypress(
                [Keypress::BACKSPACE, Keypress::ARROW_LEFT],
                fn () => $this->leave(),
                description: 'Parent',
            ),
        ];
    }

    public function build(): Node
    {
        $palette = $this->themes->palette();

        $rows = Node::each(
            $this->entries(),
            fn (string $name, int $i) => $this->row($name, $i),
            fn (string $name) => $name,
        );

        $list = Container::make()
          ->children($rows)
          ->axis('vertical')
          ->scrollY()
          ->scrollColour(thumb: $palette->accent, track: $palette->muted)
          ->ref($this->list)
          ->paddingX(1)
          ->grow();

        $footer = Container::make([KeybindBar::mount()])
          ->paddingX(1);

        return Container::make([$list, $footer])
          ->axis('vertical')
          ->border('rounded')
          ->borderTitle(' '.basename($this->path).' ')
          ->borderColour(line: $palette->muted)
          ->grow();
    }

    private function row(string $name, int $index): Node
    {
        $palette = $this->themes->palette();
        $isDir = is_dir($this->path.'/'.$name);
        $selected = $index === $this->selected;

        $label = $isDir && $name !== '..' ? $name.'/' : $name;

        return Text::make(($selected ? '❯ ' : '  ').$label)
          ->colour(match (true) {
              $selected => $palette->accent,
              $isDir => $palette->info,
              default => $palette->text,
          })
          ->bold($selected)
          ->growX();
    }

    private function open(): void
    {
        $entries = $this->entries();

        if (! isset($entries[$this->selected])) {
            return;
        }

        $this->enter($this->path.'/'.$entries[$this->selected]);
    }

    private function leave(): void
    {
        $this->enter($this->path.'/..');
    }

    private function enter(string $target): void
    {
        if (! is_dir($target)) {
            return;
        }

        $this->path = realpath($target) ?: $this->path;
        $this->selected = 0;
        $this->list->scroll()->to(0, 0);
    }

    private function move(int $delta): void
    {
        $count = count($this->entries());

        if ($count === 0) {
            return;
        }

        $this->selected = max(0, min($count - 1, $this->selected + $delta));
        $this->list->scroll()->intoView($this->selected);
    }

    /** @return string[] */
    private function entries(): array
    {
        $all = scandir($this->path) ?: [];

        $entries = array_values(array_filter(
            $all,
            fn (string $name) => $name !== '.',
        ));

        usort($entries, function (string $a, string $b) {
            if ($a === '..' || $b === '..') {
                return $a === '..' ? -1 : 1;
            }

            $aDir = is_dir($this->path.'/'.$a);
            $bDir = is_dir($this->path.'/'.$b);

            return $aDir === $bDir
                ? strcasecmp($a, $b)
                : ($aDir ? -1 : 1);
        });

        return $entries;
    }
}

Screen::mount(new FileBrowser($argv[1] ?? getcwd()))->run();

Where to go next

Now that you've got a taste, the how-to guides show more of what Phui is capable of. You might use basic nodes to lay out a screen, interact with components through keyboard and mouse, or compose components into something larger.

For the full API surface — every class and method — head to the reference.

Released under the MIT License.