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