use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; use uuid::Uuid; use zellij_tile::prelude::*; use std::collections::{BTreeMap, HashMap}; pub struct SearchResult { plugin_id: u32, plugin_info: PluginInfo, indices: Vec, score: i64, } impl SearchResult { pub fn new(plugin_id: u32, plugin_info: &PluginInfo, indices: Vec, score: i64) -> Self { SearchResult { plugin_id, plugin_info: plugin_info.clone(), indices, score, } } } pub struct NewPluginScreen { new_plugin_url: String, new_plugin_config: Vec<(String, String)>, // key/val for easy in-place manipulation new_config_key: String, new_config_val: String, entering_plugin_url: bool, entering_config_key: bool, entering_config_val: bool, selected_config_index: Option, request_ids: Vec, load_in_background: bool, colors: Styling, } impl Default for NewPluginScreen { fn default() -> Self { NewPluginScreen { new_plugin_url: String::new(), new_plugin_config: vec![], new_config_key: String::new(), new_config_val: String::new(), entering_plugin_url: true, entering_config_key: false, entering_config_val: false, selected_config_index: None, request_ids: vec![], load_in_background: false, colors: Palette::default().into(), } } } impl NewPluginScreen { pub fn new(colors: Styling) -> Self { Self { colors, ..Default::default() } } pub fn render(&self, rows: usize, cols: usize) { self.render_title(cols); self.render_url_field(cols); self.render_configuration_title(); let config_list_len = self.render_config_list(cols, rows.saturating_sub(10)); // 10 - the rest self.render_background_toggle(6 + config_list_len + 1); if !self.editing_configuration() { self.render_help(rows); } } fn render_title(&self, cols: usize) { let title_text = format!("LOAD NEW PLUGIN"); let title_text_len = title_text.chars().count(); let title = Text::new(title_text); print_text_with_coordinates( title, (cols / 2).saturating_sub(title_text_len / 2), 0, None, None, ); } fn render_url_field(&self, cols: usize) { let url_field = if self.entering_plugin_url { let truncated_url = truncate_string_start(&self.new_plugin_url, cols.saturating_sub(19)); // 17 the length of the prompt + 2 for padding and cursor let text = format!("Enter Plugin URL: {}_", truncated_url); Text::new(text).color_range(2, ..=16).color_range(3, 18..) } else { let truncated_url = truncate_string_start(&self.new_plugin_url, cols.saturating_sub(18)); // 17 the length of the prompt + 1 for padding let text = format!("Enter Plugin URL: {}", truncated_url); Text::new(text).color_range(2, ..=16).color_range(0, 18..) }; print_text_with_coordinates(url_field, 0, 2, None, None); let url_helper = NestedListItem::new(format!(" - Load from Disk")).color_range(3, ..=8); print_nested_list_with_coordinates(vec![url_helper], 0, 3, None, None); } fn render_configuration_title(&self) { let configuration_title = if !self.editing_configuration() && self.new_plugin_config.is_empty() { Text::new(format!("Plugin Configuration: - Edit")) .color_range(2, ..=20) .color_range(3, 22..=26) } else if !self.editing_configuration() { Text::new(format!( "Plugin Configuration: - Edit, <↓↑> - Navigate, - Delete" )) .color_range(2, ..=20) .color_range(3, 22..=26) .color_range(3, 36..=39) .color_range(3, 53..=57) } else { Text::new(format!( "Plugin Configuration: [Editing: - Next, - Accept]" )) .color_range(2, ..=20) .color_range(3, 32..=36) .color_range(3, 46..=52) }; print_text_with_coordinates(configuration_title, 0, 5, None, None); } fn editing_configuration(&self) -> bool { self.entering_config_key || self.entering_config_val } fn render_config_list(&self, cols: usize, rows: usize) -> usize { let mut items = vec![]; let mut more_config_items = 0; for (i, (config_key, config_val)) in self.new_plugin_config.iter().enumerate() { let is_selected = Some(i) == self.selected_config_index; if i >= rows { more_config_items += 1; } else if is_selected && self.editing_config_line() { items.push(self.render_editing_config_line(config_key, config_val, cols)); } else { items.push(self.render_config_line(config_key, config_val, is_selected, cols)); } } if self.editing_new_config_line() { items.push(self.render_editing_config_line( &self.new_config_key, &self.new_config_val, cols, )); } else if items.is_empty() { items.push(NestedListItem::new("").color_range(0, ..)); } let config_list_len = items.len(); print_nested_list_with_coordinates(items, 0, 6, Some(cols), None); if more_config_items > 0 { let more_text = format!("[+{}]", more_config_items); print_text_with_coordinates( Text::new(more_text).color_range(1, ..), 0, 6 + config_list_len, None, None, ); } config_list_len } fn editing_config_line(&self) -> bool { self.entering_config_key || self.entering_config_val } fn editing_new_config_line(&self) -> bool { (self.entering_config_key || self.entering_config_val) && self.selected_config_index.is_none() } fn render_editing_config_line( &self, config_key: &str, config_val: &str, config_line_max_len: usize, ) -> NestedListItem { let config_line_max_len = config_line_max_len.saturating_sub(6); // 3 - line padding, 1 - // cursor, 2 ": " let config_key_max_len = config_line_max_len / 2; let config_val_max_len = config_line_max_len.saturating_sub(config_key_max_len); let config_key = if config_key.chars().count() > config_key_max_len { truncate_string_start(&config_key, config_key_max_len) } else { config_key.to_owned() }; let config_val = if config_val.chars().count() > config_val_max_len { truncate_string_start(&config_val, config_val_max_len) } else { config_val.to_owned() }; if self.entering_config_key { let val = if config_val.is_empty() { "".to_owned() } else { config_val }; NestedListItem::new(format!("{}_: {}", config_key, val)) .color_range(3, ..=config_key.chars().count()) .color_range(1, config_key.chars().count() + 3..) } else { let key = if config_key.is_empty() { "".to_owned() } else { config_key }; NestedListItem::new(format!("{}: {}_", key, config_val)) .color_range(0, ..key.chars().count()) .color_range(3, key.chars().count() + 2..) } } fn render_config_line( &self, config_key: &str, config_val: &str, is_selected: bool, config_line_max_len: usize, ) -> NestedListItem { let config_line_max_len = config_line_max_len.saturating_sub(5); // 3 - line padding, // 2 - ": " let config_key = if config_key.is_empty() { "" } else { config_key }; let config_val = if config_val.is_empty() { "" } else { config_val }; let config_key_max_len = config_line_max_len / 2; let config_val_max_len = config_line_max_len.saturating_sub(config_key_max_len); let config_key = if config_key.chars().count() > config_key_max_len { truncate_string_start(&config_key, config_key_max_len) } else { config_key.to_owned() }; let config_val = if config_val.chars().count() > config_val_max_len { truncate_string_start(&config_val, config_val_max_len) } else { config_val.to_owned() }; let mut item = NestedListItem::new(format!("{}: {}", config_key, config_val)) .color_range(0, ..config_key.chars().count()) .color_range(1, config_key.chars().count() + 2..); if is_selected { item = item.selected() } item } fn render_background_toggle(&self, y_coordinates: usize) { let key_shortcuts_text = format!("Ctrl l"); print_text_with_coordinates( Text::new(&key_shortcuts_text).color_range(3, ..).opaque(), 0, y_coordinates, None, None, ); let background = self.colors.text_unselected.background; let bg_color = match background { PaletteColor::Rgb((r, g, b)) => format!("\u{1b}[48;2;{};{};{}m\u{1b}[0K", r, g, b), PaletteColor::EightBit(color) => format!("\u{1b}[48;5;{}m\u{1b}[0K", color), }; println!( "\u{1b}[{};{}H{}", y_coordinates + 1, key_shortcuts_text.chars().count() + 1, bg_color ); let load_in_background_text = format!("Load in Background"); let load_in_foreground_text = format!("Load in Foreground"); let (load_in_background_ribbon, load_in_foreground_ribbon) = if self.load_in_background { ( Text::new(&load_in_background_text).selected(), Text::new(&load_in_foreground_text), ) } else { ( Text::new(&load_in_background_text), Text::new(&load_in_foreground_text).selected(), ) }; print_ribbon_with_coordinates( load_in_background_ribbon, key_shortcuts_text.chars().count() + 1, y_coordinates, None, None, ); print_ribbon_with_coordinates( load_in_foreground_ribbon, key_shortcuts_text.chars().count() + 1 + load_in_background_text.chars().count() + 4, y_coordinates, None, None, ); } fn render_help(&self, rows: usize) { let enter_line = Text::new(format!( "Help: - Accept and Load Plugin, - Cancel" )) .color_range(3, 6..=12) .color_range(3, 40..=44); print_text_with_coordinates(enter_line, 0, rows, None, None); } fn get_field_being_edited_mut(&mut self) -> Option<&mut String> { if self.entering_plugin_url { Some(&mut self.new_plugin_url) } else { match self.selected_config_index { Some(selected_config_index) => { if self.entering_config_key { self.new_plugin_config .get_mut(selected_config_index) .map(|(key, _val)| key) } else if self.entering_config_val { self.new_plugin_config .get_mut(selected_config_index) .map(|(_key, val)| val) } else { None } }, None => { if self.entering_config_key { Some(&mut self.new_config_key) } else if self.entering_config_val { Some(&mut self.new_config_val) } else { None } }, } } } fn add_edit_buffer_to_config(&mut self) { if !self.new_config_key.is_empty() || !self.new_config_val.is_empty() { self.new_plugin_config.push(( self.new_config_key.drain(..).collect(), self.new_config_val.drain(..).collect(), )); } } pub fn handle_key(&mut self, key: KeyWithModifier) -> (bool, bool) { let (mut should_render, mut should_close) = (false, false); match key.bare_key { BareKey::Char(character) if key.has_no_modifiers() => { if let Some(field) = self.get_field_being_edited_mut() { field.push(character); } should_render = true; }, BareKey::Backspace if key.has_no_modifiers() => { if let Some(field) = self.get_field_being_edited_mut() { field.pop(); } should_render = true; }, BareKey::Enter if key.has_no_modifiers() => { if self.editing_configuration() { self.add_edit_buffer_to_config(); self.entering_config_key = false; self.entering_config_val = false; self.entering_plugin_url = true; should_render = true; } else { let plugin_url: String = self.new_plugin_url.drain(..).collect(); self.add_edit_buffer_to_config(); let config = self.new_plugin_config.drain(..).into_iter().collect(); let load_in_background = self.load_in_background; let skip_plugin_cache = true; load_new_plugin(plugin_url, config, load_in_background, skip_plugin_cache); should_render = true; should_close = true; } }, BareKey::Tab if key.has_no_modifiers() => { if self.entering_plugin_url { self.entering_plugin_url = false; self.entering_config_key = true; } else if self.entering_config_key { self.entering_config_key = false; self.entering_config_val = true; } else if self.entering_config_val { self.entering_config_val = false; if self.selected_config_index.is_none() { // new config, add it to the map self.add_edit_buffer_to_config(); self.entering_config_key = true; } else { self.entering_plugin_url = true; } self.selected_config_index = None; } else if self.selected_config_index.is_some() { self.entering_config_key = true; } else { self.entering_plugin_url = true; } should_render = true; }, BareKey::Esc if key.has_no_modifiers() => { if self.entering_config_key || self.entering_config_val || self.selected_config_index.is_some() { self.entering_plugin_url = true; self.entering_config_key = false; self.entering_config_val = false; self.selected_config_index = None; self.add_edit_buffer_to_config(); should_render = true; } else { should_close = true; } }, BareKey::Down if key.has_no_modifiers() => { if !self.editing_configuration() { let max_len = self.new_plugin_config.len().saturating_sub(1); let has_config_values = !self.new_plugin_config.is_empty(); if self.selected_config_index.is_none() && has_config_values { self.selected_config_index = Some(0); } else if self.selected_config_index == Some(max_len) { self.selected_config_index = None; } else { self.selected_config_index = self.selected_config_index.map(|s| s + 1); } } should_render = true; }, BareKey::Up if key.has_no_modifiers() => { if !self.editing_configuration() { let max_len = self.new_plugin_config.len().saturating_sub(1); let has_config_values = !self.new_plugin_config.is_empty(); if self.selected_config_index.is_none() && has_config_values { self.selected_config_index = Some(max_len); } else if self.selected_config_index == Some(0) { self.selected_config_index = None; } else { self.selected_config_index = self.selected_config_index.map(|s| s.saturating_sub(1)); } } should_render = true; }, BareKey::Delete if key.has_no_modifiers() => { if let Some(selected_config_index) = self.selected_config_index.take() { self.new_plugin_config.remove(selected_config_index); should_render = true; } }, BareKey::Char('f') if key.has_modifiers(&[KeyModifier::Ctrl]) => { let mut args = BTreeMap::new(); let request_id = Uuid::new_v4(); self.request_ids.push(request_id.to_string()); let mut config = BTreeMap::new(); config.insert("request_id".to_owned(), request_id.to_string()); args.insert("request_id".to_owned(), request_id.to_string()); pipe_message_to_plugin( MessageToPlugin::new("filepicker") .with_plugin_url("filepicker") .with_plugin_config(config) .new_plugin_instance_should_have_pane_title( "Select a .wasm file to load as a plugin...", ) .new_plugin_instance_should_be_focused() .with_args(args), ); }, BareKey::Char('l') if key.has_modifiers(&[KeyModifier::Ctrl]) => { self.load_in_background = !self.load_in_background; should_render = true; }, _ => {}, } (should_render, should_close) } } #[derive(Default)] struct State { userspace_configuration: BTreeMap, plugins: BTreeMap, search_results: Vec, selected_index: Option, expanded_indices: Vec, tab_position_to_tab_name: HashMap, plugin_id_to_tab_position: HashMap, search_term: String, new_plugin_screen: Option, colors: Styling, } register_plugin!(State); impl ZellijPlugin for State { fn load(&mut self, configuration: BTreeMap) { self.userspace_configuration = configuration; subscribe(&[ EventType::ModeUpdate, EventType::PaneUpdate, EventType::TabUpdate, EventType::Key, EventType::SessionUpdate, ]); let own_plugin_id = get_plugin_ids().plugin_id; rename_plugin_pane(own_plugin_id, "Plugin Manager"); } fn pipe(&mut self, pipe_message: PipeMessage) -> bool { if pipe_message.name == "filepicker_result" { match (pipe_message.payload, pipe_message.args.get("request_id")) { (Some(payload), Some(request_id)) => { match self .new_plugin_screen .as_mut() .and_then(|n| n.request_ids.iter().position(|p| p == request_id)) { Some(request_id_position) => { self.new_plugin_screen .as_mut() .map(|n| n.request_ids.remove(request_id_position)); let chosen_plugin_location = std::path::PathBuf::from(payload); self.new_plugin_screen.as_mut().map(|n| { n.new_plugin_url = format!("file:{}", chosen_plugin_location.display()) }); }, None => { eprintln!("request id not found"); }, } }, _ => {}, } true } else { false } } fn update(&mut self, event: Event) -> bool { let mut should_render = false; match event { Event::ModeUpdate(mode_info) => { self.colors = mode_info.style.colors; should_render = true; }, Event::SessionUpdate(live_sessions, _dead_sessions) => { for session in live_sessions { if session.is_current_session { if session.plugins != self.plugins { self.plugins = session.plugins; self.reset_selection(); self.update_search_term(); } for tab in session.tabs { self.tab_position_to_tab_name.insert(tab.position, tab.name); } } } should_render = true; }, Event::PaneUpdate(pane_manifest) => { for (tab_position, panes) in pane_manifest.panes { for pane_info in panes { if pane_info.is_plugin { self.plugin_id_to_tab_position .insert(pane_info.id, tab_position); } } } }, Event::Key(key) => match self.new_plugin_screen.as_mut() { Some(new_plugin_screen) => { let (should_render_new_plugin_screen, should_close_new_plugin_screen) = new_plugin_screen.handle_key(key); if should_close_new_plugin_screen { self.new_plugin_screen = None; should_render = true; } else { should_render = should_render_new_plugin_screen; } }, None => should_render = self.handle_main_screen_key(key), }, _ => (), }; should_render } fn render(&mut self, rows: usize, cols: usize) { match &self.new_plugin_screen { Some(new_plugin_screen) => { new_plugin_screen.render(rows, cols); }, None => { self.render_search(cols); let list_y = 2; let max_list_items = rows.saturating_sub(4); // 2 top padding, 2 bottom padding let (selected_index_in_list, plugin_list) = if self.is_searching() { self.render_search_results(cols) } else { self.render_plugin_list(cols) }; let (more_above, more_below, truncated_list) = self.truncate_list_to_screen( selected_index_in_list, plugin_list, max_list_items, ); self.render_more_indication( more_above, more_below, cols, list_y, truncated_list.len(), ); print_nested_list_with_coordinates(truncated_list, 0, list_y, Some(cols), None); self.render_help(rows, cols); }, } } } impl State { fn render_search_results(&self, cols: usize) -> (Option, Vec) { let mut selected_index_in_list = None; let mut plugin_list = vec![]; for (i, search_result) in self.search_results.iter().enumerate() { let is_selected = Some(i) == self.selected_index; if is_selected { selected_index_in_list = Some(plugin_list.len()); } let is_expanded = self.expanded_indices.contains(&i); plugin_list.append(&mut self.render_search_result( search_result, is_selected, is_expanded, cols, None, )); } (selected_index_in_list, plugin_list) } fn render_plugin_list(&self, cols: usize) -> (Option, Vec) { let mut selected_index_in_list = None; let mut plugin_list = vec![]; for (i, (plugin_id, plugin_info)) in self.plugins.iter().enumerate() { let is_selected = Some(i) == self.selected_index; let is_expanded = self.expanded_indices.contains(&i); if is_selected { selected_index_in_list = Some(plugin_list.len()); } plugin_list.append(&mut self.render_plugin( *plugin_id, plugin_info, is_selected, is_expanded, cols, )); } (selected_index_in_list, plugin_list) } fn render_more_indication( &self, more_above: usize, more_below: usize, cols: usize, list_y: usize, list_len: usize, ) { if more_above > 0 { let text = format!("↑ [+{}]", more_above); let text_len = text.chars().count(); print_text_with_coordinates( Text::new(text).color_range(1, ..), cols.saturating_sub(text_len), list_y.saturating_sub(1), None, None, ); } if more_below > 0 { let text = format!("↓ [+{}]", more_below); let text_len = text.chars().count(); print_text_with_coordinates( Text::new(text).color_range(1, ..), cols.saturating_sub(text_len), list_y + list_len, None, None, ); } } pub fn render_search(&self, cols: usize) { let text = format!(" SEARCH: {}_", self.search_term); if text.chars().count() <= cols { let text = Text::new(text).color_range(3, 9..); print_text_with_coordinates(text, 0, 0, None, None); } else { let truncated_search_term = truncate_string_start(&self.search_term, cols.saturating_sub(10)); // 9 the length of the SEARCH prompt + 1 for the cursor let text = format!(" SEARCH: {}_", truncated_search_term); let text = Text::new(text).color_range(3, 9..); print_text_with_coordinates(text, 0, 0, None, None); } } pub fn render_plugin( &self, plugin_id: u32, plugin_info: &PluginInfo, is_selected: bool, is_expanded: bool, cols: usize, ) -> Vec { let mut items = vec![]; let plugin_location_len = plugin_info.location.chars().count(); let max_location_len = cols.saturating_sub(3); // 3 for the bulletin let location_string = if plugin_location_len > max_location_len { truncate_string_start(&plugin_info.location, max_location_len) } else { plugin_info.location.clone() }; let mut item = self.render_plugin_line(location_string, None); if is_selected { item = item.selected(); } items.push(item); if is_expanded { let tab_line = self.render_tab_line(plugin_id, cols); items.push(tab_line); if !plugin_info.configuration.is_empty() { let config_line = NestedListItem::new(format!("Configuration:")) .color_range(2, ..=13) .indent(1); items.push(config_line); for (config_key, config_val) in &plugin_info.configuration { items.push(self.render_config_line(config_key, config_val, cols)) } } } items } fn render_config_line( &self, config_key: &str, config_val: &str, cols: usize, ) -> NestedListItem { let config_line_padding = 9; // 7, left padding + 2 for the ": " between key/val let config_line_max_len = cols.saturating_sub(config_line_padding); let config_key_max_len = config_line_max_len / 2; let config_val_max_len = config_line_max_len.saturating_sub(config_key_max_len); let config_key = if config_key.chars().count() > config_key_max_len { truncate_string_start(&config_key, config_key_max_len) } else { config_key.to_owned() }; let config_val = if config_val.chars().count() > config_val_max_len { truncate_string_start(&config_val, config_val_max_len) } else { config_val.to_owned() }; NestedListItem::new(format!("{}: {}", config_key, config_val)) .indent(2) .color_range(0, ..config_key.chars().count()) .color_range(1, config_key.chars().count() + 2..) } pub fn render_search_result( &self, search_result: &SearchResult, is_selected: bool, is_expanded: bool, cols: usize, plus_indication: Option, ) -> Vec { let mut items = vec![]; let plugin_info = &search_result.plugin_info; let plugin_id = search_result.plugin_id; let indices = &search_result.indices; let plus_indication_len = plus_indication .map(|p| p.to_string().chars().count() + 4) .unwrap_or(0); // 4 for the plus indication decorators and space let max_location_len = cols.saturating_sub(plus_indication_len + 3); // 3 for the bulletin let (location_string, indices) = if plugin_info.location.chars().count() <= max_location_len { (plugin_info.location.clone(), indices.clone()) } else { truncate_search_result(&plugin_info.location, max_location_len, indices) }; let mut item = match plus_indication { Some(plus_indication) => self.render_plugin_line_with_plus_indication( location_string, plus_indication, Some(indices), ), None => self.render_plugin_line(location_string, Some(indices)), }; if is_selected { item = item.selected(); } items.push(item); if is_expanded { let tab_line = self.render_tab_line(plugin_id, cols); items.push(tab_line); if !plugin_info.configuration.is_empty() { let config_line = NestedListItem::new(format!("Configuration:")) .color_range(2, ..=13) .indent(1); items.push(config_line); for (config_key, config_value) in &plugin_info.configuration { items.push(self.render_config_line(config_key, config_value, cols)) } } } items } fn render_plugin_line_with_plus_indication( &self, location_string: String, plus_indication: usize, indices: Option>, ) -> NestedListItem { let mut item = NestedListItem::new(&format!("{} [+{}]", location_string, plus_indication)) .color_range(0, ..) .color_range(1, location_string.chars().count() + 1..); if let Some(indices) = indices { item = item.color_indices(3, indices); } item } fn render_plugin_line( &self, location_string: String, indices: Option>, ) -> NestedListItem { let mut item = NestedListItem::new(location_string).color_range(0, ..); if let Some(indices) = indices { item = item.color_indices(3, indices); } item } fn render_tab_line(&self, plugin_id: u32, max_width: usize) -> NestedListItem { let tab_of_plugin_id = self .get_tab_of_plugin_id(plugin_id) .unwrap_or_else(|| "N/A".to_owned()); let tab_line_padding_count = 10; // 5 the length of the "Tab: " + 5 for the left padding let tab_of_plugin_id = if tab_of_plugin_id.chars().count() + tab_line_padding_count > max_width { truncate_string_start( &tab_of_plugin_id, max_width.saturating_sub(tab_line_padding_count), ) } else { tab_of_plugin_id }; let tab_line = NestedListItem::new(format!("Tab: {}", tab_of_plugin_id)) .color_range(2, ..=3) .indent(1); tab_line } pub fn render_help(&self, y: usize, cols: usize) { let full_text = "Help: <←↓↑→> - Navigate/Expand, - focus, - Reload, - Close, - New, - Exit"; let middle_text = "Help: <←↓↑→/ENTER> - Navigate, - Reload, - Close, - New, - Exit"; let short_text = "<←↓↑→/ENTER/TAB/Del> - Navigate/Expand/Reload/Close, - New, - Exit"; if cols >= full_text.chars().count() { let text = Text::new(full_text) .color_range(3, 5..=11) .color_range(3, 32..=38) .color_range(3, 49..=53) .color_range(3, 65..=69) .color_range(3, 80..=87) .color_range(3, 96..=100); print_text_with_coordinates(text, 0, y, Some(cols), None); } else if cols >= middle_text.chars().count() { let text = Text::new(middle_text) .color_range(3, 6..=17) .color_range(3, 31..=35) .color_range(3, 47..=51) .color_range(3, 62..=69) .color_range(3, 78..=82); print_text_with_coordinates(text, 0, y, Some(cols), None); } else { let text = Text::new(short_text) .color_range(3, ..=21) .color_range(3, 53..=60) .color_range(3, 69..=73); print_text_with_coordinates(text, 0, y, Some(cols), None); } } pub fn selected_plugin_id(&self) -> Option { if self.is_searching() { self.selected_index .and_then(|i| self.search_results.iter().nth(i)) .map(|search_result| search_result.plugin_id) } else { self.selected_index .and_then(|i| self.plugins.iter().nth(i)) .map(|(id, _)| *id) } } pub fn focus_selected(&self) { if let Some(selected_plugin_id) = self.selected_plugin_id() { focus_pane_with_id(PaneId::Plugin(selected_plugin_id), true, false); } } pub fn reload_selected(&self) { if let Some(selected_plugin_id) = self.selected_plugin_id() { reload_plugin_with_id(selected_plugin_id); } } pub fn close_selected(&self) { if let Some(selected_plugin_id) = self.selected_plugin_id() { close_plugin_pane(selected_plugin_id); } } pub fn reset_selection(&mut self) { self.selected_index = None; self.expanded_indices.clear(); } pub fn expand_selected(&mut self) { if let Some(selected_index) = &self.selected_index { self.expanded_indices.push(*selected_index); } } pub fn collapse_selected(&mut self) { if let Some(selected_index) = &self.selected_index { self.expanded_indices.retain(|i| i != selected_index); } } pub fn get_tab_of_plugin_id(&self, plugin_id: u32) -> Option { self.plugin_id_to_tab_position .get(&plugin_id) .and_then(|plugin_id| self.tab_position_to_tab_name.get(plugin_id)) .cloned() } pub fn update_search_term(&mut self) { if self.search_term.is_empty() { self.search_results.clear(); } else { let mut matches = vec![]; let matcher = SkimMatcherV2::default().use_cache(true); for (plugin_id, plugin_info) in &self.plugins { if let Some((score, indices)) = matcher.fuzzy_indices(&plugin_info.location, &self.search_term) { matches.push(SearchResult::new(*plugin_id, plugin_info, indices, score)); } } matches.sort_by(|a, b| b.score.cmp(&a.score)); self.search_results = matches; } } pub fn handle_main_screen_key(&mut self, key: KeyWithModifier) -> bool { let mut should_render = false; match key.bare_key { BareKey::Char(character) if key.has_no_modifiers() => { self.search_term.push(character); self.update_search_term(); self.reset_selection(); should_render = true; }, BareKey::Backspace if key.has_no_modifiers() => { self.search_term.pop(); self.update_search_term(); self.reset_selection(); should_render = true; }, BareKey::Down if key.has_no_modifiers() => { let max_len = if self.is_searching() { self.search_results.len().saturating_sub(1) } else { self.plugins.keys().len().saturating_sub(1) }; if self.selected_index.is_none() { self.selected_index = Some(0); } else if self.selected_index == Some(max_len) { self.selected_index = None; } else { self.selected_index = self.selected_index.map(|s| s + 1); } should_render = true; }, BareKey::Up if key.has_no_modifiers() => { let max_len = if self.is_searching() { self.plugins.keys().len().saturating_sub(1) } else { self.search_results.len().saturating_sub(1) }; if self.selected_index.is_none() { self.selected_index = Some(max_len); } else if self.selected_index == Some(0) { self.selected_index = None; } else { self.selected_index = self.selected_index.map(|s| s.saturating_sub(1)); } should_render = true; }, BareKey::Enter if key.has_no_modifiers() => { self.focus_selected(); }, BareKey::Right if key.has_no_modifiers() => { self.expand_selected(); should_render = true; }, BareKey::Left if key.has_no_modifiers() => { self.collapse_selected(); should_render = true; }, BareKey::Tab if key.has_no_modifiers() => { self.reload_selected(); }, BareKey::Char('a') if key.has_modifiers(&[KeyModifier::Ctrl]) => { self.new_plugin_screen = Some(NewPluginScreen::new(self.colors)); should_render = true; }, BareKey::Delete if key.has_no_modifiers() => { self.close_selected(); }, BareKey::Esc if key.has_no_modifiers() => { if !self.search_term.is_empty() { self.search_term.clear(); self.update_search_term(); should_render = true; } else { close_self(); } }, _ => {}, } should_render } pub fn is_searching(&self) -> bool { self.search_term.len() > 0 } fn truncate_list_to_screen( &self, selected_index_in_list: Option, mut plugin_list: Vec, max_list_items: usize, ) -> (usize, usize, Vec) { let mut more_above = 0; let mut more_below = 0; if plugin_list.len() > max_list_items { let anchor_line = selected_index_in_list.unwrap_or(0); let list_start = anchor_line.saturating_sub(max_list_items / 2); let list_end = (list_start + max_list_items).saturating_sub(1); let mut to_render = vec![]; for (i, item) in plugin_list.drain(..).enumerate() { if i >= list_start && i < list_end { to_render.push(item); } else if i >= list_end { more_below += 1; } else if i < list_start { more_above += 1; } } plugin_list = to_render; } (more_above, more_below, plugin_list) } } fn truncate_string_start(string_to_truncate: &str, max_len: usize) -> String { let mut truncated_string = string_to_truncate.to_owned(); let count_to_remove = truncated_string.chars().count().saturating_sub(max_len) + 5; if truncated_string.chars().count() > max_len { truncated_string.replace_range(0..count_to_remove, "[...]"); } truncated_string } fn truncate_search_result( plugin_location: &str, max_location_len: usize, indices: &Vec, ) -> (String, Vec) { let truncated_location = truncate_string_start(&plugin_location, max_location_len); let truncated_count = plugin_location .chars() .count() .saturating_sub(max_location_len); let adjusted_indices = indices .iter() .filter_map(|i| { if i.saturating_sub(truncated_count) >= 5 { Some(i.saturating_sub(truncated_count)) } else { None } }) .collect(); (truncated_location, adjusted_indices) }