Hacking Laravel Prompts for Fun and Profit
Laravel Prompts, the first-party library for creating beautiful CLI apps using PHP, is a huge leap forward in the terminal experience. Can we use it to create full interactive TUIs? Yes. We can.
This is part of a series of posts I'm writing on building TUIs using Laravel Prompts.
Prompts is an phenomenal first-party library from Laravel written by the mad genius Jess Archer. It elevates the PHP command line experience by offering a beautiful display, inline validation, and user-friendly interactions. The best part is, it doesn't even require Laravel! You can integrate it into any PHP CLI app.
Digging into the Prompts code made me realize how intelligently Jess structured everything, the rendering logic she implemented was really smart and reusable, abstracting away a lot of the more complicated bits.
It got me thinking: Can we create full interactive text-based user interfaces (TUIs) by latching onto the Prompts rendering process?
Good news: We sure can. Let's dive in.
Prompts Program Flow
You will always have two classes you need to create for each program:
A model class that tracks the state of your app and listens for keypresses, this extends
Laravel\Prompts\Prompt
A renderer class that renders your application based on the current state, this extends
Laravel\Prompts\Themes\Default\Renderer
An important concept to understand here is that your renderer class will be spun up fresh for every render and re-render the entire screen as the user should see it based on the model state, every time.
When the Prompts render
method is called from your model, it runs some internal logic that determines how many lines are different from the previous render and how many need to be replaced in order to render your new screen, so you don't need to worry about that.
To simplify: Just create the screen in its entirety based on the current state of the app in your renderer class. Hold that concept in your mind as we move forward.
Editor's Note: I think I just set a record for the word "render" written per sentence. Onwards.
Ok, Let's Build Something
Yes, let's! If you want to follow along and don't already have Prompts installed, I would throw a composer require laravel/prompts
in the terminal before we continue.
We're going to be building a simple Kanban board, here's the end result:
Tonight's Laravel Prompts experiment:
— joetannenbaum (@joetannenbaum) October 4, 2023
A simple terminal kanban board, inspired by @charmcli's tutorial from last year.
Just going to keep trying things out until I notice patterns, then develop a little TUI framework of sorts. pic.twitter.com/zLKEUGZ2fm
If you want to see the full source code right away:
Setting Up Our Basic Classes
First things first, we need our model class that extends the Prompt
class. This will keep track of our state as we interact with our app:
1<?php 2 3namespace App; 4 5use Laravel\Prompts\Prompt; 6 7class Kanban extends Prompt 8{ 9 public function value(): mixed10 {11 // Must implement a `value` method when extending Prompts,12 // although it's not actually needed in our case13 return true;14 }15}
And also our renderer class, which extends the Prompts Renderer
class. It's an invokable class that receives an instance of our Kanban
class:
1<?php 2 3namespace App\Themes\Default; 4 5use App\Kanban; 6use Laravel\Prompts\Themes\Default\Renderer; 7 8class KanbanRenderer extends Renderer 9{10 public function __invoke(Kanban $kanban): string11 {12 return $this;13 }14}
Great, we're on our way. Let's tell Prompts how to render our TUI by registering our KanbanRenderer
in the constructor:
1<?php 2 3namespace App; 4 5use App\Themes\Default\KanbanRenderer; 6use Laravel\Prompts\Prompt; 7 8class Kanban extends Prompt 9{10 public function __construct()11 {12 static::$themes['default'][Kanban::class] = KanbanRenderer::class; 13 }14 15 public function value(): mixed16 {17 return true;18 }19}
We also need a little script so that we can run our app, let's create that now:
1<?php2 3use App\Kanban;4 5require __DIR__ . '/../vendor/autoload.php';6 7(new Kanban)->prompt();
The prompt
method is part of the Prompts library, it loosely does the following:
Hides the terminal cursor
Hides output typed into the terminal
Sets up an infinite
while
loop to listen for keypressesRuns the
render
method every time it receives and processes a keypress
If you run the script now, it will just hang and it won't do anything, which is correct. You can press ctrl + c
to exit the script.
Adding Data
Next, let's add some data. We could pull this from a database or API, but to keep things simple we'll just track it in an array. Let's define our columns and load it up with some initial data:
1<?php 2 3namespace App; 4 5use App\Themes\Default\KanbanRenderer; 6use Laravel\Prompts\Prompt; 7 8class Kanban extends Prompt 9{10 public array $columns = [ 11 [12 'title' => 'To Do',13 'items' => [14 [15 'title' => 'Make Kanban Board',16 'description' => 'But in the terminal?',17 ],18 [19 'title' => 'Eat Pizza',20 'description' => '(Whole pie).',21 ],22 ],23 ],24 [25 'title' => 'In Progress',26 'items' => [27 [28 'title' => 'Get Milk',29 'description' => 'From the store (whole).',30 ],31 [32 'title' => 'Learn Go',33 'description' => 'Charm CLI looks dope.',34 ],35 [36 'title' => 'Submit Statamic PR',37 'description' => 'Nocache tag fix.',38 ],39 ],40 ],41 [42 'title' => 'Done',43 'items' => [44 [45 'title' => 'Wait Patiently',46 'description' => 'For the next prompt.',47 ],48 ],49 ],50 ]; 51
52 public function __construct()53 {54 static::$themes['default'][Kanban::class] = KanbanRenderer::class;55 }56 57 public function value(): mixed58 {59 // Must implement a `value` method when extending Prompts,60 // although it's not actually needed in our case61 return true;62 } 63}
Excellent. We now have some data, so let's render our basic Kanban board.
We want our board to have three tall boxes with titles and, within each, smaller boxes for each item that represents a card with a title and description for each one.
Some great news here: Prompts already has a super easy way to draw boxes. Literally has a trait for it, DrawsBoxes
. So we're going to hack the box
method from DrawsBoxes
and use it to our advantage.
Understanding the Renderer
Let's back up for just one moment.
The way the Prompts Renderer
class works, simplified, is that it has an output
property which is simply a string. It has a slew of traits and helper methods available to you that keep concatenating strings onto the output
, eventually producing the final string.
It then has a __toString
method that handles a bit of logic regarding spacing and new lines surrounding the given output
and returns the final string.
So what we're going to do is continually use those helper methods, capture their string output, and reset the output
property back to an empty string. This will make more sense momentarily.
Rendering the Basic Board
Back to our KanbanRenderer
class. Remember, we're simply building up a huge string that represents our app's UI. Let's see what rendering our basic board looks like:
1<?php 2 3namespace App\Themes\Default; 4 5use App\Kanban; 6use Laravel\Prompts\Themes\Default\Renderer; 7 8class KanbanRenderer extends Renderer 9{10 public function __invoke(Kanban $kanban): string11 {12 // Available width of terminal minus some buffer13 $totalWidth = $kanban->terminal()->cols() - 16;14 // Available height of terminal minus some buffer15 $totalHeight = $kanban->terminal()->lines() - 10;16 17 // Column width should be the total width divided by the number of columns18 $columnWidth = (int) floor($totalWidth / count($kanban->columns));19 // Card width is the column width minus some padding20 $cardWidth = $columnWidth - 6;21 22 // Loop through our columns and render each one23 $columns = collect($kanban->columns)->map(function ($column, $columnIndex) use (24 $cardWidth,25 $columnWidth,26 $kanban,27 $totalHeight,28 ) {29 // Loop through each card in the column and render it30 $cardsOutput = collect($column['items'])->map(31 // Render the card with a dimmed border32 fn ($card) => $this->getBoxOutput(33 $card['title'],34 PHP_EOL . $card['description'] . PHP_EOL,35 'dim',36 $cardWidth,37 ),38 );39 40 $cardContent = PHP_EOL . $cardsOutput->implode(PHP_EOL);41 42 // Add new lines to the card content to make it the same height as the terminal43 $cardContent .= str_repeat(PHP_EOL, $totalHeight - count(explode(PHP_EOL, $cardContent)) + 1);44 45 $columnTitle = $kanban->columns[$columnIndex]['title'];46 47 // Render the column with a dimmed border and the card content48 $columnContent = $this->getBoxOutput(49 $this->dim($columnTitle),50 $cardContent,51 'dim',52 $columnWidth,53 );54 55 return explode(PHP_EOL, $columnContent);56 });57 58 // Zip the columns together so we can render them side by side59 collect($columns->shift())60 ->zip(...$columns)61 ->map(fn ($lines) => $lines->implode(''))62 // Render the lines63 ->each(fn ($line) => $this->line($line));64 65 return $this;66 }67 68 protected function getBoxOutput(string $title, string $body, string $color, int $width): string69 {70 // Reset the output string71 $this->output = '';72 73 // Set the minWidth to the desired width, the box method74 // uses this to calculate how wide the box should be75 $this->minWidth = $width;76 77 $this->box(78 title: $title,79 body: $body,80 color: $color,81 );82 83 $content = $this->output;84 85 $this->output = '';86 87 return $content;88 }89}
The above code produces this:
Our Secret Weapon: The Zip Method
One of the most important things to note here is the zip
method. We're going to use that a lot when rendering TUIs. Let's take a closer look at line 59:
1$lines = collect($columns->shift())2 ->zip(...$columns)3 ->map(fn ($lines) => $lines->implode(''));
Straight from the Laravel docs: "The zip
 method merges together the values of the given array with the values of the original collection at their corresponding index."
By line 59, what we have is a collection of three columns represented as arrays of lines.
We snag the first item in the collection, then zip the remaining columns together with it. We then have one large collection with sub collections representing the corresponding lines from each column, which we can then safely implode on an empty string, creating our final and complete line of our UI.
Visually represented, it looks something like this:
Adding Interaction
At this point, let's think about how we want the user to interact with our app:
When the user loads up the app, the first column and the first card should be active by default
When the user press the
right
orleft
arrows, it should change the active columnWhen the user presses the
up
ordown
arrows, it should change the active cardWhen the user presses
enter
, it should move the active card to the next column
That means we need to track an active column index and an active item index. Let's add that to our Kanban
class:
1<?php 2 3namespace App; 4 5use App\Themes\Default\KanbanRenderer; 6use Laravel\Prompts\Prompt; 7 8class Kanban extends Prompt 9{
10 public array $columns = [11 [12 'title' => 'To Do',13 'items' => [14 [15 'title' => 'Make Kanban Board',16 'description' => 'But in the terminal?',17 ],18 [19 'title' => 'Eat Pizza',20 'description' => '(Whole pie).',21 ],22 ],23 ],24 [25 'title' => 'In Progress',26 'items' => [27 [28 'title' => 'Get Milk',29 'description' => 'From the store (whole).',30 ],31 [32 'title' => 'Learn Go',33 'description' => 'Charm CLI looks dope.',34 ],35 [36 'title' => 'Submit Statamic PR',37 'description' => 'Nocache tag fix.',38 ],39 ],40 ],41 [42 'title' => 'Done',43 'items' => [44 [45 'title' => 'Wait Patiently',46 'description' => 'For the next prompt.',47 ],48 ],49 ],50 ];51 52 public int $itemIndex = 0; 53 54 public int $columnIndex = 0; 55 56 public function __construct()57 {58 static::$themes['default'][Kanban::class] = KanbanRenderer::class;59 }60 61 public function value(): mixed62 {63 return true;64 }65}
These properties need to be public so that our renderer can access them.
Let's register some keys to listen to. The following chunk is largely taken directly from the Prompt codebase, let's add it to our Kanban
class:
1<?php 2 3namespace App; 4 5use App\Themes\Default\KanbanRenderer; 6use Laravel\Prompts\Key; 7use Laravel\Prompts\Prompt; 8 9class Kanban extends Prompt10{
11 public array $columns = [12 [13 'title' => 'To Do',14 'items' => [15 [16 'title' => 'Make Kanban Board',17 'description' => 'But in the terminal?',18 ],19 [20 'title' => 'Eat Pizza',21 'description' => '(Whole pie).',22 ],23 ],24 ],25 [26 'title' => 'In Progress',27 'items' => [28 [29 'title' => 'Get Milk',30 'description' => 'From the store (whole).',31 ],32 [33 'title' => 'Learn Go',34 'description' => 'Charm CLI looks dope.',35 ],36 [37 'title' => 'Submit Statamic PR',38 'description' => 'Nocache tag fix.',39 ],40 ],41 ],42 [43 'title' => 'Done',44 'items' => [45 [46 'title' => 'Wait Patiently',47 'description' => 'For the next prompt.',48 ],49 ],50 ],51 ];52 53 public int $itemIndex = 0;54 55 public int $columnIndex = 0;56 57 public function __construct()58 {59 static::$themes['default'][Kanban::class] = KanbanRenderer::class;60 61 $this->listenForKeys(); 62 }63 64 public function value(): mixed65 {66 return true;67 }68 69 protected function listenForKeys(): void 70 {71 $this->on('key', function ($key) {72 if ($key[0] === "\e") {73 match ($key) {74 Key::UP, Key::UP_ARROW => $this->itemIndex = max(0, $this->itemIndex - 1),75 Key::DOWN, Key::DOWN_ARROW => $this->itemIndex = min(count($this->columns[$this->columnIndex]['items']) - 1, $this->itemIndex + 1),76 Key::RIGHT, Key::RIGHT_ARROW => $this->nextColumn(),77 Key::LEFT, Key::LEFT_ARROW => $this->previousColumn(),78 default => null,79 };80 81 return;82 }83 84 // Keys may be buffered85 foreach (mb_str_split($key) as $key) {86 if ($key === Key::ENTER) {87 $this->moveCurrentItem();88 return;89 }90 }91 });92 } 93}
And now let's fill in a few of those methods:
1<?php 2 3namespace App; 4 5use App\Themes\Default\KanbanRenderer; 6use Laravel\Prompts\Key; 7use Laravel\Prompts\Prompt; 8 9class Kanban extends Prompt 10{
11 public array $columns = [ 12 [ 13 'title' => 'To Do', 14 'items' => [ 15 [ 16 'title' => 'Make Kanban Board', 17 'description' => 'But in the terminal?', 18 ], 19 [ 20 'title' => 'Eat Pizza', 21 'description' => '(Whole pie).', 22 ], 23 ], 24 ], 25 [ 26 'title' => 'In Progress', 27 'items' => [ 28 [ 29 'title' => 'Get Milk', 30 'description' => 'From the store (whole).', 31 ], 32 [ 33 'title' => 'Learn Go', 34 'description' => 'Charm CLI looks dope.', 35 ], 36 [ 37 'title' => 'Submit Statamic PR', 38 'description' => 'Nocache tag fix.', 39 ], 40 ], 41 ], 42 [ 43 'title' => 'Done', 44 'items' => [ 45 [ 46 'title' => 'Wait Patiently', 47 'description' => 'For the next prompt.', 48 ], 49 ], 50 ], 51 ]; 52 53 public int $itemIndex = 0; 54 55 public int $columnIndex = 0; 56 57 public function __construct() 58 { 59 static::$themes['default'][Kanban::class] = KanbanRenderer::class; 60 61 $this->listenForKeys(); 62 } 63 64 public function value(): mixed 65 { 66 return true; 67 } 68 69 protected function listenForKeys(): void 70 { 71 $this->on('key', function ($key) { 72 if ($key[0] === "\e") { 73 match ($key) { 74 Key::UP, Key::UP_ARROW => $this->itemIndex = max(0, $this->itemIndex - 1), 75 Key::DOWN, Key::DOWN_ARROW => $this->itemIndex = min(count($this->columns[$this->columnIndex]['items']) - 1, $this->itemIndex + 1), 76 Key::RIGHT, Key::RIGHT_ARROW => $this->nextColumn(), 77 Key::LEFT, Key::LEFT_ARROW => $this->previousColumn(), 78 default => null, 79 }; 80 81 return; 82 } 83 84 // Keys may be buffered 85 foreach (mb_str_split($key) as $key) { 86 if ($key === Key::ENTER) { 87 $this->moveCurrentItem(); 88 return; 89 } 90 } 91 }); 92 } 93 94 protected function nextColumn(): void 95 { 96 $this->columnIndex = min(count($this->columns) - 1, $this->columnIndex + 1); 97 // Reset the item index when moving to a new column 98 $this->itemIndex = 0; 99 }100 101 protected function previousColumn(): void102 {103 $this->columnIndex = max(0, $this->columnIndex - 1);104 // Reset the item index when moving to a new column105 $this->itemIndex = 0;106 }107 108 protected function moveCurrentItem(): void109 {110 $newColumnIndex = $this->columnIndex + 1;111 112 if ($newColumnIndex >= count($this->columns)) {113 $newColumnIndex = 0;114 }115 116 // Add the item to the new column117 $this->columns[$newColumnIndex]['items'][] = $this->columns[$this->columnIndex]['items'][$this->itemIndex];118 119 // Remove it from the old column120 unset($this->columns[$this->columnIndex]['items'][$this->itemIndex]);121 122 // Reindex the items123 $this->columns[$this->columnIndex]['items'] = array_values($this->columns[$this->columnIndex]['items']);124 125 // Reset the item index to the previous item in the column126 $this->itemIndex = max(0, $this->itemIndex - 1);127 } 128}
We now have active cards and columns, we should probably visually represent them for the user. Lezzdoit.
Back in our KanbanRenderer
:
1<?php 2 3namespace Chewie\Themes\Default; 4 5use Chewie\Kanban; 6use Laravel\Prompts\Themes\Default\Concerns\DrawsBoxes; 7use Laravel\Prompts\Themes\Default\Renderer; 8 9class KanbanRenderer extends Renderer10{11 use DrawsBoxes;12 13 public function __invoke(Kanban $kanban): string14 {
15 $totalWidth = $kanban->terminal()->cols() - 16;16 $totalHeight = $kanban->terminal()->lines() - 7;17 18 $columnWidth = (int) floor($totalWidth / count($kanban->columns));19 $cardWidth = $columnWidth - 6;20 21 $columns = collect($kanban->columns)->map(function ($column, $columnIndex) use (22 $cardWidth,23 $columnWidth,24 $kanban,25 $totalHeight,26 ) {27 $cardsOutput = collect($column['items'])->map(28 fn ($card, $cardIndex) => $this->getBoxOutput( 29 $card['title'],30 PHP_EOL . $card['description'] . PHP_EOL,31 $cardIndex === $kanban->itemIndex && $kanban->columnIndex === $columnIndex ? 'green' : 'dim', 32 $cardWidth,33 ),34 );35 36 $cardContent = PHP_EOL . $cardsOutput->implode(PHP_EOL);37 38 $cardContent .= str_repeat(PHP_EOL, $totalHeight - count(explode(PHP_EOL, $cardContent)) + 1);39 40 $columnTitle = $kanban->columns[$columnIndex]['title'];41 42 $columnContent = $this->getBoxOutput(43 $kanban->columnIndex === $columnIndex ? $this->cyan($columnTitle) : $this->dim($columnTitle), 44 $cardContent,45 $kanban->columnIndex === $columnIndex ? 'cyan' : 'dim', 46 $columnWidth,47 );48 49 return explode(PHP_EOL, $columnContent);50 });51 52 collect($columns->shift())53 ->zip(...$columns)54 ->map(fn ($lines) => $lines->implode(''))55 ->each(fn ($line) => $this->line($line));56 57 return $this;58 }59
60 protected function getBoxOutput(string $title, string $body, string $color, int $width): string61 {62 $this->output = '';63 64 $this->minWidth = $width;65 66 $this->box(67 title: $title,68 body: $body,69 color: $color,70 );71 72 $content = $this->output;73 74 $this->output = '';75 76 return $content;77 } 78}
Let's see what that looks like:
Adding New Items to the Board
We should let the user add new items. We're going to keep it simple and just make a "separate screen" for that using the built-in Prompts components.
Essentially, this will wipe the screen, ask the user for the title and description of the new item, then re-render the board when they are done:
1<?php 2 3namespace App; 4 5use App\Themes\Default\KanbanRenderer; 6use Laravel\Prompts\Key; 7use Laravel\Prompts\Prompt; 8 9use function Laravel\Prompts\text; 10 11class Kanban extends Prompt 12{
13 public array $columns = [ 14 [ 15 'title' => 'To Do', 16 'items' => [ 17 [ 18 'title' => 'Make Kanban Board', 19 'description' => 'But in the terminal?', 20 ], 21 [ 22 'title' => 'Eat Pizza', 23 'description' => '(Whole pie).', 24 ], 25 ], 26 ], 27 [ 28 'title' => 'In Progress', 29 'items' => [ 30 [ 31 'title' => 'Get Milk', 32 'description' => 'From the store (whole).', 33 ], 34 [ 35 'title' => 'Learn Go', 36 'description' => 'Charm CLI looks dope.', 37 ], 38 [ 39 'title' => 'Submit Statamic PR', 40 'description' => 'Nocache tag fix.', 41 ], 42 ], 43 ], 44 [ 45 'title' => 'Done', 46 'items' => [ 47 [ 48 'title' => 'Wait Patiently', 49 'description' => 'For the next prompt.', 50 ], 51 ], 52 ], 53 ]; 54 55 public int $itemIndex = 0; 56 57 public int $columnIndex = 0; 58 59 public function __construct() 60 { 61 static::$themes['default'][Kanban::class] = KanbanRenderer::class; 62 63 $this->listenForKeys(); 64 } 65 66 public function value(): bool 67 { 68 return true; 69 } 70 71 protected function listenForKeys(): void 72 { 73 $this->on('key', function ($key) { 74 if ($key[0] === "\e") { 75 match ($key) { 76 Key::UP, Key::UP_ARROW => $this->itemIndex = max(0, $this->itemIndex - 1), 77 Key::DOWN, Key::DOWN_ARROW => $this->itemIndex = min(count($this->columns[$this->columnIndex]['items']) - 1, $this->itemIndex + 1), 78 Key::RIGHT, Key::RIGHT_ARROW => $this->nextColumn(), 79 Key::LEFT, Key::LEFT_ARROW => $this->previousColumn(), 80 default => null, 81 }; 82 83 return; 84 } 85 86 // Keys may be buffered. 87 foreach (mb_str_split($key) as $key) { 88 if ($key === Key::ENTER) { 89 $this->moveCurrentItem(); 90 91 return; 92 } 93 94 match ($key) { 95 'n' => $this->addNewItem(), 96 default => null, 97 }; 98 } 99 });100 }101
102 protected function nextColumn(): void103 {104 $this->columnIndex = min(count($this->columns) - 1, $this->columnIndex + 1);105 $this->itemIndex = 0;106 }107 108 protected function previousColumn(): void109 {110 $this->columnIndex = max(0, $this->columnIndex - 1);111 $this->itemIndex = 0;112 }113 114 protected function moveCurrentItem(): void115 {116 $newColumnIndex = $this->columnIndex + 1;117 118 if ($newColumnIndex >= count($this->columns)) {119 $newColumnIndex = 0;120 }121 122 $this->columns[$newColumnIndex]['items'][] = $this->columns[$this->columnIndex]['items'][$this->itemIndex];123 124 unset($this->columns[$this->columnIndex]['items'][$this->itemIndex]);125 126 $this->columns[$this->columnIndex]['items'] = array_values($this->columns[$this->columnIndex]['items']);127 128 $this->itemIndex = max(0, $this->itemIndex - 1);129 }130 131 protected function addNewItem(): void 132 {133 // Clear our listeners so we don't capture the keypresses registered for the board134 $this->clearListeners();135 // Capture previous new lines rendered136 $this->capturePreviousNewLines();137 // Reset the cursor position back to the top138 $this->resetCursorPosition();139 // Erase everything that's currently on the screen140 $this->eraseDown();141 142 $title = text('Title', 'Title of task');143 144 $description = text('Description', 'Description of task');145 146 // Add the new item to the current column147 $this->columns[$this->columnIndex]['items'][] = [148 'title' => $title,149 'description' => $description,150 ];151 152 // Re-register our key listeners153 $this->listenForKeys();154 // Re-render the board155 $this->prompt();156 }157 158 protected function resetCursorPosition(): void159 {160 $lines = count(explode(PHP_EOL, $this->prevFrame)) - 1;161 162 $this->moveCursor(-999, $lines * -1);163 } 164}
Allowing the User to Quit the App
Almost there! Promise.
We'll also need to register a way for the user to quit the app when they are done, let's do that when they hit q
:
1<?php 2 3namespace App; 4 5use App\Themes\Default\KanbanRenderer; 6use Laravel\Prompts\Key; 7use Laravel\Prompts\Prompt; 8 9class Kanban extends Prompt 10{
11 public array $columns = [ 12 [ 13 'title' => 'To Do', 14 'items' => [ 15 [ 16 'title' => 'Make Kanban Board', 17 'description' => 'But in the terminal?', 18 ], 19 [ 20 'title' => 'Eat Pizza', 21 'description' => '(Whole pie).', 22 ], 23 ], 24 ], 25 [ 26 'title' => 'In Progress', 27 'items' => [ 28 [ 29 'title' => 'Get Milk', 30 'description' => 'From the store (whole).', 31 ], 32 [ 33 'title' => 'Learn Go', 34 'description' => 'Charm CLI looks dope.', 35 ], 36 [ 37 'title' => 'Submit Statamic PR', 38 'description' => 'Nocache tag fix.', 39 ], 40 ], 41 ], 42 [ 43 'title' => 'Done', 44 'items' => [ 45 [ 46 'title' => 'Wait Patiently', 47 'description' => 'For the next prompt.', 48 ], 49 ], 50 ], 51 ]; 52 53 public int $itemIndex = 0; 54 55 public int $columnIndex = 0; 56 57 public function __construct() 58 { 59 static::$themes['default'][Kanban::class] = KanbanRenderer::class; 60 61 $this->listenForKeys(); 62 } 63 64 public function value(): mixed 65 { 66 return true; 67 } 68 69 protected function listenForKeys(): void 70 { 71 $this->on('key', function ($key) { 72 if ($key[0] === "\e") { 73 match ($key) { 74 Key::UP, Key::UP_ARROW => $this->itemIndex = max(0, $this->itemIndex - 1), 75 Key::DOWN, Key::DOWN_ARROW => $this->itemIndex = min(count($this->columns[$this->columnIndex]['items']) - 1, $this->itemIndex + 1), 76 Key::RIGHT, Key::RIGHT_ARROW => $this->nextColumn(), 77 Key::LEFT, Key::LEFT_ARROW => $this->previousColumn(), 78 default => null, 79 }; 80 81 return; 82 } 83 84 // Keys may be buffered 85 foreach (mb_str_split($key) as $key) { 86 if ($key === Key::ENTER) { 87 $this->moveCurrentItem(); 88 return; 89 } 90 91 match ($key) { 92 'n' => $this->addNewItem(), 93 'q' => $this->quit(), 94 default => null, 95 }; 96 } 97 }); 98 } 99
100 protected function nextColumn(): void101 {102 $this->columnIndex = min(count($this->columns) - 1, $this->columnIndex + 1);103 // Reset the item index when moving to a new column104 $this->itemIndex = 0;105 }106 107 protected function previousColumn(): void108 {109 $this->columnIndex = max(0, $this->columnIndex - 1);110 // Reset the item index when moving to a new column111 $this->itemIndex = 0;112 }113 114 protected function moveCurrentItem(): void115 {116 $newColumnIndex = $this->columnIndex + 1;117 118 if ($newColumnIndex >= count($this->columns)) {119 $newColumnIndex = 0;120 }121 122 // Add the item to the new column123 $this->columns[$newColumnIndex]['items'][] = $this->columns[$this->columnIndex]['items'][$this->itemIndex];124 125 // Remove it from the old column126 unset($this->columns[$this->columnIndex]['items'][$this->itemIndex]);127 128 // Reindex the items129 $this->columns[$this->columnIndex]['items'] = array_values($this->columns[$this->columnIndex]['items']);130 131 // Reset the item index to the previous item in the column132 $this->itemIndex = max(0, $this->itemIndex - 1);133 }134 135 protected function addNewItem(): void 136 {137 // Clear our listeners so we don't capture the keypresses registered for the board138 $this->clearListeners();139 // Capture previous new lines rendered140 $this->capturePreviousNewLines();141 // Reset the cursor position back to the top142 $this->resetCursorPosition();143 // Erase everything that's currently on the screen144 $this->eraseDown();145 146 $title = text('Title', 'Title of task');147 148 $description = text('Description', 'Description of task');149 150 // Add the new item to the current column151 $this->columns[$this->columnIndex]['items'][] = [152 'title' => $title,153 'description' => $description,154 ];155 156 // Re-register our key listeners157 $this->listenForKeys();158 // Re-render the board159 $this->prompt();160 }161 162 protected function quit(): void 163 {164 static::terminal()->exit();165 } 166}
Folks, We Did It
Give yourself a pat on the back. You just built an interactive TUI by taking advantage of the framework that Laravel Prompts has so graciously laid out for us. Look at this beauty:
I hope this sparked your imagination! Let's build rad stuff for the terminal!
Seriously, hit me up! Show me what you're building. Experiment! Have fun! And if you're going to dive in and haven't build many TUIs before, check out some tips that I've gathered.
And stay tuned for more posts on several other experiments I did, coming soon.
Full source code for the Kanban demo: