Skip to main content

ratatui_kit/components/
shortcut_info_modal.rs

1// ShortcutInfoModal 组件:带输入互斥的快捷键帮助弹窗。
2
3use 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}