tilo_app/framework/views/
actions.rs1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub enum ControlKind {
24 Button,
26 Switch { on: bool },
28}
29
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct ControlDecl {
32 pub action: String,
34 pub kind: ControlKind,
35 pub icon: Option<Icon>,
36 pub label: Option<String>,
40 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 #[must_use]
73 pub fn label(mut self, label: impl Into<String>) -> Self {
74 self.label = Some(label.into());
75 self
76 }
77
78 #[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#[derive(Clone)]
88pub struct ActionHit {
89 pub spec: ActionSpec,
90 pub control: ControlDecl,
91}
92
93impl ActionHit {
94 #[must_use]
96 pub fn label(&self) -> &str {
97 self.control.label.as_deref().unwrap_or(&self.spec.label)
98 }
99}
100
101fn 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#[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#[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#[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}