diff options
| author | Aram Drevekenin <aram@poor.dev> | 2023-08-24 13:36:24 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-08-24 13:36:24 +0200 |
| commit | bc628abc1266cdc0dbce4f19a89727527a3e39a8 (patch) | |
| tree | 6a0fcac6ec35c71a237bca0128a57a5b40afb0e3 /default-plugins/session-manager/src | |
| parent | docs(changelog): permission system (diff) | |
| download | sl0ppij-bc628abc1266cdc0dbce4f19a89727527a3e39a8.tar.gz sl0ppij-bc628abc1266cdc0dbce4f19a89727527a3e39a8.tar.xz sl0ppij-bc628abc1266cdc0dbce4f19a89727527a3e39a8.zip | |
feat(sessions): add a session manager to switch between sessions, tabs and panes and create new ones (#2721)
* write/read session metadata to disk for all sessions
* switch session client side
* fix tests
* various adjustments
* fix full screen focus bug in tiled panes
* fix tests
* fix permission sorting issue
* cleanups
* add session manager
* fix tests
* various cleanups
* style(fmt): rustfmt
* clear screen before switching sessions
* I hate you clippy
* truncate controls line to width
* version session cache
* attempt to fix plugin tests
* style(fmt): rustfmt
* another attempt to fix the tests in the ci
Diffstat (limited to 'default-plugins/session-manager/src')
| -rw-r--r-- | default-plugins/session-manager/src/main.rs | 208 | ||||
| -rw-r--r-- | default-plugins/session-manager/src/session_list.rs | 381 | ||||
| -rw-r--r-- | default-plugins/session-manager/src/ui/components.rs | 577 | ||||
| -rw-r--r-- | default-plugins/session-manager/src/ui/mod.rs | 357 |
4 files changed, 1523 insertions, 0 deletions
diff --git a/default-plugins/session-manager/src/main.rs b/default-plugins/session-manager/src/main.rs new file mode 100644 index 00000000..66ff54c4 --- /dev/null +++ b/default-plugins/session-manager/src/main.rs @@ -0,0 +1,208 @@ +mod session_list; +mod ui; +use zellij_tile::prelude::*; + +use std::collections::BTreeMap; + +use ui::{ + components::{render_controls_line, render_new_session_line, render_prompt, Colors}, + SessionUiInfo, +}; + +use session_list::SessionList; + +#[derive(Default)] +struct State { + session_name: Option<String>, + sessions: SessionList, + selected_index: Option<usize>, + search_term: String, + new_session_name: Option<String>, + colors: Colors, +} + +register_plugin!(State); + +impl ZellijPlugin for State { + fn load(&mut self, _configuration: BTreeMap<String, String>) { + subscribe(&[ + EventType::ModeUpdate, + EventType::SessionUpdate, + EventType::Key, + ]); + } + + fn update(&mut self, event: Event) -> bool { + let mut should_render = false; + match event { + Event::ModeUpdate(mode_info) => { + self.colors = Colors::new(mode_info.style.colors); + should_render = true; + }, + Event::Key(key) => { + should_render = self.handle_key(key); + }, + Event::PermissionRequestResult(_result) => { + should_render = true; + }, + Event::SessionUpdate(session_infos) => { + self.update_session_infos(session_infos); + should_render = true; + }, + _ => (), + }; + should_render + } + + fn render(&mut self, rows: usize, cols: usize) { + render_prompt( + self.new_session_name.is_some(), + &self.search_term, + self.colors, + ); + let room_for_list = rows.saturating_sub(5); // search line and controls + self.sessions.update_rows(room_for_list); + let list = self + .sessions + .render(room_for_list, cols.saturating_sub(7), self.colors); // 7 for various ui + for line in list { + println!("{}", line.render()); + } + render_new_session_line( + &self.new_session_name, + self.sessions.is_searching, + self.colors, + ); + render_controls_line(self.sessions.is_searching, rows, cols, self.colors); + } +} + +impl State { + fn reset_selected_index(&mut self) { + self.selected_index = None; + } + fn handle_key(&mut self, key: Key) -> bool { + let mut should_render = false; + if let Key::Right = key { + if self.new_session_name.is_none() { + self.sessions.result_expand(); + } + should_render = true; + } else if let Key::Left = key { + if self.new_session_name.is_none() { + self.sessions.result_shrink(); + } + should_render = true; + } else if let Key::Down = key { + if self.new_session_name.is_none() { + self.sessions.move_selection_down(); + } + should_render = true; + } else if let Key::Up = key { + if self.new_session_name.is_none() { + self.sessions.move_selection_up(); + } + should_render = true; + } else if let Key::Char(character) = key { + if character == '\n' { + self.handle_selection(); + } else if let Some(new_session_name) = self.new_session_name.as_mut() { + new_session_name.push(character); + } else { + self.search_term.push(character); + self.sessions + .update_search_term(&self.search_term, &self.colors); + } + should_render = true; + } else if let Key::Backspace = key { + if let Some(new_session_name) = self.new_session_name.as_mut() { + if new_session_name.is_empty() { + self.new_session_name = None; + } else { + new_session_name.pop(); + } + } else { + self.search_term.pop(); + self.sessions + .update_search_term(&self.search_term, &self.colors); + } + should_render = true; + } else if let Key::Ctrl('w') = key { + if self.sessions.is_searching { + // no-op + } else if self.new_session_name.is_some() { + self.new_session_name = None; + } else { + self.new_session_name = Some(String::new()); + } + should_render = true; + } else if let Key::Ctrl('c') = key { + if let Some(new_session_name) = self.new_session_name.as_mut() { + if new_session_name.is_empty() { + self.new_session_name = None; + } else { + new_session_name.clear() + } + } else if !self.search_term.is_empty() { + self.search_term.clear(); + self.sessions + .update_search_term(&self.search_term, &self.colors); + self.reset_selected_index(); + } else { + self.reset_selected_index(); + hide_self(); + } + should_render = true; + } else if let Key::Esc = key { + hide_self(); + } + should_render + } + fn handle_selection(&mut self) { + if let Some(new_session_name) = &self.new_session_name { + if new_session_name.is_empty() { + switch_session(None); + } else if self.session_name.as_ref() == Some(new_session_name) { + // noop - we're already here! + self.new_session_name = None; + } else { + switch_session(Some(new_session_name)); + } + } else if let Some(selected_session_name) = self.sessions.get_selected_session_name() { + let selected_tab = self.sessions.get_selected_tab_position(); + let selected_pane = self.sessions.get_selected_pane_id(); + let is_current_session = self.sessions.selected_is_current_session(); + if is_current_session { + if let Some((pane_id, is_plugin)) = selected_pane { + if is_plugin { + focus_plugin_pane(pane_id, true); + } else { + focus_terminal_pane(pane_id, true); + } + } else if let Some(tab_position) = selected_tab { + go_to_tab(tab_position as u32); + } + } else { + switch_session_with_focus(&selected_session_name, selected_tab, selected_pane); + } + } + hide_self(); + } + fn update_session_infos(&mut self, session_infos: Vec<SessionInfo>) { + let session_infos: Vec<SessionUiInfo> = session_infos + .iter() + .map(|s| SessionUiInfo::from_session_info(s)) + .collect(); + let current_session_name = session_infos.iter().find_map(|s| { + if s.is_current_session { + Some(s.name.clone()) + } else { + None + } + }); + if let Some(current_session_name) = current_session_name { + self.session_name = Some(current_session_name); + } + self.sessions.set_sessions(session_infos); + } +} diff --git a/default-plugins/session-manager/src/session_list.rs b/default-plugins/session-manager/src/session_list.rs new file mode 100644 index 00000000..6cf6f716 --- /dev/null +++ b/default-plugins/session-manager/src/session_list.rs @@ -0,0 +1,381 @@ +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; + +use crate::ui::{ + components::{Colors, LineToRender, ListItem}, + SessionUiInfo, +}; + +#[derive(Debug, Default)] +pub struct SessionList { + pub session_ui_infos: Vec<SessionUiInfo>, + pub selected_index: SelectedIndex, + pub selected_search_index: Option<usize>, + pub search_results: Vec<SearchResult>, + pub is_searching: bool, +} + +impl SessionList { + pub fn set_sessions(&mut self, mut session_ui_infos: Vec<SessionUiInfo>) { + session_ui_infos.sort_unstable_by(|a, b| { + if a.is_current_session { + std::cmp::Ordering::Less + } else if b.is_current_session { + std::cmp::Ordering::Greater + } else { + a.name.cmp(&b.name) + } + }); + self.session_ui_infos = session_ui_infos; + } + pub fn update_search_term(&mut self, search_term: &str, colors: &Colors) { + let mut flattened_assets = self.flatten_assets(colors); + let mut matches = vec![]; + let matcher = SkimMatcherV2::default().use_cache(true); + for (list_item, session_name, tab_position, pane_id, is_current_session) in + flattened_assets.drain(..) + { + if let Some((score, indices)) = matcher.fuzzy_indices(&list_item.name, &search_term) { + matches.push(SearchResult::new( + score, + indices, + list_item, + session_name, + tab_position, + pane_id, + is_current_session, + )); + } + } + matches.sort_by(|a, b| b.score.cmp(&a.score)); + self.search_results = matches; + self.is_searching = !search_term.is_empty(); + self.selected_search_index = Some(0); + } + fn flatten_assets( + &self, + colors: &Colors, + ) -> Vec<(ListItem, String, Option<usize>, Option<(u32, bool)>, bool)> { + // list_item, session_name, tab_position, (pane_id, is_plugin), is_current_session + let mut list_items = vec![]; + for session in &self.session_ui_infos { + let session_name = session.name.clone(); + let is_current_session = session.is_current_session; + list_items.push(( + ListItem::from_session_info(session, *colors), + session_name.clone(), + None, + None, + is_current_session, + )); + for tab in &session.tabs { + let tab_position = tab.position; + list_items.push(( + ListItem::from_tab_info(session, tab, *colors), + session_name.clone(), + Some(tab_position), + None, + is_current_session, + )); + for pane in &tab.panes { + let pane_id = (pane.pane_id, pane.is_plugin); + list_items.push(( + ListItem::from_pane_info(session, tab, pane, *colors), + session_name.clone(), + Some(tab_position), + Some(pane_id), + is_current_session, + )); + } + } + } + list_items + } + pub fn get_selected_session_name(&self) -> Option<String> { + if self.is_searching { + self.selected_search_index + .and_then(|i| self.search_results.get(i)) + .map(|s| s.session_name.clone()) + } else { + self.selected_index + .0 + .and_then(|i| self.session_ui_infos.get(i)) + .map(|s_i| s_i.name.clone()) + } + } + pub fn selected_is_current_session(&self) -> bool { + if self.is_searching { + self.selected_search_index + .and_then(|i| self.search_results.get(i)) + .map(|s| s.is_current_session) + .unwrap_or(false) + } else { + self.selected_index + .0 + .and_then(|i| self.session_ui_infos.get(i)) + .map(|s_i| s_i.is_current_session) + .unwrap_or(false) + } + } + pub fn get_selected_tab_position(&self) -> Option<usize> { + if self.is_searching { + self.selected_search_index + .and_then(|i| self.search_results.get(i)) + .and_then(|s| s.tab_position) + } else { + self.selected_index + .0 + .and_then(|i| self.session_ui_infos.get(i)) + .and_then(|s_i| { + self.selected_index + .1 + .and_then(|i| s_i.tabs.get(i)) + .map(|t| t.position) + }) + } + } + pub fn get_selected_pane_id(&self) -> Option<(u32, bool)> { + // (pane_id, is_plugin) + if self.is_searching { + self.selected_search_index + .and_then(|i| self.search_results.get(i)) + .and_then(|s| s.pane_id) + } else { + self.selected_index + .0 + .and_then(|i| self.session_ui_infos.get(i)) + .and_then(|s_i| { + self.selected_index + .1 + .and_then(|i| s_i.tabs.get(i)) + .and_then(|t| { + self.selected_index + .2 + .and_then(|i| t.panes.get(i)) + .map(|p| (p.pane_id, p.is_plugin)) + }) + }) + } + } + pub fn move_selection_down(&mut self) { + if self.is_searching { + match self.selected_search_index.as_mut() { + Some(search_index) => { + *search_index = search_index.saturating_add(1); + }, + None => { + if !self.search_results.is_empty() { + self.selected_search_index = Some(0); + } + }, + } + } else { + match self.selected_index { + SelectedIndex(None, None, None) => { + if !self.session_ui_infos.is_empty() { + self.selected_index.0 = Some(0); + } + }, + SelectedIndex(Some(selected_session), None, None) => { + if self.session_ui_infos.len() > selected_session + 1 { + self.selected_index.0 = Some(selected_session + 1); + } else { + self.selected_index.0 = None; + self.selected_index.1 = None; + self.selected_index.2 = None; + } + }, + SelectedIndex(Some(selected_session), Some(selected_tab), None) => { + if self + .get_session(selected_session) + .map(|s| s.tabs.len() > selected_tab + 1) + .unwrap_or(false) + { + self.selected_index.1 = Some(selected_tab + 1); + } else { + self.selected_index.1 = Some(0); + } + }, + SelectedIndex(Some(selected_session), Some(selected_tab), Some(selected_pane)) => { + if self + .get_session(selected_session) + .and_then(|s| s.tabs.get(selected_tab)) + .map(|t| t.panes.len() > selected_pane + 1) + .unwrap_or(false) + { + self.selected_index.2 = Some(selected_pane + 1); + } else { + self.selected_index.2 = Some(0); + } + }, + _ => {}, + } + } + } + pub fn move_selection_up(&mut self) { + if self.is_searching { + match self.selected_search_index.as_mut() { + Some(search_index) => { + *search_index = search_index.saturating_sub(1); + }, + None => { + if !self.search_results.is_empty() { + self.selected_search_index = Some(0); + } + }, + } + } else { + match self.selected_index { + SelectedIndex(None, None, None) => { + if !self.session_ui_infos.is_empty() { + self.selected_index.0 = Some(self.session_ui_infos.len().saturating_sub(1)) + } + }, + SelectedIndex(Some(selected_session), None, None) => { + if selected_session > 0 { + self.selected_index.0 = Some(selected_session - 1); + } else { + self.selected_index.0 = None; + } + }, + SelectedIndex(Some(selected_session), Some(selected_tab), None) => { + if selected_tab > 0 { + self.selected_index.1 = Some(selected_tab - 1); + } else { + let tab_count = self + .get_session(selected_session) + .map(|s| s.tabs.len()) + .unwrap_or(0); + self.selected_index.1 = Some(tab_count.saturating_sub(1)) + } + }, + SelectedIndex(Some(selected_session), Some(selected_tab), Some(selected_pane)) => { + if selected_pane > 0 { + self.selected_index.2 = Some(selected_pane - 1); + } else { + let pane_count = self + .get_session(selected_session) + .and_then(|s| s.tabs.get(selected_tab)) + .map(|t| t.panes.len()) + .unwrap_or(0); + self.selected_index.2 = Some(pane_count.saturating_sub(1)) + } + }, + _ => {}, + } + } + } + fn get_session(&self, index: usize) -> Option<&SessionUiInfo> { + self.session_ui_infos.get(index) + } + pub fn result_expand(&mut self) { + // we can't move this to SelectedIndex because the borrow checker is mean + match self.selected_index { + SelectedIndex(Some(selected_session), None, None) => { + let selected_session_has_tabs = self + .get_session(selected_session) + .map(|s| !s.tabs.is_empty()) + .unwrap_or(false); + if selected_session_has_tabs { + self.selected_index.1 = Some(0); + } + }, + SelectedIndex(Some(selected_session), Some(selected_tab), None) => { + let selected_tab_has_panes = self + .get_session(selected_session) + .and_then(|s| s.tabs.get(selected_tab)) + .map(|t| !t.panes.is_empty()) + .unwrap_or(false); + if selected_tab_has_panes { + self.selected_index.2 = Some(0); + } + }, + _ => {}, + } + } + pub fn result_shrink(&mut self) { + self.selected_index.result_shrink(); + } + pub fn update_rows(&mut self, rows: usize) { + if let Some(search_result_rows_until_selected) = self.selected_search_index.map(|i| { + self.search_results + .iter() + .enumerate() + .take(i + 1) + .fold(0, |acc, s| acc + s.1.lines_to_render()) + }) { + if search_result_rows_until_selected > rows + || self.selected_search_index >= Some(self.search_results.len()) + { + self.selected_search_index = None; + } + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct SelectedIndex(pub Option<usize>, pub Option<usize>, pub Option<usize>); + +impl SelectedIndex { + pub fn tabs_are_visible(&self) -> bool { + self.1.is_some() + } + pub fn panes_are_visible(&self) -> bool { + self.2.is_some() + } + pub fn selected_tab_index(&self) -> Option<usize> { + self.1 + } + pub fn session_index_is_selected(&self, index: usize) -> bool { + self.0 == Some(index) + } + pub fn result_shrink(&mut self) { + match self { + SelectedIndex(Some(_selected_session), None, None) => self.0 = None, + SelectedIndex(Some(_selected_session), Some(_selected_tab), None) => self.1 = None, + SelectedIndex(Some(_selected_session), Some(_selected_tab), Some(_selected_pane)) => { + self.2 = None + }, + _ => {}, + } + } +} + +#[derive(Debug)] +pub struct SearchResult { + score: i64, + indices: Vec<usize>, + list_item: ListItem, + session_name: String, + tab_position: Option<usize>, + pane_id: Option<(u32, bool)>, + is_current_session: bool, +} + +impl SearchResult { + pub fn new( + score: i64, + indices: Vec<usize>, + list_item: ListItem, + session_name: String, + tab_position: Option<usize>, + pane_id: Option<(u32, bool)>, + is_current_session: bool, + ) -> Self { + SearchResult { + score, + indices, + list_item, + session_name, + tab_position, + pane_id, + is_current_session, + } + } + pub fn lines_to_render(&self) -> usize { + self.list_item.line_count() + } + pub fn render(&self, max_width: usize) -> Vec<LineToRender> { + self.list_item.render(Some(self.indices.clone()), max_width) + } +} diff --git a/default-plugins/session-manager/src/ui/components.rs b/default-plugins/session-manager/src/ui/components.rs new file mode 100644 index 00000000..6b606589 --- /dev/null +++ b/default-plugins/session-manager/src/ui/components.rs @@ -0,0 +1,577 @@ +use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; +use zellij_tile::prelude::*; + +use crate::ui::{PaneUiInfo, SessionUiInfo, TabUiInfo}; + +#[derive(Debug)] +pub struct ListItem { + pub name: String, + pub session_name: Option<Vec<UiSpan>>, + pub tab_name: Option<Vec<UiSpan>>, + pub pane_name: Option<Vec<UiSpan>>, + colors: Colors, +} + +impl ListItem { + pub fn from_session_info(session_ui_info: &SessionUiInfo, colors: Colors) -> Self { + let session_ui_line = build_session_ui_line(session_ui_info, colors); + ListItem { + name: session_ui_info.name.clone(), + session_name: Some(session_ui_line), + tab_name: None, + pane_name: None, + colors, + } + } + pub fn from_tab_info( + session_ui_info: &SessionUiInfo, + tab_ui_info: &TabUiInfo, + colors: Colors, + ) -> Self { + let session_ui_line = build_session_ui_line(session_ui_info, colors); + let tab_ui_line = build_tab_ui_line(tab_ui_info, colors); + ListItem { + name: tab_ui_info.name.clone(), + session_name: Some(session_ui_line), + tab_name: Some(tab_ui_line), + pane_name: None, + colors, + } + } + pub fn from_pane_info( + session_ui_info: &SessionUiInfo, + tab_ui_info: &TabUiInfo, + pane_ui_info: &PaneUiInfo, + colors: Colors, + ) -> Self { + let session_ui_line = build_session_ui_line(session_ui_info, colors); + let tab_ui_line = build_tab_ui_line(tab_ui_info, colors); + let pane_ui_line = build_pane_ui_line(pane_ui_info, colors); + ListItem { + name: pane_ui_info.name.clone(), + session_name: Some(session_ui_line), + tab_name: Some(tab_ui_line), + pane_name: Some(pane_ui_line), + colors, + } + } + pub fn line_count(&self) -> usize { + let mut line_count = 0; + if self.session_name.is_some() { + line_count += 1 + }; + if self.tab_name.is_some() { + line_count += 1 + }; + if self.pane_name.is_some() { + line_count += 1 + }; + line_count + } + pub fn render(&self, indices: Option<Vec<usize>>, max_cols: usize) -> Vec<LineToRender> { + let mut lines_to_render = vec![]; + if let Some(session_name) = &self.session_name { + let indices = if self.tab_name.is_none() && self.pane_name.is_none() { + indices.clone() + } else { + None + }; + let mut line_to_render = LineToRender::new(self.colors); + let mut remaining_cols = max_cols; + for span in session_name { + span.render( + indices + .clone() + .map(|i| (SpanStyle::ForegroundBold(self.colors.palette.magenta), i)), + &mut line_to_render, + &mut remaining_cols, + ); + } + lines_to_render.push(line_to_render); + } + if let Some(tab_name) = &self.tab_name { + let indices = if self.pane_name.is_none() { + indices.clone() + } else { + None + }; + let mut line_to_render = LineToRender::new(self.colors); + let mut remaining_cols = max_cols; + for span in tab_name { + span.render( + indices + .clone() + .map(|i| (SpanStyle::ForegroundBold(self.colors.palette.magenta), i)), + &mut line_to_render, + &mut remaining_cols, + ); + } + lines_to_render.push(line_to_render); + } + if let Some(pane_name) = &self.pane_name { + let mut line_to_render = LineToRender::new(self.colors); + let mut remaining_cols = max_cols; + for span in pane_name { + span.render( + indices + .clone() + .map(|i| (SpanStyle::ForegroundBold(self.colors.palette.magenta), i)), + &mut line_to_render, + &mut remaining_cols, + ); + } + lines_to_render.push(line_to_render); + } + lines_to_render + } +} + +#[derive(Debug)] +pub enum UiSpan { + UiSpanTelescope(UiSpanTelescope), + TruncatableUiSpan(TruncatableUiSpan), +} + +impl UiSpan { + pub fn render( + &self, + indices: Option<(SpanStyle, Vec<usize>)>, + line_to_render: &mut LineToRender, + remaining_cols: &mut usize, + ) { + match self { + UiSpan::UiSpanTelescope(ui_span_telescope) => { + ui_span_telescope.render(line_to_render, remaining_cols) + }, + UiSpan::TruncatableUiSpan(truncatable_ui_span) => { + truncatable_ui_span.render(indices, line_to_render, remaining_cols) + }, + } + } +} + +#[allow(dead_code)] // in the future this will be moved to be its own component +#[derive(Debug)] +pub enum SpanStyle { + None, + Bold, + Foreground(PaletteColor), + ForegroundBold(PaletteColor), +} + +impl SpanStyle { + pub fn style_string(&self, to_style: &str) -> String { + match self { + SpanStyle::None => to_style.to_owned(), + SpanStyle::Bold => format!("\u{1b}[1m{}\u{1b}[22m", to_style), + SpanStyle::Foreground(color) => match color { + PaletteColor::EightBit(byte) => { + format!("\u{1b}[38;5;{byte}m{}\u{1b}[39m", to_style) + }, + PaletteColor::Rgb((r, g, b)) => { + format!("\u{1b}[38;2;{};{};{}m{}\u{1b}[39m", r, g, b, to_style) + }, + }, + SpanStyle::ForegroundBold(color) => match color { + PaletteColor::EightBit(byte) => { + format!("\u{1b}[38;5;{byte};1m{}\u{1b}[39;22m", to_style) + }, + PaletteColor::Rgb((r, g, b)) => { + format!("\u{1b}[38;2;{};{};{};1m{}\u{1b}[39;22m", r, g, b, to_style) + }, + }, + } + } +} + +impl Default for SpanStyle { + fn default() -> Self { + SpanStyle::None + } +} + +#[derive(Debug, Default)] +pub struct TruncatableUiSpan { + text: String, + style: SpanStyle, +} + +impl TruncatableUiSpan { + pub fn new(text: String, style: SpanStyle) -> Self { + TruncatableUiSpan { text, style } + } + pub fn render( + &self, + indices: Option<(SpanStyle, Vec<usize>)>, + line_to_render: &mut LineToRender, + remaining_cols: &mut usize, + ) { + let mut rendered = String::new(); + let truncated = if *remaining_cols >= self.text.width() { + self.text.clone() + } else { + let mut truncated = String::new(); + for character in self.text.chars() { + if truncated.width() + character.width().unwrap_or(0) <= *remaining_cols { + truncated.push(character); + } else { + break; + } + } + truncated + }; + match indices { + Some((index_style, indices)) => { + for (i, character) in truncated.chars().enumerate() { + // TODO: optimize this by splitting the string up by its indices and only pushing those + // chu8nks + if indices.contains(&i) { + rendered.push_str(&index_style.style_string(&character.to_string())); + } else { + rendered.push_str(&self.style.style_string(&character.to_string())); + } + } + }, + None => { + rendered.push_str(&self.style.style_string(&truncated)); + }, + } + *remaining_cols = remaining_cols.saturating_sub(truncated.width()); + line_to_render.append(&rendered); + } +} + +#[derive(Debug, Default)] +pub struct UiSpanTelescope(Vec<StringAndLength>); + +impl UiSpanTelescope { + pub fn new(string_and_lengths: Vec<StringAndLength>) -> Self { + UiSpanTelescope(string_and_lengths) + } + pub fn render(&self, line_to_render: &mut LineToRender, remaining_cols: &mut usize) { + for string_and_length in &self.0 { + if string_and_length.length < *remaining_cols { + line_to_render.append(&string_and_length.string); + *remaining_cols -= string_and_length.length; + break; + } + } + } +} + +#[derive(Debug, Default, Clone)] +pub struct StringAndLength { + pub string: String, + pub length: usize, +} + +impl StringAndLength { + pub fn new(string: String, length: usize) -> Self { + StringAndLength { string, length } + } +} + +#[derive(Debug, Clone)] +pub struct LineToRender { + line: String, + is_selected: bool, + truncated_result_count: usize, + colors: Colors, +} + +impl LineToRender { + pub fn new(colors: Colors) -> Self { + LineToRender { + line: String::default(), + is_selected: false, + truncated_result_count: 0, + colors, + } + } + pub fn append(&mut self, to_append: &str) { + self.line.push_str(to_append) + } + pub fn make_selected(&mut self) { + self.is_selected = true; + match self.colors.palette.gray { + PaletteColor::EightBit(byte) => { + self.line = format!( + "\u{1b}[48;5;{byte}m\u{1b}[K\r\u{1b}[48;5;{byte}m{}", + self.line + ); + }, + PaletteColor::Rgb((r, g, b)) => { + self.line = format!( + "\u{1b}[48;2;{};{};{}m\u{1b}[K\r\u{1b}[48;5;{};{};{}m{}", + r, g, b, r, g, b, self.line + ); + }, + } + } + pub fn render(&self) -> String { + let mut line = self.line.clone(); + + let more = if self.truncated_result_count > 0 { + self.colors + .red(&format!(" [+{}]", self.truncated_result_count)) + } else { + String::new() + }; + + line.push_str(&more); + if self.is_selected { + self.line.clone() + } else { + format!("\u{1b}[49m{}", line) + } + } + pub fn add_truncated_results(&mut self, result_count: usize) { + self.truncated_result_count += result_count; + } +} + +pub fn build_session_ui_line(session_ui_info: &SessionUiInfo, colors: Colors) -> Vec<UiSpan> { + let mut ui_spans = vec![]; + let tab_count_text = session_ui_info.tabs.len(); + let total_pane_count_text = session_ui_info + .tabs + .iter() + .fold(0, |acc, tab| acc + tab.panes.len()); + let tab_count = format!("{}", tab_count_text); + let tab_count_styled = colors.cyan(&tab_count); + let total_pane_count = format!("{}", total_pane_count_text); + let total_pane_count_styled = colors.green(&total_pane_count); + let session_name = &session_ui_info.name; + let connected_users = format!("{}", session_ui_info.connected_users); + let connected_users_styled = colors.orange(&connected_users); + let session_bullet_span = + UiSpan::UiSpanTelescope(UiSpanTelescope::new(vec![StringAndLength::new( + format!(" > "), + 3, + )])); + let session_name_span = UiSpan::TruncatableUiSpan(TruncatableUiSpan::new( + session_name.clone(), + SpanStyle::ForegroundBold(colors.palette.orange), + )); + let tab_and_pane_count = UiSpan::UiSpanTelescope(UiSpanTelescope::new(vec![ + StringAndLength::new( + format!(" ({tab_count_styled} tabs, {total_pane_count_styled} panes)"), + 2 + tab_count.width() + 7 + total_pane_count.width() + 7, + ), + StringAndLength::new( + format!(" ({tab_count_styled}, {total_pane_count_styled})"), + 2 + tab_count.width() + 2 + total_pane_count.width() + 3, + ), + ])); + let connected_users_count = UiSpan::UiSpanTelescope(UiSpanTelescope::new(vec![ + StringAndLength::new( + format!(" [{connected_users_styled} connected users]"), + 2 + connected_users.width() + 17, + ), + StringAndLength::new( + format!(" [{connected_users_styled}]"), + 2 + connected_users.width() + 1, + ), + ])); + ui_spans.push(session_bullet_span); + ui_spans.push(session_name_span); + ui_spans.push(tab_and_pane_count); + ui_spans.push(connected_users_count); + if session_ui_info.is_current_session { + let current_session_indication = UiSpan::UiSpanTelescope(UiSpanTelescope::new(vec![ + StringAndLength::new(colors.orange(&format!(" <CURRENT SESSION>")), 18), + StringAndLength::new(colors.orange(&format!(" <CURRENT>")), 10), + StringAndLength::new(colors.orange(&format!(" <C>")), 4), + ])); + ui_spans.push(current_session_indication); + } + ui_spans +} + +pub fn build_tab_ui_line(tab_ui_info: &TabUiInfo, colors: Colors) -> Vec<UiSpan> { + let mut ui_spans = vec![]; + let tab_name = &tab_ui_info.name; + let pane_count_text = tab_ui_info.panes.len(); + let pane_count = format!("{}", pane_count_text); + let pane_count_styled = colors.green(&pane_count); + let tab_bullet_span = + UiSpan::UiSpanTelescope(UiSpanTelescope::new(vec![StringAndLength::new( + format!(" - "), + 4, + )])); + let tab_name_span = UiSpan::TruncatableUiSpan(TruncatableUiSpan::new( + tab_name.clone(), + SpanStyle::ForegroundBold(colors.palette.cyan), + )); + let connected_users_count_span = UiSpan::UiSpanTelescope(UiSpanTelescope::new(vec![ + StringAndLength::new( + format!(" ({pane_count_styled} panes)"), + 2 + pane_count.width() + 7, + ), + StringAndLength::new( + format!(" ({pane_count_styled})"), + 2 + pane_count.width() + 1, + ), + ])); + ui_spans.push(tab_bullet_span); + ui_spans.push(tab_name_span); + ui_spans.push(connected_users_count_span); + ui_spans +} + +pub fn build_pane_ui_line(pane_ui_info: &PaneUiInfo, colors: Colors) -> Vec<UiSpan> { + let mut ui_spans = vec![]; + let pane_name = pane_ui_info.name.clone(); + let exit_code = pane_ui_info.exit_code.map(|exit_code_number| { + let exit_code = format!("{}", exit_code_number); + let exit_code = if exit_code_number == 0 { + colors.green(&exit_code) + } else { + colors.red(&exit_code) + }; + exit_code + }); + let pane_bullet_span = + UiSpan::UiSpanTelescope(UiSpanTelescope::new(vec![StringAndLength::new( + format!(" > "), + 6, + )])); + ui_spans.push(pane_bullet_span); + let pane_name_span = + UiSpan::TruncatableUiSpan(TruncatableUiSpan::new(pane_name, SpanStyle::Bold)); + ui_spans.push(pane_name_span); + if let Some(exit_code) = exit_code { + let pane_name_span = UiSpan::UiSpanTelescope(UiSpanTelescope::new(vec![ + StringAndLength::new( + format!(" (EXIT CODE: {exit_code})"), + 13 + exit_code.width() + 1, + ), + StringAndLength::new(format!(" ({exit_code})"), 2 + exit_code.width() + 1), + ])); + ui_spans.push(pane_name_span); + } + ui_spans +} + +pub fn minimize_lines( + total_count: usize, + line_count_to_remove: usize, + selected_index: Option<usize>, +) -> (usize, usize, usize, usize) { + // returns: (start_index, anchor_index, end_index, lines_left_to_remove) + let (count_to_render, line_count_to_remove) = if line_count_to_remove > total_count { + (1, line_count_to_remove.saturating_sub(total_count) + 1) + } else { + (total_count.saturating_sub(line_count_to_remove), 0) + }; + let anchor_index = selected_index.unwrap_or(0); // 5 + let mut start_index = anchor_index.saturating_sub(count_to_render / 2); + let mut end_index = start_index + count_to_render; + if end_index > total_count { + start_index = start_index.saturating_sub(end_index - total_count); + end_index = total_count; + } + (start_index, anchor_index, end_index, line_count_to_remove) +} + +pub fn render_prompt(typing_session_name: bool, search_term: &str, colors: Colors) { + if !typing_session_name { + let prompt = colors.bold(&format!("> {}_", search_term)); + println!("{}\n", prompt); + } else { + println!("\n"); + } +} + +pub fn render_new_session_line(session_name: &Option<String>, is_searching: bool, colors: Colors) { + if is_searching { + return; + } + let new_session_shortcut_text = "<Ctrl w>"; + let new_session_shortcut = colors.magenta(new_session_shortcut_text); + let new_session = colors.bold("New session"); + let enter = colors.magenta("<ENTER>"); + match session_name { + Some(session_name) => { + println!( + "\u{1b}[m > {}_ ({}, {} when done)", + colors.orange(session_name), + colors.bold("Type optional name"), + enter + ); + }, + None => { + println!("\u{1b}[m > {new_session_shortcut} - {new_session}"); + }, + } +} + +pub fn render_controls_line(is_searching: bool, row: usize, max_cols: usize, colors: Colors) { + let (arrows, navigate) = if is_searching { + (colors.magenta("<↓↑>"), colors.bold("Navigate")) + } else { + (colors.magenta("<←↓↑→>"), colors.bold("Navigate and Expand")) + }; + let enter = colors.magenta("<ENTER>"); + let select = colors.bold("Switch to selected"); + let esc = colors.magenta("<ESC>"); + let to_hide = colors.bold("Hide"); + + if max_cols >= 80 { + print!( + "\u{1b}[m\u{1b}[{row}HHelp: {arrows} - {navigate}, {enter} - {select}, {esc} - {to_hide}" + ); + } else if max_cols >= 57 { + let navigate = colors.bold("Navigate"); + let select = colors.bold("Switch"); + print!( + "\u{1b}[m\u{1b}[{row}HHelp: {arrows} - {navigate}, {enter} - {select}, {esc} - {to_hide}" + ); + } else if max_cols >= 20 { + print!("\u{1b}[m\u{1b}[{row}H{arrows}/{enter}/{esc}"); + } +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct Colors { + pub palette: Palette, +} +impl Colors { + pub fn new(palette: Palette) -> Self { + Colors { palette } + } + pub fn bold(&self, text: &str) -> String { + format!("\u{1b}[1m{}\u{1b}[22m", text) + } + + fn color(&self, color: &PaletteColor, text: &str) -> String { + match color { + PaletteColor::EightBit(byte) => { + format!("\u{1b}[38;5;{};1m{}\u{1b}[39;22m", byte, text) + }, + PaletteColor::Rgb((r, g, b)) => { + format!("\u{1b}[38;2;{};{};{};1m{}\u{1b}[39;22m", r, g, b, text) + }, + } + } + pub fn orange(&self, text: &str) -> String { + self.color(&self.palette.orange, text) + } + + pub fn green(&self, text: &str) -> String { + self.color(&self.palette.green, text) + } + + pub fn red(&self, text: &str) -> String { + self.color(&self.palette.red, text) + } + + pub fn cyan(&self, text: &str) -> String { + self.color(&self.palette.cyan, text) + } + + pub fn magenta(&self, text: &str) -> String { + self.color(&self.palette.magenta, text) + } +} diff --git a/default-plugins/session-manager/src/ui/mod.rs b/default-plugins/session-manager/src/ui/mod.rs new file mode 100644 index 00000000..94a1a8a7 --- /dev/null +++ b/default-plugins/session-manager/src/ui/mod.rs @@ -0,0 +1,357 @@ +pub mod components; +use zellij_tile::prelude::*; + +use crate::session_list::{SelectedIndex, SessionList}; +use components::{ + build_pane_ui_line, build_session_ui_line, build_tab_ui_line, minimize_lines, Colors, + LineToRender, +}; + +macro_rules! render_assets { + ($assets:expr, $line_count_to_remove:expr, $selected_index:expr, $to_render_until_selected: expr, $to_render_after_selected:expr, $has_deeper_selected_assets:expr, $max_cols:expr, $colors:expr) => {{ + let (start_index, anchor_asset_index, end_index, line_count_to_remove) = + minimize_lines($assets.len(), $line_count_to_remove, $selected_index); + let mut truncated_result_count_above = start_index; + let mut truncated_result_count_below = $assets.len().saturating_sub(end_index); + let mut current_index = 1; + if let Some(assets_to_render_before_selected) = $assets.get(start_index..anchor_asset_index) + { + for asset in assets_to_render_before_selected { + let mut asset: LineToRender = + asset.as_line_to_render(current_index, $max_cols, $colors); + asset.add_truncated_results(truncated_result_count_above); + truncated_result_count_above = 0; + current_index += 1; + $to_render_until_selected.push(asset); + } + } + if let Some(selected_asset) = $assets.get(anchor_asset_index) { + if $selected_index.is_some() && !$has_deeper_selected_assets { + let mut selected_asset: LineToRender = + selected_asset.as_line_to_render(current_index, $max_cols, $colors); + selected_asset.make_selected(); + selected_asset.add_truncated_results(truncated_result_count_above); + if anchor_asset_index + 1 >= end_index { + // no more results below, let's add the more indication if we need to + selected_asset.add_truncated_results(truncated_result_count_below); + } + current_index += 1; + $to_render_until_selected.push(selected_asset); + } else { + $to_render_until_selected.push(selected_asset.as_line_to_render( + current_index, + $max_cols, + $colors, + )); + current_index += 1; + } + } + if let Some(assets_to_render_after_selected) = + $assets.get(anchor_asset_index + 1..end_index) + { + for asset in assets_to_render_after_selected.iter().rev() { + let mut asset: LineToRender = + asset.as_line_to_render(current_index, $max_cols, $colors); + asset.add_truncated_results(truncated_result_count_below); + truncated_result_count_below = 0; + current_index += 1; + $to_render_after_selected.insert(0, asset.into()); + } + } + line_count_to_remove + }}; +} + +impl SessionList { + pub fn render(&self, max_rows: usize, max_cols: usize, colors: Colors) -> Vec<LineToRender> { + if self.is_searching { + self.render_search_results(max_rows, max_cols) + } else { + self.render_list(max_rows, max_cols, colors) + } + } + fn render_search_results(&self, max_rows: usize, max_cols: usize) -> Vec<LineToRender> { + let mut lines_to_render = vec![]; + for (i, result) in self.search_results.iter().enumerate() { + if lines_to_render.len() + result.lines_to_render() <= max_rows { + let mut result_lines = result.render(max_cols); + if Some(i) == self.selected_search_index { + for line_to_render in result_lines.iter_mut() { + line_to_render.make_selected(); + } + } + lines_to_render.append(&mut result_lines); + } else { + break; + } + } + lines_to_render + } + fn render_list(&self, max_rows: usize, max_cols: usize, colors: Colors) -> Vec<LineToRender> { + let mut lines_to_render_until_selected = vec![]; + let mut lines_to_render_after_selected = vec![]; + let total_lines_to_render = self.total_lines_to_render(); + let line_count_to_remove = total_lines_to_render.saturating_sub(max_rows); + let line_count_to_remove = self.render_sessions( + &mut lines_to_render_until_selected, + &mut lines_to_render_after_selected, + line_count_to_remove, + max_cols, + colors, + ); + let line_count_to_remove = self.render_tabs( + &mut lines_to_render_until_selected, + &mut lines_to_render_after_selected, + line_count_to_remove, + max_cols, + colors, + ); + self.render_panes( + &mut lines_to_render_until_selected, + &mut lines_to_render_after_selected, + line_count_to_remove, + max_cols, + colors, + ); + let mut lines_to_render = lines_to_render_until_selected; + lines_to_render.append(&mut lines_to_render_after_selected); + lines_to_render + } + fn render_sessions( + &self, + to_render_until_selected: &mut Vec<LineToRender>, + to_render_after_selected: &mut Vec<LineToRender>, + line_count_to_remove: usize, + max_cols: usize, + colors: Colors, + ) -> usize { + render_assets!( + self.session_ui_infos, + line_count_to_remove, + self.selected_index.0, + to_render_until_selected, + to_render_after_selected, + self.selected_index.1.is_some(), + max_cols, + colors + ) + } + fn render_tabs( + &self, + to_render_until_selected: &mut Vec<LineToRender>, + to_render_after_selected: &mut Vec<LineToRender>, + line_count_to_remove: usize, + max_cols: usize, + colors: Colors, + ) -> usize { + if self.selected_index.1.is_none() { + return line_count_to_remove; + } + if let Some(tabs_in_session) = self + .selected_index + .0 + .and_then(|i| self.session_ui_infos.get(i)) + .map(|s| &s.tabs) + { + render_assets!( + tabs_in_session, + line_count_to_remove, + self.selected_index.1, + to_render_until_selected, + to_render_after_selected, + self.selected_index.2.is_some(), + max_cols, + colors + ) + } else { + line_count_to_remove + } + } + fn render_panes( + &self, + to_render_until_selected: &mut Vec<LineToRender>, + to_render_after_selected: &mut Vec<LineToRender>, + line_count_to_remove: usize, + max_cols: usize, + colors: Colors, + ) -> usize { + if self.selected_index.2.is_none() { + return line_count_to_remove; + } + if let Some(panes_in_session) = self + .selected_index + .0 + .and_then(|i| self.session_ui_infos.get(i)) + .map(|s| &s.tabs) + .and_then(|tabs| { + self.selected_index + .1 + .and_then(|i| tabs.get(i)) + .map(|t| &t.panes) + }) + { + render_assets!( + panes_in_session, + line_count_to_remove, + self.selected_index.2, + to_render_until_selected, + to_render_after_selected, + false, + max_cols, + colors + ) + } else { + line_count_to_remove + } + } + fn total_lines_to_render(&self) -> usize { + self.session_ui_infos + .iter() + .enumerate() + .fold(0, |acc, (index, s)| { + if self.selected_index.session_index_is_selected(index) { + acc + s.line_count(&self.selected_index) + } else { + acc + 1 + } + }) + } +} + +#[derive(Debug, Clone)] +pub struct SessionUiInfo { + pub name: String, + pub tabs: Vec<TabUiInfo>, + pub connected_users: usize, + pub is_current_session: bool, +} + +impl SessionUiInfo { + pub fn from_session_info(session_info: &SessionInfo) -> Self { + SessionUiInfo { + name: session_info.name.clone(), + tabs: session_info + .tabs + .iter() + .map(|t| TabUiInfo::new(t, &session_info.panes)) + .collect(), + connected_users: session_info.connected_clients, + is_current_session: session_info.is_current_session, + } + } + pub fn line_count(&self, selected_index: &SelectedIndex) -> usize { + let mut line_count = 1; // self + if selected_index.tabs_are_visible() { + match selected_index + .selected_tab_index() + .and_then(|i| self.tabs.get(i)) + .map(|t| t.line_count(&selected_index)) + { + Some(line_count_of_selected_tab) => { + // we add the line count in the selected tab minus 1 because we will account + // for the selected tab line itself in self.tabs.len() below + line_count += line_count_of_selected_tab.saturating_sub(1); + line_count += self.tabs.len(); + }, + None => { + line_count += self.tabs.len(); + }, + } + } + line_count + } + fn as_line_to_render( + &self, + _session_index: u8, + mut max_cols: usize, + colors: Colors, + ) -> LineToRender { + let mut line_to_render = LineToRender::new(colors); + let ui_spans = build_session_ui_line(&self, colors); + for span in ui_spans { + span.render(None, &mut line_to_render, &mut max_cols); + } + line_to_render + } +} + +#[derive(Debug, Clone)] +pub struct TabUiInfo { + pub name: String, + pub panes: Vec<PaneUiInfo>, + pub position: usize, +} + +impl TabUiInfo { + pub fn new(tab_info: &TabInfo, pane_manifest: &PaneManifest) -> Self { + let panes = pane_manifest + .panes + .get(&tab_info.position) + .map(|p| { + p.iter() + .filter_map(|pane_info| { + if pane_info.is_selectable { + Some(PaneUiInfo { + name: pane_info.title.clone(), + exit_code: pane_info.exit_status.clone(), + pane_id: pane_info.id, + is_plugin: pane_info.is_plugin, + }) + } else { + None + } + }) + .collect() + }) + .unwrap_or_default(); + TabUiInfo { + name: tab_info.name.clone(), + panes, + position: tab_info.position, + } + } + pub fn line_count(&self, selected_index: &SelectedIndex) -> usize { + let mut line_count = 1; // self + if selected_index.panes_are_visible() { + line_count += self.panes.len() + } + line_count + } + fn as_line_to_render( + &self, + _session_index: u8, + mut max_cols: usize, + colors: Colors, + ) -> LineToRender { + let mut line_to_render = LineToRender::new(colors); + let ui_spans = build_tab_ui_line(&self, colors); + for span in ui_spans { + span.render(None, &mut line_to_render, &mut max_cols); + } + line_to_render + } +} + +#[derive(Debug, Clone)] +pub struct PaneUiInfo { + pub name: String, + pub exit_code: Option<i32>, + pub pane_id: u32, + pub is_plugin: bool, +} + +impl PaneUiInfo { + fn as_line_to_render( + &self, + _session_index: u8, + mut max_cols: usize, + colors: Colors, + ) -> LineToRender { + let mut line_to_render = LineToRender::new(colors); + let ui_spans = build_pane_ui_line(&self, colors); + for span in ui_spans { + span.render(None, &mut line_to_render, &mut max_cols); + } + line_to_render + } +} |
