use std::{cmp::max, sync::Arc};
use itertools::Itertools;
use ratatui::{
layout::{Constraint, Flex, Layout, Rect},
style::{Color, Style, Stylize},
text::{Line, Span},
widgets::{Clear, Paragraph},
};
use crate::{
action::{Action, ActionResult},
config::{Config, Keybinding, Theme},
terminal::Frame,
ui::centered_rect,
};
use super::Component;
struct Binding {
pub bindings: Box<[Box<str>]>,
pub name: Box<str>,
}
impl Binding {
pub fn keys_len(&self) -> usize {
self.bindings.iter().fold(0, |acc, elm| acc + elm.len() + 2) - 2
}
pub fn to_line(&self, gap: usize, highlight: Color) -> Line<'_> {
Itertools::intersperse(
self.bindings
.iter()
.map(|b| Span::styled(b.as_ref(), Style::default().fg(highlight))),
Span::raw(", "),
)
.take(self.bindings.len() * 2 - 1)
.chain([
Span::raw((self.keys_len()..gap).map(|_| ".").collect::<String>()),
Span::raw(" "),
Span::raw(self.name.as_ref()),
])
.collect()
}
pub fn from_keys(key: &Keybinding, desc: &str) -> Self {
let bindings = key
.bindings()
.iter()
.map(|b| b.to_string().into())
.collect();
Self {
bindings,
name: desc.into(),
}
}
}
pub struct HelpPopupComponent {
line: u16,
global_bindings_list: Arc<[Binding]>,
search_bindings_list: Arc<[Binding]>,
page_bindings_list: Arc<[Binding]>,
config: Arc<Config>,
theme: Arc<Theme>,
}
impl HelpPopupComponent {
pub fn new(config: Arc<Config>, theme: Arc<Theme>) -> Self {
macro_rules! convert_binding {
($binding:expr, $desc:literal) => {
Binding::from_keys(&$binding, $desc)
};
($binding:expr) => {
Binding::from_keys(&$binding, {
let string_name = stringify!($binding);
&match string_name.rsplit_once('.') {
None => string_name,
Some((_, name)) => name,
}
.replace('_', " ")
})
};
}
let global_bindings_list = vec![
convert_binding!(config.bindings.global.scroll_down, "scroll down"),
convert_binding!(config.bindings.global.scroll_up, "scroll up"),
convert_binding!(config.bindings.global.scroll_to_top, "scroll to top"),
convert_binding!(config.bindings.global.scroll_to_bottom, "scroll to bottom"),
convert_binding!(config.bindings.global.half_down, "half page down"),
convert_binding!(config.bindings.global.half_up, "half page up"),
convert_binding!(config.bindings.global.unselect_scroll, "unselect scroll"),
convert_binding!(config.bindings.global.enter_search_bar, "enter search bar"),
convert_binding!(config.bindings.global.exit_search_bar, "exit search bar"),
convert_binding!(
config.bindings.global.switch_context_search,
"switch to search"
),
convert_binding!(config.bindings.global.switch_context_page, "switch to page"),
convert_binding!(
config.bindings.global.toggle_search_language_selection,
"toggle search language"
),
convert_binding!(config.bindings.global.toggle_logger, "toggle logger"),
convert_binding!(config.bindings.global.help, "show this help"),
convert_binding!(config.bindings.global.pop_popup, "close popup"),
convert_binding!(config.bindings.global.quit, "quit"),
]
.into();
let search_bindings_list = vec![convert_binding!(
config.bindings.search.continue_search,
"continue search"
)]
.into();
let page_bindings_list = vec![
convert_binding!(config.bindings.page.pop_page, "go back"),
convert_binding!(config.bindings.page.jump_to_header, "jump to header"),
convert_binding!(config.bindings.page.select_first_link, "select first link"),
convert_binding!(config.bindings.page.select_last_link, "select last link"),
convert_binding!(
config.bindings.page.select_prev_link,
"select previous link"
),
convert_binding!(config.bindings.page.select_next_link, "select next link"),
convert_binding!(config.bindings.page.open_link, "open link"),
convert_binding!(
config.bindings.page.toggle_page_language_selection,
"toggle page language"
),
convert_binding!(config.bindings.page.toggle_zen_mode, "toggle zen mode"),
convert_binding!(config.bindings.page.toggle_toc, "toggle table of contents"),
]
.into();
Self {
line: 0,
global_bindings_list,
search_bindings_list,
page_bindings_list,
config,
theme,
}
}
pub fn data_size(&self) -> u16 {
(self.global_bindings_list.len()
+ self.search_bindings_list.len()
+ self.page_bindings_list.len()
+ 6) as u16 }
}
impl Component for HelpPopupComponent {
fn handle_key_events(&mut self, key: crossterm::event::KeyEvent) -> ActionResult {
if self.config.bindings.global.pop_popup.matches_event(key)
|| self.config.bindings.global.quit.matches_event(key)
|| self.config.bindings.global.help.matches_event(key)
{
return Action::PopPopup.into();
}
if self.config.bindings.global.scroll_up.matches_event(key) {
return Action::ScrollUp(1).into();
}
if self.config.bindings.global.scroll_down.matches_event(key) {
return Action::ScrollDown(1).into();
}
if self.config.bindings.global.scroll_to_top.matches_event(key) {
return Action::ScrollToTop.into();
}
if self
.config
.bindings
.global
.scroll_to_bottom
.matches_event(key)
{
return Action::ScrollToBottom.into();
}
if self.config.bindings.global.half_up.matches_event(key) {
return Action::ScrollHalfUp.into();
}
if self.config.bindings.global.half_down.matches_event(key) {
return Action::ScrollHalfDown.into();
}
ActionResult::Ignored
}
fn update(&mut self, action: Action) -> ActionResult {
match action {
Action::ScrollUp(n) => {
self.line = self.line.saturating_sub(n);
ActionResult::consumed()
}
Action::ScrollDown(n) => {
self.line = self.line.saturating_add(n);
ActionResult::consumed()
}
Action::ScrollToTop => {
self.line = 0;
ActionResult::consumed()
}
Action::ScrollToBottom => {
self.line = self.data_size().saturating_sub(1);
ActionResult::consumed()
}
Action::ScrollHalfUp => {
self.line = self.line.saturating_sub(10);
ActionResult::consumed()
}
Action::ScrollHalfDown => {
self.line = self.line.saturating_add(10);
ActionResult::consumed()
}
Action::Quit => Action::PopPopup.into(),
_ => ActionResult::Ignored,
}
}
fn render(&mut self, f: &mut Frame<'_>, area: Rect) {
let popup_block = self
.theme
.default_block()
.title("Help - Keybindings")
.title_bottom(Line::from("<ESC> Close").right_aligned())
.style(Style::default().bg(self.theme.bg));
let area = centered_rect(area, 80, 90);
f.render_widget(Clear, area);
f.render_widget(popup_block, area);
let [text_area] = Layout::vertical([Constraint::Percentage(100)])
.margin(1)
.flex(Flex::Center)
.areas(area);
let gap = self
.global_bindings_list
.iter()
.chain(self.search_bindings_list.iter())
.chain(self.page_bindings_list.iter())
.fold(0, |acc, elm| max(acc, elm.keys_len()))
+ 2;
let highlight_color = self.theme.search_title_fg;
macro_rules! to_line {
($bindings:expr) => {
$bindings
.iter()
.map(|x| x.to_line(gap, highlight_color))
.collect()
};
}
let consolidated_list: Vec<Line> = [
vec![Line::raw("Global Keybindings").bold().underlined()],
to_line!(self.global_bindings_list),
vec![
Line::default(),
Line::raw("Search Keybindings").bold().underlined(),
],
to_line!(self.search_bindings_list),
vec![
Line::default(),
Line::raw("Page Keybindings").bold().underlined(),
],
to_line!(self.page_bindings_list),
]
.concat();
if self.data_size() <= text_area.height {
self.line = 0;
} else if self.line > self.data_size().saturating_sub(text_area.height) {
self.line = self.data_size().saturating_sub(text_area.height);
}
let paragraph_widget = Paragraph::new(consolidated_list).scroll((self.line, 0));
f.render_widget(paragraph_widget, text_area);
}
}