flams_router_search/
components.rs

1use flams_backend_types::search::{QueryFilter, SearchResult, SearchResultKind};
2use flams_utils::{impossible, vecmap::VecMap};
3use flams_web_utils::components::error_with_toaster;
4use ftml_components::components::content::FtmlViewable;
5use ftml_dom::utils::css::inject_css;
6use ftml_uris::{
7    DocumentElementUri, DocumentUri, IsNarrativeUri, SymbolUri,
8    components::{DocumentUriComponents, UriComponents},
9};
10use leptos::prelude::*;
11
12#[derive(Debug, Clone)]
13pub(crate) enum SearchState {
14    None,
15    Loading,
16    Results(Vec<(f32, SearchResult)>),
17    SymResults(Vec<(SymbolUri, Vec<(f32, SearchResult)>)>),
18}
19
20#[derive(Copy, Clone, PartialEq, Eq)]
21pub(crate) enum Filter {
22    Doc,
23    Def,
24    Par,
25    Ex,
26    Ass,
27}
28impl Filter {
29    const ALL: [Self; 5] = [Self::Doc, Self::Def, Self::Par, Self::Ex, Self::Ass];
30    fn from_value(s: &str) -> Self {
31        match s {
32            "doc" => Self::Doc,
33            "def" => Self::Def,
34            "par" => Self::Par,
35            "ex" => Self::Ex,
36            "ass" => Self::Ass,
37            _ => impossible!(),
38        }
39    }
40    const fn value_str(self) -> &'static str {
41        match self {
42            Self::Doc => "doc",
43            Self::Def => "def",
44            Self::Par => "par",
45            Self::Ex => "ex",
46            Self::Ass => "ass",
47        }
48    }
49    const fn tag_str(self) -> &'static str {
50        match self {
51            Self::Doc => "Documents",
52            Self::Def => "Definitions",
53            Self::Par => "Paragraphs",
54            Self::Ex => "Examples",
55            Self::Ass => "Assertions",
56        }
57    }
58    const fn long_str(self) -> &'static str {
59        match self {
60            Self::Doc => "Full Documents",
61            Self::Def => "Definitions",
62            Self::Par => "Other Paragraphs",
63            Self::Ex => "(Counter-)examples",
64            Self::Ass => "Assertions (Theorems, Lemmata, etc.)",
65        }
66    }
67}
68
69#[component]
70pub fn SearchTop() -> AnyView {
71    use flams_web_utils::components::ClientOnly;
72    use thaw::{
73        Divider, Flex, FlexAlign, Icon, Input, InputPrefix, Layout, LayoutHeader, Radio,
74        RadioGroup, Tag, TagPicker, TagPickerControl, TagPickerGroup, TagPickerInput,
75        TagPickerOption, ToasterInjection,
76    }; //,Combobox,ComboboxOption
77    let query = RwSignal::new(String::new());
78    let search_kind = RwSignal::new(vec![
79        Filter::Def.value_str().to_string(),
80        Filter::Par.value_str().to_string(),
81    ]);
82    let query_opts = Memo::new(move |_| {
83        search_kind.with(|v| {
84            let mut ret = QueryFilter::default();
85            ret.allow_documents = false;
86            ret.allow_paragraphs = false;
87            ret.allow_definitions = false;
88            ret.allow_examples = false;
89            ret.allow_assertions = false;
90            ret.allow_problems = false;
91            for s in v {
92                match Filter::from_value(s.as_str()) {
93                    Filter::Doc => ret.allow_documents = true,
94                    Filter::Def => ret.allow_definitions = true,
95                    Filter::Par => ret.allow_paragraphs = true,
96                    Filter::Ex => ret.allow_examples = true,
97                    Filter::Ass => ret.allow_assertions = true,
98                }
99            }
100            ret
101        })
102    });
103    let results = RwSignal::new(SearchState::None);
104    let toaster = ToasterInjection::expect_context();
105    let action = Action::new(move |&()| {
106        results.set(SearchState::Loading);
107        let s = query.get_untracked();
108        let opts = query_opts.get_untracked();
109        async move {
110            match super::search_query(s, opts, 20).await {
111                Ok(r) => results.set(SearchState::Results(r)),
112                Err(e) => {
113                    results.set(SearchState::None);
114                    error_with_toaster(e, toaster);
115                }
116            }
117        }
118    });
119    let sym_action = Action::new(move |&()| {
120        results.set(SearchState::Loading);
121        let s = query.get_untracked();
122        async move {
123            match super::search_symbols(s, 20).await {
124                Ok(r) => results.set(SearchState::SymResults(r)),
125                Err(e) => {
126                    results.set(SearchState::None);
127                    error_with_toaster(e, toaster);
128                }
129            }
130        }
131    });
132    let radio_value = RwSignal::new("X".to_string());
133    Effect::new(move || {
134        if query.with(|q| q.is_empty()) {
135            return;
136        };
137        if radio_value.with(|s| s == "S") {
138            sym_action.dispatch(());
139        } else {
140            let _ = query_opts.get(); // register dependency
141            action.dispatch(());
142        }
143    });
144    inject_css(
145        "flams-search-picker",
146        ".flams-search-picker{} .flams-search-picker-disabled { display:none; }",
147    );
148    let cls = Memo::new(move |_| match radio_value.get().as_str() {
149        "X" => "flams-search-picker".to_string(),
150        "S" => "flams-search-picker-disabled".to_string(),
151        _ => impossible!(),
152    });
153    view! {
154      <Layout>
155        <LayoutHeader><Flex>
156          <Input value=query placeholder="search...">
157              <InputPrefix slot>
158                  <Icon icon=icondata_ai::AiSearchOutlined/>
159              </InputPrefix>
160          </Input>
161          <RadioGroup value=radio_value>
162            <Radio value="S" label="Symbols"/>
163            <Radio value="X" label="Documents/Paragraphs"/>
164          </RadioGroup>
165          <ClientOnly>
166            <TagPicker selected_options=search_kind class=cls>
167                <TagPickerControl slot>
168                <TagPickerGroup>
169                  {move ||
170                    search_kind.get().into_iter().map(|option| view!{
171                      <Tag value=option.clone() attr:style="background-color:var(--colorBrandBackground2)">
172                          {Filter::from_value(option.as_str()).tag_str()}
173                      </Tag>
174                    }).collect_view()
175                  }
176                  </TagPickerGroup>
177                  <TagPickerInput />
178                </TagPickerControl>
179                {
180                  move ||
181                      search_kind.with(|opts| {
182                          Filter::ALL.iter().filter_map(|option| {
183                              if opts.iter().any(|o| o == option.value_str()) {
184                                  return None
185                              } else {
186                                  Some(view! {
187                                      <TagPickerOption value=option.value_str().to_string() text=option.long_str() />
188                                  })
189                              }
190                          }).collect_view()
191                      })
192                }
193            </TagPicker>
194          </ClientOnly>
195        </Flex></LayoutHeader>
196        <Layout>
197          <Divider/>
198          <div style="width:fit-content;padding:10px;"><Flex vertical=true align=FlexAlign::Start>{move || do_results(results)}</Flex></div>
199        </Layout>
200      </Layout>
201    }.into_any()
202}
203
204fn do_results(results: RwSignal<SearchState>) -> AnyView {
205    results.with(|r| match r {
206        SearchState::None => ().into_any(),
207        SearchState::Results(v) if v.is_empty() => "(No results)".into_any(),
208        SearchState::Loading => view!(<flams_web_utils::components::Spinner/>).into_any(),
209        SearchState::SymResults(v) => v
210            .iter()
211            .map(|(sym, res)| do_sym_result(sym, res.clone()))
212            .collect_view().into_any(),
213        SearchState::Results(v) => v
214            .iter()
215            .map(|(score, res)| do_result(*score, res))
216            .collect_view().into_any(),
217    })
218}
219
220fn do_sym_result(sym: &SymbolUri, res: Vec<(f32, SearchResult)>) -> AnyView {
221    use flams_router_content::components::Fragment;
222    use flams_web_utils::components::ClientOnly;
223    use thaw::{Body1, Card, CardHeader, CardPreview, Scrollbar};
224
225    let name = sym.as_view::<flams_router_content::backend::FtmlBackend>(); // ftml_viewer_components::components::omdoc::symbol_name(sym, &sym.to_string());
226    view! {
227      <Card>
228          <CardHeader>
229              <Body1><b>{name}</b></Body1>
230          </CardHeader>
231          <CardPreview>
232            <div style="padding:0 5px;max-width:100%">
233              <div style="width:100%;color:black;background-color:white;">
234                <Scrollbar style="max-height: 100px;width:100%;max-width:100%;">{
235                  res.into_iter().map(|(_,r)| {
236                    let SearchResult::Paragraph { uri, .. } = r else { impossible!()};
237                    view!{
238                        //<span>"Here: "{uri.to_string()}</span>
239                        //<div>"---"</div>
240                        <Fragment uri=UriComponents::Full(uri.into()) position=ftml_components::SidebarPosition::None/>
241                        //<div>"---"</div>
242                    }
243                  }).collect_view()
244                }
245                </Scrollbar>
246              </div>
247            </div>
248          </CardPreview>
249      </Card>
250    }.into_any()
251}
252
253fn do_result(score: f32, res: &SearchResult) -> AnyView {
254    use leptos::either::Either::*;
255    match res {
256        SearchResult::Document(d) => do_doc(score, d.clone()),
257        SearchResult::Paragraph {
258            uri, fors, kind, ..
259        } => do_para(score, uri.clone(), *kind, fors.clone()),
260    }
261}
262
263fn do_doc(score: f32, uri: DocumentUri) -> AnyView {
264    use flams_router_content::components::DocumentInner;
265    use thaw::{Body1, Card, CardHeader, CardHeaderAction, CardPreview, Scrollbar};
266
267    let name = uri.as_view::<flams_router_content::backend::FtmlBackend>(); //doc_name(&uri, uri.document_name().to_string());
268    view! {
269      <Card>
270          <CardHeader>
271              <Body1>
272                  <b>"Document "{name}</b>
273              </Body1>
274              /*<CardHeaderDescription slot>
275                  <Caption1>"Description"</Caption1>
276              </CardHeaderDescription>*/
277              <CardHeaderAction slot>
278                  <span>"Score: "{score}</span>
279              </CardHeaderAction>
280          </CardHeader>
281          <CardPreview>
282              <div style="padding:0 5px;max-width:100%">
283                <div style="width:100%;color:black;background-color:white;">
284                    <Scrollbar style="max-height: 100px;;width:100%;max-width:100%;"><DocumentInner doc=DocumentUriComponents::Full(uri) /></Scrollbar>
285                </div>
286              </div>
287          </CardPreview>
288          /*<CardFooter>
289              "sTeX:"<pre></pre>
290          </CardFooter>*/
291      </Card>
292    }.into_any()
293}
294
295fn do_para(
296    score: f32,
297    uri: DocumentElementUri,
298    kind: SearchResultKind,
299    fors: Vec<SymbolUri>,
300) -> AnyView {
301    use flams_router_content::components::Fragment;
302    use flams_web_utils::components::{Popover, PopoverTrigger};
303    use thaw::{
304        Body1, Caption1, Card, CardHeader, CardHeaderAction, CardHeaderDescription, CardPreview,
305        Scrollbar,
306    };
307    let uristr = uri.to_string();
308    let namestr = uri.name().to_string();
309    let name = view! {
310      <div style="display:inline-block;"><Popover>
311      <PopoverTrigger slot>{view!(<span class="ftml-comp">{namestr}</span>).into_any()}</PopoverTrigger>
312      <div style="font-size:small;">{uristr}</div>
313      </Popover></div>
314    };
315
316    let desc = ftml_components::components::content::CommaSep(
317        "For",
318        fors.into_iter()
319            .map(|s| s.as_view::<flams_router_content::backend::FtmlBackend>()),
320    )
321    .into_view();
322    view! {
323      <Card>
324          <CardHeader>
325              <Body1>
326                  <b>{kind.as_str()}" "{name}</b>
327              </Body1>
328              <CardHeaderDescription slot>
329                  <Caption1>{desc}</Caption1>
330              </CardHeaderDescription>
331              <CardHeaderAction slot>
332                  <span>"Score: "{score}</span>
333              </CardHeaderAction>
334          </CardHeader>
335          <CardPreview>
336            <div style="padding:0 5px;max-width:100%">
337              <div style="width:100%;color:black;background-color:white;">
338                <Scrollbar style="max-height: 100px;width:100%;max-width:100%;"><Fragment uri=UriComponents::Full(uri.into()) position=ftml_components::SidebarPosition::None /></Scrollbar>
339              </div>
340            </div>
341          </CardPreview>
342          /*<CardFooter>
343              "sTeX:"<pre></pre>
344          </CardFooter>*/
345      </Card>
346    }.into_any()
347}