Skip to content

Search

Every gadget participates in the launcher’s search pipeline through the search interface. This page covers the two search modes, how results flow from your gadget to the launcher, and what happens when the user activates one.

Catalog mode is for gadgets with a finite, pre-known set of items: application launchers, system commands, preference panes, bookmarks. You return the full list once from entries() and the host handles all fuzzy matching, scoring, and highlighting. The App Launcher and System Commands are built this way.

fn entries() -> Vec<CatalogEntry> {
vec![CatalogEntry {
id: "lock-screen".into(),
title: "Lock Screen".into(),
subtitle: Some("Lock the display immediately".into()),
icon: Some(EntryIcon::HeroIcon("lock-closed".into())),
keywords: vec!["lock".into(), "security".into()],
actions: vec![Action {
id: ActionId::Open,
label: "Run".into(),
}],
}]
}

On every keystroke, the host fuzzy-matches the user’s query against both title and keywords for every catalog entry across all active gadgets. Match positions on title are highlighted in the UI. Keyword matches contribute to the score but are not highlighted.

Use catalog mode whenever the full set of items is known upfront and the host’s built-in fuzzy matcher is sufficient.

Field Type Description
id string Unique within this gadget. Passed back in execute().
title string Display text, highlighted by the host on match.
subtitle option<string> Secondary text below the title.
icon option<entry-icon> See Icons.
keywords list<string> Extra match terms. Score without highlighting.
actions list<action> The first action is primary (Enter). See Actions.

Query mode is for gadgets where results are computed, fetched from an external source, or need matching logic the host’s fuzzy matcher cannot express: evaluating math expressions, searching a database, resolving URL shortcuts, calling an API. The host calls search() on every keystroke and the gadget returns its own scored results. The Calculator and Bangs are built this way.

fn search(query: String, matched_prefix: Option<String>) -> SearchResponse {
if query.is_empty() {
return SearchResponse::Nothing;
}
let results = find_matches(&query);
if results.is_empty() {
SearchResponse::Nothing
} else {
SearchResponse::Results(results)
}
}

Unlike catalog mode, you receive the raw query string and are responsible for matching, scoring, and producing ScoredEntry values. This gives you full control: call an API, query a database, run a custom matching algorithm, or use a library like nucleo for fuzzy matching.

Use query mode for computed results, external data sources, or any matching logic beyond what the host’s fuzzy matcher provides.

Both modes can coexist in the same gadget. The host calls both entries() and search() on every keystroke. Results from both are merged into the same result list.

Field Type Description
id string Unique within this gadget. Passed back in execute().
title string Display text.
subtitle option<string> Secondary text below the title.
icon option<entry-icon> See Icons.
score u32 Higher ranks higher in the result list.
title_highlight_positions list<u32> UTF-16 code unit offsets to highlight in the title.
subtitle_highlight_positions list<u32> UTF-16 code unit offsets to highlight in the subtitle.
actions list<action> The first action is primary (Enter). See Actions.
data option<string> Opaque payload round-tripped through execute().

The data field is particularly useful: the host never inspects it and passes it back unchanged when the user activates the result. You can attach any context your gadget needs for execution (a URL, a serialized struct, a database key) without maintaining external state.

A query-mode gadget can declare exclusive prefixes in its manifest:

manifest.toml
[gadget]
prefixes = ["="]

When the user’s query starts with a declared prefix, only that gadget’s search() runs. No other gadgets participate, and no catalog matching happens. The prefix is stripped from the query before it reaches your gadget, and matched_prefix carries the original prefix string so you know which one triggered:

fn search(query: String, matched_prefix: Option<String>) -> SearchResponse {
if let Some(prefix) = &matched_prefix {
// Prefix-exclusive mode: only this gadget is running.
// `query` has the prefix stripped (e.g. "42+23" not "=42+23").
return handle_prefix_query(&query, prefix);
}
// Normal mode: all gadgets are running.
handle_normal_query(&query)
}

The longest matching prefix wins. A gadget can declare multiple prefixes. Prefix routing is what the Calculator uses for = and the Emoji Picker uses for :.

Gadgets that declare prefixes also participate in normal (non-prefix) searches. If the query does not start with any declared prefix, search() is called with matched_prefix: None alongside all other gadgets. If your gadget only makes sense in prefix mode, return Nothing when no prefix matched:

fn search(query: String, matched_prefix: Option<String>) -> SearchResponse {
if matched_prefix.is_none() {
return SearchResponse::Nothing;
}
// Only reached in prefix-exclusive mode.
evaluate(&query)
}

search() returns a SearchResponse variant that tells the host what to do with the results:

Variant When to use
Nothing The gadget has nothing for this query. The host skips it.
Results(list) Standard scored results merged into the result list.
CustomUi(view_response) Replace the result list with a custom React view. Only allowed in prefix mode. See Frontend Views.
InlineUi(view_response) Render a React component above the result list. One gadget per search wins the slot. See Frontend Views.

Nothing and Results(vec![]) are semantically different. Nothing means the gadget is not participating in this query at all. Results(vec![]) means the gadget participated and explicitly produced zero results, which causes the host to clear any previous results from this gadget in the UI. Use Nothing when the query is irrelevant to your gadget. Use empty Results when the query is relevant but has no matches.

Beyond returning a list of scored entries, a gadget can take over part of the launcher UI with its own React component. There are two levels:

CustomUi views replace the entire result list with a gadget-owned surface. The Calculator uses this to show its history view when triggered via the = prefix, and the Emoji Picker uses it to render a browsable emoji grid. Custom views are only available in prefix mode, because they take over the full launcher content area and it would be disruptive to do that during a normal search where the user expects a mixed result list.

InlineUi views render a small component above the standard result list. The result list stays visible below. The Calculator uses this to show a quick result preview when it detects a math expression during normal (non-prefix) search. Inline views work in both prefix and normal mode, but only one gadget can claim the inline slot per search. If multiple gadgets return InlineUi, the first to arrive wins.

Both variants carry a ViewResponse that tells the host which React component to mount and what data to pass it:

SearchResponse::CustomUi(ViewResponse {
view: "history".into(),
data: Some(serde_json::to_string(&payload).unwrap()),
results: vec![],
})
Field Description
view Name matching a key in [frontend.views] (for custom) or [frontend.inline-views] (for inline) in the manifest.
data Optional JSON-encoded string forwarded to the React component as a prop. The gadget serializes whatever the view needs; the host passes it through without inspecting it.
results Scored entries to display alongside the view. Custom views typically pass an empty list for a full takeover. Inline views can include results that appear in the list below.

If CustomUi is returned outside of prefix mode, the host drops the view reference and logs a warning. The results are still merged into the result list, so results are not lost.

For details on building the React components themselves, see Frontend Views.

When the user activates a result, the host calls execute() with the full ScoredEntry (including the data field) and the ActionId of the action that was triggered:

fn execute(entry: ScoredEntry, action_id: ActionId) -> Result<PostAction, String> {
match action_id {
ActionId::Copy => {
clipboard::write_text(&entry.title)
.map_err(|e| format!("clipboard: {e:?}"))?;
Ok(PostAction::Dismiss)
}
ActionId::Open => {
opener::open_url(&entry.data.unwrap_or_default())
.map_err(|e| format!("open: {e:?}"))?;
Ok(PostAction::Dismiss)
}
_ => Ok(PostAction::Nothing),
}
}

execute() always receives a ScoredEntry. For query-mode results, this is exactly what your search() returned, including the data field. For catalog entries, the host constructs the ScoredEntry internally from your CatalogEntry after fuzzy matching. In both cases, entry.id matches what you originally provided, so you can dispatch by ID.

Returning Err(string) logs the error on the host side. The launcher stays open so the user can try again.

Variant Effect
Dismiss Hide the launcher (same as pressing Escape).
Nothing Leave the launcher as-is.
KeepOpen Same as Nothing. Exists as a semantic signal for multi-select workflows.

Actions define what happens when the user interacts with a search result. Each entry carries a list of Action values. The launcher’s footer bar renders these as available operations, and execute() receives the chosen ActionId so the gadget knows which action to perform.

The first action in the list is the primary action, triggered by pressing Enter. Its label is shown in the footer bar as the default operation. All remaining actions with a keybinding appear as secondary hints in the footer alongside their shortcut. Actions without a keybinding are not shown in the footer.

For example, the Bangs gadget returns two actions per result:

actions: vec![
Action {
id: ActionId::Open,
label: "Open in Browser".into(),
},
Action {
id: ActionId::Copy,
label: "Copy URL".into(),
},
],

The user sees “Open in Browser” as the primary action on Enter, and “Copy URL” as a secondary hint with the Cmd+C shortcut (assigned automatically by the host because the action uses ActionId::Copy).

Gadgets never set keybindings themselves. Instead, the host assigns default keybindings and icons based on the ActionId variant. Using a well-known variant means your action automatically gets the right shortcut and integrates consistently with the rest of the launcher:

Variant Default keybinding Typical use
Open none (primary, Enter) Open a URL, launch an app, run a command.
Copy Cmd+C Copy text to the clipboard.
Reveal Cmd+Shift+R Show a file in the OS file manager.
OpenWith Cmd+Shift+O Open with an alternative handler.
Delete Cmd+Backspace Remove the entry (e.g. delete a clipboard history item).
OpenSettings Cmd+, Jump to this gadget’s settings panel.

Choose the variant that matches the semantic intent of your action. The host does not enforce what the action actually does in execute(), but using the matching variant means users get the keyboard shortcut they expect.

For gadget-specific operations that don’t map to any well-known variant, use Custom(string) as the primary action:

actions: vec![
Action {
id: ActionId::Custom("connect".into()),
label: "Connect".into(),
},
Action {
id: ActionId::Copy,
label: "Copy Address".into(),
},
],

This entry has a custom “Connect” as its primary action (Enter) and a well-known Copy as a secondary action (reachable via Cmd+C). In execute(), match on the custom string to dispatch:

fn execute(entry: ScoredEntry, action_id: ActionId) -> Result<PostAction, String> {
match action_id {
ActionId::Custom(ref name) if name == "connect" => {
// handle connect
Ok(PostAction::Dismiss)
}
ActionId::Copy => {
clipboard::write_text(&entry.title)
.map_err(|e| format!("clipboard: {e:?}"))?;
Ok(PostAction::Dismiss)
}
_ => Ok(PostAction::Nothing),
}
}

Each entry can carry an EntryIcon for display in the result list:

Variant Value Example
HeroIcon(name) A Heroicons icon by name. "calculator", "globe-alt"
Emoji(char) A single Unicode emoji rendered as text. "👋", "🔥"
DataUrl(url) A base64-encoded inline image. "data:image/png;base64,..."
AssetIcon(path) A file bundled in the gadget archive, relative to the gadget root. "assets/icon.svg"

HeroIcon is the most common choice for gadgets that don’t ship custom artwork. AssetIcon resolves to a file inside the gadget’s .torchsnap archive (or development directory) and is served to the launcher through a custom protocol.