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
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> { 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.
CatalogEntry fields
Section titled “CatalogEntry fields”| 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
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 { 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. 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.
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 { 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 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 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.
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. |
Actions
Section titled “Actions”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.
How actions are displayed
Section titled “How actions are displayed”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).
Well-known ActionId variants
Section titled “Well-known ActionId variants”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.
Custom actions
Section titled “Custom actions”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.