diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml index 2bbdbd1..3bf7606 100644 --- a/.github/workflows/plugins.yml +++ b/.github/workflows/plugins.yml @@ -42,6 +42,7 @@ jobs: - http-codes - obsidian - paper-size + - tutorial - xkcd steps: diff --git a/Cargo.toml b/Cargo.toml index 7833f46..6930dfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,8 @@ resources = [ { src = "plugins/quick-links/manifest.json", target = "plugins/quick-links/manifest.json" }, { src = "plugins/quick-links/plugin.js", target = "plugins/quick-links/plugin.js" }, { src = "plugins/quick-links/links.json", target = "plugins/quick-links/links.json" }, + { src = "plugins/tutorial/manifest.json", target = "plugins/tutorial/manifest.json" }, + { src = "plugins/tutorial/plugin.js", target = "plugins/tutorial/plugin.js" }, { src = "plugins/unit-conversions/manifest.json", target = "plugins/unit-conversions/manifest.json" }, { src = "plugins/unit-conversions/plugin.js", target = "plugins/unit-conversions/plugin.js" }, { src = "plugins/web-search/manifest.json", target = "plugins/web-search/manifest.json" }, diff --git a/README.md b/README.md index 40c4c1c..21666c7 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,15 @@ Type to query, ↑/↓ to highlight, Enter to invoke, Esc to dismiss. The Core built-in handles `settings`, `install `, `reload`, `update`, `shutdown`, `sleep`, `lock`, etc. +`highbeam --query ""` opens with the query box pre-filled (forwarded +to a running daemon when one exists). + ## Plugins Single-directory `manifest.json` + `plugin.js`. Reference plugins -under `plugins/`; bundled ones get seeded on first launch. Authoring +under `plugins/`; bundled ones get seeded on first launch. The bundled +`tutorial` opens automatically on the first launch; type `tutorial` (or +`help`) to revisit it. Authoring guide: [docs/plugin-authoring.md](docs/plugin-authoring.md). API reference: [docs/sdk-reference.md](docs/sdk-reference.md). Dynamic, stateful screens: [docs/views.md](docs/views.md). diff --git a/plugins/package-lock.json b/plugins/package-lock.json index b6dfb3c..2f735bb 100644 --- a/plugins/package-lock.json +++ b/plugins/package-lock.json @@ -20,7 +20,7 @@ }, "app-launcher": { "name": "@high-beam-plugin/app-launcher", - "version": "0.1.0" + "version": "0.1.1" }, "bitwarden": { "name": "@high-beam-plugin/bitwarden", @@ -161,6 +161,10 @@ "resolved": "prefpanes", "link": true }, + "node_modules/@high-beam-plugin/tutorial": { + "resolved": "tutorial", + "link": true + }, "node_modules/@high-beam-plugin/unit-conversions": { "resolved": "unit-conversions", "link": true @@ -1443,6 +1447,10 @@ "name": "high-beam-plugin-quick-links", "version": "0.1.0" }, + "tutorial": { + "name": "@high-beam-plugin/tutorial", + "version": "0.1.0" + }, "unit-conversions": { "name": "@high-beam-plugin/unit-conversions", "version": "0.1.0" diff --git a/plugins/tutorial/manifest.json b/plugins/tutorial/manifest.json new file mode 100644 index 0000000..5ff59be --- /dev/null +++ b/plugins/tutorial/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "tutorial", + "displayName": "Tutorial", + "version": "0.1.0", + "description": "First-launch getting-started guide. Shown automatically the first time High Beam runs; reachable any time by typing 'tutorial', 'help', or 'welcome'.", + "entry": "plugin.js", + "timeoutMs": 500, + "memoryMb": 32, + "capabilities": ["actions"], + "defaultEnabled": true +} diff --git a/plugins/tutorial/package.json b/plugins/tutorial/package.json new file mode 100644 index 0000000..4d6ffa2 --- /dev/null +++ b/plugins/tutorial/package.json @@ -0,0 +1,8 @@ +{ + "name": "@high-beam-plugin/tutorial", + "version": "0.1.0", + "type": "module", + "scripts": { + "test": "vitest run" + } +} diff --git a/plugins/tutorial/plugin.js b/plugins/tutorial/plugin.js new file mode 100644 index 0000000..37f1a3b --- /dev/null +++ b/plugins/tutorial/plugin.js @@ -0,0 +1,57 @@ +// tutorial — first-launch getting-started guide. +// +// Shown automatically the first time High Beam runs (the daemon fires the +// preset `tutorial` query and auto-opens this view). Reachable any time by +// typing `tutorial`, `help`, or `welcome`. + +import { showView, openUrl, closeView } from 'highbeam:actions'; +import { Heading, Text, Divider, Stack, Button } from 'highbeam:view'; + +const REPO_URL = 'https://github.com/Mechazawa/high-beam'; + +export const TutorialView = { + setup: () => ({}), + + render() { + return { + title: 'Welcome to High Beam', + body: [ + Heading({ text: 'Welcome to High Beam' }), + Text({ text: 'A keyboard launcher. Here is everything you need to get going.' }), + Divider(), + Text({ text: 'Open it', size: 'lg' }), + Text({ text: 'macOS: Shift+Space. Linux: bind highbeam --open to a hotkey.', tone: 'muted' }), + Text({ text: 'Search', size: 'lg' }), + Text({ text: 'Type to query, Up/Down to highlight, Enter to run, Esc to dismiss.', tone: 'muted' }), + Text({ text: 'Built-in verbs', size: 'lg' }), + Text({ text: 'Type settings, install , reload, or update.', tone: 'muted' }), + Text({ text: 'Plugins', size: 'lg' }), + Text({ text: 'Features are single-file JS plugins. Add more with install .', tone: 'muted' }), + Divider(), + Stack({ + direction: 'h', + gap: 'sm', + children: [ + Button({ label: 'Read the docs', tone: 'primary', onClick: openUrl(REPO_URL) }), + Button({ label: 'Got it', onClick: closeView }), + ], + }), + ], + }; + }, +}; + +const KEYWORDS = ['tutorial', 'help', 'welcome']; + +export async function* query(input) { + const q = input.trim().toLowerCase(); + if (q.length < 3) return; + if (!KEYWORDS.some((keyword) => keyword.startsWith(q))) return; + yield { + key: 'tutorial-open', + title: 'High Beam tutorial', + subtitle: 'Getting started with the launcher', + weight: 100, + action: showView(TutorialView), + }; +} diff --git a/plugins/tutorial/tutorial.test.js b/plugins/tutorial/tutorial.test.js new file mode 100644 index 0000000..70e3846 --- /dev/null +++ b/plugins/tutorial/tutorial.test.js @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { query, TutorialView } from './plugin.js'; + +async function collect(iter) { + const out = []; + for await (const item of iter) out.push(item); + return out; +} + +describe('tutorial plugin', () => { + it('answers the tutorial / help / welcome keywords (and their prefixes)', async () => { + for (const q of ['tutorial', 'help', 'welcome', 'tut', 'hel']) { + const results = await collect(query(q)); + expect(results, q).toHaveLength(1); + expect(results[0]).toMatchObject({ + key: 'tutorial-open', + action: { kind: 'showView' }, + }); + } + }); + + it('stays silent for unrelated or too-short queries', async () => { + for (const q of ['', 'a', 'to', 'calc', 'smoke']) { + expect(await collect(query(q)), q).toHaveLength(0); + } + }); + + it('renders the getting-started view with action buttons', () => { + const tree = TutorialView.render.call(TutorialView.setup()); + + expect(tree.title).toBe('Welcome to High Beam'); + expect(tree.body[0]).toMatchObject({ kind: 'heading', text: 'Welcome to High Beam' }); + + const actions = tree.body.find((block) => block.kind === 'stack'); + const buttons = actions.children.filter((child) => child.kind === 'button'); + expect(buttons).toHaveLength(2); + expect(buttons.map((b) => b.onClick.kind)).toEqual(['openUrl', 'closeView']); + }); +}); diff --git a/plugins/tutorial/vitest.config.ts b/plugins/tutorial/vitest.config.ts new file mode 100644 index 0000000..281d66e --- /dev/null +++ b/plugins/tutorial/vitest.config.ts @@ -0,0 +1,2 @@ +import config from '../../sdk/highbeam/vitest.config.example'; +export default config; diff --git a/src/app/callbacks.rs b/src/app/callbacks.rs index a89c6c4..bd25b42 100644 --- a/src/app/callbacks.rs +++ b/src/app/callbacks.rs @@ -347,8 +347,14 @@ fn invoke_selected( ) { let Some(w) = weak.upgrade() else { return }; - let idx = usize::try_from(w.get_selected_index().max(0)).unwrap_or(0); - let query_text = w.get_query_text().to_string(); + // A non-empty `auto-invoke-plugin` means this fire was machine-initiated + // by the first-launch tutorial, not a user pressing Enter. Resolve the + // target by plugin name against the live `latest` (not `selected-index`, + // which is read from a possibly-stale render snapshot) so a yield that + // re-sorted the list can't make us run the wrong result; leave the flag + // armed until the target actually appears so a later render retries. + let auto_target = w.get_auto_invoke_plugin(); + let is_auto = !auto_target.is_empty(); let snapshot = match latest.lock() { Ok(s) => s, Err(err) => { @@ -356,6 +362,15 @@ fn invoke_selected( return; } }; + let idx = if is_auto { + let Some(found) = snapshot.iter().position(|r| r.plugin_name == auto_target.as_str()) else { + return; + }; + + found + } else { + usize::try_from(w.get_selected_index().max(0)).unwrap_or(0) + }; let Some(picked) = snapshot.get(idx) else { return; }; @@ -376,18 +391,25 @@ fn invoke_selected( let result_key = picked.result.key.clone(); drop(snapshot); - // Push the query to history before the action runs — if the action hides - // the window we still want the entry recorded. - push_history( - history_db, - history_state, - &query_text, - settings.query_history_max_entries(), - ); + if is_auto { + // One-shot: now that the target resolved we've committed to firing. + w.set_auto_invoke_plugin(slint::SharedString::new()); + } else { + // Push the query to history before the action runs — if the action + // hides the window we still want the entry recorded. The synthetic + // auto-invoke is skipped: it must not seed history or earn frecency. + let query_text = w.get_query_text().to_string(); + push_history( + history_db, + history_state, + &query_text, + settings.query_history_max_entries(), + ); + } match actions::execute(&action) { Ok(outcome) => { - if let Some(db) = frecency_db { + if !is_auto && let Some(db) = frecency_db { spawn_pick_bump(db, plugin_name.clone(), result_key); } diff --git a/src/app/query.rs b/src/app/query.rs index 8794a7a..d6a08e7 100644 --- a/src/app/query.rs +++ b/src/app/query.rs @@ -122,6 +122,19 @@ pub(super) fn render_results(window: &QueryWindow, results: &[RankedResult]) { if previously_selected >= row_count || previously_selected < 0 { window.set_selected_index(0); } + + maybe_auto_invoke(window); +} + +/// First-launch hook: while `auto-invoke-plugin` is armed, drive the normal +/// invoke path on every render. `invoke_selected` resolves the named plugin's +/// result against the live results and clears the flag once it fires, so this +/// runs exactly once and can never hit a different plugin answering the same +/// query. +fn maybe_auto_invoke(window: &QueryWindow) { + if !window.get_auto_invoke_plugin().is_empty() { + window.invoke_invoke_selected(false, false, false, false); + } } /// Anything that isn't a base64 data URI is treated as "no icon" so the row diff --git a/src/bundle_install.rs b/src/bundle_install.rs index 6130afa..f1f231c 100644 --- a/src/bundle_install.rs +++ b/src/bundle_install.rs @@ -20,17 +20,21 @@ use crate::logging::LogErr; /// Install bundled default plugins into the user's plugin directory if the /// directory is empty or absent. Errors are logged and swallowed — a failed /// install must not prevent the daemon from booting. -pub fn install_default_plugins_if_needed() { +/// +/// Returns `true` only when this call actually seeded the directory — i.e. +/// the first launch. Every skip path (already populated, unbundled, stat or +/// copy failure) returns `false`. +pub fn install_default_plugins_if_needed() -> bool { let Some(user_dir) = crate::paths::plugins_dir() else { tracing::debug!("bundle-install: no platform plugin dir; skipping"); - return; + return false; }; let Some(bundled) = bundled_plugins_dir() else { // Running unbundled (cargo run) — not an error. tracing::debug!("bundle-install: no bundled resources; running unbundled"); - return; + return false; }; match user_dir_needs_seeding(&user_dir) { @@ -40,7 +44,7 @@ pub fn install_default_plugins_if_needed() { "bundle-install: user plugin dir already populated" ); - return; + return false; } Ok(true) => {} Err(err) => { @@ -50,7 +54,7 @@ pub fn install_default_plugins_if_needed() { "bundle-install: could not stat user plugin dir; skipping install", ); - return; + return false; } } @@ -61,21 +65,29 @@ pub fn install_default_plugins_if_needed() { "bundle-install: failed to create user plugin dir", ); - return; + return false; } match copy_dir_recursive(&bundled, &user_dir) { - Ok(()) => tracing::info!( - plugins_dir = %user_dir.display(), - source = %bundled.display(), - "bundle-install: copied default plugins into user dir", - ), - Err(err) => tracing::warn!( - source = %bundled.display(), - target = %user_dir.display(), - %err, - "bundle-install: copy failed; user must install plugins manually", - ), + Ok(()) => { + tracing::info!( + plugins_dir = %user_dir.display(), + source = %bundled.display(), + "bundle-install: copied default plugins into user dir", + ); + + true + } + Err(err) => { + tracing::warn!( + source = %bundled.display(), + target = %user_dir.display(), + %err, + "bundle-install: copy failed; user must install plugins manually", + ); + + false + } } } diff --git a/src/cli.rs b/src/cli.rs index f2f9c6e..1caaef7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -29,4 +29,10 @@ pub struct Args { /// present) or the platform plugin dir. #[arg(long, value_name = "PATH")] pub plugins_dir: Option, + + /// Open the launcher with the query box pre-filled with this text, as if + /// the user had typed it. Forwarded to a running daemon when one exists, + /// otherwise the cold-started daemon opens with it. Implies `--open`. + #[arg(long, value_name = "TEXT")] + pub query: Option, } diff --git a/src/daemon.rs b/src/daemon.rs index 861e4bf..0817761 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -41,8 +41,20 @@ pub struct Options { /// Override for the plugins directory. `None` uses the default search /// order in [`crate::plugins::loader::LoaderOptions::resolve`]. pub plugins_dir: Option, + /// Text to pre-fill the query box with on the cold-start open (the + /// `--query` flag). Forwarded daemons receive it over IPC instead; see + /// [`crate::ipc::Command::OpenQuery`]. + pub initial_query: Option, } +/// Name of the bundled tutorial plugin (its manifest `name`). The first-launch +/// path auto-invokes this plugin's result to open its view. +const TUTORIAL_PLUGIN_NAME: &str = "tutorial"; + +/// Query fired on first launch; the tutorial plugin answers it (and the typed +/// `tutorial` / `help` keywords) with the row that opens its view. +const TUTORIAL_LAUNCH_QUERY: &str = "tutorial"; + /// Run the daemon. Blocks until the Slint event loop exits. /// /// # Errors @@ -68,9 +80,11 @@ pub fn run(options: Options) -> Result<(), Box> { // it. A `--plugins-dir` override bypasses the platform default and // therefore the bundle install path too — that's intentional, devs // pointing at an arbitrary checkout don't want their workspace seeded. - if options.plugins_dir.is_none() { - bundle_install::install_default_plugins_if_needed(); - } + // + // The return is `true` only when this run actually seeded — the first + // launch, which is when the tutorial auto-opens (the override skips + // seeding, so it never triggers the tutorial either). + let first_launch = options.plugins_dir.is_none() && bundle_install::install_default_plugins_if_needed(); // Independent of the plugins-dir override — themes live in the config dir. bundle_install::install_default_themes_if_needed(); @@ -172,8 +186,27 @@ pub fn run(options: Options) -> Result<(), Box> { #[cfg(not(target_os = "macos"))] let _ = hotkey_spec; - if options.open_on_start { - window::show(&window, &settings_controller, options.activation_token.as_deref()); + if first_launch && options.initial_query.is_none() { + // Open the tutorial on first launch — even a background daemon start + // (no `--open`) should surface it. An explicit `--query` takes + // precedence (handled by the branch below), since the user asked for + // something specific. The query queues on the runtime thread and runs + // once plugins finish loading; `auto-invoke-plugin` makes the + // tutorial's result open its view with no interaction. + window.set_auto_invoke_plugin(TUTORIAL_PLUGIN_NAME.into()); + window::show( + &window, + &settings_controller, + options.activation_token.as_deref(), + Some(TUTORIAL_LAUNCH_QUERY), + ); + } else if options.open_on_start { + window::show( + &window, + &settings_controller, + options.activation_token.as_deref(), + options.initial_query.as_deref(), + ); } // `run_event_loop_until_quit` (not `window.run()`) — the daemon must @@ -190,18 +223,23 @@ fn spawn_ipc_listener( ) -> io::Result<()> { let server = Server::bind(socket_path)?; thread::Builder::new().name("highbeam-ipc".into()).spawn(move || { - let result = server.run(move |cmd| match cmd { - Command::Open { activation_token } => { - let weak = weak.clone(); - let settings = settings.clone(); + let result = server.run(move |cmd| { + let weak = weak.clone(); + let settings = settings.clone(); + let (activation_token, query) = match cmd { + Command::Open { activation_token } => (activation_token, None), + Command::OpenQuery { + query, + activation_token, + } => (activation_token, Some(query)), + }; - slint::invoke_from_event_loop(move || { - if let Some(w) = weak.upgrade() { - window::show(&w, &settings, activation_token.as_deref()); - } - }) - .log_debug("ipc: post Open to event loop"); - } + slint::invoke_from_event_loop(move || { + if let Some(w) = weak.upgrade() { + window::show(&w, &settings, activation_token.as_deref(), query.as_deref()); + } + }) + .log_debug("ipc: post show to event loop"); }); if let Err(err) = result { @@ -333,7 +371,7 @@ fn spawn_hotkey_listener( // The macOS hotkey path doesn't use an activation // token — `activate_and_make_key` handles focus // itself via `NSApp.activate`. - window::show(&w, &settings, None); + window::show(&w, &settings, None, None); } }) .log_debug("hotkey: post show to event loop"); diff --git a/src/ipc.rs b/src/ipc.rs index 1504523..5a1341f 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -1,8 +1,15 @@ //! Unix-domain-socket IPC for single-instance coordination. //! -//! Newline-terminated ASCII commands; today there's exactly one (`open`), -//! so a fixed read buffer is fine. Length-prefixed framing waits until we -//! carry payloads bigger than a few bytes. +//! Newline-terminated commands. `open` is space-delimited; `query` carries a +//! free-text payload and so uses tab-delimited fields (`query\t\t`) +//! — a tab can't appear in an activation token and is effectively absent from a +//! single-line launcher query, so it separates the fields unambiguously without +//! length-prefixed framing. +//! +//! Sends are one-way and fire-and-forget: a daemon predating a command (e.g. an +//! older build that doesn't know `query`) rejects it and logs a warning, but +//! the client still sees success — the launcher won't open until that daemon is +//! restarted. use std::io::{self, BufRead, BufReader, Write}; use std::os::unix::net::{UnixListener, UnixStream}; @@ -23,6 +30,14 @@ pub enum Command { /// keybind that runs `high-beam --open` from a context where /// `XDG_ACTIVATION_TOKEN` was already consumed, or non-Wayland callers. Open { activation_token: Option }, + + /// Open the query window and pre-fill the query box with `query`, as if + /// the user had typed it. `activation_token` carries the same Wayland + /// focus token as [`Self::Open`]. + OpenQuery { + query: String, + activation_token: Option, + }, } impl Command { @@ -30,16 +45,44 @@ impl Command { /// * `"open"` — no token (legacy + WM keybind path) /// * `"open "` — with token; `` may not contain whitespace /// (real XDG activation tokens are opaque ASCII without spaces). + /// * `"query\t\t"` — pre-filled query; `` is empty when + /// absent and `` is the query (single line; CR/LF are collapsed to + /// spaces so the newline framing holds). fn as_wire(&self) -> String { match self { Self::Open { activation_token: None } => "open".to_owned(), Self::Open { activation_token: Some(t), } => format!("open {t}"), + Self::OpenQuery { + query, + activation_token, + } => { + let token = activation_token.as_deref().unwrap_or(""); + // The wire is newline-framed and the daemon reads one line per + // connection; collapse any CR/LF so an embedded newline can't + // truncate the query on the read side. + let query = query.replace(['\n', '\r'], " "); + + format!("query\t{token}\t{query}") + } } } fn parse(line: &str) -> Result { + // Strip only the line terminator — the query payload may carry + // meaningful leading/trailing spaces, so it must not be `trim`ed. + let line = line.trim_end_matches(['\n', '\r']); + + if let Some(rest) = line.strip_prefix("query\t") { + let (token, query) = rest.split_once('\t').unwrap_or(("", rest)); + + return Ok(Self::OpenQuery { + query: query.to_owned(), + activation_token: optional_token(token), + }); + } + let trimmed = line.trim(); if trimmed == "open" { @@ -47,10 +90,8 @@ impl Command { } if let Some(rest) = trimmed.strip_prefix("open ") { - let token = rest.trim(); - return Ok(Self::Open { - activation_token: (!token.is_empty()).then(|| token.to_owned()), + activation_token: optional_token(rest.trim()), }); } @@ -58,6 +99,11 @@ impl Command { } } +/// A wire token field is empty when the caller had no activation token. +fn optional_token(raw: &str) -> Option { + (!raw.is_empty()).then(|| raw.to_owned()) +} + #[derive(Debug)] enum ParseError { Unknown(String), @@ -193,6 +239,69 @@ mod tests { assert_eq!(Command::parse("open xdg-foo-bar-123\n").unwrap(), with_token); } + #[test] + fn open_query_roundtrips_without_token() { + let cmd = Command::OpenQuery { + query: "calc 2 + 2".to_owned(), + activation_token: None, + }; + assert_eq!(cmd.as_wire(), "query\t\tcalc 2 + 2"); + assert_eq!(Command::parse("query\t\tcalc 2 + 2\n").unwrap(), cmd); + } + + #[test] + fn open_query_roundtrips_with_token() { + let cmd = Command::OpenQuery { + query: "http 404".to_owned(), + activation_token: Some("xdg-tok-9".to_owned()), + }; + assert_eq!(cmd.as_wire(), "query\txdg-tok-9\thttp 404"); + assert_eq!(Command::parse("query\txdg-tok-9\thttp 404\n").unwrap(), cmd); + } + + #[test] + fn open_query_preserves_query_whitespace_and_tabs() { + // Leading/trailing spaces are part of the query and must survive; a + // stray tab inside the text stays with the query (we split on the + // first tab only). + let cmd = Command::OpenQuery { + query: " spaced \tquery ".to_owned(), + activation_token: None, + }; + assert_eq!(Command::parse(&format!("{}\n", cmd.as_wire())).unwrap(), cmd); + } + + #[test] + fn open_query_collapses_newlines_to_stay_single_line() { + // The wire is newline-framed, so embedded CR/LF must be coerced rather + // than truncated on the read side. + let cmd = Command::OpenQuery { + query: "foo\nbar\rbaz".to_owned(), + activation_token: None, + }; + assert_eq!(cmd.as_wire(), "query\t\tfoo bar baz"); + assert_eq!( + Command::parse(&format!("{}\n", cmd.as_wire())).unwrap(), + Command::OpenQuery { + query: "foo bar baz".to_owned(), + activation_token: None, + } + ); + } + + #[test] + fn open_query_does_not_collide_with_open() { + // A query whose text is literally "open" must parse as OpenQuery, not + // the bare Open command. + assert_eq!( + Command::parse("query\t\topen").unwrap(), + Command::OpenQuery { + query: "open".to_owned(), + activation_token: None, + } + ); + } + #[test] fn server_receives_client_command() { let path = tmp_socket("roundtrip"); diff --git a/src/main.rs b/src/main.rs index 48827e8..2590fab 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,29 +32,40 @@ fn main() -> ExitCode { // etc.) don't inherit a stale token. let activation_token = consume_activation_token(); - // `--open` first tries to contact a running daemon; if there isn't one - // it falls through and starts a daemon that opens immediately. + // `--open` and `--query` first try to contact a running daemon; if there + // isn't one they fall through and start a daemon that opens immediately. + // `--query` carries its text (and so takes precedence over a bare `--open`). // // `--once` deliberately skips the IPC fast-path: every invocation // cold-starts a fresh process that exits on dismiss, no single-instance // lock, no socket. That sidesteps the Slint-1.16 Wayland re-show issues // entirely. `--once` implies `--open`. - if args.open && !args.once { - let cmd = Command::Open { - activation_token: activation_token.clone(), + if !args.once { + let cmd = match &args.query { + Some(query) => Some(Command::OpenQuery { + query: query.clone(), + activation_token: activation_token.clone(), + }), + None if args.open => Some(Command::Open { + activation_token: activation_token.clone(), + }), + None => None, }; - if ipc::send(&socket_path, &cmd).is_ok() { + if let Some(cmd) = cmd + && ipc::send(&socket_path, &cmd).is_ok() + { return ExitCode::SUCCESS; } } let options = Options { - open_on_start: args.open || args.once, + open_on_start: args.open || args.once || args.query.is_some(), once: args.once, activation_token, socket_path, plugins_dir: args.plugins_dir, + initial_query: args.query, }; match daemon::run(options) { diff --git a/src/window.rs b/src/window.rs index ed8e410..bdaef2a 100644 --- a/src/window.rs +++ b/src/window.rs @@ -216,7 +216,12 @@ pub(crate) fn apply_theme(window: &QueryWindow, theme: &ThemeVariant) { /// `activation_token` (forwarded from the invoking process) or the /// compositor won't raise the surface — GNOME-Mutter opens it behind the /// active window. macOS uses `NSApp.activate`; X11 lets winit handle it. -pub(crate) fn show(window: &QueryWindow, settings: &SettingsController, activation_token: Option<&str>) { +pub(crate) fn show( + window: &QueryWindow, + settings: &SettingsController, + activation_token: Option<&str>, + initial_query: Option<&str>, +) { // On Linux the previous dismiss may have left us in the `is-hidden` // collapsed-1×1 state instead of going through Slint's hide (which is // broken on Wayland — see `window_wayland`). Flip back BEFORE the show @@ -244,6 +249,12 @@ pub(crate) fn show(window: &QueryWindow, settings: &SettingsController, activati #[cfg(not(any(target_os = "macos", target_os = "linux")))] let _ = activation_token; window.invoke_focus_input(); + + // Pre-fill after the window is shown so the dispatched query renders into + // a live launcher view. A no-op when nothing was requested. + if let Some(query) = initial_query { + window.invoke_prefill_query(query.into()); + } } /// Hide the window and clear the input so the next open starts fresh. Does @@ -257,6 +268,10 @@ pub(crate) fn show(window: &QueryWindow, settings: &SettingsController, activati /// `shown` state intact. pub(crate) fn hide(window: &QueryWindow) { window.invoke_clear_input(); + // Disarm a first-launch tutorial auto-invoke that never fired — an aborted + // first launch must not hijack a later manual query. The happy path clears + // this on fire, before any hide. + window.set_auto_invoke_plugin(slint::SharedString::new()); #[cfg(target_os = "linux")] { window.set_is_hidden(true); diff --git a/ui/query.slint b/ui/query.slint index 14edbfe..fd80ca5 100644 --- a/ui/query.slint +++ b/ui/query.slint @@ -32,6 +32,11 @@ export component QueryWindow inherits Window { in-out property selected-index: 0; in-out property max-rows: 9; + // When non-empty, the next result render that contains a row from this + // plugin auto-invokes it (the first-launch tutorial uses this to open its + // view without interaction). Cleared by Rust the moment it fires. + in-out property auto-invoke-plugin: ""; + // View state. 0 = launcher (query), 1 = settings, 2 = install confirm, // 3 = plugin view frame (top of the view stack). property VIEW-QUERY: 0; @@ -267,6 +272,20 @@ export component QueryWindow inherits Window { launcher.set-input-text(text); } + // Pre-fill the query box and dispatch it as a real query. Unlike + // `set-input-text` this fires `query-edited`, so the plugin pipeline runs + // exactly as if the user had typed the text. Used by the `--query` launch + // path and the first-launch tutorial. + // + // `show-query` first so a query forwarded to a daemon parked in settings / + // confirm / a plugin view lands on the visible, focused launcher rather + // than rendering into a hidden component. + public function prefill-query(text: string) { + root.show-query(); + launcher.set-input-text(text); + root.query-edited(text); + } + // Switch between launcher and settings views. Public so Rust can invoke // (from the Core `settings` verb action). public function show-settings() {