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