1 extern crate unicode_width;
2
3 use super::super::app::{ActiveBlock, App, RouteId};
4 use crate::event::Key;
5 use crate::network::IoEvent;
6 use std::convert::TryInto;
7 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
8
9 // Handle event when the search input block is active
handler(key: Key, app: &mut App)10 pub fn handler(key: Key, app: &mut App) {
11 match key {
12 Key::Ctrl('k') => {
13 app.input.drain(app.input_idx..app.input.len());
14 }
15 Key::Ctrl('u') => {
16 app.input.drain(..app.input_idx);
17 app.input_idx = 0;
18 app.input_cursor_position = 0;
19 }
20 Key::Ctrl('l') => {
21 app.input = vec![];
22 app.input_idx = 0;
23 app.input_cursor_position = 0;
24 }
25 Key::Ctrl('w') => {
26 if app.input_cursor_position == 0 {
27 return;
28 }
29 let word_end = match app.input[..app.input_idx].iter().rposition(|&x| x != ' ') {
30 Some(index) => index + 1,
31 None => 0,
32 };
33 let word_start = match app.input[..word_end].iter().rposition(|&x| x == ' ') {
34 Some(index) => index + 1,
35 None => 0,
36 };
37 let deleted: String = app.input[word_start..app.input_idx].iter().collect();
38 let deleted_len: u16 = UnicodeWidthStr::width(deleted.as_str()).try_into().unwrap();
39 app.input.drain(word_start..app.input_idx);
40 app.input_idx = word_start;
41 app.input_cursor_position -= deleted_len;
42 }
43 Key::End | Key::Ctrl('e') => {
44 app.input_idx = app.input.len();
45 let input_string: String = app.input.iter().collect();
46 app.input_cursor_position = UnicodeWidthStr::width(input_string.as_str())
47 .try_into()
48 .unwrap();
49 }
50 Key::Home | Key::Ctrl('a') => {
51 app.input_idx = 0;
52 app.input_cursor_position = 0;
53 }
54 Key::Left | Key::Ctrl('b') => {
55 if !app.input.is_empty() && app.input_idx > 0 {
56 let last_c = app.input[app.input_idx - 1];
57 app.input_idx -= 1;
58 app.input_cursor_position -= compute_character_width(last_c);
59 }
60 }
61 Key::Right | Key::Ctrl('f') => {
62 if app.input_idx < app.input.len() {
63 let next_c = app.input[app.input_idx];
64 app.input_idx += 1;
65 app.input_cursor_position += compute_character_width(next_c);
66 }
67 }
68 Key::Esc => {
69 app.set_current_route_state(Some(ActiveBlock::Empty), Some(ActiveBlock::Library));
70 }
71 Key::Enter => {
72 let input_str: String = app.input.iter().collect();
73
74 process_input(app, input_str);
75 }
76 Key::Char(c) => {
77 app.input.insert(app.input_idx, c);
78 app.input_idx += 1;
79 app.input_cursor_position += compute_character_width(c);
80 }
81 Key::Backspace | Key::Ctrl('h') => {
82 if !app.input.is_empty() && app.input_idx > 0 {
83 let last_c = app.input.remove(app.input_idx - 1);
84 app.input_idx -= 1;
85 app.input_cursor_position -= compute_character_width(last_c);
86 }
87 }
88 Key::Delete | Key::Ctrl('d') => {
89 if !app.input.is_empty() && app.input_idx < app.input.len() {
90 app.input.remove(app.input_idx);
91 }
92 }
93 _ => {}
94 }
95 }
96
process_input(app: &mut App, input: String)97 fn process_input(app: &mut App, input: String) {
98 // Don't do anything if there is no input
99 if input.is_empty() {
100 return;
101 }
102
103 // On searching for a track, clear the playlist selection
104 app.selected_playlist_index = Some(0);
105
106 if attempt_process_uri(app, &input, "https://open.spotify.com/", "/")
107 || attempt_process_uri(app, &input, "spotify:", ":")
108 {
109 return;
110 }
111
112 // Default fallback behavior: treat the input as a raw search phrase.
113 app.dispatch(IoEvent::GetSearchResults(input, app.get_user_country()));
114 app.push_navigation_stack(RouteId::Search, ActiveBlock::SearchResultBlock);
115 }
116
spotify_resource_id(base: &str, uri: &str, sep: &str, resource_type: &str) -> (String, bool)117 fn spotify_resource_id(base: &str, uri: &str, sep: &str, resource_type: &str) -> (String, bool) {
118 let uri_prefix = format!("{}{}{}", base, resource_type, sep);
119 let id_string_with_query_params = uri.trim_start_matches(&uri_prefix);
120 let query_idx = id_string_with_query_params
121 .find('?')
122 .unwrap_or_else(|| id_string_with_query_params.len());
123 let id_string = id_string_with_query_params[0..query_idx].to_string();
124 // If the lengths aren't equal, we must have found a match.
125 let matched = id_string_with_query_params.len() != uri.len() && id_string.len() != uri.len();
126 (id_string, matched)
127 }
128
129 // Returns true if the input was successfully processed as a Spotify URI.
attempt_process_uri(app: &mut App, input: &str, base: &str, sep: &str) -> bool130 fn attempt_process_uri(app: &mut App, input: &str, base: &str, sep: &str) -> bool {
131 let (album_id, matched) = spotify_resource_id(base, input, sep, "album");
132 if matched {
133 app.dispatch(IoEvent::GetAlbum(album_id));
134 return true;
135 }
136
137 let (artist_id, matched) = spotify_resource_id(base, input, sep, "artist");
138 if matched {
139 app.get_artist(artist_id, "".to_string());
140 app.push_navigation_stack(RouteId::Artist, ActiveBlock::ArtistBlock);
141 return true;
142 }
143
144 let (track_id, matched) = spotify_resource_id(base, input, sep, "track");
145 if matched {
146 app.dispatch(IoEvent::GetAlbumForTrack(track_id));
147 return true;
148 }
149
150 let (playlist_id, matched) = spotify_resource_id(base, input, sep, "playlist");
151 if matched {
152 app.dispatch(IoEvent::GetPlaylistTracks(playlist_id, 0));
153 return true;
154 }
155
156 let (show_id, matched) = spotify_resource_id(base, input, sep, "show");
157 if matched {
158 app.dispatch(IoEvent::GetShow(show_id));
159 return true;
160 }
161
162 false
163 }
164
compute_character_width(character: char) -> u16165 fn compute_character_width(character: char) -> u16 {
166 UnicodeWidthChar::width(character)
167 .unwrap()
168 .try_into()
169 .unwrap()
170 }
171
172 #[cfg(test)]
173 mod tests {
174 use super::*;
175
str_to_vec_char(s: &str) -> Vec<char>176 fn str_to_vec_char(s: &str) -> Vec<char> {
177 String::from(s).chars().collect()
178 }
179
180 #[test]
test_compute_character_width_with_multiple_characters()181 fn test_compute_character_width_with_multiple_characters() {
182 assert_eq!(1, compute_character_width('a'));
183 assert_eq!(1, compute_character_width('ß'));
184 assert_eq!(1, compute_character_width('ç'));
185 }
186
187 #[test]
test_input_handler_clear_input_on_ctrl_l()188 fn test_input_handler_clear_input_on_ctrl_l() {
189 let mut app = App::default();
190
191 app.input = str_to_vec_char("My text");
192
193 handler(Key::Ctrl('l'), &mut app);
194
195 assert_eq!(app.input, str_to_vec_char(""));
196 }
197
198 #[test]
test_input_handler_ctrl_u()199 fn test_input_handler_ctrl_u() {
200 let mut app = App::default();
201
202 app.input = str_to_vec_char("My text");
203
204 handler(Key::Ctrl('u'), &mut app);
205 assert_eq!(app.input, str_to_vec_char("My text"));
206
207 app.input_cursor_position = 3;
208 app.input_idx = 3;
209 handler(Key::Ctrl('u'), &mut app);
210 assert_eq!(app.input, str_to_vec_char("text"));
211 }
212
213 #[test]
test_input_handler_ctrl_k()214 fn test_input_handler_ctrl_k() {
215 let mut app = App::default();
216
217 app.input = str_to_vec_char("My text");
218
219 handler(Key::Ctrl('k'), &mut app);
220 assert_eq!(app.input, str_to_vec_char(""));
221
222 app.input = str_to_vec_char("My text");
223 app.input_cursor_position = 2;
224 app.input_idx = 2;
225 handler(Key::Ctrl('k'), &mut app);
226 assert_eq!(app.input, str_to_vec_char("My"));
227
228 handler(Key::Ctrl('k'), &mut app);
229 assert_eq!(app.input, str_to_vec_char("My"));
230 }
231
232 #[test]
test_input_handler_ctrl_w()233 fn test_input_handler_ctrl_w() {
234 let mut app = App::default();
235
236 app.input = str_to_vec_char("My text");
237
238 handler(Key::Ctrl('w'), &mut app);
239 assert_eq!(app.input, str_to_vec_char("My text"));
240
241 app.input_cursor_position = 3;
242 app.input_idx = 3;
243 handler(Key::Ctrl('w'), &mut app);
244 assert_eq!(app.input, str_to_vec_char("text"));
245 assert_eq!(app.input_cursor_position, 0);
246 assert_eq!(app.input_idx, 0);
247
248 app.input = str_to_vec_char(" ");
249 app.input_cursor_position = 3;
250 app.input_idx = 3;
251 handler(Key::Ctrl('w'), &mut app);
252 assert_eq!(app.input, str_to_vec_char(" "));
253 assert_eq!(app.input_cursor_position, 0);
254 assert_eq!(app.input_idx, 0);
255 app.input_cursor_position = 1;
256 app.input_idx = 1;
257 handler(Key::Ctrl('w'), &mut app);
258 assert_eq!(app.input, str_to_vec_char(""));
259 assert_eq!(app.input_cursor_position, 0);
260 assert_eq!(app.input_idx, 0);
261
262 app.input = str_to_vec_char("Hello there ");
263 app.input_cursor_position = 13;
264 app.input_idx = 13;
265 handler(Key::Ctrl('w'), &mut app);
266 assert_eq!(app.input, str_to_vec_char("Hello "));
267 assert_eq!(app.input_cursor_position, 6);
268 assert_eq!(app.input_idx, 6);
269 }
270
271 #[test]
test_input_handler_esc_back_to_playlist()272 fn test_input_handler_esc_back_to_playlist() {
273 let mut app = App::default();
274
275 app.set_current_route_state(Some(ActiveBlock::MyPlaylists), None);
276 handler(Key::Esc, &mut app);
277
278 let current_route = app.get_current_route();
279 assert_eq!(current_route.active_block, ActiveBlock::Empty);
280 }
281
282 #[test]
test_input_handler_on_enter_text()283 fn test_input_handler_on_enter_text() {
284 let mut app = App::default();
285
286 app.input = str_to_vec_char("My tex");
287 app.input_cursor_position = app.input.len().try_into().unwrap();
288 app.input_idx = app.input.len();
289
290 handler(Key::Char('t'), &mut app);
291
292 assert_eq!(app.input, str_to_vec_char("My text"));
293 }
294
295 #[test]
test_input_handler_backspace()296 fn test_input_handler_backspace() {
297 let mut app = App::default();
298
299 app.input = str_to_vec_char("My text");
300 app.input_cursor_position = app.input.len().try_into().unwrap();
301 app.input_idx = app.input.len();
302
303 handler(Key::Backspace, &mut app);
304 assert_eq!(app.input, str_to_vec_char("My tex"));
305
306 // Test that backspace deletes from the cursor position
307 app.input_idx = 2;
308 app.input_cursor_position = 2;
309
310 handler(Key::Backspace, &mut app);
311 assert_eq!(app.input, str_to_vec_char("M tex"));
312
313 app.input_idx = 1;
314 app.input_cursor_position = 1;
315
316 handler(Key::Ctrl('h'), &mut app);
317 assert_eq!(app.input, str_to_vec_char(" tex"));
318 }
319
320 #[test]
test_input_handler_delete()321 fn test_input_handler_delete() {
322 let mut app = App::default();
323
324 app.input = str_to_vec_char("My text");
325 app.input_idx = 3;
326 app.input_cursor_position = 3;
327
328 handler(Key::Delete, &mut app);
329 assert_eq!(app.input, str_to_vec_char("My ext"));
330
331 app.input = str_to_vec_char("ラスト");
332 app.input_idx = 1;
333 app.input_cursor_position = 1;
334
335 handler(Key::Delete, &mut app);
336 assert_eq!(app.input, str_to_vec_char("ラト"));
337
338 app.input = str_to_vec_char("Rust");
339 app.input_idx = 2;
340 app.input_cursor_position = 2;
341
342 handler(Key::Ctrl('d'), &mut app);
343 assert_eq!(app.input, str_to_vec_char("Rut"));
344 }
345
346 #[test]
test_input_handler_left_event()347 fn test_input_handler_left_event() {
348 let mut app = App::default();
349
350 app.input = str_to_vec_char("My text");
351 let input_len = app.input.len().try_into().unwrap();
352 app.input_idx = app.input.len();
353 app.input_cursor_position = input_len;
354
355 handler(Key::Left, &mut app);
356 assert_eq!(app.input_cursor_position, input_len - 1);
357 handler(Key::Left, &mut app);
358 assert_eq!(app.input_cursor_position, input_len - 2);
359 handler(Key::Left, &mut app);
360 assert_eq!(app.input_cursor_position, input_len - 3);
361 handler(Key::Ctrl('b'), &mut app);
362 assert_eq!(app.input_cursor_position, input_len - 4);
363 handler(Key::Ctrl('b'), &mut app);
364 assert_eq!(app.input_cursor_position, input_len - 5);
365
366 // Pretend to smash the left event to test the we have no out-of-bounds crash
367 for _ in 0..20 {
368 handler(Key::Left, &mut app);
369 }
370
371 assert_eq!(app.input_cursor_position, 0);
372 }
373
374 #[test]
test_input_handler_on_enter_text_non_english_char()375 fn test_input_handler_on_enter_text_non_english_char() {
376 let mut app = App::default();
377
378 app.input = str_to_vec_char("ыа");
379 app.input_cursor_position = app.input.len().try_into().unwrap();
380 app.input_idx = app.input.len();
381
382 handler(Key::Char('ы'), &mut app);
383
384 assert_eq!(app.input, str_to_vec_char("ыаы"));
385 }
386
387 #[test]
test_input_handler_on_enter_text_wide_char()388 fn test_input_handler_on_enter_text_wide_char() {
389 let mut app = App::default();
390
391 app.input = str_to_vec_char("你");
392 app.input_cursor_position = 2; // 你 is 2 char wide
393 app.input_idx = 1; // 1 char
394
395 handler(Key::Char('好'), &mut app);
396
397 assert_eq!(app.input, str_to_vec_char("你好"));
398 assert_eq!(app.input_idx, 2);
399 assert_eq!(app.input_cursor_position, 4);
400 }
401
402 mod test_uri_parsing {
403 use super::*;
404
405 const URI_BASE: &str = "spotify:";
406 const URL_BASE: &str = "https://open.spotify.com/";
407
check_uri_parse(expected_id: &str, parsed: (String, bool))408 fn check_uri_parse(expected_id: &str, parsed: (String, bool)) {
409 assert_eq!(parsed.1, true);
410 assert_eq!(parsed.0, expected_id);
411 }
412
run_test_for_id_and_resource_type(id: &str, resource_type: &str)413 fn run_test_for_id_and_resource_type(id: &str, resource_type: &str) {
414 check_uri_parse(
415 id,
416 spotify_resource_id(
417 URI_BASE,
418 &format!("spotify:{}:{}", resource_type, id),
419 ":",
420 resource_type,
421 ),
422 );
423 check_uri_parse(
424 id,
425 spotify_resource_id(
426 URL_BASE,
427 &format!("https://open.spotify.com/{}/{}", resource_type, id),
428 "/",
429 resource_type,
430 ),
431 )
432 }
433
434 #[test]
artist()435 fn artist() {
436 let expected_artist_id = "2ye2Wgw4gimLv2eAKyk1NB";
437 run_test_for_id_and_resource_type(expected_artist_id, "artist");
438 }
439
440 #[test]
album()441 fn album() {
442 let expected_album_id = "5gzLOflH95LkKYE6XSXE9k";
443 run_test_for_id_and_resource_type(expected_album_id, "album");
444 }
445
446 #[test]
playlist()447 fn playlist() {
448 let expected_playlist_id = "1cJ6lPBYj2fscs0kqBHsVV";
449 run_test_for_id_and_resource_type(expected_playlist_id, "playlist");
450 }
451
452 #[test]
show()453 fn show() {
454 let expected_show_id = "3aNsrV6lkzmcU1w8u8kA7N";
455 run_test_for_id_and_resource_type(expected_show_id, "show");
456 }
457
458 #[test]
track()459 fn track() {
460 let expected_track_id = "10igKaIKsSB6ZnWxPxPvKO";
461 run_test_for_id_and_resource_type(expected_track_id, "track");
462 }
463
464 #[test]
invalid_format_doesnt_match()465 fn invalid_format_doesnt_match() {
466 let swapped = "show:spotify:3aNsrV6lkzmcU1w8u8kA7N";
467 let totally_wrong = "hehe-haha-3aNsrV6lkzmcU1w8u8kA7N";
468 let random = "random string";
469 let (_, matched) = spotify_resource_id(URI_BASE, swapped, ":", "track");
470 assert_eq!(matched, false);
471 let (_, matched) = spotify_resource_id(URI_BASE, totally_wrong, ":", "track");
472 assert_eq!(matched, false);
473 let (_, matched) = spotify_resource_id(URL_BASE, totally_wrong, "/", "track");
474 assert_eq!(matched, false);
475 let (_, matched) = spotify_resource_id(URL_BASE, random, "/", "track");
476 assert_eq!(matched, false);
477 }
478
479 #[test]
parse_with_query_parameters()480 fn parse_with_query_parameters() {
481 // If this test ever fails due to some change to the parsing logic, it is likely a sign we
482 // should just integrate the url crate instead of trying to do things ourselves.
483 let playlist_url_with_query =
484 "https://open.spotify.com/playlist/1cJ6lPBYj2fscs0kqBHsVV?si=OdwuJsbsSeuUAOadehng3A";
485 let playlist_url = "https://open.spotify.com/playlist/1cJ6lPBYj2fscs0kqBHsVV";
486 let expected_id = "1cJ6lPBYj2fscs0kqBHsVV";
487
488 let (actual_id, matched) = spotify_resource_id(URL_BASE, playlist_url, "/", "playlist");
489 assert_eq!(matched, true);
490 assert_eq!(actual_id, expected_id);
491
492 let (actual_id, matched) =
493 spotify_resource_id(URL_BASE, playlist_url_with_query, "/", "playlist");
494 assert_eq!(matched, true);
495 assert_eq!(actual_id, expected_id);
496 }
497
498 #[test]
mismatched_resource_types_do_not_match()499 fn mismatched_resource_types_do_not_match() {
500 let playlist_url =
501 "https://open.spotify.com/playlist/1cJ6lPBYj2fscs0kqBHsVV?si=OdwuJsbsSeuUAOadehng3A";
502 let (_, matched) = spotify_resource_id(URL_BASE, playlist_url, "/", "album");
503 assert_eq!(matched, false);
504 }
505 }
506 }
507