Skip to main content

yog_cli/design/
codebehind.rs

1//! Event handlers in a screen's code-behind — what double-clicking a button
2//! in Visual Studio's designer does: `foo.yui`'s `on_click="play"` gets an
3//! `events::PLAY => play(),` arm in the `match` over `click(event)` in
4//! `foo.rs` and an empty `fn play()`, and the editor opens there. An
5//! existing arm is just navigated to. A missing `foo.rs` is created with the
6//! usual skeleton (`ui_include!`, `on_event`, `register`).
7
8use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11use syn::visit::Visit;
12
13/// Where the editor should go.
14#[derive(Debug, Serialize, PartialEq)]
15#[serde(rename_all = "camelCase")]
16pub struct HandlerLocation {
17    pub path: String,
18    /// 1-based.
19    pub line: usize,
20    /// 1-based.
21    pub column: usize,
22    /// The file was written (created or a handler added).
23    pub changed: bool,
24}
25
26/// The `events::` constant for an event name (yog-cli's `const_name`).
27pub fn const_name(value: &str) -> String {
28    let mut out = String::new();
29    let mut prev_lower = false;
30    for c in value.chars() {
31        if c.is_ascii_alphanumeric() {
32            if c.is_ascii_uppercase() && prev_lower {
33                out.push('_');
34            }
35            prev_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
36            out.push(c.to_ascii_uppercase());
37        } else {
38            if !out.ends_with('_') {
39                out.push('_');
40            }
41            prev_lower = false;
42        }
43    }
44    let out = out.trim_matches('_').to_string();
45    if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) {
46        format!("_{out}")
47    } else {
48        out
49    }
50}
51
52/// A Rust fn name for an event: `play`, `mymod:btn-play` → `mymod_btn_play`.
53pub fn handler_name(event: &str) -> String {
54    let name = const_name(event).to_lowercase();
55    let name = name.trim_start_matches('_');
56    if name.is_empty() {
57        "on_click".to_string()
58    } else {
59        name.to_string()
60    }
61}
62
63/// `foo.yui` → `foo.rs` beside it.
64pub fn code_behind_path(yui: &Path) -> PathBuf {
65    yui.with_extension("rs")
66}
67
68fn stem(yui: &Path) -> String {
69    yui.file_stem()
70        .map(|s| s.to_string_lossy().into_owned())
71        .unwrap_or_else(|| "screen".into())
72}
73
74fn skeleton(yui: &Path, constant: &str, handler: &str) -> String {
75    let stem = stem(yui);
76    format!(
77        r#"//! Code-behind for `{stem}.yui` — the screen's widgets come from the
78//! generated `build()`/`render()`/`click()`/`events`/`ids` (`yog build`).
79
80yog_api::ui_include!("{stem}");
81
82/// Handles one UI event of the screen; `true` = used.
83fn on_event(event: &str) -> bool {{
84    let Some(clicked) = click(event) else {{
85        return false;
86    }};
87    match clicked.as_str() {{
88        events::{constant} => {handler}(),
89        _ => {{}}
90    }}
91    true
92}}
93
94fn {handler}() {{
95}}
96
97/// Registers the screen; open it with [`open`].
98pub fn register(registry: &mut yog_api::Registry) {{
99    registry.on_ui_render(ID, |gfx| render(gfx));
100    registry.register_ui(ID, |_ui_id, event| {{
101        on_event(event);
102    }});
103}}
104"#
105    )
106}
107
108/// The `match` over clicked events: one with an `events::` arm, else one
109/// on `….as_str()` of something named like a click.
110struct FindMatch {
111    best: Option<(u8, MatchInfo)>,
112    /// The enclosing top-level fn's last line while visiting.
113    fn_end: usize,
114}
115
116#[derive(Clone)]
117struct MatchInfo {
118    /// Line/column (1-based / 0-based) of the first catch-all arm, if any.
119    catch_all: Option<(usize, usize)>,
120    /// Line of the closing `}` of the match, and its column.
121    close: (usize, usize),
122    /// Column of the first arm (indentation for a new one).
123    arm_col: usize,
124    fn_end: usize,
125}
126
127impl<'ast> Visit<'ast> for FindMatch {
128    fn visit_item_fn(&mut self, f: &'ast syn::ItemFn) {
129        let saved = self.fn_end;
130        self.fn_end = f.block.brace_token.span.close().end().line;
131        syn::visit::visit_item_fn(self, f);
132        self.fn_end = saved;
133    }
134
135    fn visit_expr_match(&mut self, m: &'ast syn::ExprMatch) {
136        let events_arm = m
137            .arms
138            .iter()
139            .any(|a| pat_path(&a.pat).is_some_and(|p| p.starts_with("events::")));
140        let scrutinee = quote_ish(&m.expr);
141        let score = if events_arm {
142            2
143        } else if scrutinee.contains("click") || scrutinee.contains("event") {
144            1
145        } else {
146            0
147        };
148        if score > 0 && self.best.as_ref().is_none_or(|(s, _)| *s < score) {
149            let catch_all = m
150                .arms
151                .iter()
152                .find(|a| matches!(&a.pat, syn::Pat::Wild(_) | syn::Pat::Ident(_)))
153                .and_then(|a| pat_start(&a.pat));
154            let arm_col = m
155                .arms
156                .first()
157                .and_then(|a| pat_start(&a.pat))
158                .map_or(m.match_token.span.start().column + 4, |(_, c)| c);
159            let close = m.brace_token.span.close();
160            self.best = Some((
161                score,
162                MatchInfo {
163                    catch_all,
164                    close: (close.start().line, close.start().column),
165                    arm_col,
166                    fn_end: self.fn_end,
167                },
168            ));
169        }
170        syn::visit::visit_expr_match(self, m);
171    }
172}
173
174/// Line (1-based) and column (0-based) where an arm's pattern starts.
175fn pat_start(p: &syn::Pat) -> Option<(usize, usize)> {
176    let span = match p {
177        syn::Pat::Path(p) => p.path.segments.first()?.ident.span(),
178        syn::Pat::Ident(i) => i.ident.span(),
179        syn::Pat::Wild(w) => w.underscore_token.span,
180        syn::Pat::Lit(l) => match &l.lit {
181            syn::Lit::Str(s) => s.span(),
182            _ => return None,
183        },
184        _ => return None,
185    };
186    Some((span.start().line, span.start().column))
187}
188
189fn pat_path(p: &syn::Pat) -> Option<String> {
190    match p {
191        syn::Pat::Path(p) => Some(
192            p.path
193                .segments
194                .iter()
195                .map(|s| s.ident.to_string())
196                .collect::<Vec<_>>()
197                .join("::"),
198        ),
199        _ => None,
200    }
201}
202
203/// A rough source form of an expression — enough to look for names in it.
204fn quote_ish(e: &syn::Expr) -> String {
205    struct Names(String);
206    impl<'ast> Visit<'ast> for Names {
207        fn visit_ident(&mut self, i: &'ast proc_macro2::Ident) {
208            self.0.push_str(&i.to_string());
209            self.0.push(' ');
210        }
211    }
212    let mut n = Names(String::new());
213    n.visit_expr(e);
214    n.0.to_lowercase()
215}
216
217/// 1-based line/column of the first `needle` in `src`.
218fn find(src: &str, needle: &str) -> Option<(usize, usize)> {
219    let at = src.find(needle)?;
220    let line = src[..at].matches('\n').count() + 1;
221    let col = at - src[..at].rfind('\n').map_or(0, |i| i + 1) + 1;
222    Some((line, col))
223}
224
225/// Byte offset of the start of 1-based `line`.
226fn line_start(src: &str, line: usize) -> usize {
227    if line <= 1 {
228        return 0;
229    }
230    src.match_indices('\n')
231        .nth(line - 2)
232        .map_or(src.len(), |(i, _)| i + 1)
233}
234
235/// Adds (or finds) the handler for `event` in `src`; `None` when there's no
236/// event `match` to add an arm to. Returns the new text and the 1-based
237/// line/column of the handler's body (or the existing arm).
238pub fn add_handler(src: &str, event: &str) -> Result<(String, usize, usize, bool), String> {
239    let constant = const_name(event);
240    let arm_ref = format!("events::{constant}");
241    let handler = handler_name(event);
242    // Already handled: go to the handler fn if there is one, else the arm.
243    if let Some(at) = src.find(&arm_ref).filter(|&at| {
244        !src[at + arm_ref.len()..].starts_with(|c: char| c.is_alphanumeric() || c == '_')
245    }) {
246        let fn_def = format!("fn {handler}(");
247        if let Some((line, col)) = find(src, &fn_def) {
248            return Ok((src.to_string(), line + 1, col + 4, false));
249        }
250        let line = src[..at].matches('\n').count() + 1;
251        let col = at - src[..at].rfind('\n').map_or(0, |i| i + 1) + 1;
252        return Ok((src.to_string(), line, col, false));
253    }
254    let file = syn::parse_file(src).map_err(|e| format!("the code-behind doesn't parse: {e}"))?;
255    let mut finder = FindMatch {
256        best: None,
257        fn_end: 0,
258    };
259    finder.visit_file(&file);
260    let Some((_, m)) = finder.best else {
261        return Err("no `match` over the clicked event (`match clicked.as_str() { … }`) to add the handler to".into());
262    };
263    let indent = " ".repeat(m.arm_col);
264    let arm = format!("{indent}{arm_ref} => {handler}(),\n");
265    let has_fn = src.contains(&format!("fn {handler}("));
266    // The arm: before the catch-all, else just before the match's `}`.
267    let arm_at = match m.catch_all {
268        Some((line, _)) => line_start(src, line),
269        None => line_start(src, m.close.0),
270    };
271    let mut edits = vec![(arm_at, arm)];
272    if !has_fn {
273        // The fn: after the fn holding the match (or at the end).
274        let fn_at = if m.fn_end > 0 {
275            line_start(src, m.fn_end + 1)
276        } else {
277            src.len()
278        };
279        let lead = if fn_at == src.len() && !src.ends_with('\n') {
280            "\n\n"
281        } else {
282            "\n"
283        };
284        edits.push((fn_at, format!("{lead}fn {handler}() {{\n    \n}}\n")));
285    }
286    edits.sort_by_key(|(at, _)| std::cmp::Reverse(*at));
287    let mut out = src.to_string();
288    for (at, text) in edits {
289        out.insert_str(at, &text);
290    }
291    let (line, _) = find(&out, &format!("fn {handler}(")).unwrap_or((0, 1));
292    Ok((out, line + 1, 5, true))
293}
294
295/// Makes sure `yui`'s code-behind handles `event`, writing the file when
296/// needed; where to put the caret.
297pub fn ensure_handler(yui: &Path, event: &str) -> Result<HandlerLocation, String> {
298    let rs = code_behind_path(yui);
299    let path = rs.to_string_lossy().into_owned();
300    if !rs.exists() {
301        let handler = handler_name(event);
302        let text = skeleton(yui, &const_name(event), &handler);
303        std::fs::write(&rs, &text).map_err(|e| format!("{}: {e}", rs.display()))?;
304        let (line, _) = find(&text, &format!("fn {handler}() {{")).unwrap_or((1, 1));
305        return Ok(HandlerLocation {
306            path,
307            line: line + 1,
308            column: 5,
309            changed: true,
310        });
311    }
312    let src = std::fs::read_to_string(&rs).map_err(|e| format!("{}: {e}", rs.display()))?;
313    let (text, line, column, changed) = add_handler(&src, event)?;
314    if changed {
315        std::fs::write(&rs, &text).map_err(|e| format!("{}: {e}", rs.display()))?;
316    }
317    Ok(HandlerLocation {
318        path,
319        line,
320        column,
321        changed,
322    })
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    const SRC: &str = "yog_api::ui_include!(\"menu\");
330
331fn on_event(event: &str) -> bool {
332    let Some(clicked) = click(event) else {
333        return false;
334    };
335    match clicked.as_str() {
336        events::DONE => close(),
337        other => {
338            let _ = other;
339        }
340    }
341    true
342}
343
344fn close() {}
345";
346
347    #[test]
348    fn names_follow_the_generator() {
349        assert_eq!(const_name("filter_all"), "FILTER_ALL");
350        assert_eq!(const_name("mymod:btn-play"), "MYMOD_BTN_PLAY");
351        assert_eq!(handler_name("filterAll"), "filter_all");
352        assert_eq!(handler_name("play_click"), "play_click");
353    }
354
355    #[test]
356    fn adds_an_arm_before_the_catch_all_and_a_fn_after() {
357        let (out, line, col, changed) = add_handler(SRC, "play_click").unwrap();
358        assert!(changed);
359        assert!(out.contains("        events::DONE => close(),\n        events::PLAY_CLICK => play_click(),\n        other => {"), "{out}");
360        assert!(
361            out.contains("    true\n}\n\nfn play_click() {\n    \n}\n\nfn close() {}"),
362            "{out}"
363        );
364        let lines: Vec<&str> = out.lines().collect();
365        assert_eq!(lines[line - 2], "fn play_click() {");
366        assert_eq!(col, 5);
367        syn::parse_file(&out).unwrap();
368    }
369
370    #[test]
371    fn an_existing_handler_is_found_not_duplicated() {
372        let (out, line, _, changed) = add_handler(SRC, "done").unwrap();
373        assert!(!changed);
374        assert_eq!(out, SRC);
375        assert_eq!(line, 8);
376    }
377
378    #[test]
379    fn a_missing_code_behind_gets_the_skeleton() {
380        let dir = std::env::temp_dir().join(format!("yog-idle-cb-{}", std::process::id()));
381        std::fs::create_dir_all(&dir).unwrap();
382        let yui = dir.join("menu.yui");
383        let loc = ensure_handler(&yui, "play").unwrap();
384        let text = std::fs::read_to_string(dir.join("menu.rs")).unwrap();
385        assert!(text.contains("events::PLAY => play(),"));
386        assert_eq!(text.lines().nth(loc.line - 2), Some("fn play() {"));
387        syn::parse_file(&text).unwrap();
388        // Again: just found.
389        assert!(!ensure_handler(&yui, "play").unwrap().changed);
390        std::fs::remove_dir_all(&dir).unwrap();
391    }
392}