Python version of Morphic?

Fun fact! There was a Python version of Morphic developed either before or during the start of Morphic! (of course, I dont know much as I’m basing this off GitHub). Yeah, the last version was from 2009, but its still interesting it exists! I probably could change it all the way up to the API of modern Morphic, and even try to port Snap! to it..

Uh, that’s just the regular morphic repo…

Oh. Whoops! Fixed.

Also, I got a version of it working!

So now I need to up it up to the latest version of Morphic. Should I do it in seperate commits (mock the morphic.js history) or just commit all the new updates?

just commit it at at once!

I actually have gave up on trying to convert the current python file so if i ever do do this itll be just a from-scratch port of Morphic (which will still be cool).

That was before the JS version that we’re using now, it was my first shot at a stand-alone Morphic implementation. At that time Dan Garcia was rooting for Snap! to be rewritten in Python, because basically we all loved Python (except Brian, haha) and because there was (and still is) a strong community around it.

I was looking at Python because the Scratch team was reimplementing Scratch from Squeak in Flash/ActionScript, which to me even at the time felt like a very bad idea (but you should’ve seen all those experts in the forums back then who unanimously demanded a rewrite in Flash rather than in any other programming language. Popular wisdom is a fragile base for decisions).

The Python Morphic experiment first went terribly wrong when I tried to use Tcl/Tk for the graphics. I then checked out SDL/PyGame, and that went much better. In the end I really loved Python a lot, probably better than JavaScript. But the setup with PyGame felt brittle and not well supported at the time. Most important, however, was that we wanted to run Snap! in the browser, and not as a desktop App. That’s why I decided to rewrite Morphic all over in JavaScript. The first JS implementation was more or less a direct port of my Python prototype. It has evolved since…

Later I reimplemented another version of Morphic from the ground up in GP. I loved that version best of all, because John Maloney and I decided to base it on a totally different architecture, avoiding any inheritance. It was really cool, but, alas! GP didn’t have the time it needed to mature, and I got stranded at SAP and decided to revitalize Snap’s Morphic platform instead.

Lesson: Doing the same thing over and over is actually fun and improves your product

Interesting! Now I feel like porting Morphic to Python (which as per that other reply) from scratch, perhaps rendering onto a Surface for the World instead of the pygame window (replicate the Canvas architecture of Snap!’s Morphic), and eventually port all the other Snap! modules to it eventually ending up in a version of Snap! in Python3.

I actually DMed Jens back in July(?) about Morphic.py and if he’d ever consider porting Snap! to it. Unfortunately, he did not reply, however. I also messaged either ego-lay or another friend (who used this platform) on Discord about it.

Heres a very work in progress version (I don’t have any way to test it, so I don’t even know if this tiny animation class is working):

Code
from collections.abc import Callable
from typing import Union, Optional
import math
from datetime import datetime, timedelta

morphic_version = '2025-September-15-py' # copy of original, yes, but for now im aiming for accuracy
modules = {} # keep track of additional loaded modules

number = Union[int | float]

def is_string(target):
    return isinstance(target, str)

def radians(degrees):
    return degrees * math.pi / 180;

def degrees(radians):
    return radians * 180 / math.pi;

class Animation:
    """
    Animations handle gradual transitions between one state and another over a
    period of time. Transition effects can be specified using easing functions.
    An easing function maps a fraction of the transition time to a fraction of
    the state delta. This way accelerating / decelerating and bouncing sliding
    effects can be accomplished.

    Animations are generic and not limited to motion, i.e. they can also handle
    other transitions such as color changes, transparency fadings, growing,
    shrinking, turning etc.

    Animations need to be stepped by a scheduler, e. g. an interval function.
    In Morphic the preferred way to run an animation is to register it with
    the World by adding it to the World's animation queue. The World steps each
    registered animation once per display cycle independently of the Morphic
    stepping mechanism.

    For an example how to use animations look at how the Morph's methods

        glideTo()
        fadeTo()

    and

        slideBackTo()

    are implemented.
    """

    EASINGS: dict = {
        # dictionary of a few pre-defined easing functions used to transition
        # two states

        # ease both in and out:
        "linear": lambda t: t,
        "sinusoidal": lambda t: 1 - math.cos(radians(t * 90)),
        "quadratic": lambda t: 2 * t * t if t < 0.5 else ((4 - (2 * t)) * t) - 1,
        "cubic": (lambda t:
            4 * t * t * t if t < 0.5
                else ((t - 1) * ((2 * t) - 2) * ((2 * t) - 2)) + 1
        ),
        "elastic": (lambda t:
            (0.01 + 0.01 / (t - 0.5)) * math.sin(50 * (t - 0.5)) if 
                (t - 0.5) < 0 else
                    (0.02 - 0.01 / (t - 0.5)) * math.sin(50 * (t - 0.5)) + 1
        ),

        # ease in only:
        "sine_in": lambda t: 1 - math.sin(radians(90 + (t * 90))),
        "quad_in": lambda t: t * t,
        "cubic_in": lambda t: t * t * t,
        "elastic_in": lambda t: (0.04 - 0.04 / t) * math.sin(25 * t) + 1,

        # ease out only:
        "sine_out": lambda t: math.sin(radians(t * 90)),
        "quad_out": lambda t: t * (2 - t),
        "elastic_out": lambda t: 0.04 * t / (t - 1) * math.sin(25 * (t - 1))
    }

    def __init__(self, setter: Callable, getter: Callable, 
                 delta: Optional[number], duration: Optional[number],
                 easing: Union[str, Callable, None], onComplete: Optional[Callable] = None):
        
        self.setter = setter
        self.getter = getter
        self.delta = delta if delta is not None else 0
        self.duration = duration if duration is not None else 0
        
        if isinstance(easing, str):
            self.easing = Animation.EASINGS.get(easing, Animation.EASINGS["sinusoidal"])
        elif easing is None:
            self.easing = Animation.EASINGS["sinusoidal"]
        else:
            self.easing = easing

        self.onComplete = onComplete
        self.endTime = None
        self.destination = None
        self.isActive = False
        self.start()

    def start(self):
        # (re-) activate the animation, e.g. if is has previously completed,
        # make sure to plug it into something that repeatedly triggers step(),
        # e.g. the World's animations queue
        self.endTime = datetime.now() + timedelta(milliseconds=self.duration)
        self.destination = self.getter(self) + self.delta;
        self.isActive = True

    def step(self):
        if not self.isActive: return
        if self.endTime is None: return

        now = datetime.now()
        if now > self.endTime:
            self.setter(self.destination)
            self.isActive = False
            if self.onComplete: self.onComplete()
        else:
            self.setter(
                self.destination -
                    (self.delta * self.easing((self.endTime - now).total_seconds() / 1000 / self.duration))
            )

New class done!

Color Class
class Color:

    # Color instance creation:

    def __init__(self, r: float = 0, g: float = 0, b: float = 0, a: float = 1) -> None:
        self.r = r
        self.g = g
        self.b = b
        self.a = a

    # Color string representation: e.g. 'rgba(255,165,0,1)'

    def to_string(self) -> str:
        return f"rgba({self.r}, {self.g}, {self.b}, {self.a})"
    
    def to_rgb_string(self) -> str:
        return f"rgb({self.r}, {self.g}, {self.b})"

    def __str__(self) -> str:
        return self.to_string()
    
    @staticmethod
    def from_string(string: str) -> "Color":
        components = re.split(r"[\(),]", string)
        channels = (components[1:5] 
            if string.startswith("rgba") else components[0:4])
        
        return Color(*map(int, channels))
    
    # Color copying:
    def copy(self) -> "Color":
        return Color(
            self.r, self.g, self.b, self.a
        )
        
    # Color comparison:
    def eq(self, other: "Color", observe_alpha = False) -> bool:
        """=="""
        return (isinstance(other, Color) and
            self.r == other.r and
            self.g == other.g and
            self.b == other.b and
            (self.a == other.a if observe_alpha else True))

    def __eq__(self, other) -> bool:
        if isinstance(other, Color):
            return self.eq(other)
    
        return NotImplemented

    def is_close_to(self, other: "Color", observe_alpha = False, tolerance = 10) -> bool:
        """
        experimental - answer whether a color is "close" to another one by
        a given percentage. tolerance is the percentage by which each color
        channel may diverge, alpha needs to be the exact same unless ignored
        """
        thres = 2.55 * tolerance

        def dist(a, b):
            diff = a - b
            return 255 + diff if diff < 0 else diff
        
        return (isinstance(other, Color) and
                dist(self.r, other.r) < thres and
                dist(self.g, other.g) < thres and
                dist(self.b, other.b) < thres and
                (self.a == other.a if observe_alpha else True))

    # Color conversion (hsv):
    def hsv(self) -> tuple[float, float, float]:
        """ignores alpha"""
        rr = self.r / 255
        gg = self.g / 255
        bb = self.b / 255
        h, s, v = colorsys.rgb_to_hsv(rr, gg, bb)
        return h, s, v

    def set_hsv(self, h, s, v) -> None:
        """ignores alpha, h, s and v are to be within [0, 1]"""
        rr, gg, bb = colorsys.hsv_to_rgb(h, s, v)
        self.r = rr * 255
        self.g = gg * 255
        self.b = bb * 255
    
    # Color conversion (hsl):
    def hsl(self) -> tuple[float, float, float]:
        """ignores alpha"""
        rr = self.r / 255
        gg = self.g / 255
        bb = self.b / 255
        h, l, s = colorsys.rgb_to_hls(rr, gg, bb)
        return h, s, l

    def set_hsl(self, h, s, l) -> None:
        """ignores alpha, h, s and l are to be within [0, 1]"""
        rr, gg, bb = colorsys.hls_to_rgb(h, l, s)
        self.r = rr * 255
        self.g = gg * 255
        self.b = bb * 255

    # Color mixing:
    def mixed(self, proportion: float, other: "Color") -> "Color":
        """answer a copy of this color mixed with another color, ignore alpha"""
        frac1 = min(max(proportion, 0), 1)
        frac2 = 1 - frac1

        return Color(
            self.r * frac1 + other.r * frac2,
            self.g * frac1 + other.g * frac2,
            self.b * frac1 + other.b * frac2
        )
    
    def darker(self, percent: Optional[float]) -> "Color":
        """return an rgb-interpolated darker copy of me, ignore alpha"""
        fract = 0.8333
        if percent is not None:
            fract = (100 - percent) / 100
        
        return self.mixed(fract, BLACK)
    
    def lighter(self, percent: Optional[float]) -> "Color":
        """return an rgb-interpolated lighter copy of me, ignore alpha"""
        fract = 0.8333
        if percent is not None:
            fract = (100 - percent) / 100
        
        return self.mixed(fract, WHITE)
    
    def dansDarker(self) -> "Color":
        """return an hsv-interpolated darker copy of me, ignore alpha"""
        hsv = self.hsv()
        result = Color()
        vv = max(hsv[2] - 0.16, 0)

        result.set_hsv(hsv[0], hsv[1], vv)
        return result
    
    def inverted(self) -> "Color":
        return Color(
            255 - self.r,
            255 - self.g,
            255 - self.b
        )
    
    def solid(self) -> "Color":
        return Color(
            self.r,
            self.g,
            self.b
        )