Skip to main content

banished_ui/
query.rs

1//! Finding elements by a short query, for tests and scripted runs (not in the original).
2//!
3//! A query is a list of steps separated by spaces. Each step finds elements inside the elements
4//! the step before it found (anywhere below them, not only their children). A step is one of:
5//!
6//! | Step | Finds |
7//! |---|---|
8//! | `buttonExit` | the element with this name in its resource |
9//! | `text:Exit` or `text:"New Game"` | the element whose text is this (letter case ignored) |
10//! | `kind:button` | elements of this kind (see [`Ui::kind_name`]) |
11//! | `ref:PauseMenu` | elements whose resource reference contains this (letter case ignored) |
12//! | `*` | any element |
13//!
14//! Only shown elements count: a hidden element and everything inside it are skipped. For
15//! example, `ref:Dialog/Options buttonVideo` is the Video tab of the Options dialog.
16
17use crate::{Control, Kind, NodeId, Ui};
18
19/// One step of a query.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum Step {
22    /// An element name, compared exactly.
23    Name(String),
24    /// The element's text, letter case ignored.
25    Text(String),
26    /// The element's kind ([`Ui::kind_name`]).
27    Kind(String),
28    /// Part of the element's resource reference, letter case ignored.
29    Reference(String),
30    /// Any element.
31    Any,
32}
33
34/// A parsed query: its steps, in order.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct Query(pub Vec<Step>);
37
38impl Query {
39    /// Parse a query. Text in double quotes may contain spaces. An empty query is an error.
40    pub fn parse(text: &str) -> Result<Query, String> {
41        let mut steps = Vec::new();
42        let mut chars = text.trim().chars().peekable();
43        while chars.peek().is_some() {
44            let mut word = String::new();
45            let mut quoted = false;
46            while let Some(&c) = chars.peek() {
47                chars.next();
48                if c == '"' {
49                    quoted = !quoted;
50                } else if c.is_whitespace() && !quoted {
51                    break;
52                } else {
53                    word.push(c);
54                }
55            }
56            if quoted {
57                return Err(format!("unclosed quote in {text:?}"));
58            }
59            while chars.peek().is_some_and(|c| c.is_whitespace()) {
60                chars.next();
61            }
62            if word.is_empty() {
63                continue;
64            }
65            steps.push(match word.split_once(':') {
66                Some(("text", t)) => Step::Text(t.to_string()),
67                Some(("kind", k)) => Step::Kind(k.to_ascii_lowercase()),
68                Some(("ref", r)) => Step::Reference(r.to_string()),
69                Some((other, _)) if !other.is_empty() && !other.contains('/') => {
70                    return Err(format!("unknown step {word:?} (use text:, kind: or ref:)"));
71                }
72                _ if word == "*" => Step::Any,
73                _ => Step::Name(word),
74            });
75        }
76        if steps.is_empty() {
77            return Err("empty query".into());
78        }
79        Ok(Query(steps))
80    }
81}
82
83/// What a query reports about one element.
84#[derive(Debug, Clone, PartialEq)]
85pub struct ElementInfo {
86    /// The element.
87    pub id: NodeId,
88    /// Its name in its resource.
89    pub name: String,
90    /// The resource it was made from.
91    pub reference: String,
92    /// Its kind ([`Ui::kind_name`]).
93    pub kind: &'static str,
94    /// Its text, or its label's text for a button; empty when it has none.
95    pub text: String,
96    /// Its rectangle after the last layout: left, top, right, bottom.
97    pub rect: [i32; 4],
98    /// Whether it can be used (enabled and not locked).
99    pub enabled: bool,
100    /// For a check box: whether it is checked.
101    pub checked: Option<bool>,
102    /// For a slider, scroll bar or progress bar: its value.
103    pub value: Option<f32>,
104}
105
106impl Ui {
107    /// Every shown element below `roots` (and the roots themselves) that matches `query`, in
108    /// tree order with duplicates removed.
109    pub fn query(&self, roots: &[NodeId], query: &Query) -> Vec<NodeId> {
110        let mut found: Vec<NodeId> = roots
111            .iter()
112            .copied()
113            .filter(|&r| self.nodes[r].visible())
114            .collect();
115        for (i, step) in query.0.iter().enumerate() {
116            let mut next = Vec::new();
117            for &start in &found {
118                // The first step may match a root itself; later steps look strictly inside.
119                self.collect(start, step, i == 0, &mut next);
120            }
121            let mut seen = std::collections::BTreeSet::new();
122            next.retain(|id| seen.insert(*id));
123            found = next;
124        }
125        found
126    }
127
128    fn collect(&self, id: NodeId, step: &Step, include_self: bool, out: &mut Vec<NodeId>) {
129        if !self.nodes[id].visible() {
130            return;
131        }
132        if include_self && self.matches(id, step) {
133            out.push(id);
134        }
135        for c in self.child_ids(id) {
136            self.collect(c, step, true, out);
137        }
138    }
139
140    fn matches(&self, id: NodeId, step: &Step) -> bool {
141        let n = &self.nodes[id];
142        match step {
143            Step::Name(name) => &n.name == name,
144            Step::Text(text) => !text.is_empty() && self.text_of(id).eq_ignore_ascii_case(text),
145            Step::Kind(kind) => self.kind_name(id) == kind,
146            Step::Reference(r) => n
147                .reference
148                .to_ascii_lowercase()
149                .contains(&r.to_ascii_lowercase()),
150            Step::Any => true,
151        }
152    }
153
154    /// A short lower-case name for an element's kind: `group`, `ribbon`, `border`, `image`,
155    /// `animation`, `button`, `text`, `label`, `viewport`, `list`, `lines`, `picture`,
156    /// `unsupported`, or for a control `check`, `slider`, `scroll`, `edit`, `combo`, and so on.
157    pub fn kind_name(&self, id: NodeId) -> &'static str {
158        match &self.nodes[id].kind {
159            Kind::Group => "group",
160            Kind::Ribbon { .. } => "ribbon",
161            Kind::Border { .. } => "border",
162            Kind::Image { .. } => "image",
163            Kind::AnimatedImage { .. } => "animation",
164            Kind::Button { .. } => "button",
165            Kind::Text(_) => "text",
166            Kind::Label { .. } => "label",
167            Kind::Viewport => "viewport",
168            Kind::List(_) => "list",
169            Kind::Lines { .. } => "lines",
170            Kind::Picture { .. } => "picture",
171            Kind::Unsupported(_) => "unsupported",
172            Kind::Control(c) => control_name(c),
173        }
174    }
175
176    /// An element's text: its own for text, labels and edits, its content's for a button.
177    pub fn text_of(&self, id: NodeId) -> String {
178        match &self.nodes[id].kind {
179            Kind::Text(t) => t.text.clone(),
180            Kind::Label { text, .. } => text.text.clone(),
181            Kind::Button { content, .. } => content.map(|c| self.text_of(c)).unwrap_or_default(),
182            Kind::Control(Control::Edit(e)) => e.text.text.clone(),
183            _ => String::new(),
184        }
185    }
186
187    /// What a query reports about `id`.
188    pub fn info(&self, id: NodeId) -> ElementInfo {
189        let n = &self.nodes[id];
190        let control = matches!(n.kind, Kind::Control(_));
191        ElementInfo {
192            id,
193            name: n.name.clone(),
194            reference: n.reference.clone(),
195            kind: self.kind_name(id),
196            text: self.text_of(id),
197            rect: n.rect,
198            enabled: n.usable(),
199            checked: matches!(n.kind, Kind::Control(Control::Check { .. }))
200                .then(|| self.checked(id)),
201            value: if control { self.value(id) } else { None },
202        }
203    }
204}
205
206/// The lower-case name of a control's kind.
207fn control_name(c: &Control) -> &'static str {
208    match c {
209        Control::Slider(_) => "slider",
210        Control::Scroll { .. } => "scroll",
211        Control::Progress { .. } => "progress",
212        Control::Edit(_) => "edit",
213        Control::Graph { .. } => "graph",
214        Control::Table { .. } => "table",
215        Control::Layout(_) => "layout",
216        Control::Tabs { .. } => "tabs",
217        Control::Clip { .. } => "clip",
218        Control::Container { .. } => "container",
219        Control::Check { .. } => "check",
220        Control::Combo { .. } => "combo",
221        Control::Spinner { .. } => "spinner",
222        Control::Page { .. } => "page",
223        Control::Radio { .. } => "radio",
224        Control::Document { .. } => "document",
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn parses_names_kinds_text_and_references() {
234        let q =
235            Query::parse(r#"ref:Dialog/Options  kind:button text:"New Game" buttonOk *"#).unwrap();
236        assert_eq!(
237            q.0,
238            vec![
239                Step::Reference("Dialog/Options".into()),
240                Step::Kind("button".into()),
241                Step::Text("New Game".into()),
242                Step::Name("buttonOk".into()),
243                Step::Any,
244            ]
245        );
246    }
247
248    #[test]
249    fn rejects_bad_queries() {
250        assert!(Query::parse("").is_err());
251        assert!(Query::parse("text:\"open").is_err());
252        assert!(Query::parse("colour:red").is_err());
253    }
254}