ChrisBuilds / terminaltexteffects

TerminalTextEffects (TTE) is a terminal visual effects engine, application, and Python library.
https://chrisbuilds.github.io/terminaltexteffects/
MIT License
2.82k stars 49 forks source link

[Feature Suggestion/Request] Python library #3

Closed AnonymouX47 closed 4 months ago

AnonymouX47 commented 10 months ago

First of all, nice work here. :smiley:

Are there currently any plans for this to be used as a library?

That is, in a way the text effects can be easily embedded into applications or even other libraries.

If there's any interest, I may have some ideas or even be able to help with API design or implementation (though the latter largely depends on my availability).

Thank you.

ChrisBuilds commented 10 months ago

Looking into that is on my todo list. I'm thinking of at least allowing the effects to be imported and passed strings to run against. Something like the following:

from terminaltexteffects import effects

rain_effect = effects.rain()
rain_effect.run('your string')

The terminal output area state could be cleared or left with the results of the effect. That would allow the effects to run in the middle of other scripts without significantly disrupting the state of the terminal.

In addition, I'm working on better documenting the API so others can more easily write effects.

AnonymouX47 commented 10 months ago

Great to know it's on your TODO!

I'm thinking of at least allowing the effects to be imported and passed strings to run against.

Yeah, this is actually along the lines of what I have in mind.


Something like the following:

from terminaltexteffects import effects

rain_effect = effects.rain()
rain_effect.run('your string')

The terminal output area state could be cleared or left with the results of the effect. That would allow the effects to run in the middle of other scripts without significantly disrupting the state of the terminal.

This though, isn't exactly or entirely all I have in mind.

Yes, directly running and controlling animations is useful but only in synchronous CLI programs (i.e that perform operations one after another). For the animations to be more widely useful e.g in more complex CLIs, TUIs or even built upon in other libraries, a more flexible/concise API would be required.

What I have in mind is more of modelling effects as iterators (since they're animations) which would yield the next frame of the animation on subsequent iterations. By "frame" I mean a unique single state of an animation at a specific time, which I want to believe already exists in the current design/implementation in some form. A frame can possibly be modeled as a string (or some other abstraction containing a string along with some other data).

Yes, the effect classes and instances are still going to be useful. Currently, effect instances seem to be designed for single-use (i.e run the animation once and that's all). That, actually, is exactly what iterators are by design i.e single-use objects.

Hence, I propose that effect classes should simply define the effect, it's properties, etc but that actually generation of the animation be devoted to iterator classes. More clearly, effect classes could implement __iter__() which returns a new iterator instance representing the current state of the effect instance.

The iterator classes on the other hand, should implement what is actually required to generate the animation and __next__() to yield frames of the animation. An iterator instance should copy the [mutable] state of an effect instance upon its creation in order to avoid unexpected results. This way, a single effect instance can be used multiple times in multiple ways and even simultaneously. There could be a base iterator class upon which a concrete one is built for each effect. This base class can implement common operations or steps across effects and define a unified interface.

Effect classes would still implement the run() method as you described and possibly other one-off operations. These operations would simply use an iterator instance, yield the frames and do whatever they want to do with them.

Also, concerning exporting the effects, I think exporting the effect classes themselves will do the job well.


In addition, I'm working on better documenting the API so others can more easily write effects.

That's definitely going to be handy.

Talking about others writing effects (and in extension to what I've written earlier), I would suggest that effects have a unified/common API that models the core properties of and operations performed on and by any effect, which each individual effect can then extend if necessary.

Looking into the current source, I see there's .template.NamedEffect and then the concrete effect classes are in .effect.effect_*. This design (i.e using a template class) is probably okay for an application but for a library, I believe an abstract base class (from which all effects classes would be derived) would be more appropriate for multiple reasons, some of which are:

Also, looking at NamedEffect and the concrete effect classes at the moment, there doesn't seem to be any much unification across the interfaces at all. For a (potentially extensible) library, I believe more unification would be required or at least, useful.


NOTE: This is just a downpour of my raw thoughts (some on the fly). In case I made any typos or any aspect is unclear, feel free to bring it to my attention.

Thanks for your audience. :heart:

AnonymouX47 commented 6 months ago

Well done @ChrisBuilds and @Navid-Means-Promise!

Do you think this issue would be completely resolved once #5 is fully implemented across the code base?

Navid-Means-Promise commented 6 months ago

Thank you @AnonymouX47 😉 Well I found your insight in this issue considerable. I can briefly write it as the two following clauses:

However the PR @ChrisBuilds approved, will help to uses the TTE as a library but it don't cover none of your suggestions. So please don't close this issue. I spent sometime and now I have a better understanding of the project's fundamental design, so maybe I can add more code to cover your suggestions. Of course I will start my work after @ChrisBuilds do agree on both or one of them.

AnonymouX47 commented 6 months ago

Noted. Thanks for the clarification.

ChrisBuilds commented 5 months ago

A new branch has been published with a first draft implementation of library features.

TTE as a library should provide two primary features:

In the spirit of limiting scope creep and keeping things simple, use cases beyond these two are unlikely to be supported. That being said, compelling cases with general applicability will be considered.

All effects have been refactored into generator functions which yield frames as a string.

Basic usage is as follows:

from terminaltexteffects.effects import effect_rain

input_data = "Your String Here"
rain = effect_rain(input_data)
for frame in rain:
    # do something with the frame string
    ...

The Terminal class has been refactored to enable outputting the animation should the user of the library desire. Using effect.terminal.prep_outputarea() will add the required space for the animation frames and disable the terminal cursor. effect.terminal.print() writes the frame to stdout and respects animation timing. If the user does not want to respect animation timing, effect.terminal.print() accepts a boolean arg ensure_animation_rate which can be set to False to ignore timing.

Note: If the user prints anything else between calls to effect.terminal.print(), the positioning will be effected and the animation output will perform unexpectedly.


input_data = "Your String Here"
rain = effect_rain.RainEffect(input_data)
rain.terminal.prep_outputarea() # adds space for the effect and hides the cursor
for frame in rain:
    rain.terminal.print(frame)
rain.terminal.restore_cursor()

Configuration changes which were previously passed as arguments through the CLI are available through TerminalConfig and EffectConfig objects which should be built and passed to the effect.

from terminaltexteffects.effects import effect_rain
from terminaltexteffects.utils.terminal import TerminalConfig

term_conf = TerminalConfig()
effect_conf = effect_rain.EffectConfig()

term_conf.no_wrap = True
term_conf.xterm_colors = True

effect_conf.rain_colors = ("ff0000", "00ff00")
effect_conf.movement_speed = (0.1, 0.9)

input_data = "Your String Here"
rain = effect_rain.RainEffect(input_data, effect_config=effect_conf, terminal_config=term_conf)
rain.terminal.prep_outputarea()
for frame in rain:
    rain.terminal.print(frame)
rain.terminal.restore_cursor()

Some effects may require longer build times. To enable the library user some control over when the effect is built, all effects implement a build() method. After an effect has been exhausted, it must be rebuilt. If an effect is iterated over without an explicit call to build(), it will build itself.


input_data = "Your String Here"
rain = effect_rain.RainEffect(input_data)
rain.build()
rain.terminal.prep_outputarea()
for frame in rain:
    rain.terminal.print(frame)
rain.terminal.restore_cursor()

Check out the branch and provide any comments/suggestions for improvements. GLHF.

AnonymouX47 commented 5 months ago

This looks great and promising already. Well done and thank you for your hardwork. :tada:

Quick Suggestion Just one quick comment/suggestion while I'm yet to check/test it out. I think a method on the effect base class would be very appropriate to encapsulate: ```python rain.terminal.prep_outputarea() for frame in rain: rain.terminal.print(frame) rain.terminal.restore_cursor() ``` as it seems to be a potential common pattern in code using the library. By the way, a `try... finally` would be appropriate there in order to ensure the cursor is alwsys restored particularly when and exception is raised (for whatever reason) or the process is terminated by some signal or `Ctrl-C` is hit. If there's already such a method, please ignore this and forgive my ignorance. :smiley:

I'll check and test it out very soon and give my feedback.

Thank you very much.

AnonymouX47 commented 5 months ago
  1. I noticed the config class for every effect is named EffectConfig. Might be more convenient for users importing/using different effects within the same or overlapping namespace(s) if they were named <EffectName>EffectConfig (e.g RainEffectConfig) or so. I reckon this might've been an oversight since the classes were previously named <EffectName>EffectArgs i.e included the effect name.

  2. I also noticed effect iterators (returned by Effect.__iter__()) use and mutate instance state. This disallows the simultaneous use of multiple iterators for the same effect instance (use cases are primarily in non-scrolling-output TUIs).

    This (i.e modifying instance state) isn't typical of iterators returned by an iterable (see any built-in or stdlib iterable). I feel any state/data unique to or modified by an iterator should be local to the iterator; after all, an effect instance's private state (attributes) is only used for effect iteration (at least for the effects I checked).

    Yes, this behaviour can be worked around by creating new instances of the effect but that's less desirable and would result in extra code for the user of effect classes.

    If this behaviour will be changed i.e iterators will be made standalone and to allow simultaneous use, the Effect.build() method and Effect.built property wouldn't be necessary anymore. A couple possibilities:

    • .build()'s code can be merged into __iter__() and the private attributes currently used turned into local variables.
    • the build() method can be made private (-> ._build()), the private attributes currently used turned into local variables and returned. The method can then be called at the start of __iter__(). (NB: This is merely a modularised version of the previous alternative)

    As for effect classes (such as BlackholeEffect) that have other methods (such as .prepare_blackhole(), .rotate_blackhole(), etc) called only during effect iteration, the methods can be moved into __iter__() (i.e as nested functions) and made to work with enclosing scope variables (via nonlocal). From what I can see, such methods aren't actually meant to be called on their own by a user of the class; they're only used for the effect iteration. Hence, they're not actually meant to be public.

    Anyways, if this behaviour will be kept, I think it's worth documenting so it won't catch users by surprise.

    For what it's worth... I had actually anticipated this. That was why I suggested having separate effect iterator classes in my original proposal above. If these were to be used, then the extra methods and modifying the iterator's state would be okay. Please, note that I'm not suggesting going this route now; the suggestions above (in this comment) will also achieve the goal, albeit less tidy.
  3. In relation to (2), the use of ._built and instance state for/with iteration results in an iterator continuing from where a previous one stopped if the previous one wasn't exhausted. To repoduce

    • Create an iterator via iter(effect).
    • Iterate over it a couple times but don't exhaust it.
    • Create a new iterator via iter(effect). (Note that the effect isn't rebuilt since ._built is still True)
    • Iterate over it.
    • Observe that the first frame yielded is the one that should've been after the last

    To observe this visually, run your sample code (without the explicit build) above in an REPL session. Hit Ctrl-C at any point during the animation, then run the code again.

    This behaviour is not typical of (non-iterator) iterables but instead of iterators.

  4. EffectConfig (for every effect) and TerminalConfig are both mutable, yet their instances are used as default argument values of effect class initializers. As a result, modifying the .config or .terminal attribute of any effect instance created with the default argument values will affect all other such instances of the same effect.

    Typically in this case, None is used as the default value and a new instance is created within the function or method i.e something like:

    def __init__(..., config: EffectConfig | None = None, ...):
       self.config = config or EffectConfig()

    On the hand, the instances of these classes can be made "immutable", if they aren't actually intended to be mutable.

    For what it's worth, I think this was just an oversight which really wouldn't have come to light until now when the package is to be used as a library.

Aside these points (for now), I believe everything else is great including the format of the frames (though I might have something to note about the way SGR sequences are used but not in this issue).

Side note I'm not sure but I don't think this is related to the changes on the branch, but I guess it's worthy of note:

I noticed there's an _animate_chars() method defined on every effect class I checked. Why not define this as an animate_chars(characters) function in the tte.base_character module and import it into the effect modules, instead of the repetition? The proposed function will accept, as an argument, an iterable of characters to be animated.

So sorry for the very long (finally done after hours :weary:) feedback. :smiling_face_with_tear:

Thank you very much. :smiley:

AnonymouX47 commented 5 months ago

For what it's worth... I'm willing to submit PR(s) addressing any or all of the changes I suggested above, if it's okay by you.

Thank you.

ChrisBuilds commented 5 months ago
  1. I noticed the config class for every effect is named EffectConfig. Might be more convenient for users importing/using different effects within the same or overlapping namespace(s) if they were named <EffectName>EffectConfig (e.g RainEffectConfig) or so. I reckon this might've been an oversight since the classes were previously named <EffectName>EffectArgs i.e included the effect name.

This was done for consistency assuming the user would import the effect and access the config within the effect namespace such as conf = effect_rain.EffectConfig() rather than something like from effect_rain import RainEffect, EffectConfig. The user would always know the name of the config class. I'm open to returning the config to the previous style.

2. I also noticed effect iterators (returned by `Effect.__iter__()`) use and mutate instance state. This disallows the simultaneous use of multiple iterators for the same effect instance (use cases are primarily in non-scrolling-output TUIs).
   This (i.e modifying instance state) isn't typical of iterators returned by an iterable (see any built-in or stdlib iterable). I feel any state/data unique to or modified by an iterator should be local to the iterator; after all, an effect instance's private state (attributes) is only used for effect iteration (at least for the effects I checked).

Yeah, I'm not satisfied with the current implementation and will be addressing this in the next few days.

   As for effect classes (such as `BlackholeEffect`) that have other methods (such as `.prepare_blackhole()`, `.rotate_blackhole()`, etc) called only during effect iteration, the methods can be moved into `__iter__()` (i.e as nested functions) and made to work with enclosing scope variables (via `nonlocal`). From what I can see, such methods aren't actually meant to be called on their own by a user of the class; they're only used for the effect iteration. Hence, they're not actually meant to be public.

Your assessment is correct. Methods such as those do not need to be exposed. Some of them were refactored into __iter__ already and the rest will be handled in the coming updates to the branch.

3. In relation to (2), the use of `._built` and instance state for/with iteration results in an iterator continuing from where a previous one stopped if the previous one wasn't exhausted.

I noticed this but left the behavior in while testing ideas for the interface. As you've indicated, this will be fixed by refactoring iter as is the current plan.

4. `EffectConfig` (for every effect) and `TerminalConfig` are both mutable, yet their instances are used as default argument values of effect class initializers. As a result, modifying the `.config` or `.terminal` attribute of any effect instance created with the default argument values will affect all other such instances of the same effect.

Oops, haha. Unintended.

I noticed there's an _animate_chars() method defined on every effect class I checked. Why not define this as an animate_chars(characters) function in the tte.base_character module and import it into the effect modules, instead of the repetition? The proposed function will accept, as an argument, an iterable of characters to be animated.

The _animate_chars() methods are really just a personal decision that I (as a the user of the engine, rather than engine dev, in this case) made while writing effects early on. This method is not necessary, which is why it's not in base_effect. It's actually a vestigial method at this point. Prior to base_character.tick() existing, there was more that needed to be done to update the state of each character's motion/animation handlers for each frame. All of that was refactored into the animation/motion handlers and ultimately simplified into a single call to tick(). The _animate_chars() method could be removed and the loop over self._active_chars could be dropped straight into place over any calls to _animate_chars().

You could argue the loop itself could be made a part of base_effect but, at this time, I have left most of the effect design choices open to the effect writer. Effects only follow a consistent pattern because I am the only one writing them. Ultimately, you could write an effect which does not contain _animate_chars() or, for that matter, self.active_chars at all. Once the engine is fully documented, I would bet people are going to write effects that follow a completely different design from mine altogether.

As always, thanks for your hard work. I appreciate the suggestions and will be implementing some version of them in the near future. Time is the primary challenge for me at the moment, but this whole endeavor is for fun/learning, so I don't mind handling the refactors myself, though it will take at least a few days. Stay tuned for updates to this branch.

AnonymouX47 commented 5 months ago

As always, thanks for your hard work.

It's a pleasure. :smiley:

I appreciate the suggestions and will be implementing some version of them in the near future.

Glad to hear that. :+1:

Time is the primary challenge for me at the moment, but this whole endeavor is for fun/learning, so I don't mind handling the refactors myself, though it will take at least a few days. Stay tuned for updates to this branch.

Totally understandable.

For the record, I'm in no rush. Currently, I don't even have any particular use case but since the moment I came across the project, I saw the potential it held and wanted to help drive it beyond being just a command-line utility.

Well done. :+1:

ChrisBuilds commented 5 months ago

I've had a few hours to work on this, here's an update.

I have two goals, at this time, for the library refactor.

For the first goal, the current approach is the following:

Effects now subclass two abstract classes found in terminaltexteffects.base_effect. The abstract classes implement most of the boilerplate for an effect and provide some concrete helper functions to keep effect development free from repetitive code and accomplish the iterable -> iterator relationship.

BaseEffect is an iterable which creates new instances of effect iterators and provides a context manager to handle terminal setup/teardown. The effect developer does not need to provide an __iter__ method in their effect class. They must only provide the configuration class and iterator class as class attributes and a signature which accepts the input data. Just subclass BaseEffect, drop in the two attributes and init the superclass with the input data.

Here's an example using the RandomSequence effect:

class RandomSequence(BaseEffect[EffectConfig]):
    """Prints the input data in a random sequence, one character at a time."""

    _config_cls = EffectConfig
    _iterator_cls = RandomSequenceIterator

    def __init__(self, input_data: str) -> None:
        super().__init__(input_data)

BaseEffectIterator is an iterator which should contain all of the effect logic. When BaseEffect.__iter__() is called, it instantiates a new instance of the BaseEffectIterator subclass which the effect dev provides as the _iterator_cls attribute seen above. The subclass of BaseEffectIterator must simply implement a __next__ method, a signature which accepts the effect subclass and init the superclass. On initialization, BaseEffectIterator creates a deep copy of the EffectConfig and TerminalConfig from the BaseEffect instance and creates a local Terminal instance at self._terminal. This prevents any state sharing between iterators.

Here's an example using the RandomSequence effect:

class RandomSequenceIterator(BaseEffectIterator[EffectConfig]):
    def __init__(self, effect: "RandomSequence") -> None:
        super().__init__(effect)
    # add any other attributes

    # add any other methods

    def __next__(self) -> str:
    if some_condition:
        # do effect logic to progress the effect state
        next_frame = self._terminal.get_formatted_output_string()
            return next_frame
        raise StopIteration

For the second goal, keeping library usage simple, the current approach is the following:

The effect and terminal configuration classes do not need to be imported or instantiated separate from the effect. That is handled by the BaseEffect class. Users of the library need only deal with the configurations within the effect object.

from terminaltexteffects.effects import effect_random_sequence

effect = effect_random_sequence.RandomSequence(test_input)

effect.effect_config.speed = 0.1
effect.terminal_config.tab_width = 2

for frame in effect:
    ...

The effect/terminal configuration can be changed at any time without impacting existing iterators. If the user needs multiple configurations, they can instantiate multiple effect objects.

Finally, here is an example of the context manager handling terminal setup/teardown.

from terminaltexteffects.effects import effect_random_sequence

effect = effect_random_sequence.RandomSequence(test_input)
with effect.terminal_output() as terminal:
    for frame in effect:
        terminal.print(frame)

As a note, I will be making significant changes to the Terminal class to separate out functionality that actually deals with the terminal from character handling. The Terminal class has grown to include too much disparate functionality and needs a refactor to enable additional library features involving the dimensions of the output area independent of the actual terminal device.

As always, I welcome suggestions and feedback as some of this is new territory for me.

ChrisBuilds commented 5 months ago

The library refactor branch has been merged and published in version 0.8.0. I'm sure there are plenty of improvements to be made, but it's working well enough to start getting feedback. A few additions beyond my last message:

The Terminal now supports custom dimensions and ignoring terminal device dimensions.

For instances where you want to ignore terminal dimensions altogether, such as working with a TUI library or outputting in any other unpredictable method:

effect.terminal_config.ignore_terminal_dimensions = True

This will set the output area dimensions based solely off the input text. Nothing will be wrapped or cut off.

For instances where you want to limit the output area to a specific size manually.

effect.terminal_config.terminal_dimensions = (80, 24) # width, height

If this value is set to the default (0, 0), terminal auto-detection will occur, otherwise the dimensions provided will be used.

I'm going to leave this issue open for feedback to keep everything organized in here until the library matures and more specific issue are being raised.