Introducing BigText CLI0:00
My name is Joe Tannenbaum, and in this series, we're going to be doing some CLI experiments. And so what does that mean? It means we're going to be building text-based user interfaces in the terminal, and more importantly, it means we're going to have some fun. Let's kick it off right away with an app that I like to call BigText. BigText just takes what you type into the terminal and prints it out as large ASCII art words. Let's dive in. I've gone ahead and scaffolded out some of our app to get us started. So let's take a look at what we have here.
Reviewing Project Scaffold0:32
I've gone ahead and scaffolded out some of our app to get us started. So let's take a look at what we have here. We have a composer.json. I've already installed Laravel prompts at the time of this recording. It's version 0.1.15. I also have a library that I put together called Chewy that helps us glue together some prompt stuff with some of the apps that we're going to be making. So the version at the time of recording is 0.1.0. I've also set up some PSR-4 autoloading here, and so let's take a look at this source directory. In the source directory, at the moment, we only have BigText, and we have BigTextRenderer.
I've also set up some PSR-4 autoloading here, and so let's take a look at this source directory. In the source directory, at the moment, we only have BigText, and we have BigTextRenderer. We'll come back to that in just a little bit. In our lab directory, we have a simple php file just called BigText.php, and all that does is fire up our BigText class and fire the prompt method. And finally, we have an art directory, and there's a subdirectory called Characters, and these are all the characters that we support. These are all the characters we're going to be printing out, and all this is is just ASCII art. It's just little files that contain one character in ASCII art per file.
Application State and Renderer1:31
art. It's just little files that contain one character in ASCII art per file. Great. Let's go back and re-examine these files in source. Every app that we have is going to have two primary files. The first class is our ApplicationState class. It will always extend the BasePrompt class, and it controls the state of our application and sets up things like keypress listeners so that we can listen to input during the course of our app. It has one required method, which is the value method, and we're not going to use that in
course of our app. It has one required method, which is the value method, and we're not going to use that in this case, so we're just going to return null. Our second class is our Renderer class, in this case BigTextRenderer. It will always extend the RendererBase class from Prompt, and it's an invocable class. It will receive an instance of our ApplicationState class, and it will return a string. And since the Renderer implements toString, we can simply return this. So how do we tell Prompts that we want our BigTextRenderer to be associated with the BigText ApplicationState class? Well, we can easily do that with Chewy, so we can import a trait called UseRegistersThemes,
Big Text ApplicationState class? Well, we can easily do that with Chewy, so we can import a trait called UseRegistersThemes, and all we have to do is say, ThisRegisterThemeBigTextRendererClass. And now we've told Prompts, okay, when you fire up the Big Text app, use BigTextRenderer as the renderer. Let's fire this up in the terminal and see what happens. If we run our script and press enter, nothing happens. And that's right, that's what's supposed to happen, because Prompts is waiting for a key press, but we haven't registered any keys to listen to, and we're not rendering anything out from the renderer.
Adding Keypress Handlers3:13
press, but we haven't registered any keys to listen to, and we're not rendering anything out from the renderer. So let's listen for some keys, and let's render those keys out to the terminal. Back in our ApplicationState class, let's set up some keys to listen to. To save us some time, I've actually pre-written this out. So these are our valid characters, and these are based on the art that we have here. So these are all the characters that we support. So we support a range from A to Z, a range from uppercase A to uppercase Z, and then a space, a period, a comma, a question mark, an exclamation point, and an apostrophe. So how do we go ahead and listen for these keys?
a space, a period, a comma, a question mark, an exclamation point, and an apostrophe. So how do we go ahead and listen for these keys? Well, Chewy has a way to do this pretty easily. We can use the keyPress listener for this, and on any of these valid characters, we are going to receive a callback, and the argument is going to be the key that was pressed, and we're going to need a public property for the renderer to be able to read, to render the message out to the terminal. So we'll call that message, and we'll say this message, and we'll just append the key to that, and we'll listen. And let's set up that public property.
to that, and we'll listen. And let's set up that public property. So we'll say public string message, and we'll default that to a blank string. There's a couple of other things I want to add while we're here. We're going to add a way to clear this message. So we can say on key, and this is something that comes with Laravel prompts, enter. And when we press enter, we want to clear the message. So every time we press enter, the message will be back to an empty string. We want to be able to allow the user to erase characters from the message, so on key, backspace. We want to erase the last character of our message property.
We want to be able to allow the user to erase characters from the message, so on key, backspace. We want to erase the last character of our message property. Finally, we want the user to be able to exit our application. So we can say on key, Control-C. We are going to exit, so we'll say this terminal, exit. And terminal is a method out of the BasePrompts class. Okay, so here we are. We've set up some key press listeners for valid characters, the enter key, backspace, and Control-C, and some functionality associated with each of them. So okay, we were registering this, and we're building up this message. How do we print this out to the terminal?
So okay, we were registering this, and we're building up this message. How do we print this out to the terminal? Let's go to our BigTextRenderer class. Remember, in our renderer, we receive an instance of our BigText application state. So we have access to that message property. Let's just, for now, print the message out to the screen. This line, prompt, message. This will print the message out as a line to our terminal. Let's check it out. We're running our application, and if we type A, B, C, D, E, F, looks good.
Rendering ASCII Art6:07
Let's check it out. We're running our application, and if we type A, B, C, D, E, F, looks good. If we hit backspace, that also looks good. If we hit enter, clears the message, and we can say, hello, this is working. Great, we are rendering text out to the terminal out of our application based on key listeners. Let's keep going. So remember, we don't want to just print the characters to the terminal. We are trying to print the ASCII art associated with those characters to the terminal. Chewy has an art helper for us, and in order to use it even more effectively, we're going to tell Chewy where to find the ASCII art that we are looking for.
Chewy has an art helper for us, and in order to use it even more effectively, we're going to tell Chewy where to find the ASCII art that we are looking for. So we're going to set a directory, and we're going to import Chewy's art, and we're going to set the directory to dir. We're going to back out one and say art/characters. So we're saying, hey, Chewy, whenever we tell you to find art, look for it in this directory. Okay, let's head back to our Renderer class. So first things first, we can officially get rid of this here, because we're not going to be using this anymore. We want to normalize the message a bit.
to be using this anymore. We want to normalize the message a bit. So we want to make the message equal to mb_strtolower $promptMessage. A good rule of thumb is if you have a multibyte version of a string function, try to always use that. We're going to be working with a lot of multibyte strings when building out these Chewys. Next, we want to split this string character by character and replace the character with the lines of the ASCII art. $lines equals collect(mb_split("", $stringMessage)). And then we're going to map over that map $letter.
Lines equals collect mb split string message. And then we're going to map over that map letter. So now we're going letter by letter, and we're going to say match the letter. We're going to say match each letter to the corresponding ASCII art. To make this a little bit easier, I've already pre-written some of this, so you don't have to watch me type it out. And here's what we've got. So if it's a space, create a space character. If it's a period, get the art lines for the period. And comma, comma, question mark, so on and so forth.
If it's a period, get the art lines for the period. And comma, comma, question mark, so on and so forth. If it's not any of these, we can go ahead and just default for the art lines for the letter. Now where is this artLines method coming from? Chewy provides a trait called DrawsArt. And this allows us to pull in the file, the corresponding file, and break it into an array of lines that we can print to the terminal. So you might be wondering, for the space, where is this 7 coming from? Well, if we look at our A character, it's 7 lines tall.
So you might be wondering, for the space, where is this 7 coming from? Well, if we look at our A character, it's 7 lines tall. So we want to fill an array of 7 lines worth of space. So we're filling an array of 7, and we're saying our space is going to be 4 characters wide between ASCII art if we encounter a space. Okay, let's var_dump this and just see what we have here so we are clear about what this gives us. So as we initially run, we get an empty array, but if we type A and then B, well, now we have an array of 2, and it's an array of arrays. So every line of the ASCII art is broken out into its own line.
have an array of 2, and it's an array of arrays. So every line of the ASCII art is broken out into its own line. And right now, these are individual. We can think of these as columns that we want to print side-by-side. So how do we print these columns side-by-side so we get A, B, C, D in a row? Chewy can help us out there. This is a very common pattern when building TUIs. You've built up columns made of lines, and you need to print those columns side-by-side, line-by-line. So Chewy has a little helper for us.
line-by-line. So Chewy has a little helper for us. We can say lines from columns, and we can pass the lines that we've created in here. We want to get the resulting lines, and each of those lines we can print out. Okay, let's see what this gives us. So if we start typing A, B, C, D, E, F, G, okay, looks good. Press enter. Hello! Okay! This looks good.
Okay! This looks good. So at this point, it's important to note what the rendering process is actually doing. Prompts is rendering our text out to the terminal, but with every keystroke, it's actually re-rendering all of the lines that have changed again. So it's not just adding lines. It's re-rendering line-by-line. It's moving the cursor to the beginning. It's erasing down, and it's re-rendering our entire application. That's important to think about as we move forward.
Handling Terminal Wrapping10:34
It's erasing down, and it's re-rendering our entire application. That's important to think about as we move forward. So this is good, but what if we kept typing and made a really long message? What would happen? This is a longer message. Whoa! This gets messy very quickly, and so what's happening here? We need to consider the width that's available to us in the terminal, because if we don't, the terminal will auto-wrap our lines, and as we re-render our lines, we lose control over the formatting that we have.
the terminal will auto-wrap our lines, and as we re-render our lines, we lose control over the formatting that we have. So we need to be very conscious about how much width our characters that we're printing to the terminal are taking up. First things first, we need to figure out the available width of our terminal. So the width is going to be equal to the prompt, and we're going to reach for that terminal method again. We're going to say calls. That's the number of columns available to us, and we're going to do a little bit of buffer, because if you go right to the edge, sometimes it still gets a little funky.
That's the number of columns available to us, and we're going to do a little bit of buffer, because if you go right to the edge, sometimes it still gets a little funky. And spoiler alert, if you go past the height of the terminal, it's going to get funky there, too. So let's calculate the height now as well. So prompt, terminal, lines, and we're going to give that a buffer of about five, just to be safe. So now what do we want to do here? We want to word wrap our message to ensure that it doesn't go beyond the available width of the terminal.
We want to word wrap our message to ensure that it doesn't go beyond the available width of the terminal. So our friend wordwrap is going to be helpful to us here. Word wrap. We're going to pass the message as a string. So width is going to be the floor of our available width divided by 7. And why 7? Because if we go back to our character here, 1, 2, 3, 4, 5, 6, 7. So we want to make sure that we have enough room to print every character across the screen. And we're going to cut long words, so that longer words just cut off and wrap as we go.
So we want to make sure that we have enough room to print every character across the screen. And we're going to cut long words, so that longer words just cut off and wrap as we go. Okay, so we need to alter, and that's not flute, that's floor. And so we need to alter our logic here a little bit, because we're no longer splitting the message characters. What we're instead doing is we are going to collect each line of our resulting word wrapped message, and we are going to then split each line of that message. So let's collect, explode, PHP, end of line, message lines. And we are going to map each line and split that into individual characters. And so we're making a collection of each character of that.
And we are going to map each line and split that into individual characters. And so we're making a collection of each character of that. So now this map actually receives a collection of letters. And we are going to map those letters, lots of mapping here, into letter. And now we can go back to our original match logic. Okay, this is starting to feel like something. So now we've got a couple of sub-arrays here, and we want to sort of flat-map this down back to lines so we can use our fromColumns method. So let's flat-map this down to, we now have letters here, and we're going to say lines, fromColumns, letters, and we're going to return the lines from that.
So let's flat-map this down to, we now have letters here, and we're going to say lines, from columns, letters, and we're going to return the lines from that. And just as a heads up, because we also need to deal with the height, we want to slice off all the last x lines of this so that we don't go past the height of the available terminal. So we want to slice the height times negative 1, so get the last x lines equal to the height. Last but not least, let's go over this and print each of these to the terminal so we can get rid of this, and this should give us what we're looking for. Let's check it out. Okay, so now, hello, this is my longer message, yay!
Let's check it out. Okay, so now, hello, this is my longer message, yay! And if we go past the height, you can see it's starting to just get cut off. And that's okay, that's what we want it to do. It'll just keep typing and you can keep going and nothing gets jumbled, it just gets cut off up here and it word wraps along the side. Great, let's go back and just clean things up a little bit. Let's do a couple of final things that'll just make the app a little more user-friendly for whatever user is going to be using this app. So first, I think we should center the lines on the screen, so both vertically and horizontally.
Centering and Hotkeys UI15:01
for whatever User is going to be using this app. So first, I think we should center the lines on the screen, so both vertically and horizontally. So instead of just looping them here, we'll end up using this lines variable. And Chewy has a great little trait called useAligns, and we can import that. And now we can just say this->center, and we can pass it the lines, and we can say center it within this width, this height, and it returns a collection, and we can say each this line. Let's also tell our User what hotkeys are available to them, and in this app, the hotkey that's available to them mostly is enter. It tells them they can clear a message.
that's available to them mostly is enter. It tells them they can clear a message. So again, not to be a broken record, but Chewy has her back here, and we can say drawsHotkeys. We can import that trait, and we can say, okay, this hotkey, the hotkey is going to be enter. By entering, you can clear, this is the label we're telling the user, and it's only active when the message is not equal to an empty string. Active in this case means stylistically. It'll gray out when it's not active, and it'll light up when it is active. It's still our state class's responsibility to actually have this implementation in it.
It'll gray out when it's not active, and it'll light up when it is active. It's still our State class's responsibility to actually have this implementation in it. And we are going to want to center horizontally this hotkeys according to the width available to us. This center horizontally comes from the Chewy aligns class, and we can loop over that and say this line, and that should do it. We also need to create a little bit of buffer, let's just call it four lines, so that we have some space for our hotkeys to render. This is looking pretty good. I want to do two other things in the State class.
Default Message and Alt Screen16:54
This is looking pretty good. I want to do two other things in the state class. Let's first provide a default message that tells the user exactly what they can do. So type a message, enter to clear. The second thing I want to do is create this app within an alt screen, which is something we'll do a lot. This writes our output to an alternate screen, which means that it creates a more immersive and full screen experience. Chewy can help us out there again. Use createsAlternateScreen.
Chewy can help us out there again. Use creates alternate screen. And then here, after we register our theme, we can say this creates alt screen. We should be in a good place. Let's go back to the terminal and see what we got. Awesome. As you can see, we have a default message here, and you can see our hotkeys, and it's lit up because it is available to us. We have a message that is not an empty string. So if we press enter, you can see it grays out and disables.
We have a message that is not an empty string. So if we press enter, you can see it grays out and disables. Here is my message. Our word wrapping is still working. If we press enter, it clears again. This is looking pretty good. Hey, we did it. We made our first app, and honestly, it came out pretty great. Let's dive into the next experiment.
Let's dive into the next experiment.
