Skip to main content

nightshade_api/web/
jump.rs

1//! A keyboard "jump to" overlay that labels on-screen targets and selects one by typed prefix.
2
3use leptos::prelude::*;
4
5/// A place the jump overlay can send you: a stable `id` reported on selection and
6/// the screen position (`x`, `y`, in pixels) where its label is drawn.
7#[derive(Clone)]
8pub struct JumpTarget {
9    /// Identifier passed to the jump callback when this target is chosen.
10    pub id: String,
11    /// Label position in pixels from the left of the overlay.
12    pub x: f64,
13    /// Label position in pixels from the top of the overlay.
14    pub y: f64,
15}
16
17impl JumpTarget {
18    /// Creates a target with the given id and pixel position.
19    pub fn new(id: impl Into<String>, x: f64, y: f64) -> Self {
20        Self {
21            id: id.into(),
22            x,
23            y,
24        }
25    }
26}
27
28fn labels(count: usize) -> Vec<String> {
29    let alphabet: Vec<char> = "asdfghjklqwertyuiopzxcvbnm".chars().collect();
30    if count <= alphabet.len() {
31        alphabet
32            .iter()
33            .take(count)
34            .map(|character| character.to_string())
35            .collect()
36    } else {
37        let mut out = Vec::new();
38        'outer: for first in &alphabet {
39            for second in &alphabet {
40                out.push(format!("{first}{second}"));
41                if out.len() >= count {
42                    break 'outer;
43                }
44            }
45        }
46        out
47    }
48}
49
50/// A full-screen overlay that assigns short letter labels to each `targets`
51/// entry while `open` is set. Typing narrows the labels by prefix; a full match
52/// closes the overlay and runs `on_jump` with that target's id, and Escape
53/// cancels.
54#[component]
55pub fn JumpOverlay(
56    open: RwSignal<bool>,
57    #[prop(into)] targets: Signal<Vec<JumpTarget>>,
58    on_jump: Callback<String>,
59) -> impl IntoView {
60    let typed = RwSignal::new(String::new());
61
62    Effect::new(move |_| {
63        if open.get() {
64            typed.set(String::new());
65        }
66    });
67
68    let labelled = move || {
69        let list = targets.get();
70        let names = labels(list.len());
71        list.into_iter()
72            .zip(names)
73            .map(|(target, label)| (label, target))
74            .collect::<Vec<_>>()
75    };
76
77    let handle = window_event_listener(leptos::ev::keydown, move |event| {
78        if !open.get_untracked() {
79            return;
80        }
81        match event.key().as_str() {
82            "Escape" => {
83                event.prevent_default();
84                open.set(false);
85            }
86            "Backspace" => {
87                event.prevent_default();
88                typed.update(|value| {
89                    value.pop();
90                });
91            }
92            key if key.len() == 1 && key.chars().all(|character| character.is_alphabetic()) => {
93                event.prevent_default();
94                let mut next = typed.get_untracked();
95                next.push_str(&key.to_lowercase());
96                let matched = labelled()
97                    .into_iter()
98                    .find(|(label, _)| label == &next)
99                    .map(|(_, target)| target.id);
100                if let Some(id) = matched {
101                    open.set(false);
102                    typed.set(String::new());
103                    on_jump.run(id);
104                } else if labelled().iter().any(|(label, _)| label.starts_with(&next)) {
105                    typed.set(next);
106                } else {
107                    typed.set(String::new());
108                }
109            }
110            _ => {}
111        }
112    });
113    on_cleanup(move || handle.remove());
114
115    view! {
116        <Show when=move || open.get() fallback=|| ()>
117            <div class="nightshade-jump-overlay" on:click=move |_| open.set(false)>
118                {move || {
119                    let prefix = typed.get();
120                    labelled()
121                        .into_iter()
122                        .filter(|(label, _)| label.starts_with(&prefix))
123                        .map(|(label, target)| {
124                            let (head, tail) = label.split_at(prefix.len());
125                            view! {
126                                <span
127                                    class="nightshade-jump-label"
128                                    style=format!("left:{}px;top:{}px", target.x, target.y)
129                                >
130                                    <span class="nightshade-jump-typed">{head.to_string()}</span>
131                                    {tail.to_string()}
132                                </span>
133                            }
134                        })
135                        .collect_view()
136                }}
137            </div>
138        </Show>
139    }
140}