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::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/// ShortcutInfoModal 组件的主题 slot。快捷键取 `accent` 高亮,其余中性;遮罩委托给 [`Modal`]。
19#[non_exhaustive]
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct ShortcutInfoModalTheme {
22    /// 外框及分组框样式。
23    pub border_style: Style,
24    /// 标题样式。
25    pub title_style: Style,
26    /// 分组标题样式。
27    pub section_title_style: Style,
28    /// 快捷键描述样式。
29    pub description_style: Style,
30    /// 快捷键按键样式(高亮)。
31    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    // 遮罩样式覆盖(透传给 `Modal`)。`None` 用 `ModalTheme`(默认 DIM)。
102    pub style: Option<Style>,
103    // 以下样式覆盖:`None` 用 `ShortcutInfoModalTheme`,`Some(s)` 以 `theme.patch(s)` 覆盖。
104    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    // 主题解析:每个 slot 铺底,对应 props 的 Option<Style> 在上 patch(None → 用主题)。
157    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}