aboutsummaryrefslogtreecommitdiffstats
path: root/default-plugins/plugin-manager
diff options
context:
space:
mode:
authorAram Drevekenin <aram@poor.dev>2024-09-30 18:04:40 +0200
committerGitHub <noreply@github.com>2024-09-30 18:04:40 +0200
commit9f1e38f9fa6efd166f5205eaa8603d6b05a68b2b (patch)
tree3e41bdf65e4bb83fc08f3ad23f913630a97a3d97 /default-plugins/plugin-manager
parentdocs(changelog): background plugins (diff)
downloadsl0ppij-9f1e38f9fa6efd166f5205eaa8603d6b05a68b2b.tar.gz
sl0ppij-9f1e38f9fa6efd166f5205eaa8603d6b05a68b2b.tar.xz
sl0ppij-9f1e38f9fa6efd166f5205eaa8603d6b05a68b2b.zip
feat(ui): built-in plugin manager (#3633)
* add plugin list to session info * feat(plugins): new_plugin and reload_plugin API commands * feat(plugins): built-in plugin manager * style(fmt): rustfmt * update plugins
Diffstat (limited to 'default-plugins/plugin-manager')
-rw-r--r--default-plugins/plugin-manager/.cargo/config.toml2
-rw-r--r--default-plugins/plugin-manager/.gitignore1
-rw-r--r--default-plugins/plugin-manager/Cargo.toml10
l---------default-plugins/plugin-manager/LICENSE.md1
-rw-r--r--default-plugins/plugin-manager/src/main.rs1073
5 files changed, 1087 insertions, 0 deletions
diff --git a/default-plugins/plugin-manager/.cargo/config.toml b/default-plugins/plugin-manager/.cargo/config.toml
new file mode 100644
index 00000000..6b77899c
--- /dev/null
+++ b/default-plugins/plugin-manager/.cargo/config.toml
@@ -0,0 +1,2 @@
+[build]
+target = "wasm32-wasi"
diff --git a/default-plugins/plugin-manager/.gitignore b/default-plugins/plugin-manager/.gitignore
new file mode 100644
index 00000000..ea8c4bf7
--- /dev/null
+++ b/default-plugins/plugin-manager/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/default-plugins/plugin-manager/Cargo.toml b/default-plugins/plugin-manager/Cargo.toml
new file mode 100644
index 00000000..71ef3626
--- /dev/null
+++ b/default-plugins/plugin-manager/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "plugin-manager"
+version = "0.1.0"
+authors = ["Aram Drevekenin <aram@poor.dev>"]
+edition = "2018"
+
+[dependencies]
+uuid = { version = "1.7.0", features = ["v4"] }
+fuzzy-matcher = "0.3.7"
+zellij-tile = { path = "../../zellij-tile" }
diff --git a/default-plugins/plugin-manager/LICENSE.md b/default-plugins/plugin-manager/LICENSE.md
new file mode 120000
index 00000000..f0608a63
--- /dev/null
+++ b/default-plugins/plugin-manager/LICENSE.md
@@ -0,0 +1 @@
+../../LICENSE.md \ No newline at end of file
diff --git a/default-plugins/plugin-manager/src/main.rs b/default-plugins/plugin-manager/src/main.rs
new file mode 100644
index 00000000..bc451d8a
--- /dev/null
+++ b/default-plugins/plugin-manager/src/main.rs
@@ -0,0 +1,1073 @@
+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<usize>,
+ score: i64,
+}
+
+impl SearchResult {
+ pub fn new(plugin_id: u32, plugin_info: &PluginInfo, indices: Vec<usize>, 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<usize>,
+ request_ids: Vec<String>,
+ load_in_background: bool,
+}
+
+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: true,
+ }
+ }
+}
+
+impl NewPluginScreen {
+ 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!("<Ctrl d> - 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: <TAB> - Edit"))
+ .color_range(2, ..=20)
+ .color_range(3, 22..=26)
+ } else if !self.editing_configuration() {
+ Text::new(format!(
+ "Plugin Configuration: <TAB> - Edit, <↓↑> - Navigate, <Del> - 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: <TAB> - Next, <ENTER> - 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("<NO CONFIGURATION>").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() {
+ "<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() {
+ "<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() {
+ "<EMPTY>"
+ } else {
+ config_key
+ };
+ let config_val = if config_val.is_empty() {
+ "<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, ..),
+ 0,
+ y_coordinates,
+ None,
+ None,
+ );
+ 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: <ENTER> - Accept and Load Plugin, <ESC> - 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() && character != ' ' => {
+ 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('d') 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...",
+ )
+ .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<String, String>,
+ plugins: BTreeMap<u32, PluginInfo>,
+ search_results: Vec<SearchResult>,
+ selected_index: Option<usize>,
+ expanded_indices: Vec<usize>,
+ tab_position_to_tab_name: HashMap<usize, String>,
+ plugin_id_to_tab_position: HashMap<u32, usize>,
+ search_term: String,
+ new_plugin_screen: Option<NewPluginScreen>,
+}
+
+register_plugin!(State);
+
+impl ZellijPlugin for State {
+ fn load(&mut self, configuration: BTreeMap<String, String>) {
+ self.userspace_configuration = configuration;
+ subscribe(&[
+ EventType::ModeUpdate,
+ EventType::PaneUpdate,
+ EventType::TabUpdate,
+ EventType::Key,
+ EventType::SessionUpdate,
+ EventType::PermissionRequestResult,
+ ]);
+ }
+ 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::PermissionRequestResult(_) => {
+ let own_plugin_id = get_plugin_ids().plugin_id;
+ rename_plugin_pane(own_plugin_id, "Plugin Manager");
+ 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<usize>, Vec<NestedListItem>) {
+ 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<usize>, Vec<NestedListItem>) {
+ 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<NestedListItem> {
+ 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<usize>,
+ ) -> Vec<NestedListItem> {
+ 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<Vec<usize>>,
+ ) -> 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<Vec<usize>>,
+ ) -> 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, <ENTER> - focus, <TAB> - Reload, <Del> - Close, <INSERT> - New";
+ let middle_text =
+ "Help: <←↓↑→/ENTER> - Navigate, <TAB> - Reload, <Del> - Close, <INSERT> - New";
+ let short_text = "<←↓↑→/ENTER/TAB/Del> - Navigate/Expand/Reload/Close, <INSERT> - New";
+ 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);
+ 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);
+ 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);
+ print_text_with_coordinates(text, 0, y, Some(cols), None);
+ }
+ }
+ pub fn selected_plugin_id(&self) -> Option<u32> {
+ 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);
+ }
+ }
+ 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<String> {
+ 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() && character != ' ' => {
+ 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::Insert if key.has_no_modifiers() => {
+ self.new_plugin_screen = Some(Default::default());
+ should_render = true;
+ },
+ BareKey::Delete if key.has_no_modifiers() => {
+ self.close_selected();
+ },
+ BareKey::Esc if key.has_no_modifiers() => {
+ self.search_term.clear();
+ self.update_search_term();
+ should_render = true;
+ },
+ _ => {},
+ }
+ should_render
+ }
+ pub fn is_searching(&self) -> bool {
+ self.search_term.len() > 0
+ }
+ fn truncate_list_to_screen(
+ &self,
+ selected_index_in_list: Option<usize>,
+ mut plugin_list: Vec<NestedListItem>,
+ max_list_items: usize,
+ ) -> (usize, usize, Vec<NestedListItem>) {
+ 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<usize>,
+) -> (String, Vec<usize>) {
+ 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)
+}