mirror of
https://github.com/prdlk/noctalia-ostt.git
synced 2026-08-02 09:31:38 +00:00
feat(core): add example plugin with declarative and imperative API examples
This commit is contained in:
@@ -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
|
||||
+77
@@ -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
|
||||
+67
@@ -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/<lang>.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
|
||||
+102
@@ -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
|
||||
@@ -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
|
||||
+15
@@ -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
|
||||
Reference in New Issue
Block a user