Skip to main content

tilo_app/framework/views/
actions.rs

1//! Actions as the UI: a control is an action of the app rendered as a
2//! control, so searching the app's actions searches its real controls.
3//!
4//! An app says how each of its actions looks as a control
5//! ([`ControlDecl`]: a button or a switch and its state, its icon, and
6//! where in the app the control lives). A query view lists the actions that
7//! match in a small trailing group, "In this app" ([`action_hits`]), each as
8//! that same control, bound to the same action ([`actions_group`]), and
9//! offers to take the person to where it lives.
10
11use std::rc::Rc;
12
13use tilo_actions::ActionSpec;
14use tilo_resource::Resource;
15use tilo_search::{FieldWeight, ListIndex};
16use ui_core::icons::Icon;
17use ui_core::widget::{Button, ButtonRole, Column, ControlSize, Element, Row, Text, Toggle};
18
19use super::super::Actions;
20
21/// How an action looks as a control.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum ControlKind {
24    /// A button that runs it.
25    Button,
26    /// A switch, on or off now, that runs it when flipped.
27    Switch { on: bool },
28}
29
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct ControlDecl {
32    /// The id of the published action or effect.
33    pub action: String,
34    pub kind: ControlKind,
35    pub icon: Option<Icon>,
36    /// The control's own name where it lives ("Shuffle"), which a person
37    /// knows it by, when it differs from the action's label ("Toggle
38    /// shuffle").
39    pub label: Option<String>,
40    /// Where the control lives, in the app's own words (a designed view's
41    /// id): a hit offers to go there.
42    pub home: Option<String>,
43}
44
45impl ControlDecl {
46    #[must_use]
47    pub fn button(action: impl Into<String>) -> Self {
48        Self {
49            action: action.into(),
50            kind: ControlKind::Button,
51            icon: None,
52            label: None,
53            home: None,
54        }
55    }
56
57    #[must_use]
58    pub fn switch(action: impl Into<String>, on: bool) -> Self {
59        Self {
60            kind: ControlKind::Switch { on },
61            ..Self::button(action)
62        }
63    }
64
65    #[must_use]
66    pub fn icon(mut self, icon: Icon) -> Self {
67        self.icon = Some(icon);
68        self
69    }
70
71    /// The control is called `label` where it lives.
72    #[must_use]
73    pub fn label(mut self, label: impl Into<String>) -> Self {
74        self.label = Some(label.into());
75        self
76    }
77
78    /// The control lives in the view `home`.
79    #[must_use]
80    pub fn home(mut self, home: impl Into<String>) -> Self {
81        self.home = Some(home.into());
82        self
83    }
84}
85
86/// An action that matches a query, as the control it is.
87#[derive(Clone)]
88pub struct ActionHit {
89    pub spec: ActionSpec,
90    pub control: ControlDecl,
91}
92
93impl ActionHit {
94    /// What the hit reads: the control's own name, else the action's label.
95    #[must_use]
96    pub fn label(&self) -> &str {
97        self.control.label.as_deref().unwrap_or(&self.spec.label)
98    }
99}
100
101/// Whether a person can run `spec` from a control right now: offered to
102/// people, available, a verb of its own, and asking for nothing more unless
103/// the app declares the control (`declared`), which asks itself (New
104/// playlist asks for its name).
105fn controllable(spec: &ActionSpec, declared: bool) -> bool {
106    spec.audience().reaches_people()
107        && matches!(
108            spec.contract.availability,
109            tilo_actions::Availability::Available
110        )
111        && !spec.carries_actions()
112        && (declared || !spec.params().iter().any(|param| param.required))
113}
114
115/// The actions of `specs` that match `query`, best first, at most `limit`:
116/// each as its declaration in `controls` says, a button where none does.
117/// Only actions a control can run are listed: those asking for nothing
118/// more, and those whose declared control asks for it.
119#[must_use]
120pub fn action_hits(
121    specs: &[ActionSpec],
122    controls: &[ControlDecl],
123    query: &str,
124    limit: usize,
125) -> Vec<ActionHit> {
126    if query.trim().is_empty() || limit == 0 {
127        return Vec::new();
128    }
129    let offered: Vec<&ActionSpec> = specs
130        .iter()
131        .filter(|spec| controllable(spec, controls.iter().any(|decl| decl.action == spec.id)))
132        .collect();
133    let index = ListIndex::default();
134    let ranks = index.ranks(
135        query,
136        offered.iter().map(|spec| {
137            let mut fields = vec![(spec.label.as_str(), FieldWeight::TITLE)];
138            if let Some(label) = controls
139                .iter()
140                .find(|decl| decl.action == spec.id)
141                .and_then(|decl| decl.label.as_deref())
142            {
143                fields.push((label, FieldWeight::KEYWORD));
144            }
145            if let Some(description) = &spec.description {
146                fields.push((description.as_str(), FieldWeight::BODY));
147            }
148            fields
149        }),
150    );
151    let mut found: Vec<(i32, &ActionSpec)> = offered
152        .into_iter()
153        .zip(ranks)
154        .filter_map(|(spec, rank)| rank.map(|rank| (rank, spec)))
155        .collect();
156    found
157        .sort_by(|(a, a_spec), (b, b_spec)| b.cmp(a).then_with(|| a_spec.label.cmp(&b_spec.label)));
158    let mut seen = std::collections::HashSet::new();
159    found
160        .into_iter()
161        .filter(|(_, spec)| seen.insert(spec.id.clone()))
162        .take(limit)
163        .map(|(_, spec)| ActionHit {
164            spec: spec.clone(),
165            control: controls
166                .iter()
167                .find(|decl| decl.action == spec.id)
168                .cloned()
169                .unwrap_or_else(|| ControlDecl::button(spec.id.clone())),
170        })
171        .collect()
172}
173
174/// The control `hit` is, bound to its action through `actions`; an action
175/// the resource no longer publishes shows disabled.
176#[must_use]
177pub fn action_control<R: Resource>(actions: &Actions<R>, hit: &ActionHit) -> Element {
178    let label = hit.label();
179    let id = format!("view-action:{}", hit.spec.id);
180    match hit.control.kind {
181        ControlKind::Button => {
182            let mut button = Button::new(&id, label)
183                .role(ButtonRole::Secondary)
184                .control_size(ControlSize::Small);
185            if let Some(icon) = hit.control.icon {
186                button = button.with_icon(icon);
187            }
188            match actions.spec_binding(&hit.spec) {
189                Some(binding) => Box::new(button.on_action(binding)),
190                None => Box::new(button.with_enabled(false)),
191            }
192        }
193        ControlKind::Switch { on } => {
194            let mut toggle = Toggle::new(&id, "").with_accessible_name(label);
195            toggle.set_checked(on);
196            let control: Element = match actions.spec_switch_binding(&hit.spec) {
197                Some(binding) => Box::new(toggle.on_action(binding)),
198                None => Box::new(toggle.with_enabled(false)),
199            };
200            Box::new(
201                Row::new()
202                    .with_gap(8.0)
203                    .with_align_items(ui_core::taffy::AlignItems::Center)
204                    .push(control)
205                    .push(Box::new(Text::new(label))),
206            )
207        }
208    }
209}
210
211/// The trailing "In this app" group of a query view: its heading, then each
212/// hit as its real control, with a button that takes the person to where the
213/// control lives (`show`, given the control's home) for those that have
214/// one. `heading` and `show_label` are the app's words for them.
215#[must_use]
216pub fn actions_group<R: Resource>(
217    actions: &Actions<R>,
218    hits: &[ActionHit],
219    heading: &str,
220    show_label: &str,
221    show: &Rc<dyn Fn(&str)>,
222) -> Element {
223    let mut column = Column::new().with_no_padding().with_gap(4.0).push(Box::new(
224        Text::new(heading).with_role(ui_core::tokens::TextRole::Heading),
225    ));
226    for hit in hits {
227        let mut row = Row::new()
228            .with_gap(8.0)
229            .with_align_items(ui_core::taffy::AlignItems::Center)
230            .push(action_control(actions, hit));
231        if let Some(home) = hit.control.home.clone() {
232            let show = Rc::clone(show);
233            row = row.push(Box::new(
234                Button::new(&format!("view-action-show:{}", hit.spec.id), show_label)
235                    .role(ButtonRole::Plain)
236                    .control_size(ControlSize::Small)
237                    .with_accessible_name(&format!("{show_label}: {}", hit.label()))
238                    .on_view(move || show(&home)),
239            ));
240        }
241        column = column.push(Box::new(row));
242    }
243    Box::new(column)
244}