nightshade_api/web/
jump.rs1use leptos::prelude::*;
4
5#[derive(Clone)]
8pub struct JumpTarget {
9 pub id: String,
11 pub x: f64,
13 pub y: f64,
15}
16
17impl JumpTarget {
18 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#[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}