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#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ContextMenu {
18 pub items: Vec<ContextMenuEntry>,
20 pub width: Option<f32>,
22 pub min_width: f32,
24 pub max_width: Option<f32>,
26 pub padding: [f32; 4],
28 pub gap: f32,
30 pub border_radius: f32,
32 pub background: Option<Color>,
34 pub border_color: Option<Color>,
36 pub border_width: f32,
38 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#[derive(Debug, Clone, Serialize, Deserialize)]
143pub enum ContextMenuEntry {
144 Item(ContextMenuItem),
146 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#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct ContextMenuItem {
180 pub id: String,
182 pub child: Widget,
184 pub on_select: Option<ActionEnvelope>,
186 pub enabled: bool,
188 pub close_on_select: bool,
190 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#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct ContextMenuRegion {
332 pub id: Option<WidgetId>,
334 pub child: Widget,
336 pub menu: ContextMenu,
338 pub enabled: bool,
340 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#[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 pub enabled: bool,
436 pub actions: Vec<TextContextMenuAction>,
438 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}