Skip to main content

tui_lipan/widgets/
command_palette.rs

1use std::sync::Arc;
2
3use crate::app::input::command_registry::{CommandEntry, CommandId, CommandRegistry};
4use crate::callback::Callback;
5use crate::core::component::{Component, Context, Update};
6use crate::core::element::Element;
7use crate::overlay::OverlayScope;
8use crate::style::{Align, BorderStyle, Length, Padding, RichText, Span, Style};
9use crate::widgets::{
10    ItemDescription, ListItem, Modal, SearchEntry, SearchHighlight, SearchItem, SearchPalette,
11};
12
13#[derive(Clone, PartialEq)]
14struct CommandPaletteProps {
15    on_close: Option<Callback<()>>,
16    show_disabled: bool,
17    width: Length,
18    height: Length,
19    backdrop_style: Style,
20    frame_style: Style,
21    border: bool,
22    border_style: BorderStyle,
23    padding: Padding,
24    title: Option<RichText>,
25    title_style: Style,
26    title_alignment: Align,
27    scope: OverlayScope,
28}
29
30#[derive(Clone, Default, PartialEq)]
31struct CommandPaletteState {
32    entries: Vec<SearchEntry<CommandId>>,
33    disabled_ids: Vec<CommandId>,
34}
35
36#[derive(Clone, PartialEq)]
37struct DisabledRenderStyle {
38    style: Style,
39    disabled_ids: Vec<CommandId>,
40}
41
42struct CommandPaletteComponent;
43
44impl Component for CommandPaletteComponent {
45    type Message = ();
46    type Properties = CommandPaletteProps;
47    type State = CommandPaletteState;
48
49    fn create_state(&self, _props: &Self::Properties) -> Self::State {
50        CommandPaletteState::default()
51    }
52
53    fn init(&mut self, ctx: &mut Context<Self>) -> Option<crate::core::component::Command> {
54        rebuild_state(ctx);
55        None
56    }
57
58    fn on_props_changed(
59        &mut self,
60        old_props: &Self::Properties,
61        ctx: &mut Context<Self>,
62    ) -> Update {
63        if old_props.show_disabled != ctx.props.show_disabled
64            || old_props.on_close != ctx.props.on_close
65            || old_props.width != ctx.props.width
66            || old_props.height != ctx.props.height
67            || old_props.backdrop_style != ctx.props.backdrop_style
68            || old_props.frame_style != ctx.props.frame_style
69            || old_props.border != ctx.props.border
70            || old_props.border_style != ctx.props.border_style
71            || old_props.padding != ctx.props.padding
72            || old_props.title != ctx.props.title
73            || old_props.title_style != ctx.props.title_style
74            || old_props.title_alignment != ctx.props.title_alignment
75            || old_props.scope != ctx.props.scope
76        {
77            rebuild_state(ctx);
78            return Update::full();
79        }
80        Update::none()
81    }
82
83    fn view(&self, ctx: &Context<Self>) -> Element {
84        let muted_style = ctx.theme().muted.dim();
85        let disabled_style = DisabledRenderStyle {
86            style: muted_style,
87            disabled_ids: ctx.state.disabled_ids.clone(),
88        };
89        let render_item = Arc::new(
90            move |item: &SearchItem<CommandId>, _highlight: &SearchHighlight| {
91                if !disabled_style
92                    .disabled_ids
93                    .iter()
94                    .any(|id| id == &item.value)
95                {
96                    return None;
97                }
98
99                let mut spans = vec![Span::new(item.label.clone()).style(disabled_style.style)];
100                if let Some(description) = &item.description
101                    && let Some(left) = &description.left
102                {
103                    spans.push(Span::new(" - ").style(disabled_style.style));
104                    spans.push(Span::new(left.clone()).style(disabled_style.style));
105                }
106
107                let mut row = ListItem::from_spans(spans).style(disabled_style.style);
108                if let Some(description) = &item.description
109                    && let Some(right) = &description.right
110                {
111                    row = row.description_spans([
112                        Span::new(" ").style(disabled_style.style),
113                        Span::new(right.clone()).style(disabled_style.style),
114                    ]);
115                }
116
117                Some(row)
118            },
119        );
120
121        let registry = ctx.command_registry();
122        let disabled_for_activate = ctx.state.disabled_ids.clone();
123        let on_close_for_activate = ctx.props.on_close.clone();
124        let on_activate = Callback::new(move |event: crate::widgets::SearchEvent<CommandId>| {
125            if disabled_for_activate
126                .iter()
127                .any(|id| id == &event.item.value)
128            {
129                return;
130            }
131            if registry.execute(event.item.value.clone())
132                && let Some(on_close) = &on_close_for_activate
133            {
134                on_close.emit(());
135            }
136        });
137
138        let palette = SearchPalette::<CommandId>::new()
139            .entries(ctx.state.entries.clone())
140            .height(Length::Flex(1))
141            .input_border(false)
142            .list_border(false)
143            .list_selection_full_width(true)
144            .preserve_groups(true)
145            .on_activate(on_activate)
146            .render_item(render_item);
147
148        let mut modal = Modal::new()
149            .child(palette)
150            .width(ctx.props.width)
151            .height(ctx.props.height)
152            .backdrop_style(ctx.props.backdrop_style)
153            .frame_style(ctx.props.frame_style)
154            .border(ctx.props.border)
155            .border_style(ctx.props.border_style)
156            .padding(ctx.props.padding)
157            .title_style(ctx.props.title_style)
158            .title_alignment(ctx.props.title_alignment)
159            .scope(ctx.props.scope);
160
161        if let Some(title) = ctx.props.title.clone() {
162            modal = modal.title(title);
163        }
164        if let Some(on_close) = ctx.props.on_close.clone() {
165            modal = modal.on_close(on_close);
166        }
167
168        modal.into()
169    }
170
171    fn update(&mut self, _msg: Self::Message, _ctx: &mut Context<Self>) -> Update {
172        Update::none()
173    }
174}
175
176fn rebuild_state(ctx: &mut Context<CommandPaletteComponent>) {
177    let registry = ctx.command_registry();
178    let (entries, disabled_ids) = build_palette_entries(&registry, ctx.props.show_disabled);
179    ctx.state.entries = entries;
180    ctx.state.disabled_ids = disabled_ids;
181}
182
183fn build_palette_entries(
184    registry: &CommandRegistry,
185    show_disabled: bool,
186) -> (Vec<SearchEntry<CommandId>>, Vec<CommandId>) {
187    let mut commands: Vec<CommandEntry> = registry.entries();
188    commands.sort_by(|left, right| {
189        let left_category = left.category.as_deref().unwrap_or("General");
190        let right_category = right.category.as_deref().unwrap_or("General");
191        left_category
192            .cmp(right_category)
193            .then_with(|| left.label.cmp(&right.label))
194            .then_with(|| left.id.as_str().cmp(right.id.as_str()))
195    });
196
197    let mut entries = Vec::new();
198    let mut disabled_ids = Vec::new();
199    let mut active_category: Option<Arc<str>> = None;
200
201    for command in commands {
202        if !command.enabled {
203            disabled_ids.push(command.id.clone());
204            if !show_disabled {
205                continue;
206            }
207        }
208
209        let category = command.category.unwrap_or_else(|| Arc::from("General"));
210        if active_category.as_ref() != Some(&category) {
211            entries.push(SearchEntry::header(category.clone()));
212            active_category = Some(category);
213        }
214
215        let mut entry = SearchEntry::item(command.label.clone(), command.id.clone());
216        if command.description.is_some() || command.keybinding_hint.is_some() {
217            let mut description = ItemDescription::new();
218            if let Some(left) = command.description {
219                description = description.left(left);
220            }
221            if let Some(right) = command.keybinding_hint {
222                description = description.right(right);
223            }
224            entry = entry.description(description);
225        }
226        entries.push(entry);
227    }
228
229    (entries, disabled_ids)
230}
231
232/// Composite command palette built from the runtime [`CommandRegistry`].
233#[derive(Clone)]
234pub struct CommandPalette {
235    props: CommandPaletteProps,
236}
237
238impl CommandPalette {
239    /// Create a command palette with modal defaults.
240    pub fn new() -> Self {
241        Self::default()
242    }
243
244    /// Set close callback fired for both modal close and command activation.
245    pub fn on_close(mut self, cb: Callback<()>) -> Self {
246        self.props.on_close = Some(cb);
247        self
248    }
249
250    /// Include disabled commands in search results.
251    pub fn show_disabled(mut self, show: bool) -> Self {
252        self.props.show_disabled = show;
253        self
254    }
255
256    /// Set modal width.
257    pub fn width(mut self, width: Length) -> Self {
258        self.props.width = width;
259        self
260    }
261
262    /// Set modal height.
263    pub fn height(mut self, height: Length) -> Self {
264        self.props.height = height;
265        self
266    }
267
268    /// Set modal backdrop style.
269    pub fn backdrop_style(mut self, style: Style) -> Self {
270        self.props.backdrop_style = style;
271        self
272    }
273
274    /// Set modal frame style.
275    pub fn frame_style(mut self, style: Style) -> Self {
276        self.props.frame_style = style;
277        self
278    }
279
280    /// Enable or disable modal border.
281    pub fn border(mut self, border: bool) -> Self {
282        self.props.border = border;
283        self
284    }
285
286    /// Set modal border style.
287    pub fn border_style(mut self, border_style: BorderStyle) -> Self {
288        self.props.border_style = border_style;
289        self
290    }
291
292    /// Set modal content padding.
293    pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
294        self.props.padding = padding.into();
295        self
296    }
297
298    /// Set modal title.
299    pub fn title(mut self, title: impl Into<RichText>) -> Self {
300        self.props.title = Some(title.into());
301        self
302    }
303
304    /// Set modal title style.
305    pub fn title_style(mut self, style: Style) -> Self {
306        self.props.title_style = style;
307        self
308    }
309
310    /// Set modal title alignment.
311    pub fn title_alignment(mut self, alignment: Align) -> Self {
312        self.props.title_alignment = alignment;
313        self
314    }
315
316    /// Set overlay scope.
317    pub fn scope(mut self, scope: OverlayScope) -> Self {
318        self.props.scope = scope;
319        self
320    }
321}
322
323impl Default for CommandPalette {
324    fn default() -> Self {
325        Self {
326            props: CommandPaletteProps {
327                on_close: None,
328                show_disabled: false,
329                width: Length::Px(80),
330                height: Length::Px(20),
331                backdrop_style: Style::default(),
332                frame_style: Style::default(),
333                border: true,
334                border_style: BorderStyle::Plain,
335                padding: 0.into(),
336                title: None,
337                title_style: Style::default(),
338                title_alignment: Align::Start,
339                scope: OverlayScope::RootPortal,
340            },
341        }
342    }
343}
344
345impl From<CommandPalette> for Element {
346    fn from(palette: CommandPalette) -> Self {
347        crate::child(|| CommandPaletteComponent, palette.props)
348    }
349}