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
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:
php browser.phpThe 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:
/** @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:
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:
protected bool $focusable = true;
#[State]
public int $selected = 0;Add a bindings() method:
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:
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:
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:
#[State]
public string $path = '';Update bindings() to add Enter and Backspace:
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:
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():
/** @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:
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:
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:
#[Ref]
public ElementRef $list;Update build() to give the list container the ref and turn on scrolling:
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:
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:
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:
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:
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:
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
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.