DEV Community

Cover image for Textual: The Definitive Guide - Part 1.
Mahmoud Harmouch
Mahmoud Harmouch

Posted on • Updated on

Textual: The Definitive Guide - Part 1.

This is part one of my series on demystifying every damn aspect of Textual. In this article, we are going to explore the not-so-popular world of text-based user interfaces and how to create one using Textual. I’ve chosen to write about textual, partially because it has been used during my participation at the deepgram hackathon, but mostly because there isn’t any tutorial about textual yet in the wild world of the internet besides the readme file.

If you have experience with text user interfaces in the past, you might come across other frameworks such as urwid, curtsies, asciimatics, prompt-toolkit to name a few. Nevertheless, If you have not, you are just fine because you are in the right place to learn about TUIs in general and using Textual specifically. I’ll show you how to develop a wordle clone step by step.

Psst: It's me from the future. I just wanted to let you know that when you see a $ sign in front of a command that tells you to run it in the shell rather than the Python interpreter.

πŸ‘‰ Table Of Content (TOC).

What is a TUI?

Arguably, the most popular place to look up definitions is Wikipedia, and I quote:

In computing, text-based user interfaces (TUI) (alternately terminal user interfaces, to reflect a dependence upon the properties of computer terminals and not just text), is a retronym describing a type of user interface (UI) common as an early form of human–computer interaction, before the advent of graphical user interfaces (GUIs).

In other words, a text user interface (TUI) is a type of user interface that relies on text rather than graphics to display information. And the most common use of TUI is in the form of a command-line interface.
The CLI of a TUI is generally not graphical, may have no mouse support, which is not the case of Textual, and may be designed for keyboard input. For example, the Linux cat command, btw I love cats, can be called on a TUI, in this case, your terminal, with the command $ cat.

This will bring up a text-based interface, where you can type text and then press enter to display back the typed text.

Now, having a high overview of TUIs, let's dive into the world of Textual.

What is Textual?

πŸ” Go To TOC.

Textual is a relatively new text-based user interface toolkit. It allows programmers to build interactive yet sophisticated, user-friendly TUIs within the terminal. It is attractive to many programmers like me, and it is going to revolutionalize the world of terminal applications. What makes Textual attractive is the following core features:

  • Textual helps you to build your terminal application with ease elegantly.
  • Textual is the only way to create a highly interactive yet complex application within your terminal.
  • Textual removes the horrible boilerplates of earlier TUIs by getting rid of decorators for handling events and such.

Regardless of what reasons you are going to use Textual, I’m glad you stumbled across this article. I will be going step by step through Textual basics and how to create a fully functional application inside your terminal. Each article I am going to publish from now on will dive into details about every element of Textual.
I’ve chosen to develop a wordle clone with textual, mainly because it has the right complexity level.

While I hope this article will appeal to a diverse range of programmers, I have a unique audience in mind as I write it. As with any job description, you don’t have to know everything damn thing they mention, but it will help you to understand who I’m thinking about and how you might differ. My intended audience is:

  • A beginner to intermediate programming skills using Python.
  • Looking for some advanced Python concepts, which I hope you are eager to learn.
  • Wants to learn about programming workflow, not just Textual.
  • And, of course, has a good sense of humor.

Nevertheless, if you’re interested in creating a working application in Textual, you’re still in the right place! I’ll be showing you how to develop a wordle clone step by step. You’ll start with setting up a development environment with poetry and end up with an application running on your terminal. How exciting is that!

Install Textual

πŸ” Go To TOC.

It’s quite an unfortunate truth in the world of programming that the fun part has to come after a hustle. However, Getting Textual up and running is not a terribly complicated process, just by rinning one command pip install textual or by checking out the repo and running poetry install. Since I used the latter in deepwordle, let me give you a high overview of what poetry is and how it has been my go-to tool for building packages and managing dependencies.

Dependency management with poetry

πŸ” Go To TOC.

This section will highlight the most important things that will help you start working with poetry.
However, it is worth mentioning that there are many ways to install a python package: using pip, pypenv, pdm, poetry, and many more. Poetry is not only "yet another" python dependency management tool, but also it can be used to deliver packages. I just like this comic:

Standards. Source: https://xkcd.com/927/.

What is Poetry

πŸ” Go To TOC.

Poetry is a pretty intuitive yet an elegant command-line tool for installing, managing dependencies, projects, and virtual environments, an all-in-one solution. The idea of inventing this tool is because of the different conventions of package management (using requirements.txt, MANIFEST.ini, setup.cfg, and many others) seemed to the creator of Poetry not very convenient. Only one file should be required, namely pyproject.toml, which aims at being clear, readable, and part of the PEP 517 and PEP 518 standard.

Whether you are new to dependency management or not, I recommend giving this tool a try. It is effortless, easy to use, and can simplify the maintenance and development of your python project.

Poetry installation

πŸ” Go To TOC.

To install this tool, you can follow along with the official installation guide.

Essentially, If you are a Linux user, all you need to do is run the following command in your terminal.

$ curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python -
Enter fullscreen mode Exit fullscreen mode

python versionning

When it comes to python versioning, I recommend using pyenv. Therefore I am going to explain how to install a specific version of python interpreter with pyenv

pyenv

pyenv is a handy tool that allows you to install a specific version of the python interpreter into your machine with ease. Unlike the traditional way of going to the official python website and following the daunting steps to install a specific python interpreter, pyenv makes it easier by just running a simple command.

Pyenv installation

πŸ” Go To TOC.

The Readme file of the pyenv repository is fruitful with information about the installation process and how to use this tool.
However, I have compiled the essential parts to get this tool up and running on your machine if you are a Linux user.

Configure pyenv on zsh:

$ cat << EOF >> ~/.zshrc
# pyenv config
export PATH="${HOME}/.pyenv/bin:${PATH}"
export PYENV_ROOT="${HOME}/.pyenv"
eval "$(pyenv init -)"
EOF
Enter fullscreen mode Exit fullscreen mode

Or if you are using the default bash shell, run the following command instead:

$ cat << EOF >> ~/.bashrc
# pyenv config
export PATH="${HOME}/.pyenv/bin:${PATH}"
export PYENV_ROOT="${HOME}/.pyenv"
eval "$(pyenv init -)"
EOF
Enter fullscreen mode Exit fullscreen mode

Close your terminal and open a new shell session. Now, You can verify the installation process by running the following command:

$ pyenv --version 
Enter fullscreen mode Exit fullscreen mode
Python versioning with pyenv

πŸ” Go To TOC.

You can issue the following command to install a specific python interpreter:

$ pyenv install <python_version>
Enter fullscreen mode Exit fullscreen mode

For this tutorial, I am going to install python 3.10.1:

$ pyenv install 3.10.1
Enter fullscreen mode Exit fullscreen mode

To list all available python versions you can install with pyenv, you can run the following command:

$ pyenv install --list | grep \ 3\.
Enter fullscreen mode Exit fullscreen mode

To list all installed versions of python on your machine, you can run:

$ pyenv versions

  system
* 3.10.1 (set by PYENV_VERSION environment variable)
  3.8.10
  3.9.10
Enter fullscreen mode Exit fullscreen mode

You can make it globally available by running:

$ pyenv global system 3.10.1
Enter fullscreen mode Exit fullscreen mode

These are the essential commands for python versioning with pyenv. Now, let's go back to poetry.

virtual environments with poetry

πŸ” Go To TOC.

Having the python executable in your PATH using pyenv, you can use it with poetry:

$ poetry env use 3.10.1
Enter fullscreen mode Exit fullscreen mode

However, you are most likely to run into the following issue if you have virtualenv already installed using apt(if you didn't get this issue, that is great!):

Creating virtualenv deepwordle-dxc671ba-py3.10 in ~/.cache/pypoetry/virtualenvs

ModuleNotFoundError

No module named 'virtualenv.seed.via_app_data'

at <frozen importlib._bootstrap>:973 in _find_and_load_unlocked
Enter fullscreen mode Exit fullscreen mode

To resolve it, you need to reinstall virtualenv through pip:

$ sudo apt remove --purge python3-virtualenv virtualenv
$ python3 -m pip install -U virtualenv
Enter fullscreen mode Exit fullscreen mode

Now, you can tell poetry to use the pre-installed Python interpreter, 3.10.1 in this case:

$ poetry env use 3.10.1
Using virtualenv: ~/.cache/pypoetry/virtualenvs/deepwordle-dxc671ba-py3.10
Enter fullscreen mode Exit fullscreen mode

Poetry has a proper path for setting up virtual environments(under ~/.cache/pypoetry/virtualenvs/). However, if you want to let poetry create a virtual env called .venv inside your project directory, you can run the following command:

$ poetry config virtualenvs.in-project true
Enter fullscreen mode Exit fullscreen mode

You can take a look at different configurations of poetry by running:

$ poetry config --list
Enter fullscreen mode Exit fullscreen mode

Starting a new Project

πŸ” Go To TOC.

Having a virtual environment set up, you can use poetry to create a new project along with a virtual environment:

$ poetry new deepwordle && cd deeepwordle
Enter fullscreen mode Exit fullscreen mode

This will equip you with the bare minimum to get things started.

deepwordle
β”œβ”€β”€ pyproject.toml
β”œβ”€β”€ README.rst
β”œβ”€β”€ deepwordle
β”‚   └── __init__.py
└── tests
    β”œβ”€β”€ __init__.py
    └── test_deepwordle.py
Enter fullscreen mode Exit fullscreen mode

Activating a virtual environment

πŸ” Go To TOC.

If you are inside the project directory, you can activate a virtual environment by simply running:

$ poetry shell
Enter fullscreen mode Exit fullscreen mode

or you can source the virtualenv path:

$ source ~/.cache/pypoetry/virtualenvs/<your_virtual_environment_name>/bin/activate
Enter fullscreen mode Exit fullscreen mode

you can look up for previously installed virtual environments under ~/.cache/pypoetry/virtualenvs:

$ ls ~/.cache/pypoetry/virtualenvs
Enter fullscreen mode Exit fullscreen mode

To exit this virtual environment, use exit, Ctrl-d, deactivate, or your favorite way to nuke the shell.

Adding packages to your project

πŸ” Go To TOC.

Like the traditional way of using pip to install packages, poetry is capable of doing so by running the following command to import a package into your project:

$ poetry add <name_of_the_package_you_want_to_install>
Enter fullscreen mode Exit fullscreen mode

This will add an entry under the tool.poetry.dependencies section:

[tool.poetry.dependencies]
name_of_the_package_you_want_to_install = "^package_version"
Enter fullscreen mode Exit fullscreen mode

You can always refer to the official documentation for more detailed information about setting up projects with poetry.

I think this section is a good starting point for building a new project using poetry. In the follow-up sections, we are going to use this tool to install and manage Textual.

Install Textual: revisited

πŸ” Go To TOC.

As far as I can tell from the code base, Textual is currently available for Linux os only MacOS / Linux / Windows. To add textual to your project, run the following command:

$ poetry add textual
Enter fullscreen mode Exit fullscreen mode

As you can tell, writing about dependency and environments setup is also frustrating for me. I don’t know what operating system you’re using; I hope a Linux flavor. Otherwise, I can’t predict how things might go wrong for you.

Basic Textual App

πŸ” Go To TOC.

Now everything is set up, let's create the most basic Textual app. To do so, create a file called app.py using your favorite code editor and add the following code:

from textual.app import App

App.run()
Enter fullscreen mode Exit fullscreen mode

This is the most basic Textual app you can ever code; Just importing the App class and then calls the run method class.

Run this code with python if you are still inside your virtualenv by running the following command:

$ python app.py
Enter fullscreen mode Exit fullscreen mode

or using poetry, but it is not required because we have already done $ poetry shell which means that you are inside the directory that contains the pyproject.toml file and the virtualenv is activated, still you can use:

$ poetry run python app.py
Enter fullscreen mode Exit fullscreen mode

It will create a blank window with a black background within your terminal.

Basic app

If a blank window with a black background is exactly the kind of application you were looking to write, then you’re done! Congratulations. Just kidding, ofcource it is not! right?

Now let's buid a slightly basic Textual app uisng the followin code:

from textual.app import App

class MainApp(App):
    ...

MainApp.run()
Enter fullscreen mode Exit fullscreen mode

This version uses inheritance to create a new subclass of App called MainApp. This is the application you’ll be developing throughout this series of article. You didn’t actually do anything woth the new class, so it still behaves like the previous basic version. However, we are going to do a lot in this and subsequent articles.

In the following sections, we will discuss the different components of Textual and how to use them for building our TUI app. But first, we need to design our app, aka mockup.

User Interface

πŸ” Go To TOC.

Being good at designing UIs is an essential skill to possess as a front-end developer or an engineer. It gives quick insights into how to lay out visual application components before the coding phase. Let's apply this practice to our deepwordle app. Here are some capabilities I want to cover in this project:

  • Render each word being guessed on the screen with spaces in between.
  • From a basic wordle app standpoint, the word is only five letters long.
  • Only six attempts are allowed for the user to guess the word.
  • Display a nicely formatted message to tell the user what is happening.
  • Tell the user the available keys for this game.

Given these features, it’s fairly easy to imagine the set of views and widgets for the application will require:

  • A 6x5 grid view.
  • A Button for each letter.
  • Message panel.
  • A header and footer.

Textual Widgets

πŸ” Go To TOC.

In Textual, a Widget is the base visual component of TUI interfaces. It comes with a Canvas that can be used to draw on the terminal. It receives events and reacts to them. I find it convenient to think of a widget as a container with reactive attributes and behaviors, and it can contain other containers. The Widget class is the most basic such container.

Widgets come with twelve reactive attributes you can manipulate its visual properties such as the style of the widget, its border, padding, size and many more. Under the hood, A reactive attribute is implemented as a python descriptor.

Each reactive attribute has a separate watcher, which can be defined using the watch keyword followed by _ and the name of the attribute to watch:

  foo = Reactive("")
  def watch_foo(self, val):
    if val == "bar":
      do_something()
    #
    # custom logic
    #
Enter fullscreen mode Exit fullscreen mode

When writing this article, Textual provides thirteen out-of-the-box types of widgets. We will discuss how to use the ones used in our project.

Placeholder

πŸ” Go To TOC.

For prototyping purposes, a Placeholder can be used to see what the app looks like before the implementation phase. For example, our application looks like the following in terms of placeholders.

from textual.app import App
from textual.widgets import Placeholder


class MainApp(App):

    async def on_mount(self) -> None:
        await self.view.dock(Placeholder(name="header"), edge="top", size=3)
        await self.view.dock(Placeholder(name="footer"), edge="bottom", size=3)
        await self.view.dock(Placeholder(name="stats"), edge="left", size=40)
        await self.view.dock(Placeholder(name="message"), edge="right", size=40)
        await self.view.dock(Placeholder(name="grid"), edge="top")

MainApp.run()
Enter fullscreen mode Exit fullscreen mode


Deepwordle UI Design.

Button

πŸ” Go To TOC.

A button is a Label with associated events triggered when the button is clicked. A button has three properties:

  • label: the text being rendered on the button.
  • name: the name of the widget.
  • style: label's style. It is defined using the 'foreground on background' notation. for example: style = "white on dark_blue"
from textual.app import App
from textual.widgets import Button


class MainApp(App):

    async def on_mount(self) -> None:
        button1 = Button(label='Hello', name='button1')
        button2 = Button(label='world', name='button2', style='black on white')
        await self.view.dock(button1, button2, edge="left")

MainApp.run()
Enter fullscreen mode Exit fullscreen mode


Vertical Buttons.

Header

πŸ” Go To TOC.

This widget defines a header for a terminal app. It can be used to display information such as the app's title, time, and icon(not customizable at the moment, but it has been requested in one of the issues/PRs)...

from textual.app import App
from textual.widgets import Header


class MainApp(App):

    async def on_mount(self) -> None:
        header = Header(tall=False)
        await self.view.dock(header)

MainApp.run(title="DeepWordle")
Enter fullscreen mode Exit fullscreen mode


Header.

Footer

πŸ” Go To TOC.

This widget defines a footer for a terminal app, and it can be used to display the available keys for the user.

from textual.app import App
from textual.widgets import Footer


class MainApp(App):

    async def on_load(self) -> None:
        """Bind keys here."""
        await self.bind("q", "quit", "Quit")
        await self.bind("t", "tweet", "Tweet")
        await self.bind("r", "None", "Record")

    async def on_mount(self) -> None:
        footer = Footer()
        await self.view.dock(footer, edge="bottom")

MainApp.run(title="DeepWordle")
Enter fullscreen mode Exit fullscreen mode


Footer.

ScrollView

πŸ” Go To TOC.

from textual.app import App
from textual.widgets import ScrollView, Button


class MainApp(App):


    async def on_mount(self) -> None:
        scroll_view = ScrollView(contents= Button(label='button'), auto_width=True)
        await self.view.dock(scroll_view)

MainApp.run()
Enter fullscreen mode Exit fullscreen mode


ScrollView.

Static

πŸ” Go To TOC.

from textual.app import App
from textual.widgets import Static, Button


class MainApp(App):


    async def on_mount(self) -> None:
        static = Static(renderable= Button(label='button'), name='')
        await self.view.dock(static)

MainApp.run()
Enter fullscreen mode Exit fullscreen mode

You can play with other widgets such as TreeClick, TreeControl, TreeNode, NodeID, ButtonPressed, DirectoryTree, FileClick.

Custom Widgets

πŸ” Go To TOC.

By extending the generic widget class, you can create any sort of widget you want.

from textual.app import App
from textual.widget import Widget
from textual.reactive import Reactive
from rich.console import RenderableType
from rich.padding import Padding
from rich.align import Align
from rich.text import Text

class Letter(Widget):

    label = Reactive("")

    def render(self) -> RenderableType:
        return Padding(
            Align.center(Text(text=self.label), vertical="middle"),
            (0, 1),
            style="white on rgb(51,51,51)",
        )

class MainApp(App):

    async def on_mount(self) -> None:
        letter = Letter()
        letter.label = "A"
        await self.view.dock(letter)

MainApp.run(title="DeepWordle")
Enter fullscreen mode Exit fullscreen mode


A Custom Widget.

It's just a simple widget Class declaring your custom Letter component, with custom rendering.

For more information about Widgets, besides this article, the only place to look for is the Readme file and the code base.

Reusable components

πŸ” Go To TOC.

When developing web applications or any sort of apps in general, you tend to reuse existing code in your project. And to make your code reusable, the best practice is to create each component of the app in a separate file. This way, your codebase will look much more organized and structured.

β”œβ”€β”€ deepwordle
β”‚             β”œβ”€β”€ __init__.py
β”‚             β”œβ”€β”€ app.py
β”‚             β”œβ”€β”€ components
β”‚             β”‚             β”œβ”€β”€ __init__.py
β”‚             β”‚             β”œβ”€β”€ constants.py
β”‚             β”‚             β”œβ”€β”€ letter.py
β”‚             β”‚             β”œβ”€β”€ letters_grid.py
β”‚             β”‚             β”œβ”€β”€ message.py
β”‚             β”‚             β”œβ”€β”€ rich_text.py
β”‚             β”‚             └── utils.py
Enter fullscreen mode Exit fullscreen mode

Organize with views

πŸ” Go To TOC.

Widgets in Textual are organized within a view that uses a docking technique to arrange them on the terminal. Docking is similar to the css grid layout, and it can be customized.

By default, a widget will be rendered on the center of the terminal. In textual, you can dock or arrange widgets to the terminal's left, right, top, and bottom sides by changing the edge argument to left, right, top, bottom respectively.

In Textual, there are five types of views:

DockView

πŸ” Go To TOC.

It is the default view used by a textual app. It groups widgets either vertically(default) or horizontally to fill up all the terminal space. The edge argument can be used to control how the widget can be grouped.

By default edge = top.

from textual.app import App
from textual.widgets import Placeholder
from textual.views import DockView


class SimpleApp(App):

    async def on_mount(self) -> None:
        view: DockView = await self.push_view(DockView())
        await view.dock(Placeholder(), Placeholder(), Placeholder())

SimpleApp.run()
Enter fullscreen mode Exit fullscreen mode


Horizontal orientation.

from textual.app import App
from textual.widgets import Placeholder
from textual.views import DockView


class SimpleApp(App):

    async def on_mount(self) -> None:
        view: DockView = await self.push_view(DockView())
        await view.dock(Placeholder(), Placeholder(), Placeholder(), edge='left')

SimpleApp.run()
Enter fullscreen mode Exit fullscreen mode


Vertical orientation.

You can also control the size of each widget in terms of characters.

from textual.app import App
from textual.widgets import Placeholder
from textual.views import DockView


class SimpleApp(App):

    async def on_mount(self) -> None:
        view: DockView = await self.push_view(DockView())
        await view.dock(Placeholder(), Placeholder(), Placeholder(), size=10)

SimpleApp.run()
Enter fullscreen mode Exit fullscreen mode


Horizontal orientation with fixed size.

As you can see, each widget(Placeholder) has a height of 10 characters.

GridView

πŸ” Go To TOC.

GridView is used to layout TUIs widgets in a rectangular/tabular manner by specifying the number of rows and columns, and a list of widgets to lay them out on the terminal.

Example of an empty grid:

from textual.app import App
from textual.widgets import Placeholder
from textual.views import GridView


class SimpleApp(App):

    async def on_mount(self) -> None:
        await self.view.dock(GridView(), size=10)
        await self.view.dock(Placeholder(name='sad'), size=10)
        await self.view.dock(GridView(), size=10)


SimpleApp.run(log="textual.log")
Enter fullscreen mode Exit fullscreen mode


Empty Gridview.

Example of a 6x6 placeholders' grid:

from textual.app import App
from textual import events
from textual.widgets import Placeholder


class GridView(App):
    async def on_mount(self, event: events.Mount) -> None:
        """Create a grid with auto-arranging cells."""

        grid = await self.view.dock_grid()

        grid.add_column("col", repeat=6, size=7)
        grid.add_row("row",  repeat=6, size=7)
        grid.set_align("stretch", "center")

        placeholders = [Placeholder() for _ in range(36)]
        grid.place(*placeholders)


GridView.run(title="Grid View", log="textual.log")
Enter fullscreen mode Exit fullscreen mode


6x6 placeholders' grid

WindowView

πŸ” Go To TOC.

A placeholder for widget.

from textual.app import App
from textual.widgets import Placeholder
from textual.views import WindowView


class SimpleApp(App):

    async def on_mount(self) -> None:
        await self.view.dock(WindowView(widget=Placeholder(name='sad')), size=10)
        await self.view.dock(WindowView(widget=Placeholder(name='sad')), size=10)
        await self.view.dock(Placeholder(name='sad'), size=10)

SimpleApp.run(log="textual.log")
Enter fullscreen mode Exit fullscreen mode


WindowView

Widget Event Handler

πŸ” Go To TOC.

In Textual, you can assign handlers for a widget using the underscore naming convention, unlike the traditional way of decorating a handler. For example, a key event handler can be simply written as:

def on_key(self, event):
  ...
Enter fullscreen mode Exit fullscreen mode

Instead of the usual way of capturing events like the following:

@on(event)
def key(self):
Enter fullscreen mode Exit fullscreen mode

Using the underscore convention, your code looks more readable and contains less boilerplate code. However, as a python developer, you might argue that writing boilerplate looks more Pythonic than the so-called "best practices", and I would agree with that. For instance, at first, I wasn't aware of that underscore notation and what was going on under the hood, and how in the world an event is being fired and handled until I went into the source code, and everything made sense to me. I actually like that notation, and it is more readable.

Wrapping Up

πŸ” Go To TOC.

Building your own TUI-based app allows you to take your UI skills to the next level. By using Textual, you can build any sort of terminal application you want. It can save you a lot of time and give your audience a better experience.

The Textual package hides many low-level details, allowing you to focus on the logic of your app.

In this article, you learned how to:

  • Install and use Poetry.
  • Install and use pyenv.
  • Install and build custom interfaces with Textual.

You are free to use the code in this article as a starting point for various needs. Don’t forget to take a look at the readme file and use your imagination to make more complex apps that are meaningful to your use case.

Upcoming blogs on dev

πŸ” Go To TOC.

I am currently planning to share my experience on this platform that is made for developers. I joined Dev.to a few weeks ago, and as you can see from my profile on medium(I will be writing why I left medium in a separate article.), I mainly publish technical blogs on data science and computer vision with Python. I think this is just the start of me regularly publishing here, so let's grow together!

So, what's the catch? Well, there isn't one. I'm just giving this article away. This is a gift to you, and you can share it with whomever you like or use it in any way that would be beneficial to your personal and professional development. Thank you in advance for your ultimate support!

Happy Coding, folks; see you in the next one.

Discussion (4)

Collapse
99hats profile image
99hats

This is great, thanks for sharing!

Collapse
wiseai profile image
Mahmoud Harmouch Author

Glad you enjoyed it!

Collapse
slothyrulez profile image
Alex

Great stuff, please continue

Collapse
wiseai profile image
Mahmoud Harmouch Author

Definitely!