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.
The Search trait
Section titled “The Search trait”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
Section titled “Catalog mode”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.
CatalogEntry fields
Section titled “CatalogEntry fields”| 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
Section titled “Query mode”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.
ScoredEntry fields
Section titled “ScoredEntry fields”| 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. |
Prefix routing
Section titled “Prefix routing”A query-mode gadget can declare exclusive prefixes in its manifest:
[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 responses
Section titled “Search responses”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 vs empty Results
Section titled “Nothing vs empty Results”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.
Custom and inline views
Section titled “Custom and inline views”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.
Execution
Section titled “Execution”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.
PostAction
Section titled “PostAction”| 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. |
Actions
Section titled “Actions”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.
Building actions
Section titled “Building actions”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 anAction.Action::new(command)shows the slot’s default label, andAction::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.
Commands
Section titled “Commands”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.
Opening your settings
Section titled “Opening your settings”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.