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.

In Rust, a gadget implements the SDK’s Search trait. Besides its three functions, the trait has an associated type, Command, that you define: each action on an entry carries a Command value, and when the user runs the action, execute() receives that value. Commands explains the details.

use serde::{Deserialize, Serialize};
use torchsnap_gadget_sdk::prelude::*;
#[derive(Serialize, Deserialize)]
enum Command {
LockScreen,
}
impl Search for ScreenTools {
type Command = Command;
fn entries() -> Vec<CatalogEntry<Command>> { /* catalog mode */ }
fn search(query: String, matched_prefix: Option<String>) -> SearchResponse<Command> { /* query mode */ }
fn execute(command: Command) -> Result<PostAction, String> { /* run an action */ }
}

entries() has a default implementation that returns no entries, so a gadget that only uses query mode can leave it out.

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<Command>> {
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: Actions::new().primary("Lock", Command::LockScreen),
}]
}

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. The host records which entries the user picks by this ID (frecency).
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 entry-actions One optional action per slot. primary runs on 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<Command> {
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. The host records which entries the user picks by this ID (frecency).
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 entry-actions One optional action per slot. primary runs on Enter. See Actions.

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<Command> {
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<Command> {
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 runs an action, the host calls execute() with that action’s command. The Bangs gadget, for example, opens or copies the URL its command carries:

fn execute(command: Command) -> Result<PostAction, String> {
match command {
Command::OpenUrl(url) => {
opener::open_url(&url).map_err(|e| format!("open URL: {e}"))?;
}
Command::CopyUrl(url) => {
clipboard::write_text(&url).map_err(|e| format!("copy URL to clipboard: {e}"))?;
}
}
Ok(PostAction::Dismiss)
}

execute() receives only the command. It does not see the entry or the slot the action sits in. Put everything the action needs into the command.

Returning Err(string) logs the error on the host side. The launcher stays open so the user can try again, and its footer shows <entry title> failed: <message> until the user’s next key press or action. A command that does not decode into your Command type becomes an Err before your execute() runs.

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.
OpenSettings Hide the launcher and open the Settings window on this gadget’s section. A gadget without a settings section of its own gets the Gadgets page.

An entry’s actions field holds up to six actions, one per slot. The slot decides which key runs the action and which label the launcher shows if you give none. The launcher’s footer bar shows the selected entry’s primary action and a hint for every other filled slot.

Slot Key Default label
primary Enter, or a click on the entry None. Give a label.
secondary Cmd+Enter None. Give a label.
copy Cmd+C “Copy”
reveal Cmd+Shift+R “Reveal in Finder”
delete Cmd+Backspace “Delete”
open_settings Cmd+, “Open settings”

On platforms other than macOS, Ctrl takes the place of Cmd. The WIT names the last slot open-settings. The default labels are the same English text on every platform.

Gadgets do not set keybindings. An empty slot has no key and no footer hint. A primary or secondary action without a label is shown with the entry’s title.

Actions::new() starts with every slot empty. Each slot has a builder method that fills it:

  • .primary(label, command) and .secondary(label, command) take a label, as these two slots have no default.
  • .copy(action), .reveal(action), .delete(action) and .open_settings(action) take an Action. Action::new(command) shows the slot’s default label, and Action::labeled(label, command) shows your own.

The Bangs gadget gives each result two actions:

actions: Actions::new()
.primary("Open in Browser", Command::OpenUrl(url.to_string()))
.copy(Action::labeled("Copy URL", Command::CopyUrl(url.to_string()))),

The user sees “Open in Browser” as the primary action on Enter, and “Copy URL” as a hint with Cmd+C.

The same command can fill several slots. When copying is an entry’s main action, put it in primary and in copy, and both Enter and Cmd+C copy:

actions: Actions::new()
.primary("Copy", Command::CopyName(name.to_string()))
.copy(Action::new(Command::CopyName(name.to_string()))),

Calling a builder method twice for the same slot replaces the first action. Actions is also a struct with one public Option field per slot, so you can write it as a struct literal with ..Default::default() instead. There, filling a slot twice does not compile.

actions.iter() yields the filled slots as (Slot, &Action) pairs, in the order of the table above.

A command is a value of your gadget’s Command type. It says what running an action does and carries the data the action needs, such as a URL or a network ID. The host stores it with the entry, never inspects it, and hands it back unchanged to execute().

Command must implement serde’s Serialize and Deserialize. The SDK encodes each command to JSON when your entries leave the gadget and decodes it again before execute() runs. If a command fails to encode, the SDK logs a warning and drops the entry.

The ZeroTier gadget has one variant per thing its entries can do. A network entry’s actions, simplified from that gadget:

#[derive(Serialize, Deserialize)]
enum Command {
/// Join the network, or leave it if joined.
Toggle(String),
CopyId(String),
/// Leave the network if joined and remove it from the history.
Forget(String),
OpenSettings,
}
fn network_actions(network_id: &str, label: &str) -> Actions<Command> {
Actions::new()
.primary(label, Command::Toggle(network_id.to_string()))
.copy(Action::labeled("Copy network id", Command::CopyId(network_id.to_string())))
.delete(Action::labeled("Forget", Command::Forget(network_id.to_string())))
}

At the WIT level, a command is a plain string. A gadget that implements the WIT export without the Rust SDK chooses its own encoding.

The host passes the command in the open_settings slot to execute() like any other. To open the Settings window on your gadget’s section, return PostAction::OpenSettings for it.

When an entry exists to send the user to a settings field, such as “API token missing”, put that command in primary so that Enter opens the settings. ZeroTier’s “token not configured” entry does this:

// In search():
actions: Actions::new().primary("Open settings", Command::OpenSettings),
// In execute():
Command::OpenSettings => Ok(PostAction::OpenSettings),

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"
AppIcon(id) A platform application identifier. The launcher shows that application’s real icon. "com.if.Amphetamine"

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. AppIcon needs the icon-cache = true manifest permission, and the entry shows without an icon if the launcher cannot find the application. Awake uses it to show Amphetamine’s own icon.