//! The search screen, described rather than built. //! //! //! //! The repository, the FTS index (`migrations/sqlite/011_full_text_search.sql`), //! the colon grammar (`goingson_core::search_parser`) and the command are what //! this draws. //! //! # A synchronous handler serves it //! //! [`crate::commands::run_search`] is synchronous: the repository trait's //! `search` is a plain function. So this needs none of the offload machinery a //! long-running write needs; it calls the command's own body and answers with //! the result. //! //! # The shape //! //! - `GET /search` — the document: the query box, and whatever the current //! query finds. //! - `GET /search/results` — the filters and the result list alone, which is //! what the box asks for as it is typed. //! //! The box is a [`Field`] with [`Field::consults`]: a field that asks a route //! about its value once the value has stood still, with the answer landing //! where [`Action::replacing`] points. The query is also a query param on //! `/search`, so a search has an address and is not a state the field holds //! privately. //! //! # The parsed filters are shown back //! //! `commands::search` collects a label per filter it understood, and drawing //! them is what makes a colon grammar learnable rather than secret: typing //! `is:overdue` and seeing `is:overdue` come back is the parser saying it //! agreed. A filter the parser did not recognise stays in the free text and //! does not appear, which is the same answer said by omission. //! //! # A row says which kind it is //! //! Results are heterogeneous across all five `type:` values, so a row carries a //! [`Tag`] naming its kind, the way status is carried elsewhere. Each row //! activates to that kind's own screen; every one of the five has a described //! detail route, which is why no kind is left inert. // Handlers take their request by value because `quasi_router::Handler` is a // plain `fn(&S, Request)` pointer, so the signature is the router's. #![allow(clippy::needless_pass_by_value)] use quasi_declare::declare; use quasi_router::screen::Tag; use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot}; use crate::commands::{SearchInput, SearchResultResponse, SearchResultsResponse}; use crate::state::AppState; #[cfg(test)] mod tests; /// The region the query box replaces as it is typed. const RESULTS: &str = "search-results"; /// The kinds a result can be, and the routes they open. /// /// Spelled here rather than inferred from the string, because a row that /// activated a path built by interpolating an unrecognised kind is a 404 the /// first time somebody presses it. fn destination(result: &SearchResultResponse) -> Option { let path = match result.result_type.as_str() { "task" => format!("/tasks/{}", result.id), "project" => format!("/projects/{}", result.id), "event" => format!("/events/{}", result.id), "contact" => format!("/contacts/{}", result.id), "email" => format!("/emails/{}", result.id), _ => return None, }; Some(Action::get(path)) } /// What a row's tag calls its kind. fn kind_label(result_type: &str) -> &str { match result_type { "task" => "Task", "project" => "Project", "event" => "Event", "contact" => "Contact", "email" => "Email", other => other, } } /// Whether a result carries the FTS extract around its match. fn has_snippet(result: &SearchResultResponse) -> bool { !snippet(result).is_empty() } /// That extract, or nothing. R9: read whether or not it is placed. fn snippet(result: &SearchResultResponse) -> &str { result.snippet.as_deref().unwrap_or_default() } /// Whether the result names a project. fn has_project(result: &SearchResultResponse) -> bool { !project(result).is_empty() } /// That project, or nothing. fn project(result: &SearchResultResponse) -> &str { result.project_name.as_deref().unwrap_or_default() } declare! { /// One result as a row. /// /// The snippet is the FTS extract around the match, which is the sentence /// that says why this row is here, so it is the row's secondary text. The /// project is a plain fact and goes in `meta`. /// /// A kind with no route opens nothing rather than opening a path built by /// interpolating a string nobody recognised. An `Option` is an iterator of /// at most one, and `.into_iter()` is the method step that says so. shape row_for(result: &SearchResultResponse) -> Row; row &result.title { secondary snippet(result) when has_snippet(result); meta project(result) when has_project(result); token Tag::badge(kind_label(&result.result_type)); for opens in destination(result).into_iter() { activate to doing opens; } } } /// A param that is present and not blank. Blank is absent, which is what an /// emptied query box means. fn text<'a>(params: &'a quasi_router::Params, name: &str) -> Option<&'a str> { params.get(name).map(str::trim).filter(|v| !v.is_empty()) } /// Run the search behind the command, from state a route handler holds. fn look(state: &AppState, query: &str) -> Result { crate::commands::run_search( state, SearchInput { query: query.to_owned(), result_type: None, project_id: None, date_from: None, date_to: None, limit: None, offset: None, }, ) .map_err(|error| RouteError::internal(error.to_string())) } declare! { /// The grammar, said once on an empty screen. /// /// Not a permanent panel: it is what there is to show when there is no /// query and no results, and it is the only place the vocabulary is written /// down for somebody who has not read the parser. shape grammar() -> Vec; text "Type to search across tasks, emails, events, projects and contacts. \ Words narrow by text; a colon filter narrows by fact."; text "is:overdue is:today is:tomorrow is:thisweek is:snoozed"; text "is:pending is:started is:completed is:waiting"; text "priority:high priority:medium priority:low"; text "type:task type:email type:event type:project type:contact"; text "in:ProjectName tag:name -tag:name"; text "after:2026-08-01 before:2026-09-01"; } /// How many results, in words. fn tally(found: &SearchResultsResponse) -> String { match found.total { 1 => "1 result".to_owned(), total => format!("{total} results"), } } /// Whether anything was asked at all. /// /// The read happens in the handler and the answer arrives here, so this shape /// is infallible: a screen that could fail would have to say what it draws when /// it does, and the route already answers that. fn asked(found: Option<&SearchResultsResponse>) -> bool { found.is_some() } declare! { /// The filters and the results, which is what the query box replaces. shape results(found: Option<&SearchResultsResponse>) -> Slot; region RESULTS as Pane { for node in grammar() { include node unless asked(found); } for hit in found.into_iter() { // What the parser understood, said back. A badge rather than a // chip: these are the query's own words read aloud, and pressing // one would have to mean removing it from a string the reader is // holding the caret in. for filter in hit.active_filters.iter() { badge filter; } empty "Nothing matches that." when hit.results.is_empty(); text tally(hit) unless hit.results.is_empty(); list { for result in hit.results.iter() { include row_for(result); } } unless hit.results.is_empty(); } } } /// What is already in the box, or nothing. fn typed(query: Option<&str>) -> &str { query.unwrap_or_default() } declare! { /// The query box. /// /// The answer replaces the results region, and the field asks for it once /// the value has stood still. No submit: a search is a read, and waiting /// for one would be the box pretending it writes something. shape box_for(query: Option<&str>) -> Field; field Text "q" "Search" { consults Action::get("/search/results").replacing(RESULTS); placeholder "invoice is:overdue in:Ledger"; value typed(query); } } /// The whole screen. fn index(state: &AppState, request: quasi_router::Request) -> Result { let query = text(&request.carried, "q"); let band = Slot::new("search-band", RegionKind::Band) .with(Node::page("Search")) .with(Node::Field(Box::new(box_for(query)))); let found = query.map(|query| look(state, query)).transpose()?; Ok(Screen::list_detail("Search", false) .at_place(super::shell::SEARCH) .with(band) .with(results(found.as_ref())) .into()) } /// The results alone, which is what the query box asks for as it is typed. fn results_only(state: &AppState, request: quasi_router::Request) -> Result { let query = text(&request.carried, "q"); let found = query.map(|query| look(state, query)).transpose()?; let slot = results(found.as_ref()); Ok(Response::fragment(RESULTS, Node::Region(slot))) } /// The search screen's routes. #[must_use] pub fn routes(router: Router) -> Router { router .get("/search", index) .get("/search/results", results_only) }