Hello, puppies and kittens! In this article, we will carry on our series demystifying every damn aspect of Textual. In the previous articles, we built a series of increasingly complex applications to demonstrate some of the concepts associated with developing TUIs with Textual, such as reactive attributes, custom widgets, event handlers, and state management, among many others.
In part 1, we introduced the concept of a GridView
view, and we didn't explain much about how it works to group and align widgets on a rectangular layout. In part 2, we created a useless dummy login form without actions, such as redirection from one widget to another and refreshing the terminal. We also explained how widget events are handled and how reactive attributes work in Textual, but we didn't explain how to make our app handle events fired by another widget, such as the submit button that we have created. And that's what we are going to explore in this article by creating a fully dynamic register/login form.
Given the fact that textual is highly involved in object-oriented programming, this article assumes that you are already familiar with this concept. If not, consider reading one of the most comprehensive tutorials out there about this topic. This article also assumes that you know the basics of SQL since it is used to do CRUD operations on the database for the login and register forms.
Throughout this article, two terms, "message" and "event" were used interchangeably and referred to the same thing.
This article will shed some light on numerous Textual concepts used when developing custom TUI applications:
• Creating a Textual widget with custom rendering capabilities and behavior.
• Using GridView to arrange widgets in terms of columns and rows on the terminal.
• Rerender widgets using Textual's built-in support for clearing and rerendering widgets.
Spoilers ahead: By the end of this article, you will learn how to create a fully functional login/register screen in Textual, as shown in the recording below.
👉 Table Of Content (TOC).
- The Login Form
- GridView
- Views vs Layouts
- Storing User Credentials
- Connecting The Database
- Putting It All Together
- Wrapping Up
- Future Work
The Login Form
🔝 Go To TOC.
In part 2, we left off at this snippet of code that defines our login form logic.
It is made up of three main components: Two custom input widgets for entering username and password positioned at the top of the terminal and a submit button placed at the bottom of the terminal. Our submit button didn't do much other than logging the value of the username entered in a log file(notice the line self.log(f"username = {self.username}")
). Now, we need to modify it to actually emulate the login process.
But first, let's tweak a bit the naming of some variables to reflect their functionalities and remove unnecessary variables.
from rich.align import Align
from rich.box import DOUBLE
from rich.console import RenderableType
from rich.panel import Panel
from rich.style import Style
from rich.text import Text
from textual import events
from textual.app import App
from textual.reactive import Reactive
from textual.widget import Widget
from textual.widgets import Button
from textual.widgets import Button, ButtonPressed
class InputText(Widget):
title: Reactive[RenderableType] = Reactive("")
content: Reactive[RenderableType] = Reactive("")
mouse_over: Reactive[RenderableType] = Reactive(False)
def __init__(self, title: str):
super().__init__(title)
self.title = title
def on_enter(self) -> None:
self.mouse_over = True
def on_leave(self) -> None:
self.mouse_over = False
def on_key(self, event: events.Key) -> None:
if self.mouse_over == True:
if event.key == "ctrl+h":
self.content = self.content[:-1]
else:
self.content += event.key
def validate_title(self, value) -> None:
try:
return value.lower()
except (AttributeError, TypeError):
raise AssertionError("title attribute should be a string.")
def render(self) -> RenderableType:
renderable = None
if self.title.lower() == "password":
renderable = "".join(map(lambda char: "*", self.content))
else:
renderable = Align.left(Text(self.content, style="bold"))
return Panel(
renderable,
title=self.title,
title_align="center",
height=3,
style="bold white on rgb(50,57,50)",
border_style=Style(color="green"),
box=DOUBLE,
)
class MainApp(App):
username: Reactive[RenderableType] = Reactive("")
password: Reactive[RenderableType] = Reactive("")
def handle_button_pressed(self, message: ButtonPressed) -> None:
"""A message sent by the submit button"""
assert isinstance(message.sender, Button)
button_name = message.sender.name
if button_name == "login":
self.username = self.username_field.content
self.password = self.password_field.content
# Query the username and password
async def on_mount(self) -> None:
self.login_button = Button(label="login", name="login")
self.username_field = InputText("username")
self.password_field = InputText("password")
await self.view.dock(self.login_button, edge="bottom", size=3)
await self.view.dock(self.username_field, edge="left", size=50)
await self.view.dock(self.password_field, edge="left", size=50)
if __name__ == "__main__":
MainApp.run(log="textual.log")
As you can tell, our login form looks wacky and a bit off proportions. We should organize the widgets in some sort of view. And that's what we are going to do in the next section.
GridView
🔝 Go To TOC.
As we saw in part 1 of the series, a grid lets us lay out our widgets in columns and rows. It is similar to HTML tables for doing layouts. We are going to illustrate the various ways you can tweak a grid to arrange and organize your widgets on your terminal.
In textual, a GridView
is one of many available views, but it has some powerful capabilities for different use cases.
As mentioned above, a GridView
provides us a way to align widgets in rows and columns that indicate the relative position of widgets. For example, widgets with the same column will be stacked above each other. Similarly, Those in the same row will be adjacent to each other.
Column and row numbers should be positive integers. The width of each column is fixed and defined beforehand. Let's take the following snippet of code to demonstrate that:
from textual.views import GridView
class LoginGrid(GridView):
async def on_mount(self) -> None:
self.grid.set_align("center", "center")
self.grid.set_gap(1, 1)
# Create rows / columns / areas
self.grid.add_column("column", repeat=2, size=40)
self.grid.add_row("row", repeat=3, size=3)
As you can tell, we have extended the base class GridView
to create our custom LoginGrid
grid. The code is pretty straightforward, it defiens a 2x3 grid with 40 characters width and 3 characters height for each column and row respectively(notice the repeat
argument of add_column
and add_row
).
Now, Let's group our widgets within this grid which can be done by calling the self.grid.add_widget
function on each widget. By doing so, our code will result in the following listing:
from rich.align import Align
from rich.box import DOUBLE
from rich.console import RenderableType
from rich.panel import Panel
from rich.style import Style
from rich.text import Text
from textual import events
from textual.app import App
from textual.reactive import Reactive
from textual.views import GridView
from textual.widget import Widget
from textual.widgets import Button, ButtonPressed
class InputText(Widget):
title: Reactive[RenderableType] = Reactive("")
content: Reactive[RenderableType] = Reactive("")
mouse_over: Reactive[RenderableType] = Reactive(False)
def __init__(self, title: str):
super().__init__(title)
self.title = title
def on_enter(self) -> None:
self.mouse_over = True
def on_leave(self) -> None:
self.mouse_over = False
def on_key(self, event: events.Key) -> None:
if self.mouse_over == True:
if event.key == "ctrl+h":
self.content = self.content[:-1]
else:
self.content += event.key
def validate_title(self, value) -> None:
try:
return value.lower()
except (AttributeError, TypeError):
raise AssertionError("title attribute should be a string.")
def render(self) -> RenderableType:
renderable = None
if self.title.lower() == "password":
renderable = "".join(map(lambda char: "*", self.content))
else:
renderable = Align.left(Text(self.content, style="bold"))
return Panel(
renderable,
title="",
title_align="center",
height=3,
style="bold white on rgb(50,57,50)",
border_style=Style(color="green"),
box=DOUBLE,
)
class LoginGrid(GridView):
username: Reactive[RenderableType] = Reactive("")
password: Reactive[RenderableType] = Reactive("")
async def on_mount(self) -> None:
# define input fields
self.username = InputText("username")
self.password = InputText("password")
self.grid.set_align("center", "center")
self.grid.set_gap(1, 1)
# Create rows / columns / areas
self.grid.add_column("column", repeat=2, size=40)
self.grid.add_row("row", repeat=3, size=3)
# Place out widgets in to the layout
button_style = "bold red on white"
label_style = "bold white on rgb(60,60,60)"
username_label = Button(label="username", name="username_label", style=label_style)
password_label = Button(label="password", name="password_label", style=label_style)
self.grid.add_widget(username_label)
self.grid.add_widget(self.username)
self.grid.add_widget(password_label)
self.grid.add_widget(self.password)
self.grid.add_widget(Button(label="register", name="register", style=button_style))
self.grid.add_widget(Button(label="login", name="login", style=button_style))
class MainApp(App):
username: Reactive[RenderableType] = Reactive("")
password: Reactive[RenderableType] = Reactive("")
def handle_button_pressed(self, message: ButtonPressed) -> None:
"""A message sent by the submit button"""
assert isinstance(message.sender, Button)
button_name = message.sender.name
if button_name == "login":
self.username = self.login_grid.username.content
self.password = self.login_grid.password.content
# Query username and password
async def on_mount(self) -> None:
self.login_grid = LoginGrid()
await self.view.dock(self.login_grid)
if __name__ == "__main__":
MainApp.run(log="textual.log")
Notice, we have added six widgets to our LoginGrid
and two reactive attributes to store the username and password entered by the user and make them accessible to the parent application MainApp
. The add_widget
function will group the widgets from left to right, top to bottom. Now, our app looks minimalistic and contains only the LoginGrid
inside the on_mount
event handler. Running the above snippet of code will result in the following:
The comment # Query username and password
is where we need to add the necessary logic to check whether or not the username and password being entered match a record in a database. For that purpose, we are going to use SQLite3 to store credentials since it comes pre-installed with python, a "Batteries-Included" programming language.
One major caveat here is the login/register buttons; In textual, you can capture click
messages from the parent app by overriding the handle_button_pressed
method, which is a special handler to read messages sent by a child button. This function will help us create the necessary logic simulating the login and register screens concept.
Views Vs Layouts
🔝 Go To TOC.
The other day, I was hopping around the source code, and I noticed that there are two ways to create a grid: Using the GridView
or the GridLayout
class. Apparently, a GridView
is a layer of abstraction on top of GridLayout
with an extra readable grid
attribute. As a programmer, you can use either class to define a grid. But, if you want to read the grid
value associated with a layout, you can use GridView
. However, if you're going to use the GridLayout
class, you can refer to this example. I think GridView
is more flexible to use. It can interact with the parent application, unlike GridLayout
.
With that noted, let's continue the implementation of our login form. In the next section, we will use sqlite3 as a database.
Storing User Credentials
🔝 Go To TOC.
In this section, we will learn how to build a little database to store the data submitted by a user. Having sqlite3 installed on our machine, we need to create a file called db.py
and add the following code:
import sqlite3
import contextlib
def create_users_table() -> None:
create_table_query = """
CREATE TABLE IF NOT EXISTS
users(
id integer primary key autoincrement,
username VARCHAR UNIQUE not null,
password VARCHAR not null
)
"""
# connect to the database
# using contextlib to avoid connections issues with
# sqlite when forget closing the connection and cursor
with contextlib.closing(sqlite3.connect("./users.sqlite")) as connection:
# create a crusor to interract with the database
with contextlib.closing(connection.cursor()) as cursor:
cursor.execute(create_table_query)
# commit the changes
connection.commit()
This code snippet creates a table called "users" that will hold each user's id, username, and password. You can extend this table to store whatever information you want, such as phone number, gender, etc. For demonstration purposes, let's stick with these data fields. In our function, notice the use of contextlib
, which is a helpful built-in module to control the context of running code; it is usually used for synchronization. In our case, we used it to allow only one connection to the database to perform CRUD operations and close it afterward. From what I have experienced, this is the best practice to interact with the database if you are using SQLite.
Similarly, let's build our function(similar to an SQL procedure) to register a user.
def register_user(username: str, password: str) -> bool:
query = """
INSERT INTO
users (
username
, password
)
VALUES (
?
, ?
)
"""
try:
with contextlib.closing(sqlite3.connect("./users.sqlite")) as connection:
# create a crusor to interract with the database
with contextlib.closing(connection.cursor()) as cursor:
cursor.execute(query, (username, password))
# commit the changes
connection.commit()
return True
except:
return False
Notice the use of the ?
symbol, which will control the values of the username and the password entered by a user. This common practice shields you against the well-known TOP 10 OWASP attacks. Notice also the use of a try-catch block to handle the case of a user registering twice. This function returns True if a user has registered successfully and False if the user already exists.
Bear in mind that, in a real-world application, you never store sensitive information inside a database; you store its hashes instead. But, for demonstration purposes, let's bypass this requirement.
Moving on, we need to create a function to check if a user exists in the database as a record.
def check_user(username: str, password: str) -> str:
result = ""
select_query = """
SELECT
*
FROM
users
WHERE
username = ?
AND
password = ?
"""
with contextlib.closing(sqlite3.connect("./users.sqlite")) as connection:
# create a crusor to interract with the database
with contextlib.closing(connection.cursor()) as cursor:
cursor.execute(select_query, (username, password))
# commit the changes
connection.commit()
result = cursor.fetchone()
return result
Take a look at the result = cursor.fetchone()
statement, which will return a record(e.g., a tuple of id, username, and a password) if the user exists, None otherwise.
Up until now, we have utility scripts that help us interact with the database with a level of abstraction. Now, let's go back to our main application.
Connecting The Database
🔝 Go To TOC.
Now, let's add the necessary logic for our login form.
class MainApp(App):
username: Reactive[RenderableType] = Reactive("")
password: Reactive[RenderableType] = Reactive("")
def handle_button_pressed(self, message: ButtonPressed) -> None:
"""A message sent by the submit button"""
assert isinstance(message.sender, Button)
button_name = message.sender.name
self.username = self.login_grid.username.content
self.password = self.login_grid.password.content
if button_name == "login":
# Query username and password to check if exists
async def on_mount(self) -> None:
self.login_grid = LoginGrid()
await self.view.dock(self.login_grid)
Let's begin with the edge cases. The first one is where a user enters an empty string in the username field and/or the password field. To address this case, we can add the following if statement:
if len(self.username) == 0 or len(self.password) == 0:
Now, we need to display a nicely formatted message to prompt users to reenter a valid username and/or password. In Textual, there is no Label widget yet, but we can use a button for that purpose:
# add new widget
await self.view.dock(
Button(
label="Please enter a valid username and password!",
style="bold white on rgb(50,57,50)",
)
)
But first, we need to clear up the screen to draw this button on the terminal. To do so, we can use the clear
function on the docks and widgets associated with the app.
self.view.layout.docks.clear()
self.view.widgets.clear()
After adding these lines of code, our main app becomes:
class MainApp(App):
username: Reactive[RenderableType] = Reactive("")
password: Reactive[RenderableType] = Reactive("")
async def handle_button_pressed(self, message: ButtonPressed) -> None:
"""A message sent by the submit button"""
assert isinstance(message.sender, Button)
button_name = message.sender.name
self.username = self.login_grid.username.content
self.password = self.login_grid.password.content
if button_name == "login":
# clear widgets and docks
self.view.layout.docks.clear()
self.view.widgets.clear()
if len(self.username) == 0 or len(self.password) == 0:
# add new widget
await self.view.dock(
Button(
label="Please enter a valid username and password!",
style="bold white on rgb(50,57,50)",
)
)
async def on_mount(self) -> None:
self.login_grid = LoginGrid()
await self.view.dock(self.login_grid)
Notice that our handle_button_pressed
becomes asynchronous because of the await self.view.dock
statements. Now, we need to display this message for a couple of seconds before redrawing back the widgets to prompt the user to enter a new username and password:
await asyncio.sleep(2)
# clear widgets
self.view.layout.docks.clear()
self.view.widgets.clear()
# redraw back the grid
await self.view.dock(self.login_grid)
If our program didn't hit this case, it means the user has entered both username and password. Now, we need to check whether or not the user exists in our database. To do so, we can add the following if-else statements:
from db import check_user
class MainApp(App):
username: Reactive[RenderableType] = Reactive("")
password: Reactive[RenderableType] = Reactive("")
async def handle_button_pressed(self, message: ButtonPressed) -> None:
"""A message sent by the submit button"""
assert isinstance(message.sender, Button)
button_name = message.sender.name
self.username = self.login_grid.username.content
self.password = self.login_grid.password.content
if button_name == "login":
# clear widgets and docks
self.view.layout.docks.clear()
self.view.widgets.clear()
if len(self.username) == 0 or len(self.password) == 0:
# add new widget
await self.view.dock(
Button(
label="Please enter a valid username and password!",
style="bold white on rgb(50,57,50)",
)
)
elif check_user(self.username, self.password):
# clear widgets
self.view.layout.docks.clear()
self.view.widgets.clear()
# add new widget
await self.view.dock(
Button(
label=f"Weclome back {self.username}!",
style="bold white on rgb(50,57,50)",
)
)
else:
# clear widgets
self.view.layout.docks.clear()
self.view.widgets.clear()
# add new widget
await self.view.dock(
Button(
label="Invalid Credentials!",
style="bold white on rgb(50,57,50)",
)
)
await asyncio.sleep(2)
# clear widgets
self.view.layout.docks.clear()
self.view.widgets.clear()
# redraw back the grid
await self.view.dock(self.login_grid)
Alternatively, we can implement our register form:
elif button_name == "register":
result = None
self.view.layout.docks.clear()
self.view.widgets.clear()
if len(self.username) == 0 or len(self.password) == 0:
# add new widget
await self.view.dock(
Button(
label="Please enter a valid username and password!",
style="bold white on rgb(50,57,50)",
)
)
await asyncio.sleep(2)
# clear widgets
self.view.layout.docks.clear()
self.view.widgets.clear()
# redraw back the grid
await self.view.dock(self.login_grid)
else:
result = register_user(self.username, self.password)
if result:
# add new widget
await self.view.dock(
Button(
label="User Registered Successfully!",
style="bold white on rgb(50,57,50)",
)
)
await asyncio.sleep(2)
# clear widgets
self.view.layout.docks.clear()
self.view.widgets.clear()
# redraw back the grid
await self.view.dock(self.login_grid)
elif not result and len(self.username) > 0:
# add new widget
await self.view.dock(
Button(
label="Username Already Exists!",
style="bold white on rgb(50,57,50)",
)
)
await asyncio.sleep(2)
# clear widgets
self.view.layout.docks.clear()
self.view.widgets.clear()
# redraw back the grid
await self.view.dock(self.login_grid)
Putting It All Together
🔝 Go To TOC.
The workflow of our program is the following after running python login.py
:
- The login screen opens up.
- Type in your user and password.
- Click on register to add a new user to the database.
- The application will display the text:
- "Please enter a valid username and password!" if one of the fields is empty
- "User Registered Successfully!" for a new user.
- "Username Already Exists!" for registered user.
- Click on login to login into the app.
- The application will display the text:
- "Weclome back username!" if the user has registered to the application.
- "Invalid Credentials!" otherwise.
The complete listing for our application looks like the following:
import asyncio
from rich.align import Align
from rich.box import DOUBLE
from rich.console import RenderableType
from rich.panel import Panel
from rich.style import Style
from rich.text import Text
from textual import events
from textual.app import App
from textual.reactive import Reactive
from textual.views import GridView
from textual.widget import Widget
from textual.widgets import Button, ButtonPressed
from db import check_user, create_users_table, register_user
class InputText(Widget):
title: Reactive[RenderableType] = Reactive("")
content: Reactive[RenderableType] = Reactive("")
mouse_over: Reactive[RenderableType] = Reactive(False)
def __init__(self, title: str):
super().__init__(title)
self.title = title
def on_enter(self) -> None:
self.mouse_over = True
def on_leave(self) -> None:
self.mouse_over = False
def on_key(self, event: events.Key) -> None:
if self.mouse_over == True:
if event.key == "ctrl+h":
self.content = self.content[:-1]
else:
self.content += event.key
def validate_title(self, value) -> None:
try:
return value.lower()
except (AttributeError, TypeError):
raise AssertionError("title attribute should be a string.")
def render(self) -> RenderableType:
renderable = None
if self.title.lower() == "password":
renderable = "".join(map(lambda char: "*", self.content))
else:
renderable = Align.left(Text(self.content, style="bold"))
return Panel(
renderable,
title="",
title_align="center",
height=3,
style="bold white on rgb(50,57,50)",
border_style=Style(color="green"),
box=DOUBLE,
)
class LoginGrid(GridView):
username: Reactive[RenderableType] = Reactive("")
password: Reactive[RenderableType] = Reactive("")
async def on_mount(self) -> None:
# define input fields
self.username = InputText("username")
self.password = InputText("password")
self.grid.set_align("center", "center")
self.grid.set_gap(1, 1)
# Create rows / columns / areas
self.grid.add_column("column", repeat=2, size=40)
self.grid.add_row("row", repeat=3, size=3)
# Place out widgets in to the layout
button_style = "bold red on white"
label_style = "bold white on rgb(60,60,60)"
username_label = Button(
label="username", name="username_label", style=label_style
)
password_label = Button(
label="password", name="password_label", style=label_style
)
self.grid.add_widget(username_label)
self.grid.add_widget(self.username)
self.grid.add_widget(password_label)
self.grid.add_widget(self.password)
self.grid.add_widget(
Button(label="register", name="register", style=button_style)
)
self.grid.add_widget(Button(label="login", name="login", style=button_style))
class MainApp(App):
username: Reactive[RenderableType] = Reactive("")
password: Reactive[RenderableType] = Reactive("")
async def handle_button_pressed(self, message: ButtonPressed) -> None:
"""A message sent by the submit button"""
assert isinstance(message.sender, Button)
button_name = message.sender.name
self.username = self.login_grid.username.content
self.password = self.login_grid.password.content
if button_name == "login":
# clear widgets
self.view.layout.docks.clear()
self.view.widgets.clear()
if len(self.username) == 0 or len(self.password) == 0:
# add new widget
await self.view.dock(
Button(
label="Please enter a valid username and password!",
style="bold white on rgb(50,57,50)",
)
)
await asyncio.sleep(2)
# clear widgets
self.view.layout.docks.clear()
self.view.widgets.clear()
# redraw back the grid
await self.view.dock(self.login_grid)
elif check_user(self.username, self.password):
# clear widgets
self.view.layout.docks.clear()
self.view.widgets.clear()
# add new widget
await self.view.dock(
Button(
label=f"Weclome back {self.username}!",
style="bold white on rgb(50,57,50)",
)
)
else:
# clear widgets
self.view.layout.docks.clear()
self.view.widgets.clear()
# add new widget
await self.view.dock(
Button(
label="Invalid Credentials!",
style="bold white on rgb(50,57,50)",
)
)
await asyncio.sleep(2)
# clear widgets
self.view.layout.docks.clear()
self.view.widgets.clear()
# redraw back the grid
await self.view.dock(self.login_grid)
elif button_name == "register":
result = None
self.view.layout.docks.clear()
self.view.widgets.clear()
if len(self.username) == 0 or len(self.password) == 0:
# add new widget
await self.view.dock(
Button(
label="Please enter a valid username and password!",
style="bold white on rgb(50,57,50)",
)
)
await asyncio.sleep(2)
# clear widgets
self.view.layout.docks.clear()
self.view.widgets.clear()
# redraw back the grid
await self.view.dock(self.login_grid)
else:
result = register_user(self.username, self.password)
if result:
# add new widget
await self.view.dock(
Button(
label="User Registered Successfully!",
style="bold white on rgb(50,57,50)",
)
)
await asyncio.sleep(2)
# clear widgets
self.view.layout.docks.clear()
self.view.widgets.clear()
# redraw back the grid
await self.view.dock(self.login_grid)
elif not result and len(self.username) > 0:
# add new widget
await self.view.dock(
Button(
label="Username Already Exists!",
style="bold white on rgb(50,57,50)",
)
)
await asyncio.sleep(2)
# clear widgets
self.view.layout.docks.clear()
self.view.widgets.clear()
# redraw back the grid
await self.view.dock(self.login_grid)
async def on_mount(self) -> None:
create_users_table()
self.login_grid = LoginGrid()
await self.view.dock(self.login_grid)
if __name__ == "__main__":
MainApp.run(log="textual.log")
Wrapping up
🔝 Go To TOC.
A view is one of the essential textual components that instructs the child components or widgets to render in the available space on the terminal. For example, as we discussed, a Grid View arranges child components by a combination of rows and columns.
And this constitutes the Login app walkthrough. The resulting application can actually register a new user and login into the application.
As always, this article is a gift to you from a higher-dimensional entity. You can share it with whomever you like or use it in any way that would be beneficial to your personal and professional development. By supporting this blog, you keep me motivated to publish high-quality content related to python in general and textual specifically. Thank you in advance for your ultimate support!
You are free to use the code in this article, which is licensed under the MIT license, as a starting point for various needs. Don’t forget to look at the readme file and use your imagination to make more complex apps meaningful to your use case.
Future Work
🔝 Go To TOC.
Now that you have created a fully functional login/register screen in textual, which worked as intended, you can make a chat room that uses these forms.
Keep in mind that this task is somewhat a medium level. It is objectively not easy to implement such applications. But, Textual provides us with a great toolkit on the user interface side of things, facilitating the ease of creating fully-featured, blazingly fast rendered widgets.
If you get stuck, it is not that big of a deal because nothing works immediately as you implement it; Sometimes, you will go through trial and error, which is so common in the fascinating programming world. Try to focus on one task at a time, and you will be surprised by your capabilities.
There is so much to explore about the fantastic world of Textal, and UI designers that are knowledgeable in this domain are few and highly valued. Remember that it will certainly pay off to dig into such topics about TUI at some point.
Happy Coding, folks; see you in the next one.
Top comments (3)
Keep the hard work, thanks
Absolutely!
Amazing, thank you for breaking this down!