rmcl 0.3.0

A fully featured Minecraft launcher TUI
// the instance list on the left side of the UI.
// handles search/filter, scrollbar sync, and inline renaming.
// each row shows instance name + "last played" or current run state.

use crate::config::theme::{BORDER_STYLE, THEME};
use crossterm::event::KeyCode;
use ratatui::{
    Frame,
    layout::Rect,
    style::{Modifier, Style},
    text::{Line, Span, Text},
    widgets::{Block, Borders, Scrollbar, ScrollbarOrientation, ScrollbarState},
};
use tui_widget_list::{ListBuilder, ListState as TuiListState, ListView};

use crate::instance::models::InstanceConfig;
use crate::running::{RunState, get as get_run_state};
use crate::tui::app::FocusedArea;

use super::{WidgetKey, search::SearchState, styled_title};

// rough human-friendly time delta. not trying to be precise here,
// "2 months ago" is close enough when months are ~30 days
fn format_last_played(last_played: Option<chrono::DateTime<chrono::Utc>>) -> String {
    let Some(dt) = last_played else {
        return "Never played".to_string();
    };
    let secs = chrono::Utc::now()
        .signed_duration_since(dt)
        .num_seconds()
        .max(0) as u64;
    match secs {
        0..=59 => "Just now".to_string(),
        60..=3599 => format!("{} minutes ago", secs / 60),
        3600..=86399 => format!("{} hours ago", secs / 3600),
        86400..=2591999 => format!("{} days ago", secs / 86400),
        2592000..=31535999 => format!("{} months ago", secs / 2592000),
        _ => "Over a year ago".to_string(),
    }
}

#[derive(Debug, Default)]
pub struct State {
    pub instances: Vec<InstanceConfig>,
    pub list_state: TuiListState,
    pub scrollbar_state: ScrollbarState,
    pub show_popup: bool,
    pub show_import_popup: bool,
    pub search: SearchState,
    pub renaming: Option<String>,
}

impl State {
    pub fn with_instances(instances: Vec<InstanceConfig>) -> Self {
        let count = instances.len();
        let mut s = State {
            instances,
            list_state: TuiListState::default(),
            scrollbar_state: ScrollbarState::default(),
            show_popup: false,
            show_import_popup: false,
            search: SearchState::default(),
            renaming: None,
        };
        if count > 0 {
            s.list_state.selected = Some(0);
        }
        s.update_scrollbar();
        s
    }

    pub fn selected_instance(&self) -> Option<&InstanceConfig> {
        let filtered = self.filtered_indices();
        self.list_state
            .selected
            .and_then(|i| filtered.get(i))
            .and_then(|&idx| self.instances.get(idx))
    }

    fn filtered_indices(&self) -> Vec<usize> {
        self.instances
            .iter()
            .enumerate()
            .filter(|(_, inst)| self.search.matches(&inst.name))
            .map(|(i, _)| i)
            .collect()
    }

    fn next(&mut self) {
        let count = self.filtered_indices().len();
        if count == 0 {
            return;
        }
        self.list_state.next();
        if self.list_state.selected.unwrap_or(0) >= count {
            self.list_state.selected = Some(0);
        }
        self.update_scrollbar();
    }

    fn previous(&mut self) {
        let count = self.filtered_indices().len();
        if count == 0 {
            return;
        }
        self.list_state.previous();
        if self.list_state.selected.is_none() {
            self.list_state.selected = Some(count.saturating_sub(1));
        }
        self.update_scrollbar();
    }

    fn update_scrollbar(&mut self) {
        let filtered = self.filtered_indices();
        let count = filtered.len();
        let items = count.saturating_sub(1);
        let index = self.list_state.selected.unwrap_or(0);

        if count == 0 {
            self.list_state.selected = None;
        } else if self.list_state.selected.is_none() {
            self.list_state.selected = Some(0);
        } else if index > items {
            self.list_state.selected = Some(items);
        }

        self.scrollbar_state =
            ScrollbarState::new(items).position(self.list_state.selected.unwrap_or(0));
    }

    pub fn wants_popup(&self) -> bool {
        self.show_popup
    }

    pub fn wants_import_popup(&self) -> bool {
        self.show_import_popup
    }

    pub fn remove_instance(&mut self, name: &str) {
        let before = self.instances.len();
        self.instances.retain(|i| i.name != name);
        let after = self.instances.len();
        if after < before {
            self.update_scrollbar();
        }
    }

    pub fn add_instance(&mut self, instance: InstanceConfig) {
        self.instances.push(instance);
        self.update_scrollbar();
    }

    pub fn replace_instance(&mut self, old_name: &str, instance: InstanceConfig) {
        if let Some(existing) = self
            .instances
            .iter_mut()
            .find(|i| i.name == old_name || i.name == instance.name)
        {
            *existing = instance;
        } else {
            self.instances.push(instance);
        }
        self.update_scrollbar();
    }
}

impl WidgetKey for State {
    fn handle_key(&mut self, key_event: &crossterm::event::KeyEvent) {
        if self.search.active {
            match key_event.code {
                KeyCode::Enter => {
                    self.search.confirm();
                    self.list_state.selected = Some(0);
                    self.update_scrollbar();
                }
                KeyCode::Esc => {
                    self.search.deactivate();
                    self.list_state.selected = Some(0);
                    self.update_scrollbar();
                }
                KeyCode::Backspace => {
                    self.search.pop();
                    self.list_state.selected = Some(0);
                    self.update_scrollbar();
                }
                KeyCode::Char(c) => {
                    self.search.push(c);
                    self.list_state.selected = Some(0);
                    self.update_scrollbar();
                }
                _ => {}
            }
            return;
        }

        match key_event.code {
            KeyCode::Char('/') => {
                self.search.activate();
                self.list_state.selected = Some(0);
                self.update_scrollbar();
            }
            KeyCode::Char('a') => {
                self.show_popup = true;
                self.update_scrollbar();
            }
            KeyCode::Char('i') => {
                self.show_import_popup = true;
            }
            KeyCode::Char('d') => {}
            KeyCode::Char('j') | KeyCode::Down => self.next(),
            KeyCode::Char('k') | KeyCode::Up => self.previous(),
            _ => {}
        }
    }
}

pub fn render(frame: &mut Frame, area: Rect, focused: FocusedArea, state: &mut State) {
    let theme = THEME.as_ref();
    let color = if focused == FocusedArea::Instances {
        theme.accent()
    } else {
        theme.border()
    };

    let mut block = Block::default()
        .title(styled_title("Instances", true))
        .borders(Borders::ALL)
        .border_type(BORDER_STYLE.to_border_type())
        .border_style(Style::default().fg(color));

    if let Some(search_line) = state.search.title_line() {
        block = block.title_top(search_line);
    }

    let scrollbar_area = Rect {
        x: area.x + area.width.saturating_sub(1),
        y: area.y + 1,
        width: 1,
        height: area.height.saturating_sub(2),
    };

    let filtered = state.filtered_indices();
    let count = filtered.len();

    let builder = ListBuilder::new(|context| {
        let theme = THEME.as_ref();
        let idx = filtered[context.index];
        let instance = &state.instances[idx];

        let stripe_bg = if context.index % 2 == 0 {
            theme.background()
        } else {
            theme.stripe()
        };

        let (name_style, meta_style, bg) = if context.is_selected {
            (
                Style::default()
                    .fg(theme.accent())
                    .add_modifier(Modifier::BOLD),
                Style::default().fg(theme.text_dim()),
                stripe_bg,
            )
        } else {
            (
                Style::default()
                    .fg(theme.text())
                    .add_modifier(Modifier::BOLD),
                Style::default().fg(theme.text_dim()),
                stripe_bg,
            )
        };

        let selector = if context.is_selected {
            Span::styled("\u{258c} ", Style::default().fg(theme.accent()))
        } else {
            Span::raw("  ")
        };

        let is_renaming = context.is_selected && state.renaming.is_some();
        let name_line = if is_renaming {
            let rename_val = state.renaming.as_deref().unwrap_or("");
            Line::from(vec![
                selector.clone(),
                Span::styled(rename_val, Style::default().fg(theme.text())),
                Span::styled(
                    "\u{2588}",
                    Style::default()
                        .fg(theme.text_dim())
                        .add_modifier(Modifier::SLOW_BLINK),
                ),
            ])
        } else {
            Line::from(vec![
                selector.clone(),
                Span::styled(instance.name.as_str(), name_style),
            ])
        };

        let (meta_text, meta_text_style) = match get_run_state(&instance.name) {
            Some(RunState::Authenticating) => (
                "Authenticating".to_string(),
                Style::default().fg(theme.success()),
            ),
            Some(RunState::Running) | Some(RunState::Starting) => {
                ("Playing".to_string(), Style::default().fg(theme.success()))
            }
            _ => (format_last_played(instance.last_played), meta_style),
        };

        let meta_line = Line::from(vec![
            selector.clone(),
            Span::styled(meta_text, meta_text_style),
        ]);

        let item = Text::from(vec![name_line, meta_line]).style(Style::default().bg(bg));
        (item, 2)
    });

    let list = ListView::new(builder, count).block(block);

    frame.render_stateful_widget(list, area, &mut state.list_state);

    frame.render_stateful_widget(
        Scrollbar::default()
            .orientation(ScrollbarOrientation::VerticalRight)
            .begin_symbol(Some("\u{25b2}"))
            .style(
                Style::default()
                    .fg(theme.text_dim())
                    .add_modifier(Modifier::BOLD),
            )
            .thumb_symbol("\u{2551}")
            .track_symbol(Some(""))
            .end_symbol(Some("\u{25bc}")),
        scrollbar_area,
        &mut state.scrollbar_state,
    );
}