Skip to main content

tilo_app/framework/views/
kind.rs

1//! What an app's resource types are made of, as its views and its search
2//! read them: each type's fields, the role a field plays in a presentation,
3//! and whether the field is found by typed text or narrows by a chip.
4
5use std::any::Any;
6use std::sync::Arc;
7
8use tilo_resource::{SearchBody, SearchEntry, SearchWeight};
9
10/// The part a field plays where an item of its type is shown.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum FieldRole {
13    /// The name a person looks for: a song's title, an album's name.
14    Title,
15    /// The line under it that tells two titles apart: the artist.
16    Subtitle,
17    /// The picture: a cover, a portrait.
18    Artwork,
19    /// When it is from: a release year, a date.
20    Date,
21    /// Anything else a presentation shows: an album on a song's row.
22    Detail,
23    /// The passage of prose a match is in, which the query view fills in
24    /// for a hit inside an item's body.
25    Excerpt,
26}
27
28/// How a type's items are presented in a list of results.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum Form {
31    /// One row each: songs, messages, settings.
32    Row,
33    /// A tile each, in a grid: albums, artists, books.
34    Tile,
35    /// The item's page of its own.
36    Detail,
37}
38
39/// How a filterable field narrows results, which decides the chip it gets.
40#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41pub enum FilterKind {
42    /// A chip naming one of the field's values: Artist: Ada.
43    Value,
44    /// A chip that is on or off: Liked, In library.
45    Toggle,
46}
47
48/// One field of a resource type.
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct FieldDecl {
51    /// The field's key in an [`Item`]'s fields, and the key a typed token
52    /// names (`artist:ada`).
53    pub key: &'static str,
54    /// What the field is called, for its chip.
55    pub label: String,
56    pub role: Option<FieldRole>,
57    /// Typed text finds items by this field.
58    pub searchable: bool,
59    /// The field narrows results by a chip.
60    pub filter: Option<FilterKind>,
61}
62
63impl FieldDecl {
64    /// A field called `label`, neither searchable nor a filter.
65    #[must_use]
66    pub fn new(key: &'static str, label: impl Into<String>) -> Self {
67        Self {
68            key,
69            label: label.into(),
70            role: None,
71            searchable: false,
72            filter: None,
73        }
74    }
75
76    /// The type's title: searchable, in the title role.
77    #[must_use]
78    pub fn title(key: &'static str, label: impl Into<String>) -> Self {
79        Self::new(key, label).role(FieldRole::Title).searchable()
80    }
81
82    #[must_use]
83    pub fn role(mut self, role: FieldRole) -> Self {
84        self.role = Some(role);
85        self
86    }
87
88    #[must_use]
89    pub fn searchable(mut self) -> Self {
90        self.searchable = true;
91        self
92    }
93
94    /// The field narrows results by a chip of `kind`.
95    #[must_use]
96    pub fn filter(mut self, kind: FilterKind) -> Self {
97        self.filter = Some(kind);
98        self
99    }
100}
101
102/// One resource type of an app: what its items are called, how they are
103/// presented in results, and their fields.
104#[derive(Clone, Debug, PartialEq, Eq)]
105pub struct KindDecl {
106    /// The resource kind (`music.song`).
107    pub kind: &'static str,
108    /// One item ("Song").
109    pub label: String,
110    /// Several, which names its group of results ("Songs").
111    pub plural: String,
112    pub form: Form,
113    pub fields: Vec<FieldDecl>,
114}
115
116impl KindDecl {
117    #[must_use]
118    pub fn new(
119        kind: &'static str,
120        label: impl Into<String>,
121        plural: impl Into<String>,
122        form: Form,
123    ) -> Self {
124        Self {
125            kind,
126            label: label.into(),
127            plural: plural.into(),
128            form,
129            fields: Vec::new(),
130        }
131    }
132
133    #[must_use]
134    pub fn field(mut self, field: FieldDecl) -> Self {
135        self.fields.push(field);
136        self
137    }
138
139    #[must_use]
140    pub fn field_of(&self, key: &str) -> Option<&FieldDecl> {
141        self.fields.iter().find(|field| field.key == key)
142    }
143}
144
145/// The filterable fields of `kinds`, each key once, in declaration order: the
146/// chips a query view offers.
147#[must_use]
148pub fn filters(kinds: &[KindDecl]) -> Vec<&FieldDecl> {
149    let mut found: Vec<&FieldDecl> = Vec::new();
150    for field in kinds
151        .iter()
152        .flat_map(|kind| &kind.fields)
153        .filter(|field| field.filter.is_some())
154    {
155        if !found.iter().any(|seen| seen.key == field.key) {
156            found.push(field);
157        }
158    }
159    found
160}
161
162/// One item of a resource type, as search and views read it: its type, its
163/// id, and its fields by key. A field may hold several values (a song by two
164/// artists). A remote source attaches what it fetched (`payload`), so the app
165/// can make the item playable or openable before anything is stored.
166#[derive(Clone)]
167pub struct Item {
168    pub kind: &'static str,
169    pub id: String,
170    pub fields: Vec<(&'static str, String)>,
171    /// More text the item is found by but that no presentation shows, each
172    /// ranked by its own weight: a book's subject, a note on a highlight.
173    pub found_by: Vec<(String, SearchWeight)>,
174    /// Long prose the item is found by (a book's chapter), read in place.
175    /// A match in a segment the body locates is a hit of its own: its
176    /// location, listed under the location's own type.
177    pub body: Option<SearchBody>,
178    pub payload: Option<Arc<dyn Any + Send + Sync>>,
179}
180
181impl std::fmt::Debug for Item {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.debug_struct("Item")
184            .field("kind", &self.kind)
185            .field("id", &self.id)
186            .field("fields", &self.fields)
187            .field("found_by", &self.found_by)
188            .field("body", &self.body.is_some())
189            .field("payload", &self.payload.is_some())
190            .finish()
191    }
192}
193
194impl PartialEq for Item {
195    fn eq(&self, other: &Self) -> bool {
196        self.kind == other.kind
197            && self.id == other.id
198            && self.fields == other.fields
199            && self.found_by == other.found_by
200            && self.body == other.body
201    }
202}
203
204impl Item {
205    #[must_use]
206    pub fn new(kind: &'static str, id: impl Into<String>) -> Self {
207        Self {
208            kind,
209            id: id.into(),
210            fields: Vec::new(),
211            found_by: Vec::new(),
212            body: None,
213            payload: None,
214        }
215    }
216
217    /// The item an app's search entry describes, typed by the declaration
218    /// whose plural names the entry's group (an entry without a group is
219    /// the first type's): its title and subtitle fill the type's title and
220    /// subtitle fields, its facets the filter fields of the same keys, and
221    /// its fields and body are what it is found by. The entry rides along
222    /// as the payload, so opening the item invokes what the entry does.
223    /// `None` when no type is the entry's or it addresses nothing.
224    #[must_use]
225    pub fn from_entry(kinds: &[KindDecl], entry: &SearchEntry) -> Option<Self> {
226        let decl = match entry.group.as_deref() {
227            Some(group) => kinds.iter().find(|kind| kind.plural == group)?,
228            None => kinds.first()?,
229        };
230        let id = entry.anchor.clone().or_else(|| {
231            entry
232                .action
233                .as_ref()
234                .map(|action| format!("{}\u{1f}{}", action.resource, action.action))
235        })?;
236        let role = |role: FieldRole| {
237            decl.fields
238                .iter()
239                .find(|field| field.role == Some(role))
240                .map(|field| field.key)
241        };
242        let mut item = Self::new(decl.kind, id);
243        if let Some(key) = role(FieldRole::Title) {
244            item = item.with(key, entry.title.clone());
245        }
246        if let (Some(key), Some(subtitle)) = (role(FieldRole::Subtitle), &entry.subtitle) {
247            item = item.with(key, subtitle.clone());
248        }
249        for (key, _, value) in &entry.facets {
250            let Some(field) = decl.fields.iter().find(|field| field.key == key) else {
251                continue;
252            };
253            // A subtitle that is also a filter (a book's author) is one value.
254            if !item.values(field.key).any(|held| held == value) {
255                item = item.with(field.key, value.clone());
256            }
257        }
258        if !entry.text.trim().is_empty() {
259            item.found_by.push((entry.text.clone(), SearchWeight::Body));
260        }
261        item.found_by.extend(entry.fields.iter().cloned());
262        item.body.clone_from(&entry.body);
263        item.payload = Some(Arc::new(entry.clone()));
264        Some(item)
265    }
266
267    /// Add `value` to the field `key`; an empty value adds nothing.
268    #[must_use]
269    pub fn with(mut self, key: &'static str, value: impl Into<String>) -> Self {
270        let value = value.into();
271        if !value.is_empty() {
272            self.fields.push((key, value));
273        }
274        self
275    }
276
277    /// Set the toggle field `key` on, when `on`.
278    #[must_use]
279    pub fn flag(self, key: &'static str, on: bool) -> Self {
280        if on { self.with(key, TOGGLE_ON) } else { self }
281    }
282
283    /// Attach what a remote source fetched.
284    #[must_use]
285    pub fn payload(mut self, payload: Arc<dyn Any + Send + Sync>) -> Self {
286        self.payload = Some(payload);
287        self
288    }
289
290    /// The first value of the field `key`.
291    #[must_use]
292    pub fn value<'a>(&'a self, key: &'a str) -> Option<&'a str> {
293        self.values(key).next()
294    }
295
296    /// Every value of the field `key`.
297    pub fn values<'a>(&'a self, key: &'a str) -> impl Iterator<Item = &'a str> + 'a {
298        self.fields
299            .iter()
300            .filter(move |(field, _)| *field == key)
301            .map(|(_, value)| value.as_str())
302    }
303
304    /// Whether the toggle field `key` is on.
305    #[must_use]
306    pub fn is_on(&self, key: &str) -> bool {
307        self.values(key).any(|value| value == TOGGLE_ON)
308    }
309
310    /// What identifies the item across sources: its type and id.
311    #[must_use]
312    pub fn key(&self) -> (&'static str, &str) {
313        (self.kind, self.id.as_str())
314    }
315
316    /// The search entry the item was made from ([`Item::from_entry`]).
317    #[must_use]
318    pub fn entry(&self) -> Option<&SearchEntry> {
319        self.payload_as()
320    }
321
322    /// The payload a remote source attached, as `T`.
323    #[must_use]
324    pub fn payload_as<T: Any + Send + Sync>(&self) -> Option<&T> {
325        self.payload.as_deref()?.downcast_ref()
326    }
327}
328
329/// The value a toggle field holds while it is on.
330pub(crate) const TOGGLE_ON: &str = "true";