serie 0.7.2

A rich git commit graph in your terminal, like magic
Documentation
use std::rc::Rc;

use ratatui::{
    crossterm::event::KeyEvent,
    layout::{Constraint, Layout, Rect},
    style::{Modifier, Stylize},
    text::{Line, Span},
    widgets::{Block, Padding, Paragraph},
    Frame,
};

use crate::{
    app::AppContext,
    color::ColorTheme,
    config::CoreConfig,
    event::{AppEvent, Sender, UserEvent, UserEventWithCount},
    keybind::KeyBind,
    view::View,
};

#[derive(Debug)]
pub struct HelpView<'a> {
    before: View<'a>,

    help_key_lines: Vec<Line<'static>>,
    help_value_lines: Vec<Line<'static>>,
    help_key_line_max_width: u16,

    offset: usize,
    height: usize,

    tx: Sender,
}

impl HelpView<'_> {
    pub fn new<'a>(before: View<'a>, ctx: Rc<AppContext>, tx: Sender) -> HelpView<'a> {
        let (help_key_lines, help_value_lines) =
            build_lines(&ctx.color_theme, &ctx.keybind, &ctx.core_config);
        let help_key_line_max_width = help_key_lines
            .iter()
            .map(|line| line.width())
            .max()
            .unwrap_or_default() as u16;
        HelpView {
            before,
            help_key_lines,
            help_value_lines,
            help_key_line_max_width,
            offset: 0,
            height: 0,
            tx,
        }
    }

    pub fn handle_event(&mut self, event_with_count: UserEventWithCount, _: KeyEvent) {
        let event = event_with_count.event;
        let count = event_with_count.count;

        match event {
            UserEvent::Quit => {
                self.tx.send(AppEvent::Quit);
            }
            UserEvent::HelpToggle | UserEvent::Cancel | UserEvent::Close => {
                self.tx.send(AppEvent::CloseHelp);
            }
            UserEvent::NavigateDown | UserEvent::SelectDown => {
                for _ in 0..count {
                    self.scroll_down();
                }
            }
            UserEvent::NavigateUp | UserEvent::SelectUp => {
                for _ in 0..count {
                    self.scroll_up();
                }
            }
            UserEvent::PageDown => {
                for _ in 0..count {
                    self.scroll_page_down();
                }
            }
            UserEvent::PageUp => {
                for _ in 0..count {
                    self.scroll_page_up();
                }
            }
            UserEvent::HalfPageDown => {
                for _ in 0..count {
                    self.scroll_half_page_down();
                }
            }
            UserEvent::HalfPageUp => {
                for _ in 0..count {
                    self.scroll_half_page_up();
                }
            }
            UserEvent::GoToTop => {
                self.select_first();
            }
            UserEvent::GoToBottom => {
                self.select_last();
            }
            _ => {}
        }
    }

    pub fn render(&mut self, f: &mut Frame, area: Rect) {
        self.update_state(area);

        let [mut key_area, mut value_area] =
            Layout::horizontal([Constraint::Percentage(30), Constraint::Percentage(70)])
                .areas(area);

        if key_area.width - 4 /* padding */ < self.help_key_line_max_width {
            [key_area, value_area] = Layout::horizontal([
                Constraint::Length(self.help_key_line_max_width + 4),
                Constraint::Min(0),
            ])
            .areas(area);
        }

        let key_lines: Vec<Line> = self
            .help_key_lines
            .iter()
            .skip(self.offset)
            .take(area.height as usize)
            .cloned()
            .collect();
        let value_lines: Vec<Line> = self
            .help_value_lines
            .iter()
            .skip(self.offset)
            .take(area.height as usize)
            .cloned()
            .collect();

        let key_paragraph = Paragraph::new(key_lines)
            .block(Block::default().padding(Padding::new(3, 1, 0, 0)))
            .right_aligned();
        let value_paragraph = Paragraph::new(value_lines)
            .block(Block::default().padding(Padding::new(1, 3, 0, 0)))
            .left_aligned();

        f.render_widget(key_paragraph, key_area);
        f.render_widget(value_paragraph, value_area);
    }
}

impl<'a> HelpView<'a> {
    pub fn take_before_view(&mut self) -> View<'a> {
        std::mem::take(&mut self.before)
    }

    fn scroll_down(&mut self) {
        self.offset = self.offset.saturating_add(1);
    }

    fn scroll_up(&mut self) {
        self.offset = self.offset.saturating_sub(1);
    }

    fn scroll_page_down(&mut self) {
        self.offset = self.offset.saturating_add(self.height);
    }

    fn scroll_page_up(&mut self) {
        self.offset = self.offset.saturating_sub(self.height);
    }

    fn scroll_half_page_down(&mut self) {
        self.offset = self.offset.saturating_add(self.height / 2);
    }

    fn scroll_half_page_up(&mut self) {
        self.offset = self.offset.saturating_sub(self.height / 2);
    }

    fn select_first(&mut self) {
        self.offset = 0;
    }

    fn select_last(&mut self) {
        self.offset = usize::MAX;
    }

    fn update_state(&mut self, area: Rect) {
        self.height = area.height as usize;
        self.offset = self.offset.min(self.help_key_lines.len() - 1)
    }
}

#[rustfmt::skip]
fn build_lines(
    color_theme: &ColorTheme,
    keybind: &KeyBind,
    core_config: &CoreConfig,
) -> (Vec<Line<'static>>, Vec<Line<'static>>) {
    let user_command_help_items = keybind
        .user_command_event_numbers()
        .into_iter()
        .flat_map(|n| {
            core_config
                .user_command
                .commands
                .get(&n.to_string())
                .map(|c| format!("Execute user command {} - {}", n, c.name))
                .map(|desc| (vec![UserEvent::UserCommand(n)], desc))
        })
        .collect::<Vec<_>>();

    let common_helps = vec![
        (vec![UserEvent::ForceQuit, UserEvent::Quit], "Quit app".into()),
        (vec![UserEvent::HelpToggle], "Open help".into()),
    ];
    let (common_key_lines, common_value_lines) = build_block_lines("Common:", common_helps, color_theme, keybind);

    let help_helps = vec![
        (vec![UserEvent::HelpToggle, UserEvent::Cancel, UserEvent::Close], "Close help".into()),
        (vec![UserEvent::NavigateDown, UserEvent::SelectDown], "Scroll down".into()),
        (vec![UserEvent::NavigateUp, UserEvent::SelectUp], "Scroll up".into()),
        (vec![UserEvent::PageDown], "Scroll page down".into()),
        (vec![UserEvent::PageUp], "Scroll page up".into()),
        (vec![UserEvent::HalfPageDown], "Scroll half page down".into()),
        (vec![UserEvent::HalfPageUp], "Scroll half page up".into()),
        (vec![UserEvent::GoToTop], "Go to top".into()),
        (vec![UserEvent::GoToBottom], "Go to bottom".into()),
    ];
    let (help_key_lines, help_value_lines) = build_block_lines("Help:", help_helps, color_theme, keybind);

    let mut list_helps = vec![
        (vec![UserEvent::NavigateDown, UserEvent::SelectDown], "Move down".into()),
        (vec![UserEvent::NavigateUp, UserEvent::SelectUp], "Move up".into()),
        (vec![UserEvent::GoToParent], "Go to parent".into()),
        (vec![UserEvent::GoToTop], "Go to top".into()),
        (vec![UserEvent::GoToBottom], "Go to bottom".into()),
        (vec![UserEvent::PageDown], "Scroll page down".into()),
        (vec![UserEvent::PageUp], "Scroll page up".into()),
        (vec![UserEvent::HalfPageDown], "Scroll half page down".into()),
        (vec![UserEvent::HalfPageUp], "Scroll half page up".into()),
        (vec![UserEvent::ScrollDown], "Scroll down".into()),
        (vec![UserEvent::ScrollUp], "Scroll up".into()),
        (vec![UserEvent::SelectTop], "Select top of the screen".into()),
        (vec![UserEvent::SelectMiddle], "Select middle of the screen".into()),
        (vec![UserEvent::SelectBottom], "Select bottom of the screen".into()),
        (vec![UserEvent::Confirm], "Show commit details".into()),
        (vec![UserEvent::RefList], "Open refs list".into()),
        (vec![UserEvent::Search], "Start search".into()),
        (vec![UserEvent::Cancel], "Cancel search".into()),
        (vec![UserEvent::GoToNext], "Go to next search match".into()),
        (vec![UserEvent::GoToPrevious], "Go to previous search match".into()),
        (vec![UserEvent::IgnoreCaseToggle], "Toggle ignore case".into()),
        (vec![UserEvent::FuzzyToggle], "Toggle fuzzy match".into()),
        (vec![UserEvent::Refresh], "Refresh".into()),
        (vec![UserEvent::ShortCopy], "Copy commit short hash".into()),
        (vec![UserEvent::FullCopy], "Copy commit hash".into()),
    ];
    list_helps.extend(user_command_help_items.clone());
    let (list_key_lines, list_value_lines) = build_block_lines("Commit List:", list_helps, color_theme, keybind);
    
    let mut detail_helps = vec![
        (vec![UserEvent::Cancel, UserEvent::Close, UserEvent::Confirm], "Close commit details".into()),
        (vec![UserEvent::NavigateDown], "Scroll down".into()),
        (vec![UserEvent::NavigateUp], "Scroll up".into()),
        (vec![UserEvent::PageDown], "Scroll page down".into()),
        (vec![UserEvent::PageUp], "Scroll page up".into()),
        (vec![UserEvent::HalfPageDown], "Scroll half page down".into()),
        (vec![UserEvent::HalfPageUp], "Scroll half page up".into()),
        (vec![UserEvent::GoToTop], "Go to top".into()),
        (vec![UserEvent::GoToBottom], "Go to bottom".into()),
        (vec![UserEvent::SelectDown], "Select older commit".into()),
        (vec![UserEvent::SelectUp], "Select newer commit".into()),
        (vec![UserEvent::GoToParent], "Select parent commit".into()),
        (vec![UserEvent::Refresh], "Refresh".into()),
        (vec![UserEvent::ShortCopy], "Copy commit short hash".into()),
        (vec![UserEvent::FullCopy], "Copy commit hash".into()),
    ];
    detail_helps.extend(user_command_help_items.clone());
    let (detail_key_lines, detail_value_lines) = build_block_lines("Commit Detail:", detail_helps, color_theme, keybind);

    let refs_helps = vec![
        (vec![UserEvent::Cancel, UserEvent::Close, UserEvent::RefList], "Close refs list".into()),
        (vec![UserEvent::NavigateDown, UserEvent::SelectDown], "Move down".into()),
        (vec![UserEvent::NavigateUp, UserEvent::SelectUp], "Move up".into()),
        (vec![UserEvent::GoToTop], "Go to top".into()),
        (vec![UserEvent::GoToBottom], "Go to bottom".into()),
        (vec![UserEvent::NavigateRight], "Open node".into()),
        (vec![UserEvent::NavigateLeft], "Close node".into()),
        (vec![UserEvent::Refresh], "Refresh".into()),
        (vec![UserEvent::ShortCopy], "Copy ref name".into()),
    ];
    let (refs_key_lines, refs_value_lines) = build_block_lines("Refs List:", refs_helps, color_theme, keybind);
    
    let mut user_command_helps = vec![
        (vec![UserEvent::Cancel, UserEvent::Close], "Close user command".into()),
        (vec![UserEvent::NavigateDown], "Scroll down".into()),
        (vec![UserEvent::NavigateUp], "Scroll up".into()),
        (vec![UserEvent::PageDown], "Scroll page down".into()),
        (vec![UserEvent::PageUp], "Scroll page up".into()),
        (vec![UserEvent::HalfPageDown], "Scroll half page down".into()),
        (vec![UserEvent::HalfPageUp], "Scroll half page up".into()),
        (vec![UserEvent::GoToTop], "Go to top".into()),
        (vec![UserEvent::GoToBottom], "Go to bottom".into()),
        (vec![UserEvent::SelectDown], "Select older commit".into()),
        (vec![UserEvent::SelectUp], "Select newer commit".into()),
        (vec![UserEvent::GoToParent], "Select parent commit".into()),
        (vec![UserEvent::Refresh], "Refresh".into()),
        (vec![UserEvent::Confirm], "Show commit details".into()),
    ];
    user_command_helps.extend(user_command_help_items);
    let (user_command_key_lines, user_command_value_lines) = build_block_lines("User Command:", user_command_helps, color_theme, keybind);

    let key_lines = join_line_groups_with_empty(vec![
        common_key_lines,
        help_key_lines,
        list_key_lines,
        detail_key_lines,
        refs_key_lines,
        user_command_key_lines,
    ]);
    let value_lines = join_line_groups_with_empty(vec![
        common_value_lines,
        help_value_lines,
        list_value_lines,
        detail_value_lines,
        refs_value_lines,
        user_command_value_lines,
    ]);

    (key_lines, value_lines)
}

fn build_block_lines(
    title: &'static str,
    helps: Vec<(Vec<UserEvent>, String)>,
    color_theme: &ColorTheme,
    keybind: &KeyBind,
) -> (Vec<Line<'static>>, Vec<Line<'static>>) {
    let mut key_lines = Vec::new();
    let mut value_lines = Vec::new();

    let key_title_lines = vec![Line::from(title)
        .fg(color_theme.help_block_title_fg)
        .add_modifier(Modifier::BOLD)];
    let value_title_lines = vec![Line::from("")];
    let key_binding_lines: Vec<Line> = helps
        .clone()
        .into_iter()
        .map(|(events, _)| {
            join_span_groups_with_space(
                events
                    .iter()
                    .flat_map(|event| keybind.keys_for_event(*event))
                    .map(|key| vec!["<".into(), key.fg(color_theme.help_key_fg), ">".into()])
                    .collect(),
            )
        })
        .collect();
    let value_binding_lines: Vec<Line> = helps
        .into_iter()
        .map(|(_, value)| Line::raw(value))
        .collect();

    key_lines.extend(key_title_lines);
    key_lines.extend(key_binding_lines);
    value_lines.extend(value_title_lines);
    value_lines.extend(value_binding_lines);

    (key_lines, value_lines)
}

fn join_line_groups_with_empty(line_groups: Vec<Vec<Line<'static>>>) -> Vec<Line<'static>> {
    let mut result = Vec::new();
    let n = line_groups.len();
    for (i, lines) in line_groups.into_iter().enumerate() {
        result.extend(lines);
        if i < n - 1 {
            result.push(Line::raw(""));
        }
    }
    result
}

fn join_span_groups_with_space(span_groups: Vec<Vec<Span<'static>>>) -> Line<'static> {
    let mut spans: Vec<Span> = Vec::new();
    let n = span_groups.len();
    for (i, ss) in span_groups.into_iter().enumerate() {
        spans.extend(ss);
        if i < n - 1 {
            spans.push(Span::raw(" "));
        }
    }
    Line::from(spans)
}