Skip to main content

ratatui_kit/components/
modal.rs

1// Modal 组件:模态弹窗,支持遮罩、居中/自定义位置、尺寸、样式等。
2//
3// ## 用法示例
4// ```rust
5// element!(Modal(
6//     open: open.get(),
7//     width: Constraint::Percentage(60),
8//     height: Constraint::Percentage(60),
9//     style: Style::default().dim(),
10// ){
11//     Border(top_title: Some(Line::from("弹窗内容"))) {
12//         // ...子内容
13//     }
14// })
15// ```
16// 通过 `open` 控制显示,`placement` 控制弹窗位置,`width/height` 控制尺寸。
17
18use ratatui::{
19    layout::{Constraint, Flex, Layout, Margin, Offset},
20    style::{Modifier, Style},
21    widgets::{Block, Clear, Widget},
22};
23use ratatui_kit_macros::{Props, with_layout_style};
24
25use crate::{
26    AnyElement, Component, ComponentTheme, Context, Palette, SystemContext,
27    components::theme::resolve_style,
28    input::{CurrentLayer, InputLayer},
29    layout_style::LayoutStyle,
30};
31
32/// Modal 组件的主题 slot。
33#[non_exhaustive]
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct ModalTheme {
36    /// 遮罩(背景)样式。默认 `DIM` 使弹窗下方内容变暗;`Style::reset()` 可清空。
37    pub style: Style,
38}
39
40impl ComponentTheme for ModalTheme {
41    fn from_palette(_palette: &Palette) -> Self {
42        Self {
43            style: Style::new().add_modifier(Modifier::DIM),
44        }
45    }
46}
47
48impl Default for ModalTheme {
49    fn default() -> Self {
50        Self::from_palette(&Palette::default())
51    }
52}
53
54#[derive(Default, Clone, Copy)]
55// 弹窗位置枚举。
56pub enum Placement {
57    Top,
58    TopLeft,
59    TopRight,
60    Bottom,
61    BottomLeft,
62    BottomRight,
63    #[default]
64    Center,
65    Left,
66    Right,
67}
68
69impl Placement {
70    pub fn to_flex(&self) -> [Flex; 2] {
71        match self {
72            Placement::Top => [Flex::Start, Flex::Center],
73            Placement::TopLeft => [Flex::Start, Flex::Start],
74            Placement::TopRight => [Flex::Start, Flex::End],
75            Placement::Bottom => [Flex::End, Flex::Center],
76            Placement::BottomLeft => [Flex::End, Flex::Start],
77            Placement::BottomRight => [Flex::End, Flex::End],
78            Placement::Center => [Flex::Center, Flex::Center],
79            Placement::Left => [Flex::Center, Flex::Start],
80            Placement::Right => [Flex::Center, Flex::End],
81        }
82    }
83}
84
85#[with_layout_style(margin, offset, width, height)]
86#[derive(Default, Props)]
87// Modal 组件属性。
88pub struct ModalProps<'a> {
89    // 弹窗内容。
90    pub children: Vec<AnyElement<'a>>,
91    // 遮罩样式覆盖。`None` 用主题(`ModalTheme`,默认 `DIM`),`Some(s)` 以 `theme.patch(s)` 覆盖。
92    pub style: Option<Style>,
93    // 弹窗位置。
94    pub placement: Placement,
95    // 是否显示弹窗。
96    pub open: bool,
97    // 外部注入的输入层句柄(父组件已 `use_input_layer` 时)。
98    //
99    // `None` → Modal 内部自开层(handler 全在 Modal 子树内的常见场景);
100    // `Some(h)` → 复用父级已登记的层(不重复登记),仅向子树注入 `CurrentLayer`——
101    // 用于「handler 注册在 Modal 父组件」的场景(父 `use_input_layer` + `use_event_handler(Layer(h))`)。
102    //
103    // **Footgun**:走 `Layer(h)` 路径时必须把 `h` 传进来,否则 Modal 自开新层会截断 `h` → 父级 handler 失聪。
104    pub layer: Option<InputLayer>,
105    // 是否截断更低层。`None` 视作 `true`(模态独占输入);非阻塞浮层可设 `Some(false)`。
106    pub blocks_lower: Option<bool>,
107}
108
109// Modal 组件实现。
110pub struct Modal {
111    pub open: bool,
112    pub margin: Margin,
113    pub offset: Offset,
114    pub width: Constraint,
115    pub height: Constraint,
116    pub placement: Placement,
117    pub style: Style,
118}
119
120impl Component for Modal {
121    type Props<'a> = ModalProps<'a>;
122    fn new(props: &Self::Props<'_>) -> Self {
123        Modal {
124            open: props.open,
125            margin: props.margin,
126            offset: props.offset,
127            width: props.width,
128            height: props.height,
129            // 样式待 update 经主题解析后写入。
130            style: Style::default(),
131            placement: props.placement,
132        }
133    }
134
135    fn update(
136        &mut self,
137        props: &mut Self::Props<'_>,
138        _hooks: crate::Hooks,
139        updater: &mut crate::ComponentUpdater,
140    ) {
141        self.open = props.open;
142        self.margin = props.margin;
143        self.offset = props.offset;
144        self.width = props.width;
145        self.height = props.height;
146        self.placement = props.placement;
147        // 主题解析:theme 遮罩铺底,props 的 Option<Style> 在上 patch(None → 用主题)。
148        // use_component_theme 返回 owned 值、读后即弃守卫,不与后续 &mut updater 冲突。
149        let theme = updater.use_component_theme::<ModalTheme>();
150        self.style = resolve_style(theme.style, props.style);
151
152        if self.open {
153            let blocks = props.blocks_lower.unwrap_or(true);
154            // 借用纪律:取 SystemContext 守卫拿 layer id 后【立即 drop】,再 update_children,
155            // 否则子树组件访问 SystemContext(use_input_layer / use_exit)会撞 AlreadyBorrowed。
156            let layer_id = match props.layer {
157                // 外部已登记该层(父级 use_input_layer):Modal 不重复 push,仅注入给子树。
158                Some(h) => h.id,
159                // 内部自开层(handler 全在 Modal 子树内):push 一个独占层。
160                None => {
161                    let mut sys = updater
162                        .get_context_mut::<SystemContext>()
163                        .expect("`SystemContext` missing (the root context always provides it)");
164                    sys.input.push_layer(true, blocks).id
165                }
166            };
167
168            // 给子树注入 CurrentLayer:子树内 use_event_handler(Current) 自动归属本层。
169            updater.update_children(
170                props.children.iter_mut(),
171                Some(Context::owned(CurrentLayer(layer_id))),
172            );
173        }
174
175        updater.set_layout_style(LayoutStyle {
176            width: Constraint::Length(0),
177            height: Constraint::Length(0),
178            ..Default::default()
179        });
180    }
181
182    fn draw(&mut self, drawer: &mut crate::ComponentDrawer<'_, '_>) {
183        if self.open {
184            // 根据终端尺寸计算弹窗尺寸和位置
185            let area = drawer.buffer_mut().area();
186            let area = area.inner(self.margin).offset(self.offset);
187
188            let block = Block::default().style(self.style);
189            block.render(area, drawer.buffer_mut());
190
191            let [v, h] = self.placement.to_flex();
192
193            let vertical = Layout::vertical([self.height]).flex(v).split(area)[0];
194            let horizontal = Layout::horizontal([self.width]).flex(h).split(vertical)[0];
195
196            // 清空弹窗区域
197            Clear.render(horizontal, drawer.buffer_mut());
198            drawer.area = horizontal;
199        }
200    }
201}