tuimux 0.1.6

A fast Rust TUI for everything tmux, with full CRUD support.
Documentation
use std::collections::BTreeSet;

use crate::tmux::session::{Session, Window};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FocusRegion {
    #[default]
    Tree,
    Details,
    Help,
    Modal,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TreeSelection {
    Session { name: String },
    Window { session_name: String, window_index: String },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusLine {
    pub message: String,
    pub is_error: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Modal {
    Input { title: String, value: String, action: InputAction },
    Confirm { title: String, prompt: String, action: ConfirmAction },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputAction {
    CreateSession,
    CreateWindow { session_name: String },
    RenameSession { session_name: String },
    RenameWindow { session_name: String, window_index: String },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfirmAction {
    CloseSession { session_name: String },
    CloseWindow { session_name: String, window_index: String },
    OverwriteSessionExport,
    RunSessionRestore,
}

#[derive(Debug, Default)]
pub struct State {
    pub focus: FocusRegion,
    pub sessions: Vec<Session>,
    pub selected_session: Option<usize>,
    pub selected_window: Option<usize>,
    pub selection: Option<TreeSelection>,
    pub expanded_sessions: BTreeSet<String>,
    pub status: Option<StatusLine>,
    pub modal: Option<Modal>,
    pub preview: String,
    pub preview_is_error: bool,
}

impl State {
    pub fn set_sessions(&mut self, sessions: Vec<Session>) {
        let previous_selection = self.selection.clone();
        let previous_expanded = self.expanded_sessions.clone();
        self.sessions = sessions;

        if self.sessions.is_empty() {
            self.selected_session = None;
            self.selected_window = None;
            self.selection = None;
            self.expanded_sessions.clear();
            return;
        }

        if previous_expanded.is_empty() {
            self.expanded_sessions.clear();
        } else {
            self.expanded_sessions =
                self.sessions
                    .iter()
                    .filter_map(|session| {
                        if previous_expanded.contains(&session.name) { Some(session.name.clone()) } else { None }
                    })
                    .collect();
        }

        if !self.restore_selection(previous_selection) {
            self.selected_session = Some(0);
            self.selected_window = None;
            self.sync_selection();
        }
    }

    pub fn move_up(&mut self) {
        let rows = self.tree_rows();
        let Some(current) = self.selected_row_index(&rows) else {
            return;
        };
        if rows.is_empty() {
            return;
        }

        let next = if current == 0 { rows.len() - 1 } else { current - 1 };
        self.apply_row_selection(&rows[next]);
    }

    pub fn move_down(&mut self) {
        let rows = self.tree_rows();
        let Some(current) = self.selected_row_index(&rows) else {
            return;
        };
        if rows.is_empty() {
            return;
        }

        let next = if current + 1 >= rows.len() { 0 } else { current + 1 };
        self.apply_row_selection(&rows[next]);
    }

    pub fn select(&mut self) {
        let Some(session_index) = self.selected_session else {
            return;
        };
        let Some(session) = self.sessions.get(session_index) else {
            return;
        };

        if session.windows.is_empty() {
            return;
        }

        self.selected_window = Some(self.selected_window.map_or(0, |index| (index + 1) % session.windows.len()));
        self.sync_selection();
    }

    pub fn back(&mut self) {
        self.collapse_selected_session();
    }

    pub fn toggle_expand(&mut self) {
        let Some(session_index) = self.selected_session else {
            return;
        };

        let Some(session) = self.sessions.get(session_index) else {
            return;
        };

        if self.expanded_sessions.contains(&session.name) {
            self.expanded_sessions.remove(&session.name);
            self.selected_window = None;
        } else {
            self.expanded_sessions.insert(session.name.clone());
        }

        self.sync_selection();
    }

    pub fn expand_selected_session(&mut self) {
        let Some(session_index) = self.selected_session else {
            return;
        };

        let Some(session) = self.sessions.get(session_index) else {
            return;
        };

        self.expanded_sessions.insert(session.name.clone());
        self.sync_selection();
    }

    pub fn collapse_selected_session(&mut self) {
        let Some(session_index) = self.selected_session else {
            return;
        };

        let Some(session) = self.sessions.get(session_index) else {
            return;
        };

        self.expanded_sessions.remove(&session.name);
        self.selected_window = None;
        self.sync_selection();
    }

    pub fn cycle_focus(&mut self) {
        self.focus = match self.focus {
            FocusRegion::Tree => FocusRegion::Details,
            FocusRegion::Details => FocusRegion::Help,
            FocusRegion::Help | FocusRegion::Modal => FocusRegion::Tree,
        };
    }

    #[must_use]
    pub fn selected_session_name(&self) -> Option<&str> {
        self.selected_session_ref().map(|session| session.name.as_str())
    }

    #[must_use]
    pub fn selected_window_index(&self) -> Option<&str> {
        self.selected_window_ref().map(|window| window.index.as_str())
    }

    pub fn select_session_by_name(&mut self, session_name: &str) {
        let Some(session_index) = self.sessions.iter().position(|session| session.name == session_name) else {
            return;
        };

        self.selected_session = Some(session_index);
        self.selected_window = None;
        self.sync_selection();
    }

    pub fn select_window_by_identity(&mut self, session_name: &str, window_index: &str) {
        let Some((session_index, session)) =
            self.sessions.iter().enumerate().find(|(_, session)| session.name == session_name)
        else {
            return;
        };

        let Some(window_pos) = session.windows.iter().position(|window| window.index == window_index) else {
            return;
        };

        self.selected_session = Some(session_index);
        self.selected_window = Some(window_pos);
        self.sync_selection();
    }

    #[must_use]
    pub fn focus_label(&self) -> &'static str {
        match self.focus {
            FocusRegion::Tree => "tree",
            FocusRegion::Details => "details",
            FocusRegion::Help => "help",
            FocusRegion::Modal => "modal",
        }
    }

    fn restore_selection(&mut self, previous_selection: Option<TreeSelection>) -> bool {
        let Some(previous_selection) = previous_selection else {
            return false;
        };

        match previous_selection {
            TreeSelection::Window { session_name, window_index } => {
                let Some((session_index, session)) =
                    self.sessions.iter().enumerate().find(|(_, session)| session.name == session_name)
                else {
                    return false;
                };

                let Some(window_index_pos) = session.windows.iter().position(|window| window.index == window_index)
                else {
                    self.selected_session = Some(session_index);
                    self.selected_window = None;
                    self.sync_selection();
                    return true;
                };

                self.selected_session = Some(session_index);
                self.selected_window = Some(window_index_pos);
                self.sync_selection();
                true
            }
            TreeSelection::Session { name } => {
                let Some(session_index) = self.sessions.iter().position(|session| session.name == name) else {
                    return false;
                };

                self.selected_session = Some(session_index);
                self.selected_window = None;
                self.sync_selection();
                true
            }
        }
    }

    fn tree_rows(&self) -> Vec<TreeRow> {
        let mut rows = Vec::new();

        for (session_index, session) in self.sessions.iter().enumerate() {
            rows.push(TreeRow { session_index, window_index: None });

            if !self.expanded_sessions.contains(&session.name) {
                continue;
            }

            for (window_index, _) in session.windows.iter().enumerate() {
                rows.push(TreeRow { session_index, window_index: Some(window_index) });
            }
        }

        rows
    }

    fn selected_row_index(&self, rows: &[TreeRow]) -> Option<usize> {
        rows.iter().position(|row| {
            self.selected_session == Some(row.session_index) && self.selected_window == row.window_index
        })
    }

    fn apply_row_selection(&mut self, row: &TreeRow) {
        self.selected_session = Some(row.session_index);
        self.selected_window = row.window_index;
        self.sync_selection();
    }

    fn sync_selection(&mut self) {
        let Some(session_index) = self.selected_session else {
            self.selection = None;
            return;
        };

        let Some(session) = self.sessions.get(session_index) else {
            self.selection = None;
            return;
        };

        if !self.expanded_sessions.contains(&session.name) {
            self.selected_window = None;
        }

        if session.windows.is_empty() {
            self.selected_window = None;
            self.selection = Some(TreeSelection::Session { name: session.name.clone() });
            return;
        }

        if let Some(window_index) = self.selected_window {
            if let Some(window) = session.windows.get(window_index) {
                self.selection = Some(TreeSelection::Window {
                    session_name: session.name.clone(),
                    window_index: window.index.clone(),
                });
                return;
            }

            self.selected_window = None;
        }

        self.selection = Some(TreeSelection::Session { name: session.name.clone() });
    }

    #[must_use]
    pub fn selected_session_ref(&self) -> Option<&Session> {
        self.selected_session.and_then(|index| self.sessions.get(index))
    }

    #[must_use]
    pub fn selected_window_ref(&self) -> Option<&Window> {
        let session = self.selected_session_ref()?;
        let index = self.selected_window?;
        session.windows.get(index)
    }
}

#[derive(Debug, Clone, Copy)]
struct TreeRow {
    session_index: usize,
    window_index: Option<usize>,
}