355 lines
13 KiB
Rust
355 lines
13 KiB
Rust
//! Shared context menu implementation for notebooks.
|
|
|
|
use pathfinder_geometry::vector::Vector2F;
|
|
use warp_core::context_flag::ContextFlag;
|
|
use warpui::{
|
|
elements::{ChildAnchor, OffsetPositioning, ParentAnchor, ParentOffsetBounds, Stack},
|
|
keymap::Trigger,
|
|
presenter::ChildView,
|
|
Action, Element, EventContext, TypedActionView, View, ViewContext, ViewHandle,
|
|
};
|
|
|
|
use crate::{
|
|
editor::EditorView,
|
|
menu::{self, Menu, MenuItem, MenuItemFields},
|
|
pane_group::{focus_state::PaneFocusHandle, PaneEvent, SplitPaneState},
|
|
util::bindings::{keybinding_name_to_display_string, trigger_to_keystroke, CustomAction},
|
|
};
|
|
|
|
use super::{
|
|
editor::{keys::custom_action_to_display, view::RichTextEditorView},
|
|
telemetry::ActionEntrypoint,
|
|
};
|
|
|
|
#[cfg(test)]
|
|
#[path = "context_menu_tests.rs"]
|
|
mod tests;
|
|
|
|
const CONTEXT_MENU_WIDTH: f32 = 200.;
|
|
|
|
pub struct ContextMenuState<V: TypedActionView + View>
|
|
where
|
|
V::Action: Clone + From<ContextMenuAction>,
|
|
{
|
|
/// The kind of menu that's open. If `None`, the menu is closed.
|
|
source: Option<MenuSource>,
|
|
menu: ViewHandle<Menu<V::Action>>,
|
|
/// Focus state of the pane containing this context menu.
|
|
focus_handle: Option<PaneFocusHandle>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum MenuSource {
|
|
RichTextEditor {
|
|
parent_offset: Vector2F,
|
|
editor: ViewHandle<RichTextEditorView>,
|
|
},
|
|
TextEditor {
|
|
parent_offset: Vector2F,
|
|
editor: ViewHandle<EditorView>,
|
|
},
|
|
}
|
|
|
|
impl<V: TypedActionView + View> ContextMenuState<V>
|
|
where
|
|
V::Event: From<PaneEvent>,
|
|
V::Action: Clone + From<ContextMenuAction>,
|
|
{
|
|
pub fn new(ctx: &mut ViewContext<V>) -> Self {
|
|
let menu = ctx.add_typed_action_view(|_| Menu::new().with_width(CONTEXT_MENU_WIDTH));
|
|
|
|
ctx.subscribe_to_view(&menu, |view, _, event, ctx| match event {
|
|
menu::Event::ItemSelected | menu::Event::ItemHovered => (),
|
|
menu::Event::Close { via_select_item } => {
|
|
view.handle_action(
|
|
&V::Action::from(ContextMenuAction::Close {
|
|
via_select_item: *via_select_item,
|
|
}),
|
|
ctx,
|
|
);
|
|
}
|
|
});
|
|
|
|
Self {
|
|
source: None,
|
|
menu,
|
|
focus_handle: None,
|
|
}
|
|
}
|
|
|
|
pub(super) fn set_focus_handle(&mut self, focus_handle: PaneFocusHandle) {
|
|
self.focus_handle = Some(focus_handle);
|
|
}
|
|
|
|
/// Renders the context menu, if it's open.
|
|
pub fn render(&self, stack: &mut Stack) {
|
|
let offset = match self.source {
|
|
Some(MenuSource::RichTextEditor { parent_offset, .. }) => parent_offset,
|
|
Some(MenuSource::TextEditor { parent_offset, .. }) => parent_offset,
|
|
None => return,
|
|
};
|
|
|
|
stack.add_positioned_overlay_child(
|
|
ChildView::new(&self.menu).finish(),
|
|
OffsetPositioning::offset_from_parent(
|
|
offset,
|
|
ParentOffsetBounds::WindowByPosition,
|
|
ParentAnchor::TopLeft,
|
|
ChildAnchor::TopLeft,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Show the context menu.
|
|
pub fn show_context_menu(&mut self, source: MenuSource, ctx: &mut ViewContext<V>) {
|
|
let mut items = vec![];
|
|
|
|
// Section 1: text selection actions.
|
|
let (has_selection, can_edit) = match &source {
|
|
MenuSource::RichTextEditor { editor, .. } => {
|
|
let editor = editor.as_ref(ctx);
|
|
(
|
|
!editor.selection_is_single_cursor(ctx) || editor.has_command_selection(ctx),
|
|
editor.is_editable(ctx),
|
|
)
|
|
}
|
|
MenuSource::TextEditor { editor, .. } => editor.read(ctx, |editor, ctx| {
|
|
(!editor.selected_text(ctx).is_empty(), editor.can_edit(ctx))
|
|
}),
|
|
};
|
|
|
|
if has_selection && can_edit {
|
|
let item = MenuItemFields::new("Cut")
|
|
.with_on_select_action(V::Action::from(ContextMenuAction::CutSelectedText))
|
|
.with_key_shortcut_label(custom_action_to_display(CustomAction::Cut));
|
|
items.push(item.into_item());
|
|
}
|
|
if has_selection {
|
|
let item = MenuItemFields::new("Copy")
|
|
.with_on_select_action(V::Action::from(ContextMenuAction::CopySelectedText))
|
|
.with_key_shortcut_label(custom_action_to_display(CustomAction::Copy));
|
|
items.push(item.into_item());
|
|
}
|
|
if can_edit {
|
|
let item = MenuItemFields::new("Paste")
|
|
.with_on_select_action(V::Action::from(ContextMenuAction::Paste))
|
|
.with_key_shortcut_label(custom_action_to_display(CustomAction::Paste));
|
|
items.push(item.into_item());
|
|
}
|
|
|
|
// Section 2: Split-pane actions
|
|
let split_pane_menu_items = self.split_pane_menu_items(ctx);
|
|
if !items.is_empty() && !split_pane_menu_items.is_empty() {
|
|
items.push(MenuItem::Separator);
|
|
}
|
|
if !split_pane_menu_items.is_empty() {
|
|
items.extend(split_pane_menu_items);
|
|
}
|
|
|
|
self.menu.update(ctx, move |menu, ctx| {
|
|
menu.set_items(items, ctx); // This also resets the selection.
|
|
});
|
|
self.source = Some(source);
|
|
ctx.focus(&self.menu);
|
|
ctx.notify();
|
|
}
|
|
|
|
fn split_pane_menu_items(&self, ctx: &mut ViewContext<V>) -> Vec<MenuItem<V::Action>> {
|
|
let mut items = vec![];
|
|
if ContextFlag::CreateNewSession.is_enabled() {
|
|
items.extend([
|
|
MenuItemFields::new("Split pane right")
|
|
.with_on_select_action(V::Action::from(ContextMenuAction::EmitPaneEvent(
|
|
PaneEvent::SplitRight(None),
|
|
)))
|
|
.with_key_shortcut_label(keybinding_name_to_display_string(
|
|
"pane_group:add_right",
|
|
ctx,
|
|
))
|
|
.into_item(),
|
|
MenuItemFields::new("Split pane left")
|
|
.with_on_select_action(V::Action::from(ContextMenuAction::EmitPaneEvent(
|
|
PaneEvent::SplitLeft(None),
|
|
)))
|
|
.with_key_shortcut_label(keybinding_name_to_display_string(
|
|
"pane_group:add_left",
|
|
ctx,
|
|
))
|
|
.into_item(),
|
|
MenuItemFields::new("Split pane down")
|
|
.with_on_select_action(V::Action::from(ContextMenuAction::EmitPaneEvent(
|
|
PaneEvent::SplitDown(None),
|
|
)))
|
|
.with_key_shortcut_label(keybinding_name_to_display_string(
|
|
"pane_group:add_down",
|
|
ctx,
|
|
))
|
|
.into_item(),
|
|
MenuItemFields::new("Split pane up")
|
|
.with_on_select_action(V::Action::from(ContextMenuAction::EmitPaneEvent(
|
|
PaneEvent::SplitUp(None),
|
|
)))
|
|
.with_key_shortcut_label(keybinding_name_to_display_string(
|
|
"pane_group:add_up",
|
|
ctx,
|
|
))
|
|
.into_item(),
|
|
]);
|
|
}
|
|
|
|
let split_pane_state = self
|
|
.focus_handle
|
|
.as_ref()
|
|
.map_or(SplitPaneState::NotInSplitPane, |h| h.split_pane_state(ctx));
|
|
if split_pane_state.is_in_split_pane() {
|
|
let is_maximized = split_pane_state.is_maximized();
|
|
items.push(
|
|
MenuItemFields::toggle_pane_action(is_maximized)
|
|
.with_on_select_action(V::Action::from(ContextMenuAction::EmitPaneEvent(
|
|
PaneEvent::ToggleMaximized,
|
|
)))
|
|
.with_key_shortcut_label(keybinding_name_to_display_string(
|
|
"pane_group:toggle_maximize_pane",
|
|
ctx,
|
|
))
|
|
.into_item(),
|
|
);
|
|
|
|
items.push(
|
|
MenuItemFields::new("Close pane")
|
|
.with_on_select_action(V::Action::from(ContextMenuAction::EmitPaneEvent(
|
|
PaneEvent::Close,
|
|
)))
|
|
.with_key_shortcut_label(
|
|
trigger_to_keystroke(&Trigger::Custom(
|
|
CustomAction::CloseCurrentSession.into(),
|
|
))
|
|
.map(|keystroke| keystroke.displayed()),
|
|
)
|
|
.into_item(),
|
|
);
|
|
}
|
|
items
|
|
}
|
|
|
|
/// Close the context menu. If `focus_parent` is true, the parent view (either the editor that
|
|
/// triggered the context menu or the notebook view) will be focused.
|
|
pub fn close_context_menu(&mut self, focus_parent: bool, ctx: &mut ViewContext<V>) {
|
|
if focus_parent {
|
|
match &self.source {
|
|
Some(MenuSource::RichTextEditor { editor, .. }) => ctx.focus(editor),
|
|
Some(MenuSource::TextEditor { editor, .. }) => ctx.focus(editor),
|
|
None => ctx.focus_self(),
|
|
}
|
|
}
|
|
self.source = None;
|
|
ctx.notify();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
/// List out the context menu items by name.
|
|
pub fn item_names<'a>(&self, ctx: &'a impl warpui::ViewAsRef) -> Vec<&'a str> {
|
|
self.menu
|
|
.as_ref(ctx)
|
|
.items()
|
|
.iter()
|
|
.map(|item| match item {
|
|
MenuItem::Item(item) => item.label(),
|
|
MenuItem::Separator => "----",
|
|
MenuItem::ItemsRow { .. } => panic!("ItemsRow not supported"),
|
|
MenuItem::Submenu { fields, .. } => fields.label(),
|
|
MenuItem::Header { fields, .. } => fields.label(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub fn handle_action(&mut self, action: &ContextMenuAction, ctx: &mut ViewContext<V>) {
|
|
match action {
|
|
ContextMenuAction::Open(source) => self.show_context_menu(source.clone(), ctx),
|
|
ContextMenuAction::Close { via_select_item } => {
|
|
self.close_context_menu(!*via_select_item, ctx)
|
|
}
|
|
ContextMenuAction::CopySelectedText => match &self.source {
|
|
Some(MenuSource::RichTextEditor { editor, .. }) => {
|
|
editor.update(ctx, |editor, ctx| editor.copy(ActionEntrypoint::Menu, ctx))
|
|
}
|
|
Some(MenuSource::TextEditor { editor, .. }) => {
|
|
editor.update(ctx, |editor, ctx| editor.copy(ctx))
|
|
}
|
|
None => (),
|
|
},
|
|
ContextMenuAction::CutSelectedText => match &self.source {
|
|
Some(MenuSource::RichTextEditor { editor, .. }) => {
|
|
ctx.focus(editor);
|
|
editor.update(ctx, |editor, ctx| editor.cut(ActionEntrypoint::Menu, ctx));
|
|
}
|
|
Some(MenuSource::TextEditor { editor, .. }) => {
|
|
ctx.focus(editor);
|
|
editor.update(ctx, |editor, ctx| editor.cut(ctx))
|
|
}
|
|
None => (),
|
|
},
|
|
ContextMenuAction::Paste => match &self.source {
|
|
Some(MenuSource::RichTextEditor { editor, .. }) => {
|
|
ctx.focus(editor);
|
|
editor.update(ctx, |editor, ctx| editor.paste(ctx))
|
|
}
|
|
Some(MenuSource::TextEditor { editor, .. }) => {
|
|
ctx.focus(editor);
|
|
editor.update(ctx, |editor, ctx| editor.paste(ctx))
|
|
}
|
|
None => (),
|
|
},
|
|
ContextMenuAction::EmitPaneEvent(event) => ctx.emit(V::Event::from(event.clone())),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Dispatch an action to show the notebook context menu for a rich text editor view.
|
|
pub fn show_rich_editor_context_menu<A>(
|
|
ctx: &mut EventContext,
|
|
position: Vector2F,
|
|
parent_position_id: &str,
|
|
editor: &ViewHandle<RichTextEditorView>,
|
|
) where
|
|
A: Action + From<ContextMenuAction>,
|
|
{
|
|
if let Some(parent_bounds) = ctx.element_position_by_id(parent_position_id) {
|
|
let offset = position - parent_bounds.origin();
|
|
ctx.dispatch_typed_action(A::from(ContextMenuAction::Open(
|
|
MenuSource::RichTextEditor {
|
|
parent_offset: offset,
|
|
editor: editor.clone(),
|
|
},
|
|
)));
|
|
}
|
|
}
|
|
|
|
/// Dispatch an action to show the notebook context menu for a plain text editor view.
|
|
pub fn show_text_editor_context_menu<A>(
|
|
ctx: &mut EventContext,
|
|
position: Vector2F,
|
|
parent_position_id: &str,
|
|
editor: &ViewHandle<EditorView>,
|
|
) where
|
|
A: Action + From<ContextMenuAction>,
|
|
{
|
|
if let Some(parent_bounds) = ctx.element_position_by_id(parent_position_id) {
|
|
let offset = position - parent_bounds.origin();
|
|
ctx.dispatch_typed_action(A::from(ContextMenuAction::Open(MenuSource::TextEditor {
|
|
parent_offset: offset,
|
|
editor: editor.clone(),
|
|
})));
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum ContextMenuAction {
|
|
Open(MenuSource),
|
|
Close { via_select_item: bool },
|
|
CopySelectedText,
|
|
CutSelectedText,
|
|
Paste,
|
|
EmitPaneEvent(PaneEvent),
|
|
}
|