Skip to main content

fission_core/ui/widgets/
context_menu.rs

1use crate::internal::InternalLower;
2use crate::lowering::{InternalIrBuilder, InternalLoweringCx};
3use crate::ui::{Button, ButtonVariant, Column, Container, Positioned, Text, TextContent, Widget};
4use crate::ActionEnvelope;
5use fission_ir::{
6    op::{BoxShadow, Color, LayoutOp, Op},
7    FocusPolicy, Semantics, WidgetId,
8};
9use serde::{Deserialize, Serialize};
10
11/// A popup menu shown for a secondary click or other contextual activation.
12///
13/// Menu items accept arbitrary child widgets, so applications can provide plain
14/// translated text, icons, shortcuts, badges, or richer branded rows without
15/// hard-coding menu strings in the framework.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ContextMenu {
18    /// Entries rendered in order from top to bottom.
19    pub items: Vec<ContextMenuEntry>,
20    /// Fixed popup width. When omitted, [`ContextMenu::min_width`] is used.
21    pub width: Option<f32>,
22    /// Minimum popup width used by the default layout.
23    pub min_width: f32,
24    /// Maximum popup width before child content wraps or clips according to its own layout.
25    pub max_width: Option<f32>,
26    /// Popup interior padding `[left, right, top, bottom]`.
27    pub padding: [f32; 4],
28    /// Gap between menu rows.
29    pub gap: f32,
30    /// Popup corner radius.
31    pub border_radius: f32,
32    /// Optional popup background colour. Defaults to the framework surface colour.
33    pub background: Option<Color>,
34    /// Optional border colour. Defaults to a subtle neutral border.
35    pub border_color: Option<Color>,
36    /// Border width in logical pixels.
37    pub border_width: f32,
38    /// Optional popup shadow.
39    pub shadow: Option<BoxShadow>,
40}
41
42impl Default for ContextMenu {
43    fn default() -> Self {
44        Self {
45            items: Vec::new(),
46            width: None,
47            min_width: 176.0,
48            max_width: Some(320.0),
49            padding: [8.0, 8.0, 8.0, 8.0],
50            gap: 4.0,
51            border_radius: 12.0,
52            background: None,
53            border_color: None,
54            border_width: 1.0,
55            shadow: Some(BoxShadow {
56                offset: (0.0, 12.0),
57                blur_radius: 28.0,
58                color: Color {
59                    r: 15,
60                    g: 23,
61                    b: 42,
62                    a: 52,
63                },
64            }),
65        }
66    }
67}
68
69impl ContextMenu {
70    pub fn with_items(items: impl IntoIterator<Item = ContextMenuEntry>) -> Self {
71        Self {
72            items: items.into_iter().collect(),
73            ..Default::default()
74        }
75    }
76
77    pub(crate) fn overlay_widget(
78        &self,
79        owner: WidgetId,
80        anchor: fission_layout::LayoutPoint,
81    ) -> Widget {
82        let children = self
83            .items
84            .iter()
85            .enumerate()
86            .map(|(index, entry)| entry.widget(owner, index))
87            .collect();
88
89        let background = self.background.unwrap_or(Color {
90            r: 255,
91            g: 255,
92            b: 255,
93            a: 248,
94        });
95        let border = self.border_color.unwrap_or(Color {
96            r: 226,
97            g: 232,
98            b: 240,
99            a: 255,
100        });
101
102        Positioned {
103            id: Some(context_menu_popup_id(owner)),
104            left: Some(anchor.x),
105            top: Some(anchor.y),
106            child: Some(
107                Container::new(Column {
108                    id: Some(WidgetId::derived(owner.as_u128(), &[0xC0A7, 2])),
109                    children,
110                    gap: Some(self.gap),
111                    ..Default::default()
112                })
113                .width(self.width.unwrap_or(self.min_width))
114                .max_width(self.max_width.unwrap_or(320.0))
115                .padding(self.padding)
116                .bg(background)
117                .border(border, self.border_width)
118                .border_radius(self.border_radius)
119                .shadow(self.shadow.unwrap_or(BoxShadow {
120                    offset: (0.0, 8.0),
121                    blur_radius: 24.0,
122                    color: Color {
123                        r: 15,
124                        g: 23,
125                        b: 42,
126                        a: 38,
127                    },
128                }))
129                .into(),
130            ),
131            ..Default::default()
132        }
133        .into()
134    }
135}
136
137/// A row inside a [`ContextMenu`].
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub enum ContextMenuEntry {
140    /// A selectable menu row.
141    Item(ContextMenuItem),
142    /// A one-pixel visual separator between groups.
143    Separator,
144}
145
146impl ContextMenuEntry {
147    fn widget(&self, owner: WidgetId, index: usize) -> Widget {
148        match self {
149            Self::Item(item) => item.widget(owner, index),
150            Self::Separator => Container {
151                id: Some(context_menu_entry_id(owner, index)),
152                child: Some(Text::new("").into()),
153                height: Some(1.0),
154                background_color: Some(Color {
155                    r: 226,
156                    g: 232,
157                    b: 240,
158                    a: 255,
159                }),
160                background_fill: Some(fission_ir::op::Fill::Solid(Color {
161                    r: 226,
162                    g: 232,
163                    b: 240,
164                    a: 255,
165                })),
166                ..Default::default()
167            }
168            .into(),
169        }
170    }
171}
172
173/// A selectable context menu item.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ContextMenuItem {
176    /// Stable id used to derive the row widget identity.
177    pub id: String,
178    /// The visible row content. Use `TextContent::Key` or `KeyWithFallback` for i18n.
179    pub child: Widget,
180    /// Action dispatched when the row is selected.
181    pub on_select: Option<ActionEnvelope>,
182    /// Whether the row can be activated.
183    pub enabled: bool,
184    /// Whether selecting the row should close the menu. Reserved for shells that support persistent menus.
185    pub close_on_select: bool,
186    /// Optional accessible label when the child is not self-describing.
187    pub semantics_label: Option<String>,
188}
189
190impl ContextMenuItem {
191    pub fn new(id: impl Into<String>, child: impl Into<Widget>) -> Self {
192        Self {
193            id: id.into(),
194            child: child.into(),
195            on_select: None,
196            enabled: true,
197            close_on_select: true,
198            semantics_label: None,
199        }
200    }
201
202    pub fn text(id: impl Into<String>, label: impl Into<String>) -> Self {
203        Self::new(id, Text::new(label.into()))
204    }
205
206    pub fn text_key(
207        id: impl Into<String>,
208        key: impl Into<String>,
209        fallback: impl Into<String>,
210    ) -> Self {
211        Self::new(
212            id,
213            Text::new(TextContent::KeyWithFallback {
214                key: key.into(),
215                fallback: fallback.into(),
216            }),
217        )
218    }
219
220    pub fn on_select(mut self, action: ActionEnvelope) -> Self {
221        self.on_select = Some(action);
222        self
223    }
224
225    pub fn enabled(mut self, enabled: bool) -> Self {
226        self.enabled = enabled;
227        self
228    }
229
230    pub fn semantics_label(mut self, label: impl Into<String>) -> Self {
231        self.semantics_label = Some(label.into());
232        self
233    }
234
235    fn widget(&self, owner: WidgetId, index: usize) -> Widget {
236        let semantics = Semantics {
237            role: fission_ir::Role::Button,
238            label: self.semantics_label.clone(),
239            focusable: true,
240            disabled: !self.enabled,
241            ..Semantics::default()
242        };
243
244        Button {
245            id: Some(
246                context_menu_item_id(owner, &self.id)
247                    .unwrap_or_else(|| context_menu_entry_id(owner, index)),
248            ),
249            child: Some(self.child.clone()),
250            on_press: self.on_select.clone().filter(|_| self.enabled),
251            semantics: Some(semantics),
252            focus_policy: FocusPolicy::PreserveCurrentOnPointer,
253            variant: ButtonVariant::Ghost,
254            padding: Some([10.0, 10.0, 8.0, 8.0]),
255            disabled: !self.enabled,
256            ..Default::default()
257        }
258        .into()
259    }
260}
261
262pub(crate) fn text_context_menu_overlay_widget(
263    config: &TextContextMenuConfig,
264    owner: WidgetId,
265    anchor: fission_layout::LayoutPoint,
266    action_enabled: impl Fn(TextContextMenuAction) -> bool,
267) -> Widget {
268    let menu = config.menu.clone();
269    let children = config
270        .actions
271        .iter()
272        .copied()
273        .map(|action| text_context_menu_item_widget(owner, action, action_enabled(action)))
274        .collect();
275
276    let background = menu.background.unwrap_or(Color {
277        r: 255,
278        g: 255,
279        b: 255,
280        a: 248,
281    });
282    let border = menu.border_color.unwrap_or(Color {
283        r: 226,
284        g: 232,
285        b: 240,
286        a: 255,
287    });
288
289    Positioned {
290        id: Some(context_menu_popup_id(owner)),
291        left: Some(anchor.x),
292        top: Some(anchor.y),
293        child: Some(
294            Container::new(Column {
295                id: Some(WidgetId::derived(owner.as_u128(), &[0xC0A7, 4])),
296                children,
297                gap: Some(menu.gap),
298                ..Default::default()
299            })
300            .width(menu.width.unwrap_or(menu.min_width))
301            .max_width(menu.max_width.unwrap_or(320.0))
302            .padding(menu.padding)
303            .bg(background)
304            .border(border, menu.border_width)
305            .border_radius(menu.border_radius)
306            .shadow(menu.shadow.unwrap_or(BoxShadow {
307                offset: (0.0, 8.0),
308                blur_radius: 24.0,
309                color: Color {
310                    r: 15,
311                    g: 23,
312                    b: 42,
313                    a: 38,
314                },
315            }))
316            .into(),
317        ),
318        ..Default::default()
319    }
320    .into()
321}
322
323/// Widget wrapper that gives any subtree a context menu.
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct ContextMenuRegion {
326    /// Stable owner id for context-menu open state.
327    pub id: Option<WidgetId>,
328    /// Subtree that can open the menu on secondary click.
329    pub child: Widget,
330    /// Menu rendered when this region is active.
331    pub menu: ContextMenu,
332    /// Whether the region responds to secondary click.
333    pub enabled: bool,
334    /// Optional semantics for the owner node. Fission sets `context_menu` automatically.
335    pub semantics: Option<Semantics>,
336}
337
338impl ContextMenuRegion {
339    pub fn new(child: impl Into<Widget>, menu: ContextMenu) -> Self {
340        Self {
341            id: None,
342            child: child.into(),
343            menu,
344            enabled: true,
345            semantics: None,
346        }
347    }
348
349    pub fn id(mut self, id: WidgetId) -> Self {
350        self.id = Some(id);
351        self
352    }
353
354    pub fn enabled(mut self, enabled: bool) -> Self {
355        self.enabled = enabled;
356        self
357    }
358
359    pub fn semantics_identifier(mut self, identifier: impl Into<String>) -> Self {
360        let semantics = self.semantics.get_or_insert_with(Semantics::default);
361        semantics.identifier = Some(identifier.into());
362        self
363    }
364}
365
366impl InternalLower for ContextMenuRegion {
367    fn lower(&self, cx: &mut InternalLoweringCx<'_>) -> WidgetId {
368        let owner = self.id.unwrap_or_else(|| cx.next_node_id());
369        cx.push_scope(owner);
370
371        let child_id = self.child.lower(cx);
372        let visual_id = if self.enabled && cx.runtime_state.context_menu.owner == Some(owner) {
373            let anchor = cx
374                .runtime_state
375                .context_menu
376                .anchor
377                .map(|screen_anchor| anchor_to_local(cx, owner, screen_anchor))
378                .unwrap_or_else(|| fission_layout::LayoutPoint::new(0.0, 0.0));
379            let menu_id = self.menu.overlay_widget(owner, anchor).lower(cx);
380            let mut stack = InternalIrBuilder::new(cx.next_node_id(), Op::Layout(LayoutOp::ZStack));
381            stack.add_child(child_id);
382            stack.add_child(menu_id);
383            stack.build(cx)
384        } else {
385            child_id
386        };
387
388        let mut semantics = self.semantics.clone().unwrap_or_default();
389        semantics.context_menu = self.enabled;
390        let mut builder = InternalIrBuilder::new(owner, Op::Semantics(semantics));
391        builder.add_child(visual_id);
392        cx.pop_scope();
393        builder.build(cx)
394    }
395}
396
397/// Standard editing/selection commands used by built-in text context menus.
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
399pub enum TextContextMenuAction {
400    Copy,
401    Cut,
402    Paste,
403    SelectAll,
404}
405
406impl TextContextMenuAction {
407    pub fn fallback_label(self) -> &'static str {
408        match self {
409            Self::Copy => "Copy",
410            Self::Cut => "Cut",
411            Self::Paste => "Paste",
412            Self::SelectAll => "Select All",
413        }
414    }
415
416    pub fn label_key(self) -> &'static str {
417        match self {
418            Self::Copy => "fission.context_menu.copy",
419            Self::Cut => "fission.context_menu.cut",
420            Self::Paste => "fission.context_menu.paste",
421            Self::SelectAll => "fission.context_menu.select_all",
422        }
423    }
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize)]
427pub struct TextContextMenuConfig {
428    /// Whether the built-in text context menu is enabled.
429    pub enabled: bool,
430    /// Standard text actions shown in order. Unsupported actions are rendered disabled.
431    pub actions: Vec<TextContextMenuAction>,
432    /// Visual menu configuration used by the built-in text menu.
433    pub menu: ContextMenu,
434}
435
436impl TextContextMenuConfig {
437    pub fn read_only() -> Self {
438        Self {
439            enabled: true,
440            actions: vec![
441                TextContextMenuAction::Copy,
442                TextContextMenuAction::SelectAll,
443            ],
444            menu: ContextMenu::default(),
445        }
446    }
447
448    pub fn editing() -> Self {
449        Self {
450            enabled: true,
451            actions: vec![
452                TextContextMenuAction::Copy,
453                TextContextMenuAction::Cut,
454                TextContextMenuAction::Paste,
455                TextContextMenuAction::SelectAll,
456            ],
457            menu: ContextMenu::default(),
458        }
459    }
460
461    pub fn disabled() -> Self {
462        Self {
463            enabled: false,
464            ..Self::read_only()
465        }
466    }
467}
468
469impl Default for TextContextMenuConfig {
470    fn default() -> Self {
471        Self::read_only()
472    }
473}
474
475pub(crate) fn text_context_menu_item_widget(
476    owner: WidgetId,
477    action: TextContextMenuAction,
478    enabled: bool,
479) -> Widget {
480    let child = Text::new(TextContent::KeyWithFallback {
481        key: action.label_key().to_string(),
482        fallback: action.fallback_label().to_string(),
483    });
484
485    Button {
486        id: Some(text_context_menu_button_id(owner, action)),
487        child: Some(child.into()),
488        semantics: Some(Semantics {
489            role: fission_ir::Role::Button,
490            label: Some(action.fallback_label().to_string()),
491            focusable: true,
492            disabled: !enabled,
493            ..Semantics::default()
494        }),
495        focus_policy: FocusPolicy::PreserveCurrentOnPointer,
496        variant: ButtonVariant::Ghost,
497        padding: Some([10.0, 10.0, 8.0, 8.0]),
498        disabled: !enabled,
499        ..Default::default()
500    }
501    .into()
502}
503
504pub(crate) fn action_index(action: TextContextMenuAction) -> usize {
505    match action {
506        TextContextMenuAction::Copy => 0,
507        TextContextMenuAction::Cut => 1,
508        TextContextMenuAction::Paste => 2,
509        TextContextMenuAction::SelectAll => 3,
510    }
511}
512
513pub(crate) fn context_menu_popup_id(owner: WidgetId) -> WidgetId {
514    WidgetId::derived(owner.as_u128(), &[0xC0A7, 0])
515}
516
517pub(crate) fn context_menu_entry_id(owner: WidgetId, index: usize) -> WidgetId {
518    WidgetId::derived(owner.as_u128(), &[0xC0A7, 1, index as u32])
519}
520
521pub(crate) fn context_menu_item_id(owner: WidgetId, id: &str) -> Option<WidgetId> {
522    if id.is_empty() {
523        None
524    } else {
525        Some(WidgetId::explicit(&format!(
526            "fission.context_menu.{owner}.{id}"
527        )))
528    }
529}
530
531pub(crate) fn text_context_menu_button_id(
532    owner: WidgetId,
533    action: TextContextMenuAction,
534) -> WidgetId {
535    WidgetId::derived(owner.as_u128(), &[0xC0A7, 3, action_index(action) as u32])
536}
537
538pub(crate) fn anchor_to_local(
539    cx: &InternalLoweringCx<'_>,
540    owner: WidgetId,
541    screen_anchor: fission_layout::LayoutPoint,
542) -> fission_layout::LayoutPoint {
543    let Some(layout) = cx.layout else {
544        return screen_anchor;
545    };
546    let Some(rect) = layout.get_node_rect(owner) else {
547        return screen_anchor;
548    };
549    fission_layout::LayoutPoint::new(
550        (screen_anchor.x - rect.origin.x).max(0.0),
551        (screen_anchor.y - rect.origin.y).max(0.0),
552    )
553}