Closed AnonymouX47 closed 4 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.
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:
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?
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.
Noted. Thanks for the clarification.
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.
This looks great and promising already. Well done and thank you for your hardwork. :tada:
I'll check and test it out very soon and give my feedback.
Thank you very much.
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.
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.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.
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
iter(effect)
.iter(effect)
. (Note that the effect isn't rebuilt since ._built
is still True
)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.
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).
_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:
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.
- 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.gRainEffectConfig
) 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 ananimate_chars(characters)
function in thette.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.
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:
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.
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.
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.