From 79f0bce1a9efaace92bfcf81ac8b85d025f59b9d Mon Sep 17 00:00:00 2001 From: Prad Nukala Date: Mon, 13 Jul 2026 11:43:33 -0400 Subject: [PATCH] feat(core): add example plugin with declarative and imperative API examples --- declarative.luau | 43 ++++++++++++++++++++ finder.luau | 77 +++++++++++++++++++++++++++++++++++ hello.luau | 67 +++++++++++++++++++++++++++++++ panel.luau | 102 +++++++++++++++++++++++++++++++++++++++++++++++ shortcut.luau | 29 ++++++++++++++ ticker.luau | 15 +++++++ 6 files changed, 333 insertions(+) create mode 100644 declarative.luau create mode 100644 finder.luau create mode 100644 hello.luau create mode 100644 panel.luau create mode 100644 shortcut.luau create mode 100644 ticker.luau diff --git a/declarative.luau b/declarative.luau new file mode 100644 index 0000000..b8890bd --- /dev/null +++ b/declarative.luau @@ -0,0 +1,43 @@ +--!nonstrict +-- Declarative bar widget: renders a ui.* tree with barWidget.render() instead +-- of the imperative setText/setGlyph patches (hello.luau). The tree replaces +-- the built-in glyph/text row. Pointer controls (ui.button, ui.toggle, …) work +-- inline and consume their own clicks; the widget-level onClick still fires on +-- the rest of the capsule. Keyboard controls (ui.input/ui.select/ui.scroll) +-- are not available in the bar. The capsule clips to bar thickness, so keep +-- the tree one control tall. + +local clicks = 0 +local tick = noctalia.state.get("tick") or 0 + +local function render() + -- Stack along the bar: a row in a horizontal bar, a column in a side bar. + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 6, align = "center" }, { + ui.glyph({ name = "sparkles", size = 14, color = "secondary" }), + -- A filled rounded badge — something the imperative setText/setGlyph API + -- cannot draw. "secondary/0.25" is the secondary role at 25% alpha, so the + -- pill stays theme-aware while the text on it remains fully opaque. + ui.row({ fill = "secondary/0.25", radius = 8, paddingH = 6, align = "center" }, { + ui.label({ text = tostring(tick), color = "secondary", fontWeight = "bold" }), + }), + ui.button({ glyph = "plus", glyphSize = 12, variant = "ghost", onClick = "onPlus" }), + })) +end + +-- Live updates from the plugin's background service (ticker.luau). +noctalia.state.watch("tick", function(value) + tick = value + render() +end) + +function update() + noctalia.setUpdateInterval(1000) + render() +end + +-- Inner-control callback: fires only when the ui.button is clicked. +function onPlus() + clicks += 1 + noctalia.notify(noctalia.tr("title"), noctalia.trp("clicks", clicks)) +end diff --git a/finder.luau b/finder.luau new file mode 100644 index 0000000..a87b133 --- /dev/null +++ b/finder.luau @@ -0,0 +1,77 @@ +--!nonstrict +-- Launcher provider for the example plugin. +-- +-- Type `/ex` in the launcher. The top level lists categories; pressing Enter on +-- one calls launcher.setQuery() to rewrite the input (e.g. `/ex fruits `) and +-- drill into it WITHOUT closing the launcher. Pressing Enter on a leaf item +-- copies it and lets the launcher close normally. +-- +-- launcher.setQuery() takes effect when called from a handler while the launcher +-- is open. Called synchronously from onActivate it keeps the panel open and +-- rewrites the input instead of closing — the basis for autocomplete / drill-in. + +local PREFIX = "/ex" + +local CATEGORIES = { + fruits = { "Apple", "Apricot", "Banana", "Cherry" }, + animals = { "Aardvark", "Bear", "Cat", "Dolphin" }, + colors = { "Amber", "Azure", "Crimson", "Teal" }, +} + +-- Top level: one row per category. Activating a row drills into it. +local function categoryRows() + local rows = {} + for name in CATEGORIES do + table.insert(rows, { + id = "category:" .. name, + title = name, + subtitle = "Browse " .. name .. " — press Enter to open", + glyph = "folder", + }) + end + return rows +end + +-- Inside a category: the items matching the text typed after the category name. +local function itemRows(category, filter) + local rows = {} + for _, item in CATEGORIES[category] do + if filter == "" or item:lower():find(filter:lower(), 1, true) then + table.insert(rows, { + id = item, + title = item, + subtitle = category .. " — press Enter to copy", + glyph = "tag", + }) + end + end + if #rows == 0 then + rows[1] = { id = "", title = "No matches", subtitle = category, glyph = "mood-empty" } + end + return rows +end + +-- `query` is the launcher text after the `/ex` prefix, leading space trimmed. +function onQuery(query) + local text = noctalia.string.trim(query) + local category, rest = text:match("^(%S+)%s*(.*)$") + if category and CATEGORIES[category] then + launcher.setResults(query, itemRows(category, noctalia.string.trim(rest))) + else + launcher.setResults(query, categoryRows()) + end +end + +function onActivate(id) + local category = id:match("^category:(.+)$") + if category then + -- Drill in: rewrite the launcher input (note the prefix and trailing space) + -- and stay open so the user can keep filtering within the category. + launcher.setQuery(PREFIX .. " " .. category .. " ") + return + end + if id ~= "" then + noctalia.copyToClipboard(id, "text/plain") + noctalia.notify("Example", id .. " copied to clipboard") + end +end diff --git a/hello.luau b/hello.luau new file mode 100644 index 0000000..78b5480 --- /dev/null +++ b/hello.luau @@ -0,0 +1,67 @@ +--!nonstrict +-- Minimal reference plugin entry: a bar widget that shows a configurable label +-- and glyph, counts clicks, and accepts IPC to change its text at runtime. +-- +-- Demonstrates the capability surface: +-- barWidget.* presentation (text / glyph / tooltip) +-- barWidget.getConfig settings (seeded from the manifest; single-arg, no fallback) +-- noctalia.tr / trp translation against this plugin's translations/.json +-- noctalia.readFile filesystem, resolved relative to the plugin's own dir +-- noctalia.http async HTTP (off-thread; response delivered back as a callback) +-- noctalia.state.* shared data published by this plugin's [[service]] (ticker.luau) +-- noctalia.notify host notification +-- onIpc shell-triggered events +-- +-- Drive it from the shell: +-- noctalia msg plugin noctalia/example:hello focused set "Hi there" +-- noctalia msg plugin noctalia/example:hello focused fetch "https://example.com" + +local label = barWidget.getConfig("label") +local glyph = barWidget.getConfig("glyph") +local showGlyph = barWidget.getConfig("show_glyph") + +local clicks = 0 +local dataNote = noctalia.readFile("data.txt") or "(no data file)" +local tick = noctalia.state.get("tick") or 0 + +local function render() + if showGlyph then + barWidget.setGlyph(glyph) + end + barWidget.setText(label) + barWidget.setTooltip(`{noctalia.trp("clicks", clicks)} — {dataNote} — service tick {tick}`) +end + +-- Live updates from the plugin's background service (ticker.luau). +noctalia.state.watch("tick", function(value) + tick = value + render() +end) + +-- Called on the widget's update interval. Static here, so slow the ticks right down. +function update() + noctalia.setUpdateInterval(1000) + render() +end + +function onClick() + clicks += 1 + noctalia.notify(noctalia.tr("title"), noctalia.trp("clicks", clicks)) + render() +end + +function onIpc(event, payload) + if event == "set" then + label = payload + render() + elseif event == "fetch" then + -- Async: the request runs off-thread and the callback fires when it lands. + noctalia.http({ url = payload }, function(res) + if res.ok then + noctalia.notify(noctalia.tr("title"), `HTTP {res.status} — {#res.body} bytes`) + else + noctalia.notify(noctalia.tr("title"), "HTTP request failed") + end + end) + end +end diff --git a/panel.luau b/panel.luau new file mode 100644 index 0000000..df8973e --- /dev/null +++ b/panel.luau @@ -0,0 +1,102 @@ +--!nonstrict +-- Reference [[panel]] entry — a settings-style panel of interactive controls. +-- +-- Panels describe their UI declaratively, exactly like desktop widgets: build a +-- tree with the ui.* constructors and hand it to panel.render(). The host diffs +-- the tree against the previous one and updates retained native controls, so +-- rendering an unchanged tree is free. +-- +-- panel.render(tree) replace the panel's UI tree +-- panel.close() ask the host to close this panel +-- panel.getConfig(key) settings (seeded from the manifest) +-- button/toggle/slider/... onClick / onChange call the plugin global of +-- that name; values arrive as string args +-- +-- Layout notes: +-- * a Flex (column/row/scroll) centers its children on the cross axis by +-- default — pass align = "stretch" so controls fill the panel width. +-- * panel size is host-owned: declare width/height on the [[panel]] manifest +-- entry, so the surface is the same size on every open. +-- +-- Stateful controls follow a controlled/uncontrolled split: +-- * toggle / slider / select are value-driven — pass the current value each +-- render; the callback reports the user's new value. +-- * input is uncontrolled — `value` seeds the field once; the host owns the +-- text buffer afterward, so re-rendering never clobbers what the user typed. +-- Observe edits via onChange (live) / onSubmit (Enter). + +local enabled = true +local volume = 50 +local greeting = "" +local themeIndex = 0 +local themes = { "Dark", "Light", "System" } + +-- A labelled section: a heading above a control. ui.column stretches children to +-- the panel width by default, so the control fills the row. +local function section(title, control) + return ui.column({ gap = 8 }, { + ui.label({ text = title, fontWeight = "medium", color = "on_surface_variant" }), + control, + }) +end + +local function render() + -- The panel surface already insets its content (Style::panelPadding), so this + -- adds gaps between rows, not its own outer padding. + panel.render(ui.column({ flexGrow = 1, gap = 16 }, { + -- Header: title on the left, close button pinned to the top right — + -- matching the built-in panels (no divider rule). + ui.row({ align = "center", justify = "space_between", gap = 8 }, { + ui.label({ text = "Example Panel", fontSize = 16, fontWeight = "bold", color = "on_surface", flexGrow = 1 }), + ui.button({ glyph = "close", onClick = "onCloseClicked" }), + }), + + -- Body: a scrollable stack of settings. + ui.scroll({ flexGrow = 1, gap = 16 }, { + section("Notifications", ui.row({ gap = 12, align = "center" }, { + ui.toggle({ checked = enabled, onChange = "onToggle" }), + ui.label({ text = enabled and "Enabled" or "Disabled", flexGrow = 1 }), + })), + + section(`Volume — {volume}%`, ui.slider({ + min = 0, max = 100, step = 1, value = volume, onChange = "onVolume", + })), + + section("Your name (press Enter to greet)", ui.column({ gap = 8 }, { + ui.input({ key = "name", placeholder = "Type a name…", onSubmit = "onNameSubmit" }), + ui.label({ text = greeting, color = "primary" }), + })), + + section("Theme", ui.select({ options = themes, selectedIndex = themeIndex, onChange = "onTheme" })), + }), + })) +end + +function onOpen(_context) + render() +end + +function onToggle(value) + enabled = value == "true" + render() +end + +function onVolume(value) + volume = math.floor(tonumber(value) or 0) + render() +end + +function onNameSubmit(value) + greeting = value ~= "" and `Hello, {value}!` or "" + noctalia.notify("Example Panel", greeting) + render() +end + +function onTheme(index, _text) + themeIndex = tonumber(index) or 0 + render() +end + +function onCloseClicked() + panel.close() +end diff --git a/shortcut.luau b/shortcut.luau new file mode 100644 index 0000000..a0edd89 --- /dev/null +++ b/shortcut.luau @@ -0,0 +1,29 @@ +--!nonstrict +-- Control-center [[shortcut]] entry: a quick-toggle tile. +-- +-- shortcut.setLabel(text) +-- shortcut.setIcon(on [, off]) -- one glyph, or distinct on/off glyphs +-- shortcut.setActive(bool) -- toggle/highlight state +-- shortcut.setEnabled(bool) +-- onClick() / onRightClick() +-- +-- State is shared via noctalia.state, so the plugin's other entries (widget, +-- service) could react to the toggle. + +local on = noctalia.state.get("toggle") == true + +local function render() + shortcut.setLabel(if on then "Example On" else "Example Off") + shortcut.setIcon("puzzle") + shortcut.setActive(on) + shortcut.setEnabled(true) +end + +-- Initial state at load. +render() + +function onClick() + on = not on + noctalia.state.set("toggle", on) + render() +end diff --git a/ticker.luau b/ticker.luau new file mode 100644 index 0000000..051eabb --- /dev/null +++ b/ticker.luau @@ -0,0 +1,15 @@ +--!nonstrict +-- Headless [[service]] entry: runs in the background with no UI, ticking once a +-- second and publishing an incrementing counter to the plugin's shared state. +-- Any of this plugin's entries (the hello widget) can read it with +-- noctalia.state.get / noctalia.state.watch. + +local count = 0 + +-- Drive this service's own tick rate +noctalia.setUpdateInterval(1000) + +function update() + count += 1 + noctalia.state.set("tick", count) +end