1use crossterm::event::{Event, KeyCode, KeyEventKind};
4use ratatui::{
5 layout::{Constraint, Direction, Margin},
6 style::{Color, Style},
7 text::Line,
8};
9use ratatui_kit_macros::{Props, component, element};
10
11use crate::{
12 AnyElement, Handler, Hooks, UseEventHandler, UseInputLayer,
13 components::{Border, Modal, ScrollView, Text, View},
14 input::{EventPriority, EventResult, EventScope},
15};
16
17#[derive(Clone, Debug, Default, PartialEq, Eq)]
18pub struct ShortcutInfo {
19 pub description: String,
20 pub keys: String,
21}
22
23impl ShortcutInfo {
24 pub fn new(description: impl Into<String>, keys: impl Into<String>) -> Self {
25 Self {
26 description: description.into(),
27 keys: keys.into(),
28 }
29 }
30}
31
32impl From<(&str, &str)> for ShortcutInfo {
33 fn from((description, keys): (&str, &str)) -> Self {
34 Self::new(description, keys)
35 }
36}
37
38#[derive(Clone, Debug, Default, PartialEq, Eq)]
39pub struct ShortcutInfoSection {
40 pub title: String,
41 pub items: Vec<ShortcutInfo>,
42}
43
44impl ShortcutInfoSection {
45 pub fn new<T>(title: impl Into<String>, items: impl IntoIterator<Item = T>) -> Self
46 where
47 T: Into<ShortcutInfo>,
48 {
49 Self {
50 title: title.into(),
51 items: items.into_iter().map(Into::into).collect(),
52 }
53 }
54}
55
56#[derive(Props)]
57pub struct ShortcutInfoModalProps {
58 pub open: bool,
59 pub title: Line<'static>,
60 pub sections: Vec<ShortcutInfoSection>,
61 pub close_hint: Option<Line<'static>>,
62 pub close_keys: Vec<KeyCode>,
63 pub on_close: Handler<'static, ()>,
64 pub width: Constraint,
65 pub height: Constraint,
66 pub style: Style,
67 pub border_style: Style,
68 pub title_style: Style,
69 pub section_title_style: Style,
70 pub description_style: Style,
71 pub key_style: Style,
72}
73
74impl Default for ShortcutInfoModalProps {
75 fn default() -> Self {
76 Self {
77 open: false,
78 title: Line::from("Shortcuts"),
79 sections: Vec::new(),
80 close_hint: Some(Line::from("Esc / I").centered()),
81 close_keys: vec![KeyCode::Esc, KeyCode::Char('i'), KeyCode::Char('I')],
82 on_close: Handler::default(),
83 width: Constraint::Percentage(60),
84 height: Constraint::Percentage(50),
85 style: Style::default().dim(),
86 border_style: Style::default(),
87 title_style: Style::default(),
88 section_title_style: Style::default(),
89 description_style: Style::default(),
90 key_style: Style::default().fg(Color::Yellow),
91 }
92 }
93}
94
95#[component]
96pub fn ShortcutInfoModal(
97 props: &mut ShortcutInfoModalProps,
98 mut hooks: Hooks,
99) -> impl Into<AnyElement<'static>> {
100 let layer = hooks.use_input_layer(props.open, true);
101 let close_keys = props.close_keys.clone();
102 let mut on_close = props.on_close.take();
103
104 hooks.use_event_handler(
105 EventScope::Layer(layer),
106 EventPriority::High,
107 move |event| {
108 if let Event::Key(key) = event
109 && key.kind == KeyEventKind::Press
110 && close_keys.contains(&key.code)
111 {
112 on_close(());
113 return EventResult::Consumed;
114 }
115 EventResult::Ignored
116 },
117 );
118
119 element!(Modal(
120 open: props.open,
121 layer: Some(layer),
122 width: props.width,
123 height: props.height,
124 style: props.style,
125 ) {
126 Border(
127 border_style: props.border_style,
128 top_title: props.title.clone().style(props.title_style).centered(),
129 bottom_title: props.close_hint.clone(),
130 ) {
131 ScrollView(margin: Margin::new(1, 1)) {
132 for (section_index, section) in props.sections.clone().into_iter().enumerate() {
133 Border(
134 key: section_index,
135 height: Constraint::Length(section.items.len() as u16 + 2),
136 border_style: props.border_style,
137 top_title: Line::from(section.title).style(props.section_title_style).centered(),
138 ) {
139 View(flex_direction: Direction::Vertical) {
140 for (row_index, item) in section.items.into_iter().enumerate() {
141 View(
142 key: row_index,
143 height: Constraint::Length(1),
144 flex_direction: Direction::Horizontal,
145 ) {
146 View(width: Constraint::Percentage(55)) {
147 Text(
148 text: item.description,
149 style: props.description_style,
150 )
151 }
152 View(width: Constraint::Percentage(45)) {
153 Text(
154 text: Line::from(item.keys).right_aligned(),
155 style: props.key_style,
156 )
157 }
158 }
159 }
160 }
161 }
162 }
163 }
164 }
165 })
166}