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.