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 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#[derive(Debug, Clone, Serialize, Deserialize)]
139pub enum ContextMenuEntry {
140 Item(ContextMenuItem),
142 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#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ContextMenuItem {
176 pub id: String,
178 pub child: Widget,
180 pub on_select: Option<ActionEnvelope>,
182 pub enabled: bool,
184 pub close_on_select: bool,
186 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#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct ContextMenuRegion {
326 pub id: Option<WidgetId>,
328 pub child: Widget,
330 pub menu: ContextMenu,
332 pub enabled: bool,
334 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#[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 pub enabled: bool,
430 pub actions: Vec<TextContextMenuAction>,
432 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}