bczsalba / pytermgui

Python TUI framework with mouse support, modular widget system, customizable and rapid terminal markup language and more!
https://ptg.bczsalba.com
MIT License
2.21k stars 54 forks source link

How can we update widgets dynamically? #74

Closed 0xrushi closed 1 year ago

0xrushi commented 2 years ago

I'm trying to build a file explorer like ranger

image

In this case I'm taking the output of ls command and listing it in the output box

st = subprocess.check_output(self._input.value, shell=True, text=True)

"""The command-line module of the library.

See ptg --help for more information.
"""

from __future__ import annotations

import builtins
import importlib
import os
import random
import sys
from argparse import ArgumentParser, Namespace
from itertools import zip_longest
from platform import platform
from typing import Any, Callable, Iterable, Type
import subprocess
import pytermgui as ptg

def _title() -> str:
    """Returns 'PyTermGUI', formatted."""

    return "[!gradient(210) bold]PyTermGUI[/!gradient /]"

class AppWindow(ptg.Window):
    """A generic application window.

    It contains a header with the app's title, as well as some global
    settings.
    """

    app_title: str
    """The display title of the application."""

    app_id: str
    """The short identifier used by ArgumentParser."""

    standalone: bool
    """Whether this app was launched directly from the CLI."""

    overflow = ptg.Overflow.SCROLL
    vertical_align = ptg.VerticalAlignment.TOP

    def __init__(self, args: Namespace | None = None, **attrs: Any) -> None:
        super().__init__(**attrs)

        self.standalone = bool(getattr(args, self.app_id, None))

        bottom = ptg.Container.chars["border"][-1]
        header_box = ptg.boxes.Box(
            [
                "",
                " x ",
                bottom * 3,
            ]
        )

        self._add_widget(ptg.Container(f"[ptg.title]{self.app_title}", box=header_box))
        self._add_widget("")

    def setup(self) -> None:
        """Centers window, sets its width & height."""

        self.width = int(self.terminal.width * 2 / 3)
        self.height = int(self.terminal.height * 2 / 3)
        self.center(store=False)

    def on_exit(self) -> None:
        """Called on application exit.

        Should be used to print current application state to the user's shell.
        """

        ptg.tim.print(f"{_title()} - [dim]{self.app_title}")
        print()

class TIMWindow(AppWindow):
    """An application to play around with TIM."""

    app_title = "TIM Playground"
    app_id = "tim"

    def __init__(self, args: Namespace | None = None, **attrs: Any) -> None:
        super().__init__(args, **attrs)

        if self.standalone:
            self.bind(
                ptg.keys.RETURN,
                lambda *_: self.manager.stop() if self.manager is not None else None,
            )

        self._generate_colors()

        self._output = ptg.Label(parent_align=0)

        self._input = ptg.InputField()
        self._input.styles.value__fill = lambda _, item: item
        self._container = ptg.Container()

        # self._showcase = self._create_showcase()

        self._input.bind(ptg.keys.ANY_KEY, lambda *_: self._update_output())

        self._add_widget(
            ptg.Container(
                ptg.Container(self._output),
                ptg.Container(self._input),
                self._container,
                box="EMPTY",
                static_width=60,
            )
        )

        self.bind(ptg.keys.CTRL_R, self._generate_colors)

        self.setup()

        self.select(0)

    @staticmethod
    def _random_rgb() -> ptg.Color:
        """Returns a random Color."""

        rgb = tuple(random.randint(0, 255) for _ in range(3))

        return ptg.RGBColor.from_rgb(rgb)  # type: ignore

    def _update_output(self) -> None:
        """Updates the output field."""
        # print(self._widgets)
        self._container = ptg.Container()
        self._widgets.__setitem__(1, self._container)
        # print(self._container)
        try:
            self._output.value = ""
            st = subprocess.check_output(self._input.value, shell=True, text=True)
            st = st.split("\n")
            for i in st:
                self._container.__iadd__(ptg.Button("button "+ str(i)))
            # print(self._container)
        except:
            pass

    def _generate_colors(self, *_) -> None:
        """Generates self._example_{255,rgb,hex}."""

        ptg.tim.alias("ptg.timwindow.255", str(random.randint(16, 233)))
        ptg.tim.alias("ptg.timwindow.rgb", ";".join(map(str, self._random_rgb().rgb)))
        ptg.tim.alias("ptg.timwindow.hex", self._random_rgb().hex)

    def on_exit(self) -> None:
        super().on_exit()
        print(ptg.tim.prettify_markup(self._input.value))
        ptg.tim.print(self._input.value)

APPLICATION_MAP = {
    ("TIM Playground", "tim"): TIMWindow,
}

def _app_from_short(short: str) -> Type[AppWindow]:
    """Finds an AppWindow constructor from its short name."""

    for (_, name), app in APPLICATION_MAP.items():
        if name == short:
            return app

    raise KeyError(f"No app found for {short!r}")

def process_args(argv: list[str] | None = None) -> Namespace:
    """Processes command line arguments."""

    parser = ArgumentParser(
        description=f"{ptg.tim.parse(_title())}'s command line environment."
    )

    apps = [short for (_, short), _ in APPLICATION_MAP.items()]

    app_group = parser.add_argument_group("Applications")
    app_group.add_argument(
        "--app",
        type=str.lower,
        help="Launch an app.",
        metavar=f"{', '.join(app.capitalize() for app in apps)}",
        choices=apps,
    )

    app_group.add_argument(
        "-t", "--tim", help="Launch the TIM Playground app.", action="store_true"
    )

    argv = argv or sys.argv[1:]
    args = parser.parse_args(args=argv)

    return args

def _create_aliases() -> None:
    """Creates all TIM alises used by the `ptg` utility.

    Current aliases:
    - ptg.title: Used for main titles.
    - ptg.body: Used for body text.
    - ptg.detail: Used for highlighting detail inside body text.
    - ptg.accent: Used as an accent color in various places.
    - ptg.header: Used for the header bar.
    - ptg.footer: Used for the footer bar.
    - ptg.border: Used for focused window borders & corners.
    - ptg.border_blurred: Used for non-focused window borders & corners.
    """

    ptg.tim.alias("ptg.title", "210 bold")
    ptg.tim.alias("ptg.body", "247")
    ptg.tim.alias("ptg.detail", "dim")
    ptg.tim.alias("ptg.accent", "72")

    ptg.tim.alias("ptg.header", "@235 242 bold")
    ptg.tim.alias("ptg.footer", "@235")

    ptg.tim.alias("ptg.border", "60")
    ptg.tim.alias("ptg.border_blurred", "#373748")

def _configure_widgets() -> None:
    """Configures default widget attributes."""

    ptg.boxes.Box([" ", " x ", " "]).set_chars_of(ptg.Window)
    ptg.boxes.SINGLE.set_chars_of(ptg.Container)
    ptg.boxes.DOUBLE.set_chars_of(ptg.Window)

    ptg.InputField.styles.cursor = "inverse ptg.accent"
    ptg.InputField.styles.fill = "245"
    ptg.Container.styles.border__corner = "ptg.border"
    ptg.Splitter.set_char("separator", "")
    ptg.Button.set_char("delimiter", ["  ", "  "])

    ptg.Window.styles.border__corner = "ptg.border"
    ptg.Window.set_focus_styles(
        focused=("ptg.border", "ptg.border"),
        blurred=("ptg.border_blurred", "ptg.border_blurred"),
    )

def run_environment(args: Namespace) -> None:
    """Runs the WindowManager environment.

    Args:
        args: An argparse namespace containing relevant arguments.
    """

    _configure_widgets()

    window: AppWindow | None = None
    with ptg.WindowManager() as manager:
        manager.layout.add_slot("Body")
        app = _app_from_short(args.app)
        window = app(args)
        manager.add(window, assign="body")

    window = window or manager.focused  # type: ignore
    if window is None or not isinstance(window, AppWindow):
        return

    window.on_exit()

def main(argv: list[str] | None = None) -> None:
    """Runs the program.

    Args:
        argv: A list of arguments, not included the 0th element pointing to the
            executable path.
    """

    _create_aliases()

    args = process_args(argv)

    args.app = "tim"

    run_environment(args)

if __name__ == "__main__":
    main(sys.argv[1:])
bczsalba commented 2 years ago

Hey again! I'm taking a bit of time off from all things programming related for personal reasons, but once I'm back I'm planning on focusing on the new documentation, which will detail this type of usecase!

0xrushi commented 2 years ago

Haha been there, I hope all is well. I'll be waiting for you to be back in God mode :-)

bczsalba commented 2 years ago

Since documentation is taking a lot longer than intended, here is what I would do in your case:

You already have self._container defined, so you can manipulate its widgets as much as you want. Simply adding widgets won't do you too well, so you can use the Container.set_widgets method:

widgets: list[Widget] = []

for line in st:
  # We do `line=line` here to avoid some odd behaviour from python closures, where
  # `line` would only be evaluated when the function is called, so it would always
  # be the last element
  widgets.append([f"Button {line!r}", lambda *_, line=line: do_something_with(line)])

self._container.set_widgets(widgets)

Does this answer your question? 😄