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