diff --git a/src/Components/ContextMenu.re b/src/Components/ContextMenu.re
new file mode 100644
index 0000000000..e3de6a0124
--- /dev/null
+++ b/src/Components/ContextMenu.re
@@ -0,0 +1,274 @@
+open Oni_Core;
+
+open Revery;
+open Revery.UI;
+open Revery.UI.Components;
+
+module Option = Utility.Option;
+
+module Constants = {
+ let menuWidth = 200;
+ // let maxMenuHeight = 600;
+};
+
+// TYPES
+
+module Id: {
+ type t;
+ let create: unit => t;
+} = {
+ type t = int;
+
+ let lastId = ref(0);
+ let create = () => {
+ incr(lastId);
+ lastId^;
+ };
+};
+
+[@deriving show({with_path: false})]
+type item('data) = {
+ label: string,
+ // icon: option(IconTheme.IconDefinition.t),
+ data: [@opaque] 'data,
+};
+
+type placement = {
+ x: int,
+ y: int,
+ orientation: ([ | `Top | `Middle | `Bottom], [ | `Left | `Middle | `Right]),
+};
+
+type t('data) = {
+ id: Id.t,
+ placement: option(placement),
+ items: list(item('data)),
+};
+
+// MENUITEM
+
+module MenuItem = {
+ module Constants = {
+ let fontSize = 12;
+ };
+
+ module Styles = {
+ open Style;
+
+ let bg = (~theme: Theme.t, ~isFocused) =>
+ isFocused ? theme.menuSelectionBackground : theme.menuBackground;
+
+ let container = (~theme, ~isFocused) => [
+ padding(10),
+ flexDirection(`Row),
+ backgroundColor(bg(~theme, ~isFocused)),
+ ];
+
+ // let icon = fgColor => [
+ // fontFamily("seti.ttf"),
+ // fontSize(Constants.fontSize),
+ // marginRight(10),
+ // color(fgColor),
+ // ];
+
+ let label = (~font: UiFont.t, ~theme: Theme.t, ~isFocused) => [
+ fontFamily(font.fontFile),
+ textOverflow(`Ellipsis),
+ fontSize(Constants.fontSize),
+ color(theme.menuForeground),
+ backgroundColor(bg(~theme, ~isFocused)),
+ ];
+ };
+
+ let component = React.Expert.component("MenuItem");
+ let make:
+ 'data.
+ (
+ ~item: item('data),
+ ~theme: Theme.t,
+ ~font: UiFont.t,
+ ~onClick: unit => unit,
+ unit
+ ) =>
+ _
+ =
+ (~item, ~theme, ~font, ~onClick, ()) =>
+ component(hooks => {
+ let ((isFocused, setIsFocused), hooks) = Hooks.state(false, hooks);
+
+ // let iconView =
+ // switch (item.icon) {
+ // | Some(icon) =>
+ // IconTheme.IconDefinition.(
+ //
+ // )
+
+ // | None =>
+ // };
+
+ let labelView = {
+ let style = Styles.label(~font, ~theme, ~isFocused);
+ ;
+ };
+
+ (
+
+ setIsFocused(_ => false)}
+ onMouseOver={_ => setIsFocused(_ => true)}>
+ // iconView
+ labelView
+ ,
+ hooks,
+ );
+ });
+};
+
+// MENU
+
+module Menu = {
+ module Styles = {
+ open Style;
+
+ let container = (~x, ~y, ~theme: Theme.t) => [
+ position(`Absolute),
+ top(y),
+ left(x),
+ backgroundColor(theme.menuBackground),
+ color(theme.menuForeground),
+ width(Constants.menuWidth),
+ boxShadow(
+ ~xOffset=-5.,
+ ~yOffset=-5.,
+ ~blurRadius=25.,
+ ~spreadRadius=-10.,
+ ~color=Color.rgba(0., 0., 0., 0.0001),
+ ),
+ ];
+ };
+
+ let component = React.Expert.component("Menu");
+ let make = (~items, ~placement, ~theme, ~font, ~onItemSelect, ()) =>
+ component(hooks => {
+ let ((maybeRef, setRef), hooks) = Hooks.state(None, hooks);
+ let {x, y, orientation: (orientY, orientX)} = placement;
+
+ let height =
+ switch (maybeRef) {
+ | Some((node: node)) => node#measurements().height
+ | None => List.length(items) * 20
+ };
+ let width = Constants.menuWidth;
+
+ let x =
+ switch (orientX) {
+ | `Left => x
+ | `Middle => x - width / 2
+ | `Right => x - width
+ };
+
+ let y =
+ switch (orientY) {
+ | `Top => y - height
+ | `Middle => y - height / 2
+ | `Bottom => y
+ };
+
+ (
+ setRef(_ => Some(node))}>
+ {items
+ |> List.map(item => {
+ let onClick = () => onItemSelect(item);
+ ;
+ })
+ |> React.listToElement}
+ ,
+ hooks,
+ );
+ });
+};
+
+// OVERLAY
+
+module Overlay = {
+ module Styles = {
+ open Style;
+
+ let overlay = [
+ position(`Absolute),
+ top(0),
+ bottom(0),
+ left(0),
+ right(0),
+ pointerEvents(`Allow),
+ cursor(MouseCursors.arrow),
+ ];
+ };
+
+ let make = (~model, ~theme, ~font, ~onOverlayClick, ~onItemSelect, ()) =>
+ switch (model) {
+ | {items, placement: Some(placement), _} =>
+
+
+
+ | _ => React.empty
+ };
+};
+
+module Make = (()) => {
+ let id = Id.create();
+
+ let init = items => {id, placement: None, items};
+
+ module Anchor = {
+ let component = React.Expert.component("Anchor");
+ let make =
+ (
+ ~model as maybeModel,
+ ~orientation=(`Bottom, `Left),
+ ~offsetX=0,
+ ~offsetY=0,
+ ~onUpdate,
+ (),
+ ) =>
+ component(hooks => {
+ let ((maybeRef, setRef), hooks) = Hooks.ref(None, hooks);
+
+ switch (maybeModel, maybeRef) {
+ | (Some(model), Some(node)) =>
+ if (model.id == id) {
+ let (x, y, width, _) =
+ Math.BoundingBox2d.getBounds(node#getBoundingBox());
+
+ let x =
+ switch (orientation) {
+ | (_, `Left) => x
+ | (_, `Middle) => x -. width /. 2.
+ | (_, `Right) => x -. width
+ };
+
+ let placement =
+ Some({
+ x: int_of_float(x) + offsetX,
+ y: int_of_float(y) + offsetY,
+ orientation,
+ });
+
+ if (model.placement != placement) {
+ onUpdate({...model, placement});
+ };
+ }
+
+ | _ => ()
+ };
+
+ ( setRef(Some(node))} />, hooks);
+ });
+ };
+};
diff --git a/src/Components/ContextMenu.rei b/src/Components/ContextMenu.rei
new file mode 100644
index 0000000000..cc0c499c7b
--- /dev/null
+++ b/src/Components/ContextMenu.rei
@@ -0,0 +1,48 @@
+open Oni_Core;
+
+open Revery.UI;
+
+[@deriving show]
+type item('data) = {
+ label: string,
+ // icon: option(IconTheme.IconDefinition.t),
+ data: [@opaque] 'data,
+};
+
+type t('data);
+
+module Overlay: {
+ let make:
+ (
+ ~model: t('data),
+ ~theme: Theme.t,
+ ~font: UiFont.t,
+ ~onOverlayClick: unit => unit,
+ ~onItemSelect: item('data) => unit,
+ unit
+ ) =>
+ React.element(React.node);
+};
+
+module Make:
+ () =>
+ {
+ let init: list(item('data)) => t('data);
+
+ module Anchor: {
+ let make:
+ (
+ ~model: option(t('data)),
+ ~orientation: (
+ [ | `Top | `Middle | `Bottom],
+ [ | `Left | `Middle | `Right],
+ )
+ =?,
+ ~offsetX: int=?,
+ ~offsetY: int=?,
+ ~onUpdate: t('data) => unit,
+ unit
+ ) =>
+ React.element(React.node);
+ };
+ };
diff --git a/src/Components/dune b/src/Components/dune
index 82b8c58f30..bfd66311e3 100644
--- a/src/Components/dune
+++ b/src/Components/dune
@@ -1,5 +1,15 @@
(library
(name Oni_Components)
(public_name Oni2.components)
- (libraries editor-core-types Oni2.core Revery)
- (preprocess (pps ppx_deriving_yojson ppx_deriving.show brisk-reconciler.ppx)))
+ (preprocess (pps brisk-reconciler.ppx ppx_deriving.show))
+ (libraries
+ str
+ bigarray
+ zed_oni
+ lwt
+ lwt.unix
+ Oni2.core
+ Rench
+ Revery
+ editor-core-types
+ ))
diff --git a/src/Model/Actions.re b/src/Model/Actions.re
index 12abe53cdd..b4804456c2 100644
--- a/src/Model/Actions.re
+++ b/src/Model/Actions.re
@@ -10,6 +10,7 @@ open Oni_Input;
open Oni_Syntax;
module Ext = Oni_Extensions;
+module ContextMenu = Oni_Components.ContextMenu;
[@deriving show({with_path: false})]
type t =
@@ -55,6 +56,9 @@ type t =
| TextInput([@opaque] Revery.Events.textInputEvent)
| HoverShow
| ChangeMode([@opaque] Vim.Mode.t)
+ | ContextMenuUpdated([@opaque] ContextMenu.t(t))
+ | ContextMenuOverlayClicked
+ | ContextMenuItemSelected(ContextMenu.item(t))
| DiagnosticsHotKey
| DiagnosticsSet(Uri.t, string, [@opaque] list(Diagnostic.t))
| DiagnosticsClear(string)
@@ -80,6 +84,7 @@ type t =
| EditorScrollToColumn(EditorId.t, int)
| ShowNotification(Notification.t)
| HideNotification(Notification.t)
+ | ClearNotifications
| FileExplorer(FileExplorer.action)
| LanguageFeature(LanguageFeatures.action)
| QuickmenuShow(quickmenuVariant)
diff --git a/src/Model/Notifications.re b/src/Model/Notifications.re
index 150a973ffc..19c0c94abe 100644
--- a/src/Model/Notifications.re
+++ b/src/Model/Notifications.re
@@ -3,12 +3,16 @@ open Notification;
type t = list(Notification.t);
+module ContextMenu =
+ Oni_Components.ContextMenu.Make({});
+
let initial: t = [];
let reduce = (state, action: Actions.t) => {
switch (action) {
| ShowNotification(item) => [item, ...state]
| HideNotification(item) => List.filter(it => it.id != item.id, state)
+ | ClearNotifications => initial
| _ => state
};
};
diff --git a/src/Model/State.re b/src/Model/State.re
index 3f1e1870ce..fef505f929 100644
--- a/src/Model/State.re
+++ b/src/Model/State.re
@@ -9,6 +9,7 @@ open Oni_Input;
open Oni_Syntax;
module Ext = Oni_Extensions;
+module ContextMenu = Oni_Components.ContextMenu;
type t = {
buffers: Buffers.t,
@@ -16,6 +17,7 @@ type t = {
bufferHighlights: BufferHighlights.t,
bufferSyntaxHighlights: BufferSyntaxHighlights.t,
commands: Commands.t,
+ contextMenu: option(ContextMenu.t(Actions.t)),
mode: Vim.Mode.t,
completions: Completions.t,
diagnostics: Diagnostics.t,
@@ -64,6 +66,7 @@ let create: unit => t =
bufferRenderers: BufferRenderers.initial,
bufferSyntaxHighlights: BufferSyntaxHighlights.empty,
commands: Commands.empty,
+ contextMenu: None,
completions: Completions.initial,
configuration: Configuration.default,
definition: Definition.empty,
diff --git a/src/Model/StatusBarModel.re b/src/Model/StatusBarModel.re
index 4ab5d210e4..9b2cf6157e 100644
--- a/src/Model/StatusBarModel.re
+++ b/src/Model/StatusBarModel.re
@@ -7,7 +7,8 @@
[@deriving show({with_path: false})]
type action =
| DiagnosticsClicked
- | NotificationCountClicked;
+ | NotificationCountClicked
+ | NotificationsContextMenu;
module Alignment = {
type t =
diff --git a/src/Store/ContextMenuStore.re b/src/Store/ContextMenuStore.re
new file mode 100644
index 0000000000..c4625ee880
--- /dev/null
+++ b/src/Store/ContextMenuStore.re
@@ -0,0 +1,58 @@
+open Oni_Model;
+open Actions;
+
+module ContextMenu = Oni_Components.ContextMenu;
+
+let contextMenu =
+ Notifications.ContextMenu.init(
+ ContextMenu.[
+ {
+ label: "Clear All",
+ // icon: None,
+ data: ClearNotifications,
+ },
+ {
+ label: "Open",
+ // icon: None,
+ data: StatusBar(NotificationCountClicked),
+ },
+ ],
+ );
+
+let start = () => {
+ let selectItemEffect = (item: ContextMenu.item(_)) =>
+ Isolinear.Effect.createWithDispatch(
+ ~name="contextMenu.selectItem", dispatch =>
+ dispatch(item.data)
+ );
+
+ let updater = (state: State.t, action) => {
+ let default = (state, Isolinear.Effect.none);
+
+ switch (action) {
+ | ContextMenuUpdated(model) => (
+ {...state, contextMenu: Some(model)},
+ Isolinear.Effect.none,
+ )
+
+ | ContextMenuOverlayClicked => (
+ {...state, contextMenu: None},
+ Isolinear.Effect.none,
+ )
+
+ | ContextMenuItemSelected(item) => (
+ {...state, contextMenu: None},
+ selectItemEffect(item),
+ )
+
+ | StatusBar(NotificationsContextMenu) => (
+ {...state, contextMenu: Some(contextMenu)},
+ Isolinear.Effect.none,
+ )
+
+ | _ => default
+ };
+ };
+
+ updater;
+};
diff --git a/src/Store/StoreThread.re b/src/Store/StoreThread.re
index cb68a6e6c2..a5619a5426 100644
--- a/src/Store/StoreThread.re
+++ b/src/Store/StoreThread.re
@@ -157,6 +157,7 @@ let start =
let titleUpdater = TitleStoreConnector.start(setTitle);
let sneakUpdater = SneakStore.start();
+ let contextMenuUpdater = ContextMenuStore.start();
let (storeDispatch, storeStream) =
Isolinear.Store.create(
@@ -185,6 +186,7 @@ let start =
titleUpdater,
sneakUpdater,
Features.update,
+ contextMenuUpdater,
]),
(),
);
diff --git a/src/UI/Root.re b/src/UI/Root.re
index e0627471d3..6ec3d04640 100644
--- a/src/UI/Root.re
+++ b/src/UI/Root.re
@@ -8,6 +8,8 @@ open Revery;
open Revery.UI;
open Oni_Model;
+module ContextMenu = Oni_Components.ContextMenu;
+
module Styles = {
open Style;
@@ -39,6 +41,7 @@ let make = (~state: State.t, ()) => {
let State.{
theme,
configuration,
+ contextMenu,
uiFont as font,
editorFont,
sideBar,
@@ -46,6 +49,9 @@ let make = (~state: State.t, ()) => {
_,
} = state;
+ let onContextMenuUpdate = model =>
+ GlobalContext.current().dispatch(ContextMenuUpdated(model));
+
let statusBarVisible =
Selectors.getActiveConfigurationValue(state, c =>
c.workbenchStatusBarVisible
@@ -70,7 +76,7 @@ let make = (~state: State.t, ()) => {
let statusBar =
statusBarVisible
?
-
+
: React.empty;
@@ -112,6 +118,22 @@ let make = (~state: State.t, ()) => {
statusBar
+ {switch (contextMenu) {
+ | Some(model) =>
+ let onOverlayClick = () =>
+ GlobalContext.current().dispatch(ContextMenuOverlayClicked);
+ let onItemSelect = item =>
+ GlobalContext.current().dispatch(ContextMenuItemSelected(item));
+
+ ;
+ | None => React.empty
+ }}
;
diff --git a/src/UI/StatusBar.re b/src/UI/StatusBar.re
index 3275a9fb34..7ddc5c4f6b 100644
--- a/src/UI/StatusBar.re
+++ b/src/UI/StatusBar.re
@@ -16,6 +16,7 @@ open Oni_Model.StatusBarModel;
module Option = Utility.Option;
module Animation = Revery.UI.Animation;
+module ContextMenu = Oni_Components.ContextMenu;
let useExpiration = (~equals=(==), ~expireAfter, items) => {
let%hook (active, setActive) = Hooks.state([]);
@@ -222,12 +223,20 @@ let section = (~children=React.empty, ~align, ()) =>
children ;
let item =
- (~children, ~backgroundColor=Colors.transparentWhite, ~onClick=?, ()) => {
+ (
+ ~children,
+ ~backgroundColor=Colors.transparentWhite,
+ ~onClick=?,
+ ~onRightClick=?,
+ (),
+ ) => {
let style = Styles.item(backgroundColor);
- switch (onClick) {
- | Some(onClick) => children
- | None => children
+ // Avoid cursor turning into pointer if there's no mouse interaction available
+ if (onClick == None && onRightClick == None) {
+ children ;
+ } else {
+ children ;
};
};
@@ -240,15 +249,33 @@ let textItem = (~background, ~font, ~theme: Theme.t, ~text, ()) =>
;
let notificationCount =
- (~font, ~foreground as color, ~background, ~notifications, ()) => {
+ (
+ ~font,
+ ~foreground as color,
+ ~background,
+ ~notifications,
+ ~contextMenu,
+ ~onContextMenuUpdate,
+ (),
+ ) => {
let text = notifications |> List.length |> string_of_int;
let onClick = () =>
GlobalContext.current().dispatch(
Actions.StatusBar(NotificationCountClicked),
);
+ let onRightClick = () =>
+ GlobalContext.current().dispatch(
+ Actions.StatusBar(NotificationsContextMenu),
+ );
- -
+
-
+
ease(Easing.ease) |> tween(50.0, 0.)
);
-let%component make = (~state: State.t, ()) => {
+let%component make =
+ (
+ ~state: State.t,
+ ~contextMenu: option(ContextMenu.t(Actions.t)),
+ ~onContextMenuUpdate,
+ (),
+ ) => {
let State.{mode, theme, uiFont: font, diagnostics, notifications, _} = state;
let%hook activeNotifications =
@@ -391,7 +424,14 @@ let%component make = (~state: State.t, ()) => {