The loop

A panel defines view(state), and optionally initial(), on_open(state) and on_action(action, state). Clink calls initial() once for the starting state, on_open each time the panel appears, then view(state) to build the interface. A control either inserts text where you are writing, sets new state, or names an action for on_action to handle, and the view is built again.

def initial():
    return {"count": 0, "step": 1}

def view(state):
    return vstack([
        text(f"{state['count']}", size=34, weight="bold", align="center"),
        segmented(["1", "5", "10"], value=str(state["step"]), action="step"),
        hstack([
            button("−", "bump", value=-1),
            button("+", "bump", value=1, style="primary"),
        ]),
        button("Type it", "type", icon="keyboard"),
    ])

def on_action(action, value, state):
    if action == "bump":
        state["count"] = state["count"] + value * state["step"]
    elif action == "step":
        state["step"] = int(value)
    elif action == "type":
        insert(str(state["count"]))
        close()
    return state
The starter panel, straight from the editor's New panel button.

Calculators, snippet boards, pickers: your panels appear behind the keyboard's panel button.

Builders

view(state) returns nodes made by these 19 functions. Nest them freely: stacks and grids take a list of children.

BuilderWhat it draws
vstack(children, spacing=6, align="leading")Stacks its children down the panel.
hstack(children, spacing=6, align="center")Stacks its children across the panel.
grid(children, columns=4, spacing=6)Flows its children into even columns.
wrap(children, spacing=6)Flows its children across the panel and on to the next line.
scroll(children, spacing=6)A scrolling region of its own, for a list longer than the panel.
card(children, padding=10, color="")Groups its children on a surface, with padding around them.
spacer(size=0)Pushes whatever follows it to the far end.
divider()A hairline between rows.
text(s, size=17, weight="regular", color="", align="leading", lines=0, mono=False)A line of text. weight takes regular, medium, semibold, bold, heavy, light or thin; align takes leading, center or trailing; lines caps how many it wraps to; color takes a colour name or #RRGGBB.
icon(name, size=20, color="")An SF Symbol, by name.
badge(s, color="")A short word in a tinted capsule.
progress(value, total=1, label="")A bar filled to value out of total.
button(label, action="", value=None, insert="", set=None, style="plain", icon="", enabled=True)A button. insert types its text into whatever you are writing, set merges keys into state, and action names a handler for on_action. style takes plain, primary, tinted, quiet or destructive.
row(title, subtitle="", detail="", icon="", action="", value=None, insert="")A tappable row: a title, a second line, an icon, and a detail on the right.
toggle(label, on=False, key="", action="")A switch. key writes True or False into that state key.
slider(value, min=0, max=100, step=1, label="", key="", action="")A slider between min and max. key writes the position into that state key.
stepper(value, min=0, max=100, step=1, label="", key="", action="")A value with − and + beside it, between min and max.
segmented(options, value=None, key="", action="")One choice out of a list. key writes the chosen option into that state key.
field(key, placeholder="", action="", submit="")A text box bound to state[key]. Tapping it aims the keys at that key; submit names the handler Return runs.

A plain string works anywhere a node does: it renders as text at the default size.

Handling a tap

A control that has to work something out names an action instead of carrying a literal set. Clink then calls on_action(action, state), or on_action(action, value, state) when you want the value the control carries. State is an ordinary dictionary, so you can change it in place and return nothing, or return a new one.

def initial():
    return {"items": [], "draft": ""}

def view(state):
    return vstack([
        field("draft", placeholder="Add one…", submit="add"),
        scroll([row(item, action="use", value=item) for item in state["items"]]),
    ])

def on_action(action, value, state):
    if action == "add" and value:
        state["items"].append(value)
        state["draft"] = ""
    elif action == "use":
        insert(value)
        close()
    return state
A list you add to, and tap to insert from.

on_open(state) runs each time the panel appears, which is where a panel seeds itself from what you are writing.

Asking the keyboard

From on_action and on_open a panel can ask the keyboard for things a return value cannot express. Each call is recorded and applied after your function returns, so a panel never edits the field while its own view is being built.

Commands

  • insert()
  • backspace()
  • delete_word()
  • replace()
  • move_cursor()
  • copy()
  • close()
  • haptic()
  • toast()
def on_action(action, state):
    if action == "quote":
        word = context()["word"]
        replace(len(word), "“" + word + "”")
        haptic("light")
    return state
Swapping the word at the cursor for a quoted one.

context() reads what is around the cursor: before, selected, after, word, clipboard, locale, language, full_access and time. It is a snapshot taken when the call started, so it cannot change under you mid-render.

State

State is an ordinary dictionary, and it holds whatever a script can build: numbers, strings, booleans, None, and lists and dictionaries of them. It is handed back to you on every call, so a list you appended to on one tap is still there on the next.

def initial():
    return {"name": ""}

def view(state):
    return vstack([
        field("name", placeholder="Who?"),
        button("Greet", insert="Hi " + state["name"] + "!", style="primary"),
    ])
A field writes straight into the state key you name.

Remember state keeps what the panel holds between openings, and between keyboard launches. Leave it off for a calculator; turn it on for a list.

State lives as long as the keyboard is on screen, and longer when the panel remembers. A panel can read the text around the cursor with context(); it cannot read anything else about you.

Limits

A keyboard gets a fraction of the memory an app does, so panels are bounded rather than trusted. Every render gets 2,000,000 interpreter steps, and a runaway loop ends with an error on screen instead of a keyboard that stops answering.

A panel that arrives as a file or from a repository is checked before it is stored. It has to be under 48,000 bytes and 1,200 lines, it has to define def view(, every import has to name one of the sandboxed namespaces, and these fragments are refused outright:

Refused in a shared panel

  • __
  • exec(
  • eval(
  • open(
  • compile(

Importable in a shared panel

  • json
  • math
  • random
  • re
  • sys
  • time

Panels you write in the app are not held to that list. The step budget is then the only thing that will stop a runaway loop, so give it a way out.

Placement

Give each panel its own button in the picker instead of nesting them behind one Panels button. Individual panels can override this in their editor.

Default follows that switch, Standalone always takes its own button, and Grouped always nests.

Writing one

  1. Open Custom Panels in Clink and tap New panel.
  2. Write the script. A live preview runs the real panel beside it, and errors show as you type.
  3. Save, switch the panel on, then open the panel button on the keyboard.

Share panel writes a .clinkpanel file, which is JSON. Opening one back in Clink stores it with a fresh id, so importing the same panel twice never overwrites the first.

Creator ›
Download on the App Store