# A plugin for Rassam: a Python file that makes a Plugin and says what it adds.
# Install it from the Plugins window (File > Settings > Plugins > Install from a file).
# It is ordinary Python: the standard library is there to import (math, random, colorsys, json...).
import math
import random

from rassam import Plugin, blank, button, choice, color, label, note, slider, toggle

plugin = Plugin('example', 'Example', version='1.2', author='You',       # its id: letters, digits, - . _
                about='Two tools, a star to insert, a group of properties, three effects, a filter, a background effect, a template, a palette, an image size, and commands (one with a window of its own).')

# The settings of a tool, an element, a filter or a group of properties ("fields") are made with:
#   slider(key, label, low, high, usual, step=1)    a number
#   color(key, label, usual)                        a color, '#RRGGBB'
#   choice(key, label, choices, usual)              one of several words
#   toggle(key, label, usual)                       on or off
#   text(key, label, usual)                         a line of words
# (an effect's are sliders and colors only). What is set comes as "settings": settings.amount, or settings['amount'].


# ---------------- tools: into the toolbox ----------------
# The function is told of the button going down on the image ('press'), the pointer moving with it down ('drag') and
# the button coming up ('release'): event.kind. event.x and event.y are where, in the image's pixels; event.start where
# the button went down; event.shift, event.ctrl and event.alt the keys held; event.color and event.color2 the colors
# in hand; event.size the brush's size; event.settings the tool's own settings; event.app the program.
# With paints=True the tool paints on a layer (the selected one, or a new one), and a stroke is one step to undo:
# event.pen draws on it as a web page's canvas is drawn on (fillStyle, fillRect, beginPath, arc, fill, drawImage...),
# and event.picture gives its pixels. "icon" is one of the program's own signs: Brush, Pencil, Eraser, Fill, Wand,
# star, Ellipse, Rectangle, Line, Text, Layer, Media, Gradient...
@plugin.tool('Spray', icon='Brush', about='Dots scattered round the pointer, in the color in hand', paints=True,
             fields=[slider('density', 'density', 1, 80, 20), toggle('rainbow', 'rainbow')])
def spray(event):
    if event.kind == 'release':
        return
    pen, reach = event.pen, max(3, event.size * 3)
    for _ in range(int(event.settings.density)):
        angle, far = random.uniform(0, math.tau), reach * math.sqrt(random.random())
        pen.fillStyle = f'hsl({random.randrange(360)} 90% 60%)' if event.settings.rainbow else event.color
        pen.fillRect(round(event.x + far * math.cos(angle)), round(event.y + far * math.sin(angle)), 2, 2)


# A tool that does not paint works with layers instead: this one puts a dot (a shape layer, which stays adjustable)
# where the image is clicked. A layer's x and y are how far its middle is from the image's.
@plugin.tool('Dots', icon='Ellipse', about='Click to put a dot there: a shape of its own', fields=[slider('size', 'size', 4, 400, 48)])
def dots(event):
    if event.kind != 'press':
        return
    w, h = event.app.size
    size = int(event.settings.size)
    event.app.add(kind='shape', name='Dot', shape='Ellipse', w=size, h=size, color=event.color, radius=0, stroke=0,
                  x=round(event.x - w / 2), y=round(event.y - h / 2))


# ---------------- elements: things to insert that the plugin draws itself ----------------
# Into the Insert menu and the + of the layers ("group" is 'Layers', 'Text', 'Shapes', 'Pictures', or a new one). The
# function draws it with a pen, on a picture settings.w by settings.h; it is called again whenever one of its settings
# changes, and those settings are the layer's properties.
@plugin.element('Star', w=240, h=240, group='Shapes', icon='star', about='A star with as many points as you like',
                fields=[slider('points', 'Points', 3, 24, 5), slider('depth', 'Depth', 0.05, 0.95, 0.5, step=0.01),
                        color('fill', 'Color', '#F7C56B'), toggle('outline', 'Outline only')])
def star(pen, settings):
    cx, cy, far = settings.w / 2, settings.h / 2, min(settings.w, settings.h) / 2 - 6
    pen.beginPath()
    for corner in range(int(settings.points) * 2):
        angle = -math.pi / 2 + corner * math.pi / settings.points
        reach = far if corner % 2 == 0 else far * (1 - settings.depth)
        pen.lineTo(cx + reach * math.cos(angle), cy + reach * math.sin(angle))
    pen.closePath()
    if settings.outline:
        pen.strokeStyle, pen.lineWidth, pen.lineJoin = settings.fill, 6, 'round'
        pen.stroke()
    else:
        pen.fillStyle = settings.fill
        pen.fill()


# ---------------- inserts: anything else to insert ----------------
# "app" is the program: app.add(...) adds a layer, app.set(id, ...) changes one, app.layers and app.doc are the
# project (as dicts and lists), app.layer() the selected layer, app.size the image's size, app.color the color in
# hand, app.say(text) a line in the status bar, app.picture() the image as a PNG.
@plugin.insert('Badge', group='Shapes', icon='Ellipse', about='A round badge in the color in hand')
def badge(app):
    app.add(kind='shape', name='Badge', shape='Ellipse', w=120, h=120, color=app.color, radius=0, stroke=0)


# ---------------- properties: a group of a layer's properties ----------------
# Each field sets the layer's value of the same key: one of the program's own ('w', 'h', 'radius', 'color', 'opacity'...)
# or one of the plugin's. "kinds" are the layers it shows for ('paint', 'image', 'text', 'shape', 'group'). Over a
# function, the function is told of each change (plugin.properties(...) alone is enough when nothing more is to be done).
SIZES = {'Small': (80, 80), 'Medium': (200, 200), 'Large': (480, 480), 'Wide': (640, 160)}


@plugin.properties('Quick shape', kinds=['shape'], fields=[choice('quick_size', 'Size', list(SIZES), 'Medium'), slider('radius', 'Corners', 0, 200, 0)])
def reshaped(app, id, key, value):
    if key == 'quick_size':
        w, h = SIZES[value]
        app.set(id, undo=False, w=w, h=h)


# ---------------- effects: into the effects window ----------------
# "group" is 'Styles', 'Colors' or 'Filters'. The function gives the filter for the settings: tools.matrix(twenty
# numbers: four rows of red, green, blue, alpha, added) changes colors; an effect stays adjustable, and can be taken off.
@plugin.effect('Warm evening', group='Colors', category='Example', fields=[slider('amount', 'Amount', 0, 1, 0.6, step=0.01)])
def warm(settings, tools):
    a = settings.amount
    return tools.matrix([1 + 0.2 * a, 0, 0, 0, 0.03 * a,
                         0, 1 + 0.05 * a, 0, 0, 0,
                         0, 0, 1 - 0.25 * a, 0, 0,
                         0, 0, 0, 1, 0])


# tools.shader(glsl, uName=number...) is a shader of your own, where texture(uTexture, vTextureCoord) is the pixel
# and finalColor what it becomes; uInputSize is the size of what is drawn. (tools.blur(amount) is the program's blur;
# a list of filters is laid on one after another.)
@plugin.effect('Blinds', category='Example', fields=[slider('size', 'Width', 2, 80, 16), slider('dark', 'Darkness', 0, 1, 0.5, step=0.01)])
def blinds(settings, tools):
    return tools.shader('''uniform float uSize; uniform float uDark;
        void main() {
          vec4 c = texture(uTexture, vTextureCoord);
          float row = mod(floor(vTextureCoord.y * uInputSize.y / uSize), 2.0);
          finalColor = vec4(c.rgb * (1.0 - uDark * row), c.a);
        }''', uSize=settings.size, uDark=settings.dark)


# A color among the settings comes as '#RRGGBB'; tools.color() gives its three parts, from 0 to 1.
@plugin.effect('Tint', group='Colors', category='Example', fields=[color('tint', 'Color', '#F0834F'), slider('amount', 'Amount', 0, 1, 0.5, step=0.01)])
def tint(settings, tools):
    r, g, b = tools.color(settings.tint)
    a, keep = settings.amount, 1 - settings.amount
    return tools.matrix([keep, 0, 0, 0, r * a,
                         0, keep, 0, 0, g * a,
                         0, 0, keep, 0, b * a,
                         0, 0, 0, 1, 0])


# ---------------- filters: into the Filter menu ----------------
# A filter repaints the selected painted layer, once (Undo takes it back). Its settings are asked for in a window.
# picture.pen draws on the layer; picture.pixels() gives its pixels as a bytearray, four bytes a pixel (red, green,
# blue, alpha), and picture.put(pixels) sets them. Slices are quick where a loop over every pixel is not.
@plugin.filter('Posterize', fields=[slider('levels', 'Levels', 2, 16, 4)])
def posterize(picture, settings):
    step = 255 / (int(settings.levels) - 1)
    table = bytes(round(round(value / step) * step) for value in range(256))
    pixels = picture.pixels()
    for channel in range(3):                                  # red, green, blue (the alpha stays)
        pixels[channel::4] = pixels[channel::4].translate(table)
    picture.put(pixels)


# ---------------- background effects: for a theme to use ----------------
# In Settings > Appearance a theme of yours can have this "Over it". The function draws the whole window anew for
# every picture: frame.w and frame.h are its size, frame.time the seconds since it began, frame.color the theme's
# color for it, frame.amount how much is asked for (100 is the usual). (Python draws thirty pictures a second at
# most: an effect with very many things in it is better written in TypeScript.)
RINGS = [(random.random(), random.random(), random.uniform(0.3, 1)) for _ in range(14)]


@plugin.backdrop('Ripples')
def ripples(pen, frame):
    pen.clearRect(0, 0, frame.w, frame.h)
    pen.strokeStyle = frame.color
    pen.lineWidth = 1.5
    for x, y, pace in RINGS[:max(1, int(len(RINGS) * frame.amount / 100))]:
        age = (frame.time * pace * 0.25 + x) % 1               # each grows from nothing and fades as it goes
        pen.globalAlpha = 0.5 * (1 - age)
        pen.beginPath()
        pen.arc(x * frame.w, y * frame.h, 20 + age * 180, 0, math.tau)
        pen.stroke()
    pen.globalAlpha = 1


# ---------------- templates: into the templates, under a kind ----------------
# The function gives back a document, as a project file (.rassam) holds one. blank(width, height) is an empty
# image; banner(...), card(...) and slot(...) are the program's designs, with what is given changed.
@plugin.template('Thumbnail', kind='Example', about='An empty 640 × 360 image with a dot in its middle.')
def thumbnail():
    return blank(640, 360, name='Thumbnail', layers=[
        {'id': 'panel', 'visible': False},
        {'id': 'dot', 'kind': 'shape', 'name': 'Dot', 'shape': 'Ellipse', 'w': 160, 'h': 160, 'color': '#F0834F', 'radius': 0, 'stroke': 0},
    ])


# ---------------- palettes and sizes ----------------
plugin.palette('Example: dusk', ['#2D1B3D', '#6C2D5C', '#C2476B', '#F0834F', '#F7C56B', '#FFF1C9'])       # into the palettes
plugin.size('Thumbnail', 640, 360, group='Screen')            # into the New image window (pixel_art=True for pixel art)


# ---------------- commands: into the Plugins menu ----------------
@plugin.command('Say how many layers there are')
def count(app):
    app.say(f'{len(app.layers)} layers')


# ---------------- windows, questions and notices ----------------
# A command (or an insert, or a tool) can open a window of its own: await app.window(title, items, buttons). Its items
# are laid out top to bottom: label(words), note(words, tone), rule(), button(words, on_click), and the fields. It
# stays until one of its buttons is pressed; the answer has the fields (answer.count) and answer.button.
# await app.ask(title, text, buttons) is a question in a small window; await app.message(title, text) only says
# something; app.notify(text, tone) is a notice that slides in at the top for a few seconds ('info', 'success',
# 'warning', 'error'). What waits for an answer is written with "async def".
def surprise(values):
    """The window's own button: what it gives back changes the fields."""
    return {'count': random.randint(3, 40), 'tint': f'#{random.randrange(0x1000000):06X}', 'size': random.choice([16, 32, 48, 64])}


@plugin.command('Scatter dots…')
async def scatter(app):
    answer = await app.window('Scatter dots', [
        label('Dots of one color, put about the image: each is a shape of its own.'),
        slider('count', 'How many', 1, 60, 12),
        slider('size', 'Size', 4, 200, 32),
        color('tint', 'Color', app.color),
        toggle('big', 'Some twice as large'),
        button('Surprise me', surprise),
        note('More than 40 at once makes a long list of layers.', 'warning'),
    ], buttons=['Cancel', 'Scatter'])
    if answer.button != 'Scatter':
        return
    if answer.count > 40 and await app.ask('That many?', f'{answer.count} dots are {answer.count} layers.', ['Cancel', 'Scatter them']) != 'Scatter them':
        return
    w, h = app.size
    dots = []
    for _ in range(int(answer.count)):
        size = int(answer.size) * (2 if answer.big and random.random() < 0.3 else 1)
        dots.append(dict(kind='shape', name='Dot', shape='Ellipse', w=size, h=size, color=answer.tint, radius=0, stroke=0,
                         x=random.randint(-w // 2, w // 2), y=random.randint(-h // 2, h // 2)))
    app.add_all(dots, group='Dots')                           # (all at once, in a group: one step to undo)
    app.notify(f'{int(answer.count)} dots scattered', 'success', detail=f'{answer.tint}, {int(answer.size)} pixels each')


# One that takes its time is written with "async def" (here, the image is added to itself as a picture).
@plugin.command('Add the image as a layer')
async def stamp(app):
    await app.add_picture('Stamp', app.picture())
