Automations
The optional automation object in a .clinkplugin file declares events, state, settings, commands, presets and shortcut slots. Each contribution has a stable ID, an exact schema version and localized titles. The host creates plugin/<plugin-id>/<kind>/<id> names. A plugin cannot claim a core or another plugin’s namespace.
Automations ›How plugins work
A panel replaces the keys, and an action runs once on a piece of text. A plugin has no screen of its own on the keyboard. Clink calls its functions at set moments, like when the keyboard opens or you finish a word, and the plugin can update the space bar or its own saved state. Its settings are drawn in the app with the same builders panels use. WPM Spacebar, below, is a complete plugin.
def initial():
return {"on": False}
def settings(state):
return section("keys.spacebar", [
toggle("Show typing speed", state["on"], action="toggle"),
], title="WPM Spacebar")
def on_action(action, value, state):
state["on"] = value
if value:
claim("spacebar.text")
else:
release("spacebar.text")
space_text(None)
return state
def on_word(word, state):
if state["on"]:
space_text(f"{stats()['wpm']} wpm")
return statePlugins need Clink Pro. Before installing one from a repository, Clink asks you to allow code from that repository, the same as it does for panels and actions.
on_key and on_word are given what you type, which is how a word counter works. PyMini has no network or file access, so a plugin can’t send what you type over the internet, and what it saves stays with Clink on your device.
Your first plugin
The quickest way in is the starter script the app writes for you. It puts a switch under Keys > Space bar and, while the switch is on, counts words on the space bar. Five steps, no file to download.
- Open the Plugins tab, turn plugins on at the top, and tap + for a new plugin.
- The editor opens on the starter script. Read it once: initial(), settings(), on_action(), on_open() and on_word() are the whole thing.
- Switch to Preview. Flip the switch, then tap on_open and on_word a few times and watch the space bar mock and the console.
- Save. The plugin is on in the list, and its switch now sits under Keys > Space bar too.
- Open the keyboard anywhere and type. The space bar counts along.
def initial():
return {"on": False, "words": 0}
def settings(state):
return section("keys.spacebar", [
toggle("Count words on the space bar", state["on"], action="toggle"),
], title="Word count")
def on_action(action, value, state):
if action == "toggle":
state["on"] = value
if value:
claim("spacebar.text")
else:
release("spacebar.text")
space_text(None)
return state
def on_open(state):
state["words"] = 0
if state["on"]:
space_text("0 words")
return state
def on_word(word, state):
state["words"] += 1
if state["on"]:
space_text(f"{state['words']} words")
return stateHooks
Define any of these 33 functions and leave out the ones you don’t need. Each one gets state as its last argument. The ones that react to something return state, changed or not. The ones that answer a question (elements, draw, effects, the look hooks, haptics, hitboxes, suggestions and correct) return their answer, and can still change state in place.
| Hook | When it runs |
|---|---|
on_automation(command, args, state) | on_automation(command, args, state) runs an approved local command in the existing sandbox. It can update the space-bar caption, publish declared state or emit a declared event. Text insertion, clipboard access, persistent setting changes and external requests are not permitted effects of this hook. |
initial() | Once, before there is any saved state. Return a dict of anything JSON can store. |
settings(state) | When the plugin’s settings are shown in the app. Return its controls as a node tree. It never runs in the keyboard. |
on_action(action, value, state) | When one of the plugin’s controls is used. on_action(action, state), without value, also works. |
on_open(state) | When the keyboard appears. A good place to read stats() or set the space bar. |
on_close(state) | When the keyboard is dismissed. State is saved right after this. |
on_key(key, state) | Each time a key types something. This runs on every keystroke, so keep it quick. |
on_word(word, state) | When a word is finished, whether by a space, a suggestion or a swipe. |
on_backspace(state) | When the backspace key is pressed. It gets no text, only the state. |
on_suggestion(word, state) | When a suggestion chip is tapped. word is the one that was tapped. |
on_language(code, state) | When the typing language changes. code is the new one, like en or de. |
on_field(kind, state) | When the keyboard connects to a field. kind is default, email, url, number, phone, password or search. |
on_tick(state) | Once a second while the keyboard is on screen, typing or not. The hook for anything that has to change on its own: a clock, a countdown, a rate that should fall back to zero when you stop. |
on_swipe(direction, state) | A flick that starts on a letter key: "left", "right", "up", "up_left" or "up_right". Flicks are only read while swipe typing is off. If the hook asks for nothing, the flick stays an ordinary keystroke; if it does, the letter it started on is taken back first. |
elements(state) | What this plugin offers a custom layout. Return element(id, name, icon=, width=) entries, or leave the hook out. |
draw(id, state) | One element's face, as a node tree. Runs about once a second while the keyboard is up. |
effects(state) | Lighting effects this plugin offers the Effects page. Return light_effect(...) entries, or leave the hook out. |
key_styles(state) | Key styles for the theme editor. Return key_style(...) entries; see Looks below. |
themes(state) | Whole themes for the Plugins tab of the theme editor. Return theme(...) entries. |
popups(state) | Key popup styles. Return popup_style(...) entries. |
animations(state) | Animations: any mix of entrance(...), press_animation(...), letter_animation(...) and transition(...). |
backgrounds(state) | Animated backgrounds. Return background(...) entries built from particles(...) layers. |
layouts(state) | Keyboard layouts. Return layout(...) entries. |
trails(state) | Swipe trails this plugin offers the trail picker. Return trail(...) entries; see Looks below. |
haptics(state) | A haptic for each key, as a dict from key names to feels. Read when the keyboard opens and about once a second. |
hitboxes(state) | A hit area for each key, as a dict from key names to hitbox(...). Read on the same beat as haptics. |
on_touch(key, x, y, state) | After every tap: the key it went to, and where on that key the finger landed. x and y run from -0.5 to 0.5, with 0 at the centre. |
suggestions(word, state) | Words for the suggestion bar while word is being typed. Runs each time the bar settles, not on every key. |
correct(word, fix, state) | Space just ended word. Return a word to commit instead, False to keep it as typed, or None to let the keyboard’s own fix stand. |
bar_items(state) | Buttons and knobs this plugin offers the top bar. Return bar_button(...) and bar_knob(...) entries, or leave the hook out. |
key_art(state) | Artwork to paint on the keys, as a dict from key names to shapes. Re-read after every event the plugin hears, so state changed in a hook shows on the keys. |
events(state) | The event names on_event asks for, read once when the plugin loads. Leave it out and the plugin hears everything except the busy ones. |
on_event(name, info, state) | One hook for everything that happens: open, close, word, backspace, suggestion, language, field, shift and plane, plus the busy key, key_down, key_up, predictions and tick, which have to be asked for by name in events(state). |
def on_key(key, state):
if key == " " and setting("language") == "en":
state["spaces"] = state.get("spaces", 0) + 1
return state
def on_close(state):
haptic("light")
return stateThe app and the keyboard share one copy of the state. Turn on a switch in the app and it’s on the next time the keyboard opens. Anything the keyboard counts is there the next time you open the plugin’s settings. The keyboard saves state when it closes, not after every key.
Current suggestion on the spacebar
Read the primary displayed suggestion with context()["suggestion"]. Subscribe to the predictions event to receive changes in info["suggestion"]. Both require Typing data access. on_suggestion runs after a suggestion is accepted, not when predictions change. Call space_text(value or None) to set the caption or restore it when suggestions clear. This changes the label, not the spacebar’s action.
def initial():
return {}
def events(state):
return ["predictions"]
def on_open(state):
space_text(context()["suggestion"] or None)
return state
def on_event(name, info, state):
if name == "predictions":
space_text(info["suggestion"] or None)
return state
def on_close(state):
space_text(None)
return stateState
State is one dict. initial() builds it the first time; after that every hook gets the same dict, changes it, and hands it back. It holds whatever JSON can hold: numbers, strings, lists, nested dicts. The app saves it after every control you use, the keyboard saves it when it closes, and both read the same file, so the two never disagree for long.
def initial():
return {"session": 0, "total": 0, "longest": ""}
def on_open(state):
state["session"] = 0 # starts over each time the keyboard opens
return state
def on_word(word, state):
state["session"] += 1
state["total"] += 1 # survives, it is saved when the keyboard closes
if len(word) > len(state["longest"]):
state["longest"] = word
return state
def settings(state):
return vstack([
text(f"{state['total']:,} words so far"),
text(f"Longest: {state['longest'] or '...'}", size=13, color="gray"),
button("Start over", "reset", style="destructive"),
])
def on_action(action, value, state):
if action == "reset":
return initial()
return stateReturning state is the habit to keep, but not strictly required: the dict is a reference, so changing it in place works too. Return a different dict, like initial() above, and that becomes the state.
Settings and sections
settings(state) returns a node tree made with the panel builders: text, toggle, slider, stepper, segmented, button, row, field and the layouts. It appears on the plugin’s page in the Plugins tab. Wrap part of it in section(anchor, children, title) and that part also shows up on one of Clink’s own settings screens, next to the setting it relates to.
def settings(state):
return vstack([
text("Counts words as you type.", size=13),
section("keys.spacebar", [
toggle("Count on the space bar", state["on"], action="toggle"),
stepper(state["goal"], min=10, max=500, step=10, label="Goal", key="goal"),
], title="Word count"),
])The controls a plugin most often needs. Each one either writes its new value into state under key, or names an action for on_action, or both. The full builder list, including the layouts, is on the panels page.
| Builder | What it draws |
|---|---|
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. |
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. |
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. |
Where sections go
An anchor is the id of a card on one of Clink’s own settings screens. Name it in section() and the plugin’s controls are drawn right under that card, with the plugin’s name over them. The easiest way to find one: in the app, open More > Developer and turn on Show ids. Every card gets a small info badge that names its id and copies it on tap; pages show theirs in the title bar.
Every anchor a section can name
analytics.heatmapanalytics.privacyanalytics.trendsanalytics.typing-testautomations.rulesgestures.accentsgestures.cursorgestures.deletegestures.generalgestures.suggestionsgestures.swipehaptics.feelkeys.adaptivekeys.faceskeys.hitboxeskeys.hitmapkeys.long-presskeys.numberrowkeys.onehandedkeys.roundnesskeys.sizekeys.spacebarkeys.spacingkeys.splitlanguages.applanguages.customlanguages.managelanguages.packslanguages.switchlanguages.typinglayout.arrangelayout.arrangementlayout.buildlayout.longpresslayout.presetslayout.topbarmotion.deletemotion.entrancemotion.glowmotion.key-pressmotion.key-responsemotion.lettersmotion.space-responsemotion.transitionpopups.stylesound.keysoundstext.automationtext.contenttext.correctionstext.historytext.punctuationtext.speedtext.suggestionstext.symbolsthemes.backgroundthemes.canvasthemes.theme
keys.spacebar has a place of its own, on the Space bar screen itself. A section with an anchor not in this list still shows on the plugin’s page, so a typo costs you a card, not the plugin.
Claims
When a plugin drives one of Clink’s own settings, the person should see that, and the two should not fight. claim(control) says the plugin owns it: the app names the plugin on that setting’s card, and the space bar text field locks while it is held. release(control) gives it back. Claim in the on_action that turns your feature on, release in the one that turns it off, and set the value back to None or its old value at the same time. A plugin switched off in the list releases everything it held.
def on_action(action, value, state):
if action == "toggle":
state["on"] = value
if value:
claim("spacebar.text")
space_text("...")
else:
release("spacebar.text")
space_text(None) # hand the person's own text back
return stateTwo plugins can claim the same control; the app names the one that claimed last. Claims are recorded by the app, so claim from on_action, which runs there. A claim made inside the keyboard is not remembered.
Commands and reads
Plugins can use every panel command, plus six commands and two reads of their own. Commands are queued and applied after your function returns, so nothing on the keyboard changes halfway through a call. Reads return values as they were just before the call.
| Call | What it does |
|---|---|
space_text(text) | Shows a caption on the space bar, up to 32 characters. Pass None to go back to the user’s own space bar text. |
space_language_text(text) | Overrides the language badge in the corner of the space bar, on its own and even when the native badge is off. One line, up to 12 characters; "" hides it and None gives the native text back. |
space_language_flag(language) | Puts a flag on that badge for a language id such as en_GB, from the bundled flag artwork. None gives native behaviour back. |
space_language_emoji(language) | The same badge as the region’s flag emoji. The three badge commands share one slot, so switching on one badge plugin switches the others off. |
set_setting(name, value) | Change one of Clink’s settings by name, to a value of the right kind: a boolean, a number within its range, one of its choices, or text. An unknown name raises KeyError. A value of the wrong kind or out of range is not applied, and the editor’s console says what the setting expects. |
claim(control) | Takes over one of Clink’s own settings. Its card in the app names the plugin that holds it. |
release(control) | Hands the setting back. Turning a plugin off releases everything it claimed. |
suggest(words) | Put your own words at the front of the suggestion bar, up to ten. They stay until one is tapped, delete is pressed or the field changes, and suggest([]) clears them sooner. Tapping one types it. |
banner(text) | Show a short message on the keyboard for a moment. For a nudge, not for a conversation. |
press(key) | Run one of the keyboard’s own keys as if it had been tapped: "space" or "delete". Any other name raises ValueError. |
pick_suggestion(slot) | Take the suggestion under the "left", "center" or "right" part of the bar, as a tap on it would. A scrolled bar counts what is on screen. Inside on_swipe it picks from the bar as it was when the finger landed. |
stats() | Returns a dict with wpm, peak_wpm, keystrokes, words and streak. wpm updates live while plugins are on. The totals come from Analytics and stop updating if Analytics is off. |
setting(name) | Reads one of the settings listed below by name. Any other name raises KeyError. |
context() | The same snapshot a panel reads, plus suggestion, shift (off, on or locked) and plane for a plugin. Typing access is what fills the document keys; shift stays readable without it. |
| Key | What it holds |
|---|---|
stats()["wpm"] | Words per minute over the last few seconds, counted from the characters that reach the field. It appears a second or two after you start, falls while you pause, and reaches 0 once you stop. Deleting never adds to it. |
stats()["peak_wpm"] | The best rate ever recorded by Analytics. |
stats()["keystrokes"] | Keys pressed, all time, as Analytics counts them. |
stats()["words"] | Words committed, all time. |
stats()["streak"] | Days in a row with typing, ending today. |
setting(name) and set_setting(name, value) share one list of names, and claim(control) takes any of them too. Booleans read as True or False, numbers as numbers, choices as their id. The list is long on purpose: a plugin can react to, or drive, nearly everything a person can set in the app.
Names setting() accepts
analyticsemoji.skin_toneemoji.trailing_spacegestures.cursorgestures.cursor_stylegestures.highlight_shiftgestures.plane_slidegestures.predictive_flickgestures.quick_accentgestures.swipegestures.swipe_deletegestures.swipe_multi_wordgestures.swipe_space_commitgestures.swipe_two_thumbgestures.trailgestures.trail_stylegestures.trail_widthhaptics.enabledhaptics.intensityhaptics.sharpnesskeys.accentskeys.glyph_scalekeys.heightkeys.long_press_hintkeys.popup_stylekeys.popupskeys.radiuskeys.row_spacingkeys.spacingkeys.uppercasekeys.widthlanguagelanguage.bar_keylanguage.bar_key_stylelanguage.modelayoutlayout.dismiss_shortcutlayout.number_rowlayout.number_row_scalelayout.one_handedlayout.one_handed_shortcutlayout.one_handed_sidelayout.one_handed_widthlayout.splitlayout.split_gaplayout.split_number_rowlayout.split_shortcutlayout.split_space_barpet.cornerpet.enabledpet.speciessettings.accentHoldDelaysettings.accentMoveCancelsettings.activateWithIconsettings.adaptiveGrowsettings.adaptiveHitboxessettings.adaptivePredictAtWordStartsettings.adaptivePredictionWeightsettings.adaptiveShrinksettings.adaptiveSpacesettings.aiAutocorrectsettings.aiCompletionssettings.aiExtensionEnabledsettings.aiSearchsettings.aiToolsColorOverridessettings.aiToolsDiffStylesettings.aiToolsDisabledsettings.aiToolsLayoutStylesettings.aiToolsOrdersettings.aiToolsPromptOverridessettings.aiTranslatesettings.alternatingSplitsettings.arabicIndicNumeralssettings.backgroundEffectOverridesettings.clipboardCloseOnPastesettings.clipboardDeleteOnPastesettings.clipboardIgnoreImagessettings.clipboardIgnorePinsOnDeletesettings.clipboardStylesettings.cursorActivationHapticsettings.cursorLineStridesettings.cursorStepHapticsettings.customPanelsStandalonesettings.customPetIDsettings.deleteWordSwipeEngagesettings.deleteWordSwipeStridesettings.dictationAssistsettings.dictationAssistCustomsettings.dictationAssistLevelsettings.dictationColorSchemesettings.dictationGlassCapsulesettings.dictationSmartActionssettings.dictationStylesettings.dictationVisualStylesettings.dragUpThresholdsettings.emojiCategoryOrdersettings.emojiCellSpacingsettings.emojiColumnCountsettings.emojiCrossAxisSwitchesTabsettings.emojiCustomSetssettings.emojiGlyphScalesettings.emojiHiddenCategoriessettings.emojiHiddenFromPanelssettings.emojiRecentsCapsettings.emojiRecentsSortsettings.emojiRememberCategorysettings.emojiRowCountsettings.emojiScrollDirectionsettings.emojiSearchSlotsettings.emojiShowABCKeysettings.emojiShowBackspaceKeysettings.emojiStartCategoryIDsettings.emojiTabBarSlotsettings.emojiTabIconStylesettings.emojiToneHoldDelaysettings.extensionOrdersettings.extraTopBarssettings.formFreeCornerssettings.formLayoutEnabledsettings.gifShareAsLinksettings.glassPerRowMergesettings.glassReleaseResponsesettings.gridSwitchAnimationsettings.gridSwitchDurationsettings.handwritingInkColorsettings.handwritingInkGlowsettings.handwritingInkStylesettings.handwritingInkWidthsettings.hitboxScalesettings.iconPickerStylesettings.keyBloomScalesettings.keyLightingsettings.keyPressGlowsettings.keyPressInstantsettings.keyPressLingersettings.keySpringDampingsettings.keySpringResponsesettings.keyboardBottomPaddingsettings.keyboardLanguagessettings.keyboardTopPaddingsettings.longPressGlyphScalesettings.minPressVisiblesettings.notepadBrowseStylesettings.notepadModesettings.numberRowFontSizesettings.numberRowLeadingKeyssettings.numberRowTrailingKeyssettings.oneHandedCustomKeyssettings.oneHandedExtraKeyssettings.panelButtonHitboxScalesettings.persistentLeadingKeyssettings.persistentTrailingKeyssettings.petIdleMotionsettings.petScalesettings.pinyinFuzzyEnabledsettings.pluginLookssettings.popupSpringDampingsettings.popupSpringResponsesettings.predictiveFlickSuggestionPositionsettings.predictiveFlickSuggestionsSeparatesettings.reduceEffectsOnLowPowersettings.repeatAccelStepsettings.repeatHoldDelaysettings.repeatInitialIntervalsettings.repeatMinIntervalsettings.replacementsLayoutsettings.rowInsetssettings.secondaryNeuralModelsEnabledsettings.separateActivationsettings.separateLanguageLayoutssettings.showCaptureOverlaysettings.showCorrectionFieldsettings.showHitboxOverlaysettings.showIconsBeforeTypingsettings.showRecentEmojisettings.showTouchHeatmapsettings.showTouchSurfaceBoundssettings.showTouchTelemetryOverlaysettings.slideUpPickerStylesettings.solidPopupOpacitysettings.soundVoicesettings.spaceBloomScalesettings.spaceCursorActivationDelaysettings.spaceCursorDragScalesettings.spaceCursorStridesettings.spaceLeanMultipliersettings.spaceSpringDampingsettings.spaceSpringResponsesettings.spatialBiasEnabledsettings.spatialBiasGainsettings.splitIndicessettings.splitOtherPlanessettings.stickerGridSnapsettings.stickerPlacementssettings.suggestionDebounceDelaysettings.suggestionHitboxScalesettings.suggestionSegmentLiquidGlasssettings.suggestionSegmentStylesettings.suggestionSegmentsFollowThemesettings.suggestionSeparatorColorsettings.suggestionSeparatorStylesettings.suggestionTopPaddingsettings.swipeKeyMorphsettings.swipeMorphRadiussettings.swipeMorphStrengthsettings.swipeTrailEndWidthsettings.swipeTrailMaxLengthsettings.swipeTrailStartWidthsettings.swipeTrailTrimsettings.toolsButtonStylesettings.toolsHiddenFromPanelssettings.topBarItemssettings.topBarOrdersettings.translateLanguageOrdersettings.translateLanguagesDisabledsettings.translateStylesettings.translateTonesettings.vietnameseInputMethodsound.enabledsound.packsound.volumespacebar.cornerspacebar.language_codespacebar.sizespacebar.textstickers.enabledtext.arithmetictext.auto_capitalizetext.auto_punctuationtext.autocorrecttext.autocorrect_everywheretext.contactstext.conversionstext.double_space_periodtext.learningtext.punctuation_spacingtext.return_to_letterstext.revert_on_deletetext.smart_quotestext.suggestionstext.suggestions_animationtext.suggestions_heighttext.suggestions_scrolltext.suggestions_stylethemetheme.backgroundtheme.darktheme.delete_glyphtheme.entrancetheme.glyph_presstheme.lighttheme.match_systemtheme.press_styletheme.reactive_backgroundtools.calculatortools.clingtools.clipboardtools.conversiontools.dictationtools.dictionarytools.emojitools.giftools.handwritingtools.layout_switchertools.notepadtools.plugin_switchertools.profilestools.replacementstools.textfxtools.theme_switchertools.translate
Commands
insert()backspace()delete_word()replace()move_cursor()copy()close()haptic()toast()
Like a panel’s view(), settings() should only describe controls. Commands called from it are ignored and logged to the console.
Examples
Five small plugins, each complete. Paste one into a new plugin, save, and it runs.
def initial():
return {"on": True}
def settings(state):
return section("keys.spacebar", [
toggle("Language on the space bar", state["on"], action="toggle"),
], title="Language badge")
def on_action(action, value, state):
if action != "toggle":
return state
state["on"] = value
if value:
claim("spacebar.text")
space_text(setting("language").upper())
else:
release("spacebar.text")
space_text(None)
return state
def on_open(state):
if state["on"]:
space_text(setting("language").upper())
return state
def on_language(code, state):
if state["on"]:
space_text(code.upper())
return stateSHORTCUTS = {"omw": "on my way", "brb": "be right back", "ty": "thank you"}
def on_key(key, state):
if key != " ":
return state
words = context()["before"].split()
if words and words[-1] in SHORTCUTS:
# the space is already in the text, so it goes too and comes back after
replace(len(words[-1]) + 1, SHORTCUTS[words[-1]] + " ")
return statedef initial():
return {"goal": 200, "count": 0}
def settings(state):
return vstack([
stepper(state["goal"], min=50, max=2000, step=50, label="Words per session", key="goal"),
progress(state["count"], total=state["goal"], label=f"{state['count']} of {state['goal']}"),
])
def on_open(state):
state["count"] = 0
return state
def on_word(word, state):
state["count"] += 1
if state["count"] == state["goal"]:
haptic("medium")
banner("Goal reached")
return statedef initial():
return {"muted": False, "was_on": True}
def on_field(kind, state):
# a password or a number field is not a place for key sounds
quiet = kind in ("password", "number", "phone")
if quiet and not state["muted"]:
state["was_on"] = setting("sound.enabled")
state["muted"] = True
set_setting("sound.enabled", False)
elif not quiet and state["muted"]:
state["muted"] = False
if state["was_on"]:
set_setting("sound.enabled", True)
return stateNAMES = ["sam", "ana", "team"]
def on_key(key, state):
typing = context()["before"].split(" ")[-1]
if typing.startswith("@"):
start = typing[1:].lower()
suggest([n for n in NAMES if n.startswith(start)])
state["showing"] = True
elif state.get("showing"):
suggest([]) # the mention is over, give the bar back
state["showing"] = False
return statedef on_swipe(direction, state):
if direction == "left":
delete_word()
elif direction == "right":
press("space") # the space bar's own path, so it autocorrects
elif direction == "up":
pick_suggestion("center")
elif direction == "up_left":
pick_suggestion("left")
elif direction == "up_right":
pick_suggestion("right")
return stateElements in a layout
A custom layout is built from keys and elements: a strip of recent emoji, a row of digits, a cursor pad. A plugin can add its own. elements(state) says what it offers and draw(id, state) paints one, so a key can show anything the plugin can compute: a typing-speed graph, a clock, a countdown, a battery of your own.
Two hooks build one. elements(state) lists what you offer, once per element, and the app reads it to fill the layout editor's palette. draw(id, state) is handed one of those ids and returns what to paint. A plugin can offer several; draw is asked for each of them by name.
| Call | What it does |
|---|---|
element(id, name, icon="", width=2) | One element on offer. width is in key widths, the way a layout counts them, and icon is the SF Symbol the editor shows in its palette. |
sparkline(values, min=None, max=None, fill=False) | A line through a series of numbers, filling the space it is given. Leave min and max out and it fits the values it has. Only a plugin can draw one, and it is made for a key. |
POINTS = 30
def initial():
return {"history": []}
def elements(state):
return [element("sparkline", "WPM sparkline", icon="waveform.path.ecg", width=3)]
def on_tick(state):
history = state["history"]
history.append(stats()["wpm"])
state["history"] = history[-POINTS:]
return state
def draw(id, state):
rate = state["history"][-1] if state["history"] else 0
return hstack([
sparkline(state["history"], min=0, max=max(60, max(state["history"] or [0])), fill=True),
text(f"{rate}", size=12, weight="semibold"),
], spacing=5, align="center")A face is a node tree, like a settings page, but a key is not a settings page: only the nodes that draw are used, and anything you can tap is ignored. The key itself already belongs to the layout, so there is nowhere inside it to put a button.
WHAT A FACE CAN DRAW
sparklinetextbadgeiconprogresshstackvstackspacerdivider
Size it for a key. width is in key widths, so 1 is a letter and 3 is about a third of a row, and the person can resize it afterwards. Text gets one line and shrinks to fit; colours default to the key's own text colour, so an element matches the caps around it unless you ask for another. A sparkline keeps its last 120 points, which is more than a key can show.
import time
def elements(state):
return [element("clock", "Clock", icon="clock", width=2)]
def draw(id, state):
return text(time.format("HH:mm"), size=15, weight="semibold")GOAL = 200
def initial():
return {"words": 0}
def elements(state):
return [element("goal", "Word goal", icon="target", width=2)]
def on_open(state):
state["words"] = 0
return state
def on_word(word, state):
state["words"] += 1
return state
def draw(id, state):
return vstack([
text(f"{state['words']}/{GOAL}", size=11),
progress(state["words"], total=GOAL),
], spacing=2)Elements live in custom layouts, so there is a layout to build first. Presets can't hold one: forking a preset into your own layout is the first thing the editor offers.
- Install the plugin and turn it on. Its elements appear the moment it does.
- Open Layout, make or fork a custom layout, and go to Arrange.
- Tap Element, pick the plugin's element from the strip, and set how wide it should be.
The editor's Preview draws every element the script offers, at about the size of the key it will sit on, under the space bar mock. Save and place it once, and the keyboard draws the same thing.
An element only shows what the plugin draws: a tap on it does nothing, because the key already belongs to the layout. A layout keeps the element even while its plugin is off or uninstalled, and the key fills back in when the plugin is back.
Buttons and knobs in the top bar
The strip above the keys is arranged under Layout > Top bar, and a plugin can offer its own controls for it. bar_items(state) returns them: bar_button(...) for something to tap, bar_knob(...) for something to turn. The list is read when the keyboard opens and again after every tap, so an item can come and go with the plugin's state.
| Call | What it does |
|---|---|
bar_items(state) | Everything this plugin offers the bar, as a list. An id only has to be unique within the plugin; the bar keeps it beside the plugin's own id. A repeated id keeps the first, and an item the hook stops returning is no longer drawn. |
bar_button(id, name, icon="", title="") | A button. name is what the editor lists and what VoiceOver reads. icon is an SF Symbol and title is up to 12 characters of label beside it: with an icon and no title the icon stands alone, with no icon the text stands alone. A button has no value and no range. A tap calls on_action(id, None, state), and everything the button does happens there. |
bar_knob(id, name, icon="", min=0, max=1, step=0, value=0, setting=None, art=None, rotor=None) | A knob. min and max are its range, step snaps it when it is above zero, and value is where it starts. setting= ties it to one of the app's own number controls instead, which then supplies both the range and the position. art= and rotor= draw it in Python. |
def initial():
return {"width": 2.0}
def bar_items(state):
return [
bar_button("sig", "Sign off", icon="signature", title="Sign off"),
bar_knob("volume", "Key volume", icon="speaker.wave.2", setting="sound.volume"),
bar_knob("width", "Trail width", icon="scribble", min=1, max=6, step=0.5, value=state["width"]),
]
def on_action(action, value, state):
if action == "sig":
insert(" sent from Clink")
elif action == "width":
state["width"] = value
return stateBoth kinds come back through on_action(action, value, state), keyed by the item id, so a plugin's bar items and its settings controls share one hook. A tap sends None. A knob sends its value at each detent while the finger is down and once more when it is let go, so a plugin can follow the drag or wait for the last value.
A knob with setting= turns a dial the app already has, named from the list under Commands and reads above, such as sound.volume or haptics.intensity. A name the app does not know is a KeyError in the console. The range and the starting position come from that control, the keyboard's own copy changes as you drag so the next keypress is already at the new level, and the value is saved when you let go. A volume or haptic knob turned up out of nothing also switches sound or haptics back on for the drag, so the detents are heard and felt on the way up; saving that switch is the plugin's own set_setting in on_action.
A knob can be drawn in Python. art= is the part that stays put, the bezel or the body, and rotor= is the face that turns, from -135 degrees at the minimum to +135 degrees at the maximum. Both take the same shapes, paints, gradients and shadows as key art, up to sixteen shapes each, and unit="key" scales coordinates to the square dial, where a pointer at negative y points up. The artwork is handed the bar's text colour as "text" and the theme's accent as "accent". Leave both out and the knob is a ring with the icon inside; the editor can also put any of the built-in finishes on a knob already in the bar.
Nothing reaches the bar on its own. An item is placed by hand, the same way the menu and the suggestions are.
- Install the plugin and turn it on. Its bar items appear right away.
- Open Layout > Top bar.
- Add the plugin's button or knob and drag it to where it should sit. A knob also gets a finish to pick.
The builders skip a keyword they do not know, without an error. setting=, min=, max=, step=, value=, art= and rotor= belong to bar_knob alone, so a bar_button handed one is still built as a plain button and nothing is said about it: a button's work belongs in on_action. title= goes the other way, and a knob skips it.
Lighting effects
A plugin can add its own key lighting. effects(state) returns light_effect(...) entries, and each one appears in the app under Effects > From plugins, beside the built-in styles and the effects people build themselves. An effect is data rather than drawing: a stack of layers the keyboard animates itself, so nothing in the script runs per frame, and faces, letters, glow, the press flare and idle sleep all work as they do for the built-in styles.
| Call | What it does |
|---|---|
light_effect(id, name, layers=[...], colors=[], icon="") | One effect. id only has to be unique within the plugin. layers is a list of light_layer(...), applied top to bottom. colors is up to eight "#rrggbb" strings the colour loops through; leave it out and the effect follows the person’s Color setting. |
light_layer(pattern, ...) | One layer: a pattern from the list below. Every keyword is optional. |
COLORS = ["#00e5ff", "#7c4dff", "#ff4081"]
def effects(state):
return [
light_effect("tide", "Tide", colors=COLORS, layers=[
light_layer("solid", low=0.2, high=0.2),
light_layer("wave", moves="both", speed=0.8, size=1.5,
direction="up"),
]),
]Patterns
solidpulsewavegradienttwinklesweeprainflickerchecker
| Key | What it does |
|---|---|
moves="light" | What the pattern changes: "light" for brightness, "color", or "both". |
mix="add" | How its brightness meets the layers above: "add", "max" to keep the brighter, or "multiply" to dim them like a mask. |
shape="smooth" | The rise and fall of pulse, wave and gradient: "smooth", "ramp", "step" or "spike". |
direction="right" | Which way wave, gradient, sweep and rain travel: "right", "left", "down", "up", or "out" from the middle. |
speed=1 | From 0 to 4. At 0 the pattern holds still. |
size=1 | From 0.25 to 4: how many times the pattern repeats across the board. For twinkle it sets how many keys are lit, for sweep the length of the tail. |
low=0, high=1 | The brightness the pattern runs between, each from 0 to 1. A low above the high turns it upside down. |
color_span=1, color_offset=0 | How far along the colours the pattern moves, and where it starts, each from 0 to 1. |
def effects(state):
return [
light_effect("scanner", "Scanner", layers=[
light_layer("wave", moves="color", speed=0.3),
light_layer("sweep", mix="multiply", speed=1.2, size=1.5),
]),
]Picking an effect copies it into the person’s settings, so it keeps working with the plugin switched off. While the plugin is on and one of its effects is running, the keyboard reads effects(state) again when it opens and about once a second after that, and swaps in whatever changed. That is how an effect follows the state, the time or stats().
def effects(state):
# Rounded, so the effect only changes when the pace really does.
tempo = round(0.3 + min(stats()["wpm"], 120) / 40, 1)
return [
light_effect("tempo", "Tempo", colors=["#ff3d7f", "#ffb000"], layers=[
light_layer("wave", moves="both", speed=tempo, low=0.15),
]),
]The effects button in the editor lists what the script offers. To see one move, save the plugin, pick the effect under Effects > From plugins, and watch the keyboard at the top of that screen.
Round anything that jitters, like a typing rate. An effect that comes back different every second is swapped every second for nothing. An unknown pattern or keyword is a ValueError in the editor console, and an effect without layers is left out.
Looks
Seven hooks offer the app things to pick: key_styles, themes, popups, animations, backgrounds, trails and layouts. Each returns entries built with the calls below, and they appear under From plugins next to the built-in choices. Themes are the exception: they get their own Plugins tab in the theme editor. A look is numbers and words, not drawing code, so nothing in the script runs per frame. Picking one copies it into the person’s settings, so it keeps working with the plugin switched off, and picking a built-in choice afterwards puts it aside.
| Call | What it does |
|---|---|
key_style(id, name, material=, variant=, shape=, shadow=, cap=cap(...)) | A key style, applied to whatever theme is open in the theme editor, from its Style card. Only the fields it names change: material, variant, shape, glass, fan, the mechanical inner_radius, face_inset, edges, raised and light_angle, shadow (0 is flat) and outline. The colours stay the theme’s. |
cap(outline="round", corner=None, travel=2, layers=[cap_layer(...)]) | A painted key cap for a key style, stacked from cap_layer(...) entries. outline is round or rect, corner overrides the radius, and travel is how far the cap sinks when it is pressed. |
cap_layer(kind, paint, inset=0, x=0, y=0, blur=0, fade=None, when=[...]) | One layer of a painted cap. kind is fill, stroke or inner, paint takes the cap colour grammar, a color(...) or a gradient(...), and when limits the layer to any of pale, dark, pressed, resting and highlighted. moves=False holds the layer still while the cap travels. |
theme(id, name, background=, keys=, key_text=, style=key_style(...), ...) | A whole theme, on the Plugins tab of the theme editor. background, keys and key_text are required "#rrggbb" colours; special, special_text, accent, background_bottom (a fade toward the bottom), dark, font and weight are optional. style=key_style(...) sets its finish. Picking it installs it as a custom theme. |
popup_style(id, name, shape="tile", width=48, height=56, lift=30, ...) | The bubble over a pressed key, under Look > Popups. shape is "tile", "round" or "balloon"; width, height, lift and font_size are in points, and response and damping set its spring. |
entrance(id, name, opacity=0, x=0, y=0, scale=1, tilt=0, spin=0, ...) | How the keyboard arrives, under Look > Entrance. opacity, x, y, scale, tilt and spin are where it starts; it springs to rest with response and damping. |
press_animation(id, name, scale=, x=0, y=0, rotation=0, ...) | The shape of a held key, under Reactions > Geometry: scale (or scale_x and scale_y), x, y and rotation at full press. |
letter_animation(id, name, scale=, x=0, y=0, rotation=0, anchor="center") | The one-shot a letter plays on each tap, under Reactions > Letters: the same numbers at the peak, plus anchor. |
transition(id, name, x=0, y=0, scale=1, tilt=0, fade=True, duration=None) | The switch between letters, 123 and #+=, under Look > Transition. x and y are how far the old keys travel, as a share of the keyboard, and the new ones arrive from the mirror. It also takes scale, tilt, fade and duration. |
background(id, name, layers=[...], colors=[]) | An animated background, under Look > Background: up to four particles(...) layers and up to eight "#rrggbb" colours. |
particles(shape="glow", count=40, size=4, speed=20, direction="none", ...) | One layer of a background. shape is "dot", "glow", "streak", "ring" or "square"; count, size, speed, direction, spread, gravity, wobble, life, twinkle and opacity shape the drift, and burst throws particles from each pressed key. |
layout(id, name, rows=[...], left=[], right=[]) | A layout, under Layout > Arrangement. Each row is a list of keys: a string is a letter, layout_key(...) is anything else. left and right put up to three keys beside the space bar. Picking it installs an ordinary custom layout. |
layout_key(glyph, action="insert", width=1) | A key that isn’t a plain letter. action is one of insert, spacer, shift, delete, space, return, numbers, emoji, globe, tab, left, right, undo, redo or dismiss, and width is in keys. |
trail(id, name, layers=[...], colors=[]) | A swipe trail. layers are trail_line, trail_stamps and trail_head entries, drawn in order, and colors is the palette they index. |
trail_line(width=1, tail_width=1, color=-1, glow=0, dash=0, gap=0, band=0, flow=0) | The stroke down the swipe. tail_width thins the old end, color=-1 blends the whole palette along the line, and dash, gap, band and flow break it up or set it moving. |
trail_stamps(shape="dot", size=1, spacing=14, scatter=0, spin=0, twinkle=0, color=-1) | Shapes dropped along the swipe: dot, ring, square, diamond, star, spark or heart. spacing is the gap between them in points, scatter throws them off the line, spin turns them and twinkle fades them in and out. |
trail_head(shape="dot", size=1.5, pulse=0, opacity=1, color=-1, glow=0) | The mark at the fingertip. pulse makes it breathe and glow spreads light around it. |
def animations(state):
return [
entrance("swoop", "Swoop", y=90, scale=0.94, tilt=-20,
response=0.5, damping=0.72),
press_animation("dip", "Dip", scale=0.93, y=2),
letter_animation("bounce", "Bounce", y=-7, scale=1.12,
anchor="bottom"),
transition("glide", "Glide", x=0.3, scale=0.96),
]def backgrounds(state):
return [
background("snowfall", "Snowfall", colors=["#ffffff", "#cfe8ff"], layers=[
particles(shape="dot", count=70, size=2.2, speed=28,
direction="down", spread=12, wobble=10, life=9),
particles(shape="glow", count=0, size=3, life=1,
burst=8, burst_speed=90),
]),
]Return trail nodes from trails(state). Each trail has a colors palette and layers such as trail_line. With color=-1, the line blends the palette along the swipe. Enable the plugin, then select its trail under From plugins in the trail picker. Storing artwork in state does not draw a trail.
def trails(state):
return [trail("rainbow", "Rainbow", colors=["#ff0000", "#ff8800", "#ffff00", "#00cc44", "#0066ff", "#9900ff"],
layers=[trail_line(width=1.3, tail_width=0.2, color=-1)])]A look is copied when it’s picked, so changing the script later doesn’t change a look someone already uses; they pick it again. A misspelt keyword, a word outside its list or text where a number goes is an error in the editor console, and numbers are held to the ranges the app’s own controls use.
Art on the keys
key_art(state) returns a dict from key names to artwork, and the keyboard paints it inside the cap. Key names are the haptics ones, with the letters and keys fallbacks. Artwork is a list of shape(...), a single shape, an art([...]) layer or a list of layers, and each layer eases into its next drawing on its own clock, so one key can carry two things moving at different speeds.
| Call | What it does |
|---|---|
art(shapes, animate=0, curve="ease_out") | One animating layer. Hand over a new drawing and the layer eases into it over animate seconds along curve: ease_out, linear, ease_in, ease_in_out or spring. Several layers on one key each animate on their own. |
shape(kind, anchor="center", unit="pt", x=0, y=0, size=, width=, height=, fill=, stroke=, ...) | One drawn thing: a circle, rect, capsule, line, path, text or icon. It sits at x and y from an anchor on the key, in points or, with unit="key", in shares of the key. fill and stroke take a hex colour, "text", "accent", a color(...) or a gradient(...), and there are line_width, corner, trim_from, trim_to, rotation, opacity, blur and up to three shadows. |
graph(values, min=None, max=None, width=0.8, height=0.25, stroke=, fill=) | A numeric series expanded into ordinary shapes, to join onto a shape list or use on its own. The newest 60 finite samples are kept, the bounds default to the series, and fill adds a closed baseline path under the line. |
color(value, opacity=1) | A paint: a hex colour, or "text" or "accent" resolved against the key it lands on, at opacity. |
gradient(kind, colors, stops=[], start=, end=, center=, radius=0.5) | A linear or radial gradient through a list of colours. stops places them, start and end aim a linear one, center and radius place a radial one. |
shadow(color, radius=4, x=0, y=0) | A shadow under a shape, up to three of them. A wash that fills the key follows the cap's corners instead of squaring them off; a glow escapes the cap. |
The art is read again after every event the plugin hears, and that is the whole loop: change state in on_event(name, info, state), draw from it in key_art(state). events(state) names the events wanted and is read once when the plugin loads. Without it a plugin hears everything except key, key_down, key_up, predictions and tick, which run a script per press or per second and have to be asked for. Shift and plane changes redraw the art whether or not anyone listens, and they reach context() as well.
def initial():
return {"shift": "off"}
def events(state):
return ["shift"]
def on_event(name, info, state):
state["shift"] = info["state"]
return state
def key_art(state):
if state["shift"] != "locked":
return {}
lamp = shape("circle", anchor="top_right", x=-7, y=7, size=5,
fill="accent", shadow=shadow("accent", radius=4))
return {"shift": art([lamp], animate=0.12)}Shapes
circlerectcapsulelinepathtexticon
Sixteen shapes to a key, counted across every layer on it, and 64 points to a path; anything after that is dropped. A shape is drawn inside a box of its own width and height, and a box with no thickness paints nothing, so a horizontal line needs a small height of its own with its points down the middle of it (height=0.03, points=[[0, 0.5], [1, 0.5]]) or it silently does not appear.
Haptics, hitboxes and corrections
haptics(state) and hitboxes(state) return dicts keyed by key name: the letter itself, "space", "delete", "return", "shift" or "globe", then "letters" for any letter not named and "keys" for everything else. Keys a plugin leaves out keep the person’s own settings. Both tables are read when the keyboard opens and about once a second, so nothing runs per keystroke for them.
| Call | What it does |
|---|---|
feel(style=None, intensity=None, sharpness=None) | A haptic: style is "soft", "light", "medium", "heavy", "rigid" or "off", and intensity and sharpness (0 to 1) adjust it. A style word on its own works too. |
hitbox(scale=1, x=0, y=0) | A hit area: x and y move the key’s target by that share of its size, half a key at most, and scale grows or shrinks it. A bare number is a scale. |
def haptics(state):
return {
"space": "heavy",
"return": "rigid",
"delete": feel(intensity=0.45, sharpness=0.9),
}on_touch(key, x, y, state) runs after every tap, once the tap has been handled, with where on the key the finger came down. The position is measured against the key as drawn, not its moved target, so a plugin can move each key toward where it’s actually hit without chasing its own shift.
def initial():
return {"keys": {}}
def on_touch(key, x, y, state):
if len(key) != 1:
return state
n, ax, ay = state["keys"].get(key, [0, 0.0, 0.0])
n = min(n + 1, 50)
ax += (x - ax) / n
ay += (y - ay) / n
state["keys"][key] = [n, ax, ay]
return state
def hitboxes(state):
boxes = {}
for key in state["keys"]:
n, x, y = state["keys"][key]
if n >= 12:
boxes[key] = hitbox(x=round(x * 0.6, 2), y=round(y * 0.6, 2))
return boxessuggestions(word, state) puts words at the front of the bar while a word is typed. correct(word, fix, state) runs once per word when space ends it, with the keyboard’s own fix or None, even with autocorrect off. Return a word to commit it, False to keep the word as typed, or None to leave it to the keyboard. The first plugin with an answer wins, and delete right after undoes it like any autocorrect.
SHORT = {"brb": "be right back", "omw": "on my way", "idk": "I don't know"}
def suggestions(word, state):
long = SHORT.get(word.lower())
return [long] if long else []
def correct(word, fix, state):
if len(word) > 1 and word.isupper():
return False
return NoneNeither hook runs in password fields. The correction chip in the bar shows the keyboard’s own fix, not what correct would return, and a table changed in on_touch reaches the keyboard on the next beat.
Testing in the editor
The editor’s Preview is a stand-in keyboard. It draws settings(state) live, fires any hook on a tap with a sample word, key or field kind, shows the space bar as the plugin left it, and lists everything the plugin asked the keyboard for. stats() returns made-up numbers there, so a rate shows up without typing.
- Use the controls in the preview. Each one runs on_action and redraws.
- Tap the hook buttons in the order the keyboard would: on_open, then on_word or on_key a few times, then on_close.
- Read the console. Commands appear as you wrote them, print() lines under them, and an error names its line.
- Reload to start the state over. Editing the script does not reset it on its own.
The preview has no document, so insert() and replace() only log. To try those, save and type in any field with the keyboard open.
Limits
When a plugin comes in as a file or from a repository, Clink checks it before saving it. It has to be under 64,000 bytes and 1,600 lines, define at least one hook, import only the allowed modules, and contain none of these:
Not allowed in shared plugins
__exec(eval(open(compile(
Modules a shared plugin can import
jsonmathrandomresystime
Each hook call gets 2,000,000 interpreter steps, the same as a panel render. on_key runs on every keystroke, though, so heavy work there will slow down typing long before it hits that limit.
The file
A plugin is one JSON document. id is stable across updates, version is free text shown in the list, icon is an SF Symbol name, and source is the script with newlines escaped. Share it from the editor’s inspector, or import one with the arrow button on the Plugins tab.
{
"id": "word-count",
"name": "Word count",
"icon": "text.word.spacing",
"summary": "Counts words on the space bar",
"version": "1.0",
"author": "You",
"enabled": true,
"source": "def initial():\n return {\"on\": False}\n..."
}Plugins are shared as .clinkplugin files, a JSON document with the script inside. They’re published through a repository the same way panels are, with a folder of files, a manifest and a release. The official repository is anti-ltd/clink-plugins.
Repositories ›When something doesn’t happen
Most of the time it is one of these.
| Symptom | What to check |
|---|---|
| Nothing happens | Plugins are off at the top of the Plugins tab, the plugin is off in the list, or there is no Clink Pro membership. All three leave the keyboard running no plugins at all. |
| Space bar text is greyed out | A plugin has claimed it. The line under the field names which one; turn that plugin’s switch off, or the plugin itself, to get the field back. |
| The section is missing | The anchor is misspelt. Compare it with Show ids in the app, and remember the section still shows on the plugin’s own page, which is where to look first. |
| An element key is blank | The plugin behind it is off, uninstalled, or its draw(id, state) raised. The layout keeps the key either way, so it fills back in once the plugin is on again. Check the editor's console for the error. |
| An element never changes | draw is being asked again, so the state behind it isn't moving. Whatever feeds the face has to be updated from a hook: on_tick for something that changes on its own, on_word or on_key for something that follows typing. |
| set_setting() did nothing | A name that isn’t in the list above raises KeyError. A value of the wrong kind or out of range is skipped, and the editor’s console says what the setting expects. A gated setting also snaps back without a membership. |
| The count reset | The keyboard saves state when it closes, not on every key. A keyboard the system killed mid-session loses what its plugins counted since it opened. Keep totals in the app-side hooks when that matters. |
| Typing feels slow | Something heavy runs in on_key. Move it to on_word, do less of it, or cache what it computes in state. |
| An effect is missing from From plugins | Plugins has to be on and the plugin enabled, and effects(state) has to return a light_effect with at least one layer. Tap effects in the editor to see what came back, and look for a ValueError in the console. |
| A top bar item does nothing when it is used | A tap and a released knob both arrive at on_action(action, value, state), matched on the item id rather than its name. A knob writes a setting only when bar_knob names one in setting=; without that the value is the plugin's to do something with, and a button never writes a setting at all. |