From 39fc1b5245c64e724a2714e639d41cadf08ec673 Mon Sep 17 00:00:00 2001 From: Glenn Slotte Date: Wed, 22 Jan 2020 02:10:09 +0100 Subject: [PATCH] Add ContextMenu to Notification Status Bar item (#1211) * add Components project * add ContextMenu * use functor to generate identity instead of Identity component * wire up context menu * add context menu to notification status abr item * fix warning * add menu item focus * extract placement from ContextMenu.t * ContextMenu -> ContextMenuUpdated * post-rebase fix * originX/Y -> orientation * add offsetX/Y * adjust origin for orientation * add box shadow * use default mouse cursor on overlay --- src/Components/ContextMenu.re | 274 +++++++++++++++++++++++++++++++++ src/Components/ContextMenu.rei | 48 ++++++ src/Components/dune | 14 +- src/Model/Actions.re | 5 + src/Model/Notifications.re | 4 + src/Model/State.re | 3 + src/Model/StatusBarModel.re | 3 +- src/Store/ContextMenuStore.re | 58 +++++++ src/Store/StoreThread.re | 2 + src/UI/Root.re | 24 ++- src/UI/StatusBar.re | 56 ++++++- 11 files changed, 479 insertions(+), 12 deletions(-) create mode 100644 src/Components/ContextMenu.re create mode 100644 src/Components/ContextMenu.rei create mode 100644 src/Store/ContextMenuStore.re 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, ()) => {
- +
leftItems