Skip to content

Commit

Permalink
feat(editor): add right click menus (onivim#3705)
Browse files Browse the repository at this point in the history
* MenuBar: rename MenuBar module to ContextMenu

* Feature_ContextMenu: refactor NativeMenu code

* Feature_ContextMenu: make right click menu view

* Feature_Clipboard: create paste menu item

* ContextMenu: refactor

* Feature_Editor: add right click menu

* Feature_ContextMenu: display menus one pixel off to avoid clicking on the first element

* Component_ContextMenu: add better cancelling behavior

* Editor: prevent visual mode selection on right click

* Service_Vim: use passed context when pasting in normal mode

* Formatting

* NativeMenu: remove unused function

* Feature_ContextMenu: use native menus on macOS

* Add to CHANGES
  • Loading branch information
zbaylin authored Jun 30, 2021
1 parent c76f28e commit a6ddd36
Show file tree
Hide file tree
Showing 40 changed files with 651 additions and 251 deletions.
1 change: 1 addition & 0 deletions CHANGES_CURRENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
- #3641 - Search: add button to enable RegEx
- #3654 - Search: add button to enable case-sensitivity
- #3661 - Search: add include/exclude file boxes
- #3705 - Editor: add right click menus

### Bug Fixes

Expand Down
46 changes: 21 additions & 25 deletions src/Components/ContextMenu/Component_ContextMenu.re
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,6 @@ module View = {
backgroundColor(bg(~isFocused).from(theme)),
];

// let icon = fgColor => [
// fontFamily("seti.ttf"),
// fontSize(Constants.fontSize),
// marginRight(10),
// color(fgColor),
// ];

let label = (~theme, ~isFocused) => [
textOverflow(`Ellipsis),
color(Colors.Menu.foreground.from(theme)),
Expand All @@ -143,19 +136,6 @@ module View = {
Hooks.state(false, hooks);
let ((maybeBbox, setBbox), hooks) = Hooks.state(None, hooks);

// let iconView =
// switch (item.icon) {
// | Some(icon) =>
// IconTheme.IconDefinition.(
// <Text
// style={Styles.icon(icon.fontColor)}
// text={FontIcon.codeToIcon(icon.fontCharacter)}
// />
// )

// | None => <Text style={Styles.icon(Colors.transparentWhite)} text="" />
// };

let labelView = {
let style = Styles.label(~theme, ~isFocused);
<Text
Expand Down Expand Up @@ -440,10 +420,25 @@ module View = {
Hooks.state(IntMap.empty);
internalSetMenus := setMenus;

let onOverlayClick = _ => {
let onOverlayClick =
(
direction: [ | `Down | `Up],
evt: NodeEvents.mouseButtonEventParams,
) => {
let isClickInsideContextMenus = wasChildOnMouseDownPressed^;
wasChildOnMouseDownPressed := false;
if (!isClickInsideContextMenus) {

let shouldCancel =
!isClickInsideContextMenus
&& {
switch (evt.button, direction) {
| (MouseButton.BUTTON_RIGHT, `Down)
| (MouseButton.BUTTON_LEFT, `Up) => true
| _ => false
};
};

if (shouldCancel) {
menus |> IntMap.iter((_key, {onCancel, _}) => onCancel());
} else {
();
Expand All @@ -453,7 +448,10 @@ module View = {
if (IntMap.is_empty(menus)) {
React.empty;
} else {
<View onMouseUp=onOverlayClick style=Styles.backdrop>
<View
onMouseUp={onOverlayClick(`Up)}
onMouseDown={onOverlayClick(`Down)}
style=Styles.backdrop>
{IntMap.bindings(menus)
|> List.map(snd)
|> List.map(({menu, _}) => menu)
Expand Down Expand Up @@ -482,9 +480,7 @@ module View = {
~orientation=(`Bottom, `Left),
~offsetX=0,
~offsetY=0,
// ~onItemSelect,
~dispatch: msg('a) => unit,
// ~onCancel,
~theme,
~font,
(),
Expand Down
File renamed without changes.
1 change: 0 additions & 1 deletion src/Core/MenuBar.rei → src/Core/ContextMenu.rei
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ module Schema: {
let menus: list(menu) => t;
let groups: list(group) => t;

//let toSchema: (~menus: list(menu)=?, ~items: list(item)) => t;
let union: (t, t) => t;
let ofList: list(t) => t;
};
Expand Down
2 changes: 1 addition & 1 deletion src/Core/Oni_Core.re
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ module LineNumber = LineNumber;
module Log = Kernel.Log;
module MarkerUpdate = MarkerUpdate;
module Menu = Menu;
module MenuBar = MenuBar;
module ContextMenu = ContextMenu;
module MinimalUpdate = MinimalUpdate;
module Mode = Mode;
module NodeTask = NodeTask;
Expand Down
11 changes: 11 additions & 0 deletions src/Feature/Clipboard/Feature_Clipboard.re
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ module Commands = {
);
};

module MenuItems = {
open Oni_Core.ContextMenu.Schema;

let paste = command(~title="Paste", Commands.paste);
};

module Keybindings = {
open Feature_Input.Schema;
let pasteNonMac =
Expand Down Expand Up @@ -100,4 +106,9 @@ module Contributions = {
let commands = [Commands.paste];

let keybindings = Keybindings.[pasteMac, pasteNonMac];

module MenuItems = {
let all = MenuItems.[paste];
let paste = MenuItems.paste;
};
};
6 changes: 6 additions & 0 deletions src/Feature/Clipboard/Feature_Clipboard.rei
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,10 @@ module Commands: {let paste: Command.t(msg);};
module Contributions: {
let commands: list(Command.t(msg));
let keybindings: list(Feature_Input.Schema.keybinding);

module MenuItems: {
let all: list(ContextMenu.Schema.item);

let paste: ContextMenu.Schema.item;
};
};
247 changes: 247 additions & 0 deletions src/Feature/ContextMenu/Feature_ContextMenu.re
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
open Oni_Core;
open ContextMenu;
module NativeMenu = Revery.Native.Menu;

module Native = Native;

type menu = {
menuSchema: Schema.t,
contextMenu: Component_ContextMenu.model(string),
xPos: int,
yPos: int,
};

type model = option(menu);

let initial = None;

let rec groupToContextMenu = (group: ContextMenu.Group.t) => {
let items =
ContextMenu.Group.items(group)
|> List.map(item =>
if (Item.isSubmenu(item)) {
let submenuItems = Item.submenu(item);
let groups = submenuItems |> List.map(groupToContextMenu);
Component_ContextMenu.Submenu({
label: Item.title(item),
items: groups,
});
} else {
Component_ContextMenu.Item({
label: Item.title(item),
data: Item.command(item),
details: Revery.UI.React.empty,
});
}
);

Component_ContextMenu.Group(items);
};

let augmentWithShortcutKeys = (~getShortcutKey, contextMenu) =>
contextMenu
|> Component_ContextMenu.map(~f=item =>
Component_ContextMenu.{
...item,
label: item.label,
details: getShortcutKey(item.data),
}
);

[@deriving show]
type msg =
| MenuRequestedDisplayAt({
[@opaque]
menuSchema: Schema.t,
xPos: int,
yPos: int,
})
| ContextMenu(Component_ContextMenu.msg(string))
| NativeMenuItemClicked(string);

type outmsg =
| Nothing
| ExecuteCommand({command: string})
| Effect(Isolinear.Effect.t(msg));

module Effects = {
let displayMenuAt = (~menuSchema: Schema.t, ~xPos: int, ~yPos: int) =>
Isolinear.Effect.createWithDispatch(
~name="Feature_ContextMenu.displayMenuAt", dispatch =>
dispatch(MenuRequestedDisplayAt({menuSchema, xPos, yPos}))
);

let displayNativeMenuAt =
(~config, ~contextKeys, ~input, ~builtMenu, ~xPos, ~yPos, ~window) =>
Isolinear.Effect.createWithDispatch(
~name="contextMenu.displayNativeMenuAt", dispatch => {
let topLevelItems = ContextMenu.top(builtMenu |> ContextMenu.schema);

Utility.OptionEx.iter2(
(item, window) => {
let title = ContextMenu.Menu.title(item);
let nativeMenu = NativeMenu.create(title);
Native.buildGroup(
~config,
~context=contextKeys,
~input,
~dispatch,
nativeMenu,
ContextMenu.Menu.contents(item, builtMenu),
);
Revery.Native.Menu.displayIn(
~x=xPos,
~y=yPos,
nativeMenu,
window |> Revery.Window.getSdlWindow,
);
},
List.nth_opt(topLevelItems, 0),
window,
);
});
};

let update = (~contextKeys, ~commands, ~config, ~input, ~window, msg, model) =>
switch (msg) {
| MenuRequestedDisplayAt({menuSchema, xPos, yPos}) =>
let builtMenu = ContextMenu.build(~contextKeys, ~commands, menuSchema);

if (Revery.Environment.isMac) {
let eff =
Effects.displayNativeMenuAt(
~config,
~contextKeys,
~input,
~builtMenu,
~xPos,
~yPos,
~window,
)
|> Isolinear.Effect.map(str => NativeMenuItemClicked(str));
(None, Effect(eff));
} else {
let topLevelItems = ContextMenu.top(menuSchema);
let maybeMenu = List.nth_opt(topLevelItems, 0);

let menu =
maybeMenu
|> Option.map(menu => {
let contextMenu =
Menu.contents(menu, builtMenu)
|> List.map(groupToContextMenu)
|> Component_ContextMenu.make;
{menuSchema, xPos, yPos, contextMenu};
});
(menu, Nothing);
};
| NativeMenuItemClicked(command) => (
model,
ExecuteCommand({command: command}),
)
| ContextMenu(contextMenuMsg) =>
let (model', eff) =
model
|> Option.map(menu => {
let (contextMenu', outmsg) =
Component_ContextMenu.update(contextMenuMsg, menu.contextMenu);

switch (outmsg) {
| Component_ContextMenu.Nothing => (
Some({...menu, contextMenu: contextMenu'}),
Nothing,
)
| Component_ContextMenu.Selected({data}) => (
None,
ExecuteCommand({command: data}),
)
| Component_ContextMenu.Cancelled => (None, Nothing)
};
})
|> Option.value(~default=(model, Nothing));

(model', eff);
};

module View = {
open Revery.UI;

module Styles = {
open Style;
let overlay = [
position(`Absolute),
top(0),
left(0),
bottom(0),
right(0),
];

let coords = (~x, ~y) => [position(`Absolute), left(x), top(y)];
};
let make =
(
~contextMenu as model,
~config,
~context,
~input,
~theme,
~font: UiFont.t,
~dispatch,
(),
) => {
let getShortcutKey = command => {
Feature_Input.commandToAvailableBindings(
~command,
~config,
~context,
input,
)
|> (
l =>
List.nth_opt(l, 0)
|> Option.map(keys =>
keys
|> List.map(Feature_Input.keyPressToString)
|> String.concat(" ")
)
|> Utility.OptionEx.or_lazy(() =>
if (Utility.StringEx.startsWith(~prefix=":", command)) {
Some(command);
} else {
None;
}
)
|> Option.map(cmd =>
<Text
fontFamily={font.family}
fontSize=11.
style=Style.[
color(Feature_Theme.Colors.Menu.foreground.from(theme)),
opacity(0.75),
]
text=cmd
/>
)
|> Option.value(~default=Revery.UI.React.empty)
);
};

let elem =
model
|> Option.map(({contextMenu, xPos, yPos, _}) => {
let contextMenu =
contextMenu |> augmentWithShortcutKeys(~getShortcutKey);
<View style={Styles.coords(~x=xPos + 1, ~y=yPos + 1)}>
<Component_ContextMenu.View
model=contextMenu
orientation=(`Top, `Left)
dispatch={msg => dispatch(ContextMenu(msg))}
theme
font
/>
</View>;
})
|> Option.value(~default=React.empty);
<View style=Styles.overlay> elem </View>;
};
};
Loading

0 comments on commit a6ddd36

Please sign in to comment.