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