Skip to main content

ravenrs_widgets/
expression_editor.rs

1//! An editor for expressions in the criteria language: the text, what can go in it
2//! grouped and searchable, a description of the chosen item, and the problems found
3//! while typing.
4
5use std::fmt;
6
7use ravenrs_criteria::{Category, Criteria, FieldType, FunctionInfo, Problem, Schema, functions, validate};
8use ravenrs_layout::Align;
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{SPACING, Semantic, TextStyle, Weight};
11
12use crate::context::{BuildCx, NodeId};
13use crate::text_field::{Cursor, byte_index};
14use crate::{
15    Alert, AnyView, Button, ButtonSize, FilterField, FilterFieldKind, HStack, IconName, ScrollView,
16    SidebarItem, Text, TextArea, TextField, Tone, VStack, View, ViewExt,
17};
18
19/// The function groups, in the order of [`Labels::expression_groups`](crate::Labels::expression_groups)
20/// after fields and parameters.
21const FUNCTION_GROUPS: [Category; 6] = [
22    Category::Logical,
23    Category::String,
24    Category::Math,
25    Category::DateTime,
26    Category::Reporting,
27    Category::Aggregate,
28];
29const OPERATORS: usize = 8;
30const CONSTANTS: usize = 9;
31
32/// Where the expression editor's groups and lists stand.
33const LISTS_HEIGHT: f32 = 340.0;
34const GROUPS_WIDTH: f32 = 140.0;
35const ITEMS_WIDTH: f32 = 200.0;
36
37/// An expression editor, as report designers have: the expression in several lines, the
38/// fields, parameters, functions, operators and constants that can go in it, a search,
39/// the signature and description of the chosen one, and a check as the text changes.
40///
41/// A click on an item shows what it is; a second click, or the insert button, puts it
42/// where the caret is. Syntax errors say their line and column; fields, parameters and
43/// functions the editor does not know are listed apart, since the expression may still
44/// run where they exist.
45///
46/// ```
47/// use ravenrs_reactive::State;
48/// use ravenrs_widgets::{ExpressionEditor, FilterField, FilterFieldKind, Ui};
49///
50/// let expression = State::new("[Quantity] * [UnitPrice]".to_owned());
51/// let editor = ExpressionEditor::new(expression.binding())
52///     .field(FilterField::new("Quantity", "Quantity", FilterFieldKind::Number))
53///     .field(FilterField::new("UnitPrice", "Unit price", FilterFieldKind::Number))
54///     .parameter(FilterField::new("MinTotal", "Least total", FilterFieldKind::Number));
55/// let mut ui = Ui::new(editor, ravenrs_style::Theme::raven_dark());
56/// ui.update();
57/// ```
58#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
59pub struct ExpressionEditor {
60    value: Binding<String>,
61    fields: Vec<FilterField>,
62    parameters: Vec<FilterField>,
63    rows: usize,
64}
65
66impl fmt::Debug for ExpressionEditor {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        f.debug_struct("ExpressionEditor")
69            .field("fields", &self.fields.len())
70            .field("parameters", &self.parameters.len())
71            .finish_non_exhaustive()
72    }
73}
74
75#[cfg_attr(feature = "live", ravenrs_live::expose)]
76impl ExpressionEditor {
77    /// An editor of `value`, offering the built-in functions, operators and constants.
78    pub fn new(value: Binding<String>) -> Self {
79        Self { value, fields: Vec::new(), parameters: Vec::new(), rows: 5 }
80    }
81
82    /// Offers a field, written `[name]`; a dotted name reads a nested value.
83    pub fn field(mut self, field: FilterField) -> Self {
84        self.fields.push(field);
85        self
86    }
87
88    /// Offers these fields.
89    pub fn fields(mut self, fields: impl IntoIterator<Item = FilterField>) -> Self {
90        self.fields.extend(fields);
91        self
92    }
93
94    /// Offers a parameter, written `?name`.
95    pub fn parameter(mut self, parameter: FilterField) -> Self {
96        self.parameters.push(parameter);
97        self
98    }
99
100    /// Offers these parameters.
101    pub fn parameters(mut self, parameters: impl IntoIterator<Item = FilterField>) -> Self {
102        self.parameters.extend(parameters);
103        self
104    }
105
106    /// How many lines of the expression show before it scrolls (5 by default).
107    pub fn rows(mut self, rows: usize) -> Self {
108        self.rows = rows.max(1);
109        self
110    }
111
112    /// What the names offered declare, for the check.
113    fn schema(&self) -> Schema {
114        let kind = |k: FilterFieldKind| match k {
115            FilterFieldKind::Text => FieldType::Text,
116            FilterFieldKind::Number => FieldType::Number,
117            FilterFieldKind::Boolean => FieldType::Boolean,
118            FilterFieldKind::Date => FieldType::Date,
119        };
120        let schema = self.fields.iter().fold(Schema::new(), |s, f| s.field(&f.name, kind(f.kind)));
121        self.parameters.iter().fold(schema, |s, p| s.parameter(&p.name, kind(p.kind)))
122    }
123
124    /// The items of group `group`.
125    fn items(&self, group: usize, labels: &crate::Labels) -> Vec<Item> {
126        let named = |f: &FilterField, insert: String| Item {
127            title: f.caption.clone(),
128            insert,
129            back: 0,
130            signature: String::new(),
131            help: String::new(),
132        };
133        match group {
134            0 => self.fields.iter().map(|f| named(f, format!("[{}]", f.name))).collect(),
135            1 => self.parameters.iter().map(|p| named(p, format!("?{}", p.name))).collect(),
136            OPERATORS => labels
137                .expression_operators
138                .iter()
139                .map(|(op, help)| {
140                    let (insert, back) = match op.as_str() {
141                        "Between" | "In" => (format!(" {op}()"), 1),
142                        _ => (format!(" {op} "), 0),
143                    };
144                    Item { title: op.clone(), insert, back, signature: String::new(), help: help.clone() }
145                })
146                .collect(),
147            CONSTANTS => labels
148                .expression_constants
149                .iter()
150                .map(|(value, help)| Item {
151                    title: value.clone(),
152                    insert: value.clone(),
153                    back: usize::from(value == "''"),
154                    signature: String::new(),
155                    help: help.clone(),
156                })
157                .collect(),
158            g => {
159                let Some(category) = FUNCTION_GROUPS.get(g.wrapping_sub(2)) else { return Vec::new() };
160                let mut list: Vec<FunctionInfo> =
161                    functions::catalog().into_iter().filter(|f| f.category == *category).collect();
162                list.sort_by_key(|f| f.name.to_lowercase());
163                list.dedup_by_key(|f| f.name);
164                list.into_iter()
165                    .map(|f| Item {
166                        title: f.name.to_owned(),
167                        insert: format!("{}()", f.name),
168                        back: 1,
169                        signature: f.signature.to_owned(),
170                        help: f.description.to_owned(),
171                    })
172                    .collect()
173            }
174        }
175    }
176}
177
178/// Something an expression editor lists.
179#[derive(Clone, Debug, PartialEq)]
180struct Item {
181    title: String,
182    /// What goes into the text.
183    insert: String,
184    /// How many characters before the end of `insert` the caret lands, such as inside
185    /// a function's parentheses.
186    back: usize,
187    signature: String,
188    help: String,
189}
190
191/// Puts `item` where the caret is, over the selection if there is one.
192fn insert(value: &Binding<String>, cursor: &State<Cursor>, item: &Item) {
193    let text = value.get();
194    let chars = text.chars().count();
195    let c = cursor.get();
196    let caret = c.caret.min(chars);
197    let (start, end) = c.selection().map_or((caret, caret), |(a, b)| (a.min(chars), b.min(chars)));
198    let (a, b) = (byte_index(&text, start), byte_index(&text, end));
199    value.set(format!("{}{}{}", &text[..a], item.insert, &text[b..]));
200    cursor.set(Cursor::at(start + item.insert.chars().count() - item.back));
201}
202
203/// The line and column, from 1, of byte `offset` in `text`.
204fn line_and_column(text: &str, offset: usize) -> (usize, usize) {
205    let before = &text[..offset.min(text.len())];
206    let line = before.matches('\n').count() + 1;
207    let column = before.rsplit('\n').next().map_or(0, |l| l.chars().count()) + 1;
208    (line, column)
209}
210
211/// How a problem the check found reads.
212fn problem_name(problem: &Problem) -> String {
213    match problem {
214        Problem::UnknownField(n) | Problem::UnknownCollection(n) => format!("[{n}]"),
215        Problem::UnknownParameter(n) => format!("?{n}"),
216        Problem::UnknownFunction(n) => format!("{n}()"),
217        other => other.to_string(),
218    }
219}
220
221#[cfg_attr(feature = "live", ravenrs_live::expose(part = "part1"))]
222impl ExpressionEditor {
223    /// What the check says about `text`, if anything.
224    fn status(&self, text: &str, labels: &crate::Labels) -> Option<Alert> {
225        if text.trim().is_empty() {
226            return None;
227        }
228        match Criteria::parse(text) {
229            Err(error) => {
230                let (line, column) = line_and_column(text, error.offset);
231                let at = labels
232                    .expression_error_at
233                    .replace("{line}", &line.to_string())
234                    .replace("{column}", &column.to_string());
235                Some(Alert::new(at, error.message).tone(Tone::Danger).compact())
236            }
237            Ok(criteria) => {
238                let problems = validate(&criteria, &self.schema());
239                if problems.is_empty() {
240                    Some(Alert::message(labels.expression_valid.clone()).tone(Tone::Success).compact())
241                } else {
242                    let names: Vec<String> = problems.iter().map(problem_name).collect();
243                    Some(
244                        Alert::new(labels.expression_unknown.clone(), names.join(", "))
245                            .tone(Tone::Warning)
246                            .compact(),
247                    )
248                }
249            }
250        }
251    }
252}
253
254impl View for ExpressionEditor {
255    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
256        let labels = cx.labels();
257        let cursor: State<Cursor> = cx.use_state(|| Cursor::at(usize::MAX));
258        let group: State<usize> = cx.use_state(|| 0);
259        let chosen: State<Option<usize>> = cx.use_state(|| None);
260        let query: State<String> = cx.use_state(String::new);
261
262        let text = self.value.get();
263        let status = self.status(&text, &labels);
264
265        // The groups, with how many items each has.
266        let open = group.get();
267        let groups: Vec<AnyView> = labels
268            .expression_groups
269            .iter()
270            .enumerate()
271            .map(|(i, name)| {
272                let (g, c) = (group.clone(), chosen.clone());
273                Box::new(
274                    SidebarItem::text(name.clone())
275                        .compact()
276                        .count(self.items(i, &labels).len().to_string())
277                        .selected(i == open)
278                        .on_click(move || {
279                            g.set(i);
280                            c.set(None);
281                        }),
282                ) as AnyView
283            })
284            .collect();
285
286        // The items of the open group that match the search, or of every group while searching.
287        let needle = query.get().trim().to_lowercase();
288        let pool: Vec<Item> = if needle.is_empty() {
289            self.items(open, &labels)
290        } else {
291            (0..labels.expression_groups.len()).flat_map(|g| self.items(g, &labels)).collect()
292        };
293        let shown: Vec<Item> = pool
294            .into_iter()
295            .filter(|item| {
296                needle.is_empty()
297                    || item.title.to_lowercase().contains(&needle)
298                    || item.insert.to_lowercase().contains(&needle)
299            })
300            .collect();
301        let picked = chosen.get().and_then(|i| shown.get(i)).cloned();
302        let rows: Vec<AnyView> = if shown.is_empty() {
303            vec![Box::new(
304                Text::new(labels.expression_no_match.clone())
305                    .style(TextStyle::Callout)
306                    .foreground(Semantic::InkMuted),
307            )]
308        } else {
309            shown
310                .iter()
311                .enumerate()
312                .map(|(i, item)| {
313                    let (c, value, caret, it) =
314                        (chosen.clone(), self.value.clone(), cursor.clone(), item.clone());
315                    Box::new(
316                        SidebarItem::text(item.title.clone())
317                            .compact()
318                            .selected(picked.as_ref() == Some(item) && chosen.get() == Some(i))
319                            .on_click(move || {
320                                // A second click on the chosen item puts it in.
321                                if c.get() == Some(i) {
322                                    insert(&value, &caret, &it);
323                                } else {
324                                    c.set(Some(i));
325                                }
326                            }),
327                    ) as AnyView
328                })
329                .collect()
330        };
331
332        let description: AnyView = match &picked {
333            Some(item) => {
334                let (value, caret, it) = (self.value.clone(), cursor.clone(), item.clone());
335                let signature = if item.signature.is_empty() {
336                    item.insert.trim().to_owned()
337                } else {
338                    item.signature.clone()
339                };
340                Box::new(
341                    VStack::new((
342                        Text::new(signature).font_weight(Weight::SEMIBOLD),
343                        (!item.help.is_empty()).then(|| {
344                            Text::new(item.help.clone())
345                                .style(TextStyle::Callout)
346                                .foreground(Semantic::InkMuted)
347                        }),
348                        Button::new(labels.expression_insert.clone())
349                            .icon(IconName::Plus)
350                            .size(ButtonSize::Small)
351                            .on_click(move || insert(&value, &caret, &it)),
352                    ))
353                    .spacing(SPACING.s2)
354                    .align(Align::Start),
355                )
356            }
357            None => Box::new(VStack::new(Vec::<AnyView>::new())),
358        };
359
360        let editor = VStack::new((
361            TextArea::new(self.value.clone()).rows(self.rows).cursor(cursor.clone()),
362            status,
363            HStack::new((
364                ScrollView::new((VStack::new(groups).spacing(2.0).align(Align::Stretch),))
365                    .width(GROUPS_WIDTH),
366                VStack::new((
367                    TextField::new(query.binding())
368                        .placeholder(labels.expression_search.clone())
369                        .icon(IconName::Search)
370                        .compact(),
371                    ScrollView::new((VStack::new(rows).spacing(2.0).align(Align::Stretch),)).grow(1.0),
372                ))
373                .spacing(SPACING.s2)
374                .align(Align::Stretch)
375                .width(ITEMS_WIDTH),
376                ScrollView::new((description,)).grow(1.0),
377            ))
378            .spacing(SPACING.s3)
379            .align(Align::Stretch)
380            .height(LISTS_HEIGHT),
381        ))
382        .spacing(SPACING.s3)
383        .align(Align::Stretch);
384        cx.child(&editor)
385    }
386}