diff --git a/src/Core/Ripgrep.re b/src/Core/Ripgrep.re
index 1ab551f4e9..6aa8d90414 100644
--- a/src/Core/Ripgrep.re
+++ b/src/Core/Ripgrep.re
@@ -2,19 +2,76 @@ open Rench;
module Time = Revery_Core.Time;
-type disposeFunction = unit => unit;
-
-/* Internal counters used for tracking */
-let _ripGrepRunCount = ref(0);
-let _ripGrepCompletedCount = ref(0);
-
-let getRunCount = () => _ripGrepRunCount^;
-let getCompletedCount = () => _ripGrepCompletedCount^;
+module List = Utility.List;
+
+module Match = {
+ type t = {
+ file: string,
+ text: string,
+ lineNumber: int,
+ charStart: int,
+ charEnd: int,
+ };
-type searchFunction =
- (string, list(string) => unit, unit => unit) => disposeFunction;
+ let fromJsonString = str => {
+ Yojson.Basic.Util.(
+ try({
+ let json = Yojson.Basic.from_string(str);
+
+ if (member("type", json) == `String("match")) {
+ let data = member("data", json);
+
+ let submatches = data |> member("submatches") |> to_list;
+
+ let matches =
+ submatches
+ |> List.map(submatch =>
+ {
+ file:
+ data |> member("path") |> member("text") |> to_string,
+ text:
+ data |> member("lines") |> member("text") |> to_string,
+ lineNumber: data |> member("line_number") |> to_int,
+ charStart: submatch |> member("start") |> to_int,
+ charEnd: submatch |> member("end") |> to_int,
+ }
+ );
+
+ Some(matches);
+ } else {
+ None; // Not a "match" message
+ };
+ }) {
+ | Type_error(message, _) =>
+ Log.error("[Ripgrep.Match] Error decoding JSON: " ++ message);
+ None;
+ | Yojson.Json_error(message) =>
+ Log.error("[Ripgrep.Match] Error parsing JSON: " ++ message);
+ None;
+ }
+ );
+ };
+};
-type t = {search: searchFunction};
+type t = {
+ search:
+ (
+ ~directory: string,
+ ~onUpdate: list(string) => unit,
+ ~onComplete: unit => unit
+ ) =>
+ dispose,
+ findInFiles:
+ (
+ ~directory: string,
+ ~query: string,
+ ~onUpdate: list(Match.t) => unit,
+ ~onComplete: unit => unit
+ ) =>
+ dispose,
+}
+
+and dispose = unit => unit;
/**
RipgrepProcessingJob is the logic for processing a [Bytes.t]
@@ -25,7 +82,6 @@ type t = {search: searchFunction};
*/
module RipgrepProcessingJob = {
type pendingWork = {
- duplicateHash: Hashtbl.t(string, bool),
callback: list(string) => unit,
bytes: list(Bytes.t),
};
@@ -36,26 +92,13 @@ module RipgrepProcessingJob = {
type t = Job.t(pendingWork, unit);
- let dedup = (hash, str) => {
- switch (Hashtbl.find_opt(hash, str)) {
- | Some(_) => false
- | None =>
- Hashtbl.add(hash, str, true);
- true;
- };
- };
-
let doWork = (pendingWork, c) => {
let newBytes =
switch (pendingWork.bytes) {
| [] => []
| [hd, ...tail] =>
let items =
- hd
- |> Bytes.to_string
- |> String.trim
- |> String.split_on_char('\n')
- |> List.filter(dedup(pendingWork.duplicateHash));
+ hd |> Bytes.to_string |> String.trim |> String.split_on_char('\n');
pendingWork.callback(items);
tail;
};
@@ -70,14 +113,13 @@ module RipgrepProcessingJob = {
};
let create = (~callback, ()) => {
- let duplicateHash = Hashtbl.create(1000);
Job.create(
~f=doWork,
~initialCompletedWork=(),
- ~name="RipgrepProcessorJob",
+ ~name="RipgrepProcessingJob",
~pendingWorkPrinter,
~budget=Time.ms(2),
- {callback, bytes: [], duplicateHash},
+ {callback, bytes: []},
);
};
@@ -93,7 +135,6 @@ module RipgrepProcessingJob = {
};
let process = (rgPath, args, callback, completedCallback) => {
- incr(_ripGrepRunCount);
let argsStr = String.concat("|", Array.to_list(args));
Log.info(
"[Ripgrep] Starting process: "
@@ -102,14 +143,15 @@ let process = (rgPath, args, callback, completedCallback) => {
++ argsStr
++ "|",
);
+
// Mutex to
let jobMutex = Mutex.create();
let job = ref(RipgrepProcessingJob.create(~callback, ()));
- let dispose3 = ref(None);
+ let disposeTick = ref(None);
Revery.App.runOnMainThread(() =>
- dispose3 :=
+ disposeTick :=
Some(
Revery.Tick.interval(
_ =>
@@ -123,11 +165,11 @@ let process = (rgPath, args, callback, completedCallback) => {
)
);
- let cp = ChildProcess.spawn(rgPath, args);
+ let childProcess = ChildProcess.spawn(rgPath, args);
- let dispose1 =
+ let disposeOnData =
Event.subscribe(
- cp.stdout.onData,
+ childProcess.stdout.onData,
value => {
Mutex.lock(jobMutex);
job := RipgrepProcessingJob.queueWork(value, job^);
@@ -135,11 +177,10 @@ let process = (rgPath, args, callback, completedCallback) => {
},
);
- let dispose2 =
+ let disposeOnClose =
Event.subscribe(
- cp.onClose,
+ childProcess.onClose,
exitCode => {
- incr(_ripGrepCompletedCount);
Log.info(
"[Ripgrep] Process completed - exit code: "
++ string_of_int(exitCode),
@@ -148,16 +189,18 @@ let process = (rgPath, args, callback, completedCallback) => {
},
);
- () => {
+ let dispose = () => {
Log.info("Ripgrep session complete.");
- dispose1();
- dispose2();
- switch (dispose3^) {
+ disposeOnData();
+ disposeOnClose();
+ switch (disposeTick^) {
| Some(v) => v()
| None => ()
};
- cp.kill(Sys.sigkill);
+ childProcess.kill(Sys.sigkill);
};
+
+ dispose;
};
/**
@@ -165,13 +208,44 @@ let process = (rgPath, args, callback, completedCallback) => {
order of the last time they were accessed, alternative sort order includes
path, modified, created
*/
-let search = (path, workingDirectory, callback, completedCallback) => {
+let search = (~executablePath, ~directory, ~onUpdate, ~onComplete) => {
+ let dedup = {
+ let seen = Hashtbl.create(1000);
+
+ List.filter(str => {
+ switch (Hashtbl.find_opt(seen, str)) {
+ | Some(_) => false
+ | None =>
+ Hashtbl.add(seen, str, true);
+ true;
+ }
+ });
+ };
+
process(
- path,
- [|"--smart-case", "--files", "--", workingDirectory|],
- callback,
- completedCallback,
+ executablePath,
+ [|"--smart-case", "--files", "--", directory|],
+ items => items |> dedup |> onUpdate,
+ onComplete,
);
};
-let make = path => {search: search(path)};
+let findInFiles =
+ (~executablePath, ~directory, ~query, ~onUpdate, ~onComplete) => {
+ process(
+ executablePath,
+ [|"--smart-case", "--hidden", "--json", "--", query, directory|],
+ items => {
+ items
+ |> List.filter_map(Match.fromJsonString)
+ |> List.concat
+ |> onUpdate
+ },
+ onComplete,
+ );
+};
+
+let make = (~executablePath) => {
+ search: search(~executablePath),
+ findInFiles: findInFiles(~executablePath),
+};
diff --git a/src/Core/Ripgrep.rei b/src/Core/Ripgrep.rei
new file mode 100644
index 0000000000..44933fe8af
--- /dev/null
+++ b/src/Core/Ripgrep.rei
@@ -0,0 +1,31 @@
+module Match: {
+ type t = {
+ file: string,
+ text: string,
+ lineNumber: int,
+ charStart: int,
+ charEnd: int,
+ };
+};
+
+type t = {
+ search:
+ (
+ ~directory: string,
+ ~onUpdate: list(string) => unit,
+ ~onComplete: unit => unit
+ ) =>
+ dispose,
+ findInFiles:
+ (
+ ~directory: string,
+ ~query: string,
+ ~onUpdate: list(Match.t) => unit,
+ ~onComplete: unit => unit
+ ) =>
+ dispose,
+}
+
+and dispose = unit => unit;
+
+let make: (~executablePath: string) => t;
diff --git a/src/Core/Utility.re b/src/Core/Utility.re
index 9819ad5c87..d1df161cf2 100644
--- a/src/Core/Utility.re
+++ b/src/Core/Utility.re
@@ -90,21 +90,6 @@ let escapeSpaces = str => {
Str.global_replace(whitespace, "\\ ", str);
};
-// TODO: Remove / replace when upgraded to OCaml 4.08
-let filterMap = (f, l) => {
- let rec inner = l =>
- switch (l) {
- | [] => []
- | [hd, ...tail] =>
- switch (f(hd)) {
- | Some(v) => [v, ...inner(tail)]
- | None => inner(tail)
- }
- };
-
- inner(l);
-};
-
// TODO: Remove / replace with Result.to_option when upgraded to OCaml 4.08
let resultToOption = r => {
switch (r) {
@@ -231,6 +216,24 @@ let ranges = indices =>
)
|> List.rev;
+// TODO: Remove after 4.08 upgrade
+module List = {
+ include List;
+
+ let filter_map = f => {
+ let rec aux = accu =>
+ fun
+ | [] => List.rev(accu)
+ | [x, ...l] =>
+ switch (f(x)) {
+ | None => aux(accu, l)
+ | Some(v) => aux([v, ...accu], l)
+ };
+
+ aux([]);
+ };
+};
+
// TODO: Remove after 4.08 upgrade
module Option = {
let map = f =>
@@ -252,10 +255,176 @@ module Option = {
let bind = f =>
fun
- | Some(x) =>
- switch (f(x)) {
- | None => None
- | Some(_) as v => v
- }
+ | Some(x) => f(x)
+ | None => None;
+
+ let join =
+ fun
+ | Some(x) => x
| None => None;
};
+
+module StringUtil = {
+ let isSpace =
+ fun
+ | ' '
+ | '\012'
+ | '\n'
+ | '\r'
+ | '\t' => true
+ | _ => false;
+
+ let trimLeft = str => {
+ let length = String.length(str);
+
+ let rec aux = i =>
+ if (i >= length) {
+ "";
+ } else if (isSpace(str.[i])) {
+ aux(i + 1);
+ } else if (i == 0) {
+ str;
+ } else {
+ String.sub(str, i, length - i);
+ };
+
+ aux(0);
+ };
+
+ let trimRight = str => {
+ let length = String.length(str);
+
+ let rec aux = j =>
+ if (j <= 0) {
+ "";
+ } else if (isSpace(str.[j])) {
+ aux(j - 1);
+ } else if (j == length - 1) {
+ str;
+ } else {
+ String.sub(str, 0, j + 1);
+ };
+
+ aux(length - 1);
+ };
+
+ let extractSnippet = (~maxLength, ~charStart, ~charEnd, text) => {
+ let originalLength = String.length(text);
+
+ let indentation = {
+ let rec aux = i =>
+ if (i >= originalLength) {
+ originalLength;
+ } else if (isSpace(text.[i])) {
+ aux(i + 1);
+ } else {
+ i;
+ };
+
+ aux(0);
+ };
+
+ let remainingLength = originalLength - indentation;
+ let matchLength = charEnd - charStart;
+
+ if (remainingLength > maxLength) {
+ // too long
+
+ let (offset, length) =
+ if (matchLength >= maxLength) {
+ (
+ // match is lonegr than allowed
+ charStart,
+ maxLength,
+ );
+ } else if (charEnd - indentation > maxLength) {
+ (
+ // match ends out of bounds
+ charEnd - maxLength,
+ maxLength,
+ );
+ } else {
+ (
+ // match is within bounds
+ indentation,
+ maxLength,
+ );
+ };
+
+ if (offset > indentation) {
+ // We're cutting non-indentation from the start, so add ellipsis
+
+ let ellipsis = "...";
+ let ellipsisLength = String.length(ellipsis);
+
+ // adjust for ellipsis
+ let (offset, length) = {
+ let availableEnd = length - (charEnd - offset);
+
+ if (ellipsisLength > length) {
+ (
+ // ellipsis won't even fit... not much to do then I guess
+ offset,
+ length,
+ );
+ } else if (ellipsisLength <= availableEnd) {
+ (
+ // fits at the end, so take it from there
+ offset,
+ length - ellipsisLength,
+ );
+ } else {
+ // won't fit at the end
+ let remainder = ellipsisLength - availableEnd;
+
+ if (remainder < charStart - offset) {
+ (
+ // remainder will fit at start
+ offset + remainder,
+ length - ellipsisLength,
+ );
+ } else {
+ (
+ // won't fit anywhere, so just chop it off the end
+ offset,
+ length - ellipsisLength,
+ );
+ };
+ };
+ };
+
+ (
+ ellipsis ++ String.sub(text, offset, length),
+ ellipsisLength + charStart - offset,
+ ellipsisLength + min(length, charEnd - offset),
+ );
+ } else {
+ (
+ // We're only cutting indentation from the start
+ String.sub(text, offset, length),
+ charStart - offset,
+ min(length, charEnd - offset),
+ );
+ };
+ } else if (indentation > 0) {
+ // not too long, but there's indentation
+
+ // do not remove indentation included in match
+ let offset = indentation > charStart ? charStart : indentation;
+ let length = min(maxLength, originalLength - offset);
+
+ (
+ String.sub(text, offset, length),
+ charStart - offset,
+ charEnd - offset,
+ );
+ } else {
+ (
+ // not too long, no indentation
+ text,
+ charStart,
+ charEnd,
+ );
+ };
+ };
+};
diff --git a/src/Extensions/ExtHostProtocol.re b/src/Extensions/ExtHostProtocol.re
index 3aded4ffbf..46077b2f33 100644
--- a/src/Extensions/ExtHostProtocol.re
+++ b/src/Extensions/ExtHostProtocol.re
@@ -8,6 +8,8 @@
open Oni_Core;
open Oni_Core.Types;
+module List = Utility.List;
+
module MessageType = {
let initialized = 0;
let ready = 1;
@@ -287,8 +289,9 @@ module DiagnosticsCollection = {
switch (json) {
| `List([`String(name), `List(perFileDiagnostics)]) =>
let perFileDiagnostics =
- List.map(Diagnostics.of_yojson, perFileDiagnostics)
- |> Utility.filterMap(Utility.resultToOption);
+ perFileDiagnostics
+ |> List.map(Diagnostics.of_yojson)
+ |> List.filter_map(Utility.resultToOption);
Some({name, perFileDiagnostics});
| _ => None
};
diff --git a/src/Input/Keybindings.re b/src/Input/Keybindings.re
index 0690768fbc..3c6ad6ac9d 100644
--- a/src/Input/Keybindings.re
+++ b/src/Input/Keybindings.re
@@ -1,5 +1,7 @@
open Oni_Core;
+module List = Utility.List;
+
module Keybinding = {
type t = {
key: string,
@@ -67,7 +69,7 @@ let of_yojson_with_errors:
// Get errors from individual keybindings, but don't let them stop parsing
let errors =
- Utility.filterMap(
+ List.filter_map(
keyBinding =>
switch (keyBinding) {
| Ok(_) => None
@@ -78,7 +80,7 @@ let of_yojson_with_errors:
// Get valid bindings now
let bindings =
- Utility.filterMap(
+ List.filter_map(
keyBinding =>
switch (keyBinding) {
| Ok(v) => Some(v)
diff --git a/src/Model/Actions.re b/src/Model/Actions.re
index 391b40f000..af9f6a5aaa 100644
--- a/src/Model/Actions.re
+++ b/src/Model/Actions.re
@@ -107,6 +107,13 @@ type t =
| EnableZenMode
| DisableZenMode
| CopyActiveFilepathToClipboard
+ | SearchShow
+ | SearchHide
+ | SearchInput(string, int)
+ | SearchStart
+ | SearchUpdate(list(Ripgrep.Match.t))
+ | SearchComplete
+ | SearchSelectResult(Ripgrep.Match.t)
| Noop
and command = {
commandCategory: option(string),
diff --git a/src/Model/EditorVisibleRanges.re b/src/Model/EditorVisibleRanges.re
index c20ec975d1..c7fdd0663b 100644
--- a/src/Model/EditorVisibleRanges.re
+++ b/src/Model/EditorVisibleRanges.re
@@ -1,7 +1,8 @@
open Oni_Core;
-
open Actions;
+module List = Utility.List;
+
type individualRange = {
editorRanges: list(Range.t),
minimapRanges: list(Range.t),
@@ -69,8 +70,8 @@ let getVisibleRangesForEditor = (editor: Editor.t, metrics: EditorMetrics.t) =>
let getVisibleBuffers = (state: State.t) => {
WindowTree.getSplits(state.windowManager.windowTree)
|> List.map((split: WindowTree.split) => split.editorGroupId)
- |> Utility.filterMap(EditorGroups.getEditorGroupById(state.editorGroups))
- |> Utility.filterMap(EditorGroup.getActiveEditor)
+ |> List.filter_map(EditorGroups.getEditorGroupById(state.editorGroups))
+ |> List.filter_map(EditorGroup.getActiveEditor)
|> List.map(e => e.bufferId);
};
@@ -78,10 +79,8 @@ let getVisibleRangesForBuffer = (bufferId: int, state: State.t) => {
let editors =
WindowTree.getSplits(state.windowManager.windowTree)
|> List.map((split: WindowTree.split) => split.editorGroupId)
- |> Utility.filterMap(
- EditorGroups.getEditorGroupById(state.editorGroups),
- )
- |> Utility.filterMap(eg =>
+ |> List.filter_map(EditorGroups.getEditorGroupById(state.editorGroups))
+ |> List.filter_map(eg =>
switch (EditorGroup.getActiveEditor(eg)) {
| None => None
| Some(v) =>
diff --git a/src/Model/LocationListItem.re b/src/Model/LocationListItem.re
new file mode 100644
index 0000000000..568a106e81
--- /dev/null
+++ b/src/Model/LocationListItem.re
@@ -0,0 +1,8 @@
+open Oni_Core.Types;
+
+type t = {
+ file: string,
+ location: Position.t,
+ text: string,
+ highlight: option((Index.t, Index.t)),
+};
diff --git a/src/Model/Oni_Model.re b/src/Model/Oni_Model.re
index 54466f0520..681e1a5769 100644
--- a/src/Model/Oni_Model.re
+++ b/src/Model/Oni_Model.re
@@ -32,11 +32,13 @@ module HoverCollector = HoverCollector;
module IconTheme = IconTheme;
module Indentation = Indentation;
module LanguageInfo = LanguageInfo;
+module LocationListItem = LocationListItem;
module Quickmenu = Quickmenu;
module FilterJob = FilterJob;
module Notification = Notification;
module Notifications = Notifications;
module Reducer = Reducer;
+module Search = Search;
module SearchHighlights = SearchHighlights;
module Selection = Selection;
module Selectors = Selectors;
diff --git a/src/Model/Search.re b/src/Model/Search.re
new file mode 100644
index 0000000000..fe780573ce
--- /dev/null
+++ b/src/Model/Search.re
@@ -0,0 +1,10 @@
+open Oni_Core;
+
+type t = {
+ queryInput: string,
+ query: string,
+ cursorPosition: int,
+ hits: list(Ripgrep.Match.t),
+};
+
+let initial = {queryInput: "", query: "", cursorPosition: 0, hits: []};
diff --git a/src/Model/State.re b/src/Model/State.re
index 577fef5f4e..200e8f3146 100644
--- a/src/Model/State.re
+++ b/src/Model/State.re
@@ -45,6 +45,7 @@ type t = {
// [darkMode] describes if the UI is in 'dark' or 'light' mode.
// Generally controlled by the theme.
darkMode: bool,
+ searchPane: option(Search.t),
};
let create: unit => t =
@@ -85,4 +86,5 @@ let create: unit => t =
fileExplorer: FileExplorer.create(),
zenMode: false,
darkMode: true,
+ searchPane: None,
};
diff --git a/src/Model/SyntaxHighlighting.re b/src/Model/SyntaxHighlighting.re
index 6d762c0403..17ca516319 100644
--- a/src/Model/SyntaxHighlighting.re
+++ b/src/Model/SyntaxHighlighting.re
@@ -7,6 +7,8 @@ open Oni_Core;
open Oni_Core.Types;
open Oni_Syntax;
+module List = Utility.List;
+
type t = {
visibleBuffers: list(int),
highlightsMap: IntMap.t(NativeSyntaxHighlights.t),
@@ -17,7 +19,7 @@ let empty = {visibleBuffers: [], highlightsMap: IntMap.empty};
let getVisibleHighlighters = (v: t) => {
v.visibleBuffers
|> List.map(b => IntMap.find_opt(b, v.highlightsMap))
- |> Utility.filterMap(v => v);
+ |> List.filter_map(v => v);
};
let getActiveHighlighters = (v: t) => {
diff --git a/src/Model/Title.re b/src/Model/Title.re
index c51f1b7abd..cd92ee000c 100644
--- a/src/Model/Title.re
+++ b/src/Model/Title.re
@@ -6,6 +6,8 @@
open Oni_Core;
+module List = Utility.List;
+
type titleSections =
| Text(string, bool)
| Separator
@@ -57,7 +59,7 @@ let _resolve = (v: t, items: StringMap.t(string)) => {
};
};
- v |> List.map(f) |> Utility.filterMap(v => v);
+ v |> List.map(f) |> List.filter_map(v => v);
};
let toString = (v: t, items: StringMap.t(string)) => {
diff --git a/src/Store/QuickmenuStoreConnector.re b/src/Store/QuickmenuStoreConnector.re
index 30b8b43f6a..07c715d710 100644
--- a/src/Store/QuickmenuStoreConnector.re
+++ b/src/Store/QuickmenuStoreConnector.re
@@ -344,9 +344,7 @@ let subscriptions = ripgrep => {
let ripgrep = (languageInfo, iconTheme) => {
let directory = Rench.Environment.getWorkingDirectory();
-
let re = Str.regexp_string(directory ++ Filename.dir_sep);
-
let getDisplayPath = fullPath => Str.replace_first(re, "", fullPath);
let stringToCommand = (languageInfo, iconTheme, fullPath) =>
diff --git a/src/Store/RipgrepSubscription.re b/src/Store/RipgrepSubscription.re
index ee07c94970..c971c5fb55 100644
--- a/src/Store/RipgrepSubscription.re
+++ b/src/Store/RipgrepSubscription.re
@@ -27,9 +27,9 @@ module Provider = {
let dispose =
ripgrep.Ripgrep.search(
- directory,
- onUpdate,
- () => {
+ ~directory,
+ ~onUpdate,
+ ~onComplete=() => {
Log.info("[QuickOpenStoreConnector] Ripgrep completed.");
dispatch(onCompleted());
},
diff --git a/src/Store/SearchStoreConnector.re b/src/Store/SearchStoreConnector.re
new file mode 100644
index 0000000000..9517c7ea97
--- /dev/null
+++ b/src/Store/SearchStoreConnector.re
@@ -0,0 +1,88 @@
+module Core = Oni_Core;
+module Model = Oni_Model;
+
+open Model.Actions;
+
+let start = () => {
+ let (stream, _dispatch) = Isolinear.Stream.create();
+
+ let searchUpdater = (state: Model.Search.t, action) => {
+ switch (action) {
+ | SearchInput(text, cursorPosition) => {
+ ...state,
+ queryInput: text,
+ cursorPosition,
+ }
+
+ | SearchStart => {...state, query: state.queryInput, hits: []}
+
+ | SearchUpdate(items) => {...state, hits: state.hits @ items}
+
+ // | SearchComplete
+
+ // | SearchSelectResult(match) =>
+ // print_endline("!! SELECT: " ++ match.file);
+ // state;
+
+ | _ => state
+ };
+ };
+
+ let updater = (state: Model.State.t, action) => {
+ switch (action) {
+ | Tick(_) => (state, Isolinear.Effect.none)
+
+ | SearchShow => (
+ {...state, searchPane: Some(Model.Search.initial)},
+ Isolinear.Effect.none,
+ )
+
+ | SearchHide => ({...state, searchPane: None}, Isolinear.Effect.none)
+
+ | _ =>
+ switch (state.searchPane) {
+ | Some(searchPane) => (
+ {...state, searchPane: Some(searchUpdater(searchPane, action))},
+ Isolinear.Effect.none,
+ )
+ | None => (state, Isolinear.Effect.none)
+ }
+ };
+ };
+
+ (updater, stream);
+};
+
+// SUBSCRIPTIONS
+
+let subscriptions = ripgrep => {
+ let (stream, dispatch) = Isolinear.Stream.create();
+
+ let search = query => {
+ let directory = Rench.Environment.getWorkingDirectory();
+
+ SearchSubscription.create(
+ ~id="workspace-search",
+ ~query,
+ ~directory,
+ ~ripgrep,
+ ~onUpdate=
+ items => {
+ Printf.printf("-- adding %n items\n%!", List.length(items));
+ dispatch(SearchUpdate(items));
+ },
+ ~onCompleted=() => SearchComplete,
+ );
+ };
+
+ let updater = (state: Model.State.t) => {
+ switch (state.searchPane) {
+ | None
+ | Some({query: "", _}) => []
+
+ | Some({query, _}) => [search(query)]
+ };
+ };
+
+ (updater, stream);
+};
diff --git a/src/Store/SearchSubscription.re b/src/Store/SearchSubscription.re
new file mode 100644
index 0000000000..f3886cec92
--- /dev/null
+++ b/src/Store/SearchSubscription.re
@@ -0,0 +1,73 @@
+module Core = Oni_Core;
+module Model = Oni_Model;
+module Log = Core.Log;
+
+module Actions = Model.Actions;
+module Ripgrep = Core.Ripgrep;
+module Subscription = Core.Subscription;
+
+module Provider = {
+ type action = Actions.t;
+ type params = {
+ directory: string,
+ query: string,
+ ripgrep: Ripgrep.t, // TODO: Necessary dependency?
+ onUpdate: list(Ripgrep.Match.t) => unit, // TODO: Should return action
+ onCompleted: unit => action,
+ };
+
+ let jobs = Hashtbl.create(10);
+
+ let start =
+ (
+ ~id,
+ ~params as {directory, query, ripgrep, onUpdate, onCompleted},
+ ~dispatch: _,
+ ) => {
+ Log.info("Starting Search subscription " ++ id);
+
+ let dispose =
+ ripgrep.Ripgrep.findInFiles(
+ ~directory,
+ ~query,
+ ~onUpdate,
+ ~onComplete=() => {
+ Log.info("Ripgrep completed.");
+ dispatch(onCompleted());
+ },
+ );
+
+ Hashtbl.replace(jobs, id, (query, dispose));
+ };
+
+ let update = (~id, ~params, ~dispatch) => {
+ switch (Hashtbl.find_opt(jobs, id)) {
+ | Some((currentQuery, dispose)) =>
+ if (currentQuery != params.query) {
+ Log.info("Updating Search subscription " ++ id);
+ dispose();
+ start(~id, ~params, ~dispatch);
+ }
+
+ | None => Log.error("Tried to dispose non-existing Search subscription")
+ };
+ };
+
+ let dispose = (~id) => {
+ switch (Hashtbl.find_opt(jobs, id)) {
+ | Some((_, dispose)) =>
+ Log.info("Disposing Search subscription " ++ id);
+ dispose();
+ Hashtbl.remove(jobs, id);
+
+ | None => Log.error("Tried to dispose non-existing Search subscription")
+ };
+ };
+};
+
+let create = (~id, ~directory, ~query, ~ripgrep, ~onUpdate, ~onCompleted) =>
+ Subscription.create(
+ id,
+ (module Provider),
+ {directory, query, ripgrep, onUpdate, onCompleted},
+ );
diff --git a/src/Store/StoreThread.re b/src/Store/StoreThread.re
index 9c3ad62329..96ddb15967 100644
--- a/src/Store/StoreThread.re
+++ b/src/Store/StoreThread.re
@@ -128,11 +128,13 @@ let start =
);
let keyBindingsUpdater = KeyBindingsStoreConnector.start();
- let ripgrep = Core.Ripgrep.make(setup.rgPath);
+ let ripgrep = Core.Ripgrep.make(~executablePath=setup.rgPath);
let (fileExplorerUpdater, explorerStream) =
FileExplorerStoreConnector.start();
+ let (searchUpdater, searchStream) = SearchStoreConnector.start();
+
let (lifecycleUpdater, lifecycleStream) =
LifecycleStoreConnector.start(quit);
let indentationUpdater = IndentationStoreConnector.start();
@@ -167,6 +169,7 @@ let start =
commandUpdater,
lifecycleUpdater,
fileExplorerUpdater,
+ searchUpdater,
indentationUpdater,
windowUpdater,
keyDisplayerUpdater,
@@ -185,9 +188,17 @@ let start =
type action = Model.Actions.t;
let id = "quickmenu-subscription";
});
- let (quickmenuSubscriptionsUpdater, _menuSubscriptionsStream) =
+ let (quickmenuSubscriptionsUpdater, quickmenuSubscriptionsStream) =
QuickmenuStoreConnector.subscriptions(ripgrep);
+ module SearchSubscriptionRunner =
+ Core.Subscription.Runner({
+ type action = Model.Actions.t;
+ let id = "search-subscription";
+ });
+ let (searchSubscriptionsUpdater, searchSubscriptionsStream) =
+ SearchStoreConnector.subscriptions(ripgrep);
+
let rec dispatch = (action: Model.Actions.t) => {
let lastState = latestState^;
let (newState, effect) = storeDispatch(action);
@@ -201,6 +212,8 @@ let start =
// TODO: Wire this up properly
let quickmenuSubs = quickmenuSubscriptionsUpdater(newState);
QuickmenuSubscriptionRunner.run(~dispatch, quickmenuSubs);
+ let searchSubs = searchSubscriptionsUpdater(newState);
+ SearchSubscriptionRunner.run(~dispatch, searchSubs);
};
let runEffects = () => {
@@ -247,6 +260,8 @@ let start =
Isolinear.Stream.connect(dispatch, quickmenuStream);
let _: Isolinear.Stream.unsubscribeFunc =
Isolinear.Stream.connect(dispatch, explorerStream);
+ let _: Isolinear.Stream.unsubscribeFunc =
+ Isolinear.Stream.connect(dispatch, searchStream);
let _: Isolinear.Stream.unsubscribeFunc =
Isolinear.Stream.connect(dispatch, lifecycleStream);
let _: Isolinear.Stream.unsubscribeFunc =
@@ -255,6 +270,10 @@ let start =
Isolinear.Stream.connect(dispatch, hoverStream);
let _: Isolinear.Stream.unsubscribeFunc =
Isolinear.Stream.connect(dispatch, merlinStream);
+ let _: Isolinear.Stream.unsubscribeFunc =
+ Isolinear.Stream.connect(dispatch, quickmenuSubscriptionsStream);
+ let _: Isolinear.Stream.unsubscribeFunc =
+ Isolinear.Stream.connect(dispatch, searchSubscriptionsStream);
dispatch(Model.Actions.SetLanguageInfo(languageInfo));
diff --git a/src/Store/VimStoreConnector.re b/src/Store/VimStoreConnector.re
index c2b696cafd..370b3cc3d5 100644
--- a/src/Store/VimStoreConnector.re
+++ b/src/Store/VimStoreConnector.re
@@ -492,8 +492,11 @@ let start =
}
);
- let openFileByPathEffect = (filePath, dir, position) =>
+ let openFileByPathEffect = (filePath, dir, location) =>
Isolinear.Effect.create(~name="vim.openFileByPath", () => {
+ open Oni_Core.Utility;
+ open Oni_Core.Types;
+
/* If a split was requested, create that first! */
switch (dir) {
| Some(direction) =>
@@ -516,10 +519,9 @@ let start =
Some(Model.LanguageInfo.getLanguageFromFilePath(languageInfo, v))
| None => None
};
- open Oni_Core.Utility;
- open Oni_Core.Types;
+
let () =
- position
+ location
|> Option.iter((pos: Position.t) => {
open Position;
let cursor =
@@ -796,9 +798,9 @@ let start =
(state, eff);
| Model.Actions.Init => (state, initEffect)
- | Model.Actions.OpenFileByPath(path, direction, position) => (
+ | Model.Actions.OpenFileByPath(path, direction, location) => (
state,
- openFileByPathEffect(path, direction, position),
+ openFileByPathEffect(path, direction, location),
)
| Model.Actions.BufferEnter(_)
| Model.Actions.SetEditorFont(_)
diff --git a/src/UI/Dock.re b/src/UI/Dock.re
index 3e2baa5b6d..2eed5932b0 100644
--- a/src/UI/Dock.re
+++ b/src/UI/Dock.re
@@ -14,6 +14,11 @@ let toggleExplorer = ({fileExplorer, _}: State.t, _) => {
GlobalContext.current().dispatch(action);
};
+let toggleSearch = ({searchPane, _}: State.t, _) => {
+ let action = searchPane == None ? Actions.SearchShow : Actions.SearchHide;
+ GlobalContext.current().dispatch(action);
+};
+
let make = (~state: State.t, ()) => {
let bg = state.theme.editorLineNumberBackground;
@@ -28,5 +33,12 @@ let make = (~state: State.t, ()) => {
+
+
+
;
};
diff --git a/src/UI/EditorGroupView.re b/src/UI/EditorGroupView.re
index ed5c86de94..4ea9b3ad17 100644
--- a/src/UI/EditorGroupView.re
+++ b/src/UI/EditorGroupView.re
@@ -13,6 +13,8 @@ module Model = Oni_Model;
module Window = WindowManager;
+module List = Utility.List;
+
let noop = () => ();
let editorViewStyle = (background, foreground) =>
@@ -57,7 +59,7 @@ let toUiTabs = (editorGroup: Model.EditorGroup.t, buffers: Model.Buffers.t) => {
};
};
- Utility.filterMap(f, editorGroup.reverseTabOrder) |> List.rev;
+ List.filter_map(f, editorGroup.reverseTabOrder) |> List.rev;
};
let make = (~state: State.t, ~windowId: int, ~editorGroupId: int, ()) => {
diff --git a/src/UI/EditorView.re b/src/UI/EditorView.re
index e0db133987..f2437df1d6 100644
--- a/src/UI/EditorView.re
+++ b/src/UI/EditorView.re
@@ -14,11 +14,7 @@ let editorViewStyle = (background, foreground) =>
Style.[
backgroundColor(background),
color(foreground),
- position(`Absolute),
- top(0),
- left(0),
- right(0),
- bottom(0),
+ flexGrow(1),
flexDirection(`Column),
];
diff --git a/src/UI/FlatList.re b/src/UI/FlatList.re
index 1a9e373db9..30656de3cf 100644
--- a/src/UI/FlatList.re
+++ b/src/UI/FlatList.re
@@ -36,14 +36,14 @@ module Constants = {
};
module Styles = {
- let container = (~width as w, ~height as h) =>
+ let container = (~height as h) =>
Style.[
position(`Relative),
top(0),
left(0),
- width(w),
height(h),
overflow(`Hidden),
+ flexGrow(1),
];
let slider =
@@ -105,14 +105,28 @@ type action =
let%component make =
(
- ~height as menuHeight,
- ~width,
~rowHeight: int,
+ ~initialRowsToRender=20,
~render as renderItem: renderFunction,
~count: int,
~focused: option(int),
+ ~ref as onRef=_ => (),
(),
) => {
+ let%hook (outerRef, setOuterRef) = Hooks.ref(None);
+ let setOuterRef = ref => {
+ setOuterRef(Some(ref));
+ onRef(ref);
+ };
+
+ let menuHeight =
+ switch (outerRef) {
+ | Some(node) =>
+ let dimensions: Dimensions.t = node#measurements();
+ dimensions.height;
+ | None => rowHeight * initialRowsToRender
+ };
+
let reducer = (action, actualScrollTop) =>
switch (action) {
| FocusedChanged =>
@@ -191,10 +205,8 @@ let%component make =
|> React.listToElement;
diff --git a/src/UI/LocationListView.re b/src/UI/LocationListView.re
new file mode 100644
index 0000000000..e2b46d75b9
--- /dev/null
+++ b/src/UI/LocationListView.re
@@ -0,0 +1,219 @@
+open Revery;
+open Revery.UI;
+open Revery.UI.Components;
+open Oni_Core;
+open Types;
+open Oni_Model;
+
+module Option = Utility.Option;
+
+// TODO: move to Revery
+let getFontAdvance = (fontFile, fontSize) => {
+ open Revery.Draw;
+
+ let scaledFontSize =
+ Text._getScaledFontSizeFromWindow(Revery.UI.getActiveWindow(), fontSize);
+ let font = FontCache.load(fontFile, scaledFontSize);
+ let shapedText = FontRenderer.shape(font, "x");
+ let Fontkit.{advance, _} =
+ FontRenderer.getGlyph(font, shapedText[0].glyphId);
+
+ float(advance) /. 64.;
+};
+
+module Styles = {
+ open Style;
+
+ let clickable = [cursor(Revery.MouseCursors.pointer)];
+
+ let result = (~theme: Theme.t, ~isHovered) => [
+ flexDirection(`Row),
+ overflow(`Hidden),
+ paddingVertical(4),
+ paddingHorizontal(8),
+ backgroundColor(
+ isHovered ? theme.menuSelectionBackground : Colors.transparentWhite,
+ ),
+ ];
+
+ let locationText = (~font: Types.UiFont.t, ~theme: Theme.t) => [
+ fontFamily(font.fontFile),
+ fontSize(font.fontSize),
+ color(theme.editorActiveLineNumberForeground),
+ textWrap(TextWrapping.NoWrap),
+ ];
+
+ let snippet = (~font: Types.EditorFont.t, ~theme: Theme.t, ~isHighlighted) => [
+ fontFamily(font.fontFile),
+ fontSize(font.fontSize),
+ color(
+ isHighlighted ? theme.oniNormalModeBackground : theme.editorForeground,
+ ),
+ textWrap(TextWrapping.NoWrap),
+ ];
+};
+
+let item =
+ (
+ ~theme,
+ ~uiFont: UiFont.t,
+ ~editorFont,
+ ~onMouseOver,
+ ~onMouseOut,
+ ~width,
+ ~isHovered,
+ ~item: LocationListItem.t,
+ (),
+ ) => {
+ let directory = Rench.Environment.getWorkingDirectory();
+ let re = Str.regexp_string(directory ++ Filename.dir_sep);
+ let getDisplayPath = fullPath => Str.replace_first(re, "", fullPath);
+
+ let onClick = () => {
+ GlobalContext.current().dispatch(
+ OpenFileByPath(item.file, None, Some(item.location)),
+ );
+
+ Revery.UI.Focus.loseFocus();
+ };
+
+ let locationText =
+ Printf.sprintf(
+ "%s:%n - ",
+ getDisplayPath(item.file),
+ Index.toInt1(item.location.line),
+ );
+
+ let locationWidth =
+ switch (Revery.UI.getActiveWindow()) {
+ | Some(window) =>
+ Revery.Draw.Text.measure(
+ ~window,
+ ~fontSize=uiFont.fontSize,
+ ~fontFamily=uiFont.fontFile,
+ locationText,
+ ).
+ width
+ | None => String.length(locationText) * uiFont.fontSize
+ };
+
+ let location = () =>
+ ;
+
+ let content = () => {
+ let unstyled = (~text, ()) =>
+ ;
+
+ let highlighted = (~text, ()) =>
+ ;
+
+ switch (item.highlight) {
+ | Some((indexStart, indexEnd)) =>
+ open Utility.StringUtil;
+
+ let availableWidth = float(width - locationWidth);
+ let maxLength =
+ int_of_float(availableWidth /. editorFont.measuredWidth);
+ let charStart = Index.toInt1(indexStart);
+ let charEnd = Index.toInt1(indexEnd);
+
+ try({
+ let (text, charStart, charEnd) =
+ extractSnippet(~maxLength, ~charStart, ~charEnd, item.text);
+ let before = String.sub(text, 0, charStart);
+ let matchedText = String.sub(text, charStart, charEnd - charStart);
+ let after = String.sub(text, charEnd, String.length(text) - charEnd);
+
+
+
+
+
+ ;
+ }) {
+ | Invalid_argument(message) =>
+ // TODO: This shouldn't happen, but you never know. Consider a sane implementation of `String.sub` instead, to avoid this
+ Log.error(
+ Printf.sprintf(
+ "[SearchPane.highlightedText] \"%s\" - (%n, %n)\n%!",
+ message,
+ charStart,
+ charEnd,
+ ),
+ );
+ ;
+ };
+ | None =>
+ };
+ };
+
+
+
+
+
+
+ ;
+};
+
+let%component make =
+ (
+ ~theme: Theme.t,
+ ~uiFont: Types.UiFont.t,
+ ~editorFont: Types.EditorFont.t,
+ ~items: array(LocationListItem.t),
+ (),
+ ) => {
+ let%hook (outerRef, setOuterRef) = Hooks.ref(None);
+ let%hook (hovered, setHovered) = Hooks.state(-1);
+
+ let editorFont = {
+ ...editorFont,
+ fontSize: uiFont.fontSize,
+ measuredWidth: getFontAdvance(editorFont.fontFile, uiFont.fontSize),
+ // measuredHeight:
+ // editorFont.measuredHeight
+ // *. (float(uiFont.fontSize) /. float(editorFont.fontSize)),
+ };
+
+ let width =
+ outerRef
+ |> Option.map(node => node#measurements().Dimensions.width)
+ |> Option.value(
+ ~default=
+ Revery.UI.getActiveWindow()
+ |> Option.map((window: Window.t) => window.metrics.size.width)
+ |> Option.value(~default=4000),
+ );
+
+ let renderItem = i => {
+ let onMouseOver = _ => setHovered(_ => i);
+ let onMouseOut = _ => setHovered(hovered => i == hovered ? (-1) : hovered);
+
+ ;
+ };
+
+ setOuterRef(Some(ref))}
+ />;
+};
diff --git a/src/UI/OniInput.re b/src/UI/OniInput.re
index 7f287c9ddd..2676b2efa2 100644
--- a/src/UI/OniInput.re
+++ b/src/UI/OniInput.re
@@ -2,13 +2,71 @@ open Revery;
open Revery.UI;
open Revery.UI.Components;
-open Oni_Core.Utility;
+module Option = Oni_Core.Utility.Option;
-type textUpdate = {
- newString: string,
- newCursorPosition: int,
+module Cursor = {
+ type state = {
+ time: Time.t,
+ isOn: bool,
+ };
+
+ type action =
+ | Reset
+ | Tick(Time.t);
+
+ let use = (~interval, ~isFocused) => {
+ let%hook (state, dispatch) =
+ Hooks.reducer(
+ ~initialState={time: Time.zero, isOn: false}, (action, state) => {
+ switch (action) {
+ | Reset => {isOn: true, time: Time.zero}
+ | Tick(increasedTime) =>
+ let newTime = Time.(state.time + increasedTime);
+
+ /* if newTime is above the interval a `Tick` has passed */
+ newTime >= interval
+ ? {isOn: !state.isOn, time: Time.zero}
+ : {...state, time: newTime};
+ }
+ });
+
+ let%hook () =
+ Hooks.effect(
+ OnMount,
+ () => {
+ let clear =
+ Tick.interval(time => dispatch(Tick(time)), Time.ms(16));
+ Some(clear);
+ },
+ );
+
+ let cursorOpacity = isFocused && state.isOn ? 1.0 : 0.0;
+
+ (cursorOpacity, () => dispatch(Reset));
+ };
+};
+
+type state = {
+ isFocused: bool, // TODO: Violates single source of truth
+ value: string,
+ cursorPosition: int,
};
+type changeEvent = {
+ value: string,
+ character: string,
+ keycode: Key.Keycode.t,
+ altKey: bool,
+ ctrlKey: bool,
+ shiftKey: bool,
+ superKey: bool,
+};
+
+type action =
+ | Focus
+ | Blur
+ | TextInput(string, int);
+
let getStringParts = (index, str) => {
switch (index) {
| 0 => ("", str)
@@ -36,7 +94,7 @@ let removeCharacterBefore = (word, cursorPosition) => {
let (startStr, endStr) = getStringParts(cursorPosition, word);
let nextPosition = getSafeStringBounds(startStr, cursorPosition, -1);
let newString = Str.string_before(startStr, nextPosition) ++ endStr;
- {newString, newCursorPosition: nextPosition};
+ (newString, nextPosition);
};
let removeCharacterAfter = (word, cursorPosition) => {
@@ -49,197 +107,292 @@ let removeCharacterAfter = (word, cursorPosition) => {
| _ => Str.last_chars(endStr, String.length(endStr) - 1)
}
);
- {newString, newCursorPosition: cursorPosition};
+ (newString, cursorPosition);
};
-let deleteWord = (str, cursorPosition) => {
- let positionToDeleteTo =
- switch (String.rindex_from_opt(str, cursorPosition - 1, ' ')) {
- | None => 0
- | Some(v) => v
- };
+let addCharacter = (word, char, index) => {
+ let (startStr, endStr) = getStringParts(index, word);
+ (startStr ++ char ++ endStr, String.length(startStr) + 1);
+};
+
+let reducer = (action, state) =>
+ switch (action) {
+ | Focus => {...state, isFocused: true}
+ | Blur => {...state, isFocused: false}
+ | TextInput(value, cursorPosition) => {...state, value, cursorPosition}
+ };
+
+module Constants = {
+ let cursorWidth = 2;
+};
+
+module Styles = {
+ let defaultPlaceholderColor = Colors.grey;
+ let defaultCursorColor = Colors.black;
+
+ let default =
+ Style.[
+ color(Colors.black),
+ paddingVertical(8),
+ paddingHorizontal(12),
+ border(~width=1, ~color=Colors.black),
+ backgroundColor(Colors.transparentWhite),
+ ];
+};
+
+let%component make =
+ (
+ ~style=Styles.default,
+ ~placeholderColor=Styles.defaultPlaceholderColor,
+ ~cursorColor=Styles.defaultCursorColor,
+ ~autofocus=false,
+ ~placeholder="",
+ ~prefix="",
+ ~onFocus=() => (),
+ ~onBlur=() => (),
+ ~onKeyDown=_ => (),
+ ~onChange=(_, _) => (),
+ ~value=?,
+ ~cursorPosition=?,
+ (),
+ ) => {
+ let%hook (state, dispatch) =
+ Hooks.reducer(
+ ~initialState={
+ isFocused: false,
+ value: Option.value(value, ~default=""),
+ cursorPosition: Option.value(cursorPosition, ~default=0),
+ },
+ reducer,
+ );
+ let%hook (textRef, setTextRef) = Hooks.ref(None);
+ let%hook (scrollOffset, _setScrollOffset) = Hooks.state(ref(0));
+
+ let value = prefix ++ Option.value(value, ~default=state.value);
+ let showPlaceholder = value == "";
+ let cursorPosition =
+ Option.value(cursorPosition, ~default=state.cursorPosition);
+
+ module Styles = {
+ open Style;
+ include Styles;
+
+ let fontSize = Selector.select(style, FontSize, 18);
+ let textColor = Selector.select(style, Color, Colors.black);
+ let fontFamily = Selector.select(style, FontFamily, "Roboto-Regular.ttf");
+
+ let _all =
+ merge(
+ ~source=[
+ flexDirection(`Row),
+ alignItems(`Center),
+ justifyContent(`FlexStart),
+ overflow(`Hidden),
+ cursor(MouseCursors.text),
+ ...default,
+ ],
+ ~target=style,
+ );
+
+ let box = extractViewStyles(_all);
+
+ let marginContainer = [
+ flexDirection(`Row),
+ alignItems(`Center),
+ justifyContent(`FlexStart),
+ flexGrow(1),
+ ];
+
+ let cursor = offset => [
+ position(`Absolute),
+ marginTop(2),
+ transform(Transform.[TranslateX(float(offset))]),
+ ];
+
+ let textContainer = [flexGrow(1), overflow(`Hidden)];
+
+ let text = [
+ color(showPlaceholder ? placeholderColor : textColor),
+ Style.fontFamily(fontFamily),
+ Style.fontSize(fontSize),
+ alignItems(`Center),
+ justifyContent(`FlexStart),
+ textWrap(TextWrapping.NoWrap),
+ transform(Transform.[TranslateX(float(- scrollOffset^))]),
+ ];
+ };
- // Get the 'before' position
- let beforeStr =
- if (positionToDeleteTo > 0) {
- String.sub(str, 0, positionToDeleteTo);
- } else {
- "";
+ let measureTextWidth = text =>
+ switch (Revery_UI.getActiveWindow()) {
+ | Some(window) =>
+ let dimensions =
+ Revery_Draw.Text.measure(
+ ~window,
+ ~fontFamily=Styles.fontFamily,
+ ~fontSize=Styles.fontSize,
+ text,
+ );
+
+ dimensions.width;
+ | None => Styles.fontSize
};
- let afterStr =
- if (cursorPosition <= String.length(str) - 1) {
- String.sub(str, cursorPosition, String.length(str) - cursorPosition);
- } else {
- "";
+ let%hook (cursorOpacity, resetCursor) =
+ Cursor.use(~interval=Time.ms(500), ~isFocused=state.isFocused);
+
+ let () = {
+ let cursorOffset =
+ measureTextWidth(String.sub(value, 0, cursorPosition));
+
+ switch (Option.bind(r => r#getParent(), textRef)) {
+ | Some(containerNode) =>
+ let container: Dimensions.t = containerNode#measurements();
+
+ if (cursorOffset < scrollOffset^) {
+ // out of view to the left, so align with left edge
+ scrollOffset := cursorOffset;
+ } else if (cursorOffset - scrollOffset^ > container.width) {
+ // out of view to the right, so align with right edge
+ scrollOffset := cursorOffset - container.width;
+ };
+
+ | None => ()
};
- {newString: beforeStr ++ afterStr, newCursorPosition: positionToDeleteTo};
-};
+ };
-let addCharacter = (word, char, index) => {
- let (startStr, endStr) = getStringParts(index, word);
- {
- newString: startStr ++ char ++ endStr,
- newCursorPosition: String.length(startStr) + 1,
+ let handleFocus = () => {
+ resetCursor();
+ onFocus();
+ dispatch(Focus);
+ };
+
+ let handleBlur = () => {
+ resetCursor();
+ onBlur();
+ dispatch(Blur);
};
-};
-let defaultHeight = 40;
-let defaultWidth = 200;
-let inputTextMargin = 10;
-
-let defaultStyles =
- Style.[
- color(Colors.black),
- width(defaultWidth),
- height(defaultHeight),
- border(
- /*
- The default border width should be 5% of the full input height
- */
- ~width=float_of_int(defaultHeight) *. 0.05 |> int_of_float,
- ~color=Colors.black,
- ),
- backgroundColor(Colors.transparentWhite),
- ];
-
-let make =
- (
- ~style=defaultStyles,
- ~placeholderColor=Colors.grey,
- ~cursorColor=Colors.black,
- ~autofocus=false,
- ~placeholder="",
- ~prefix="",
- ~onChange=(_, _) => (),
- ~onKeyDown=_ => (),
- ~fontSize=14,
- ~cursorPosition,
- ~text,
- (),
- ) => {
- let valueToDisplay = prefix ++ text;
- let showPlaceholder = valueToDisplay == "";
+ // TODO:This ought to be in the reducer, but since reducer calls are deferred
+ // the ordering of side-effects can't be guaranteed.
+ //
+ // Refactor when https://github.com/briskml/brisk-reconciler/issues/54 has been fixed
+ let update = (value, cursorPosition) => {
+ onChange(value, cursorPosition);
+ dispatch(TextInput(value, cursorPosition));
+ };
let handleTextInput = (event: NodeEvents.textInputEventParams) => {
- let {newString, newCursorPosition} =
- addCharacter(text, event.text, cursorPosition);
- onChange(newString, newCursorPosition);
+ resetCursor();
+ let (value, cursorPosition) =
+ addCharacter(value, event.text, cursorPosition);
+ update(value, cursorPosition);
};
let handleKeyDown = (event: NodeEvents.keyEventParams) => {
- switch (event.keycode) {
- | v when v == Key.Keycode.left =>
- onKeyDown(event);
- onChange(text, getSafeStringBounds(text, cursorPosition, -1));
-
- | v when v == Key.Keycode.right =>
- onKeyDown(event);
- onChange(text, getSafeStringBounds(text, cursorPosition, 1));
+ resetCursor();
+ onKeyDown(event);
- | v when v == 117 /*Key.Keycode.u*/ && event.ctrlKey => onChange("", 0)
-
- | v when v == 119 /*Key.Keycode.w*/ && event.ctrlKey =>
- let {newString, newCursorPosition} = deleteWord(text, cursorPosition);
- onChange(newString, newCursorPosition);
+ switch (event.keycode) {
+ | v when Key.Keycode.left == v =>
+ let cursorPosition = getSafeStringBounds(value, cursorPosition, -1);
+ update(value, cursorPosition);
- | v when v == Key.Keycode.delete =>
- let {newString, newCursorPosition} =
- removeCharacterAfter(text, cursorPosition);
- onChange(newString, newCursorPosition);
+ | v when Key.Keycode.right == v =>
+ let cursorPosition = getSafeStringBounds(value, cursorPosition, 1);
+ update(value, cursorPosition);
- | v when v == 104 /*Key.Keycode.h*/ && event.ctrlKey =>
- let {newString, newCursorPosition} =
- removeCharacterBefore(text, cursorPosition);
- onChange(newString, newCursorPosition);
+ | v when Key.Keycode.delete == v =>
+ let (value, cursorPosition) =
+ removeCharacterAfter(value, cursorPosition);
+ update(value, cursorPosition);
- | v when v == Key.Keycode.backspace =>
- let {newString, newCursorPosition} =
- removeCharacterBefore(text, cursorPosition);
- onChange(newString, newCursorPosition);
+ | v when Key.Keycode.backspace == v =>
+ let (value, cursorPosition) =
+ removeCharacterBefore(value, cursorPosition);
+ update(value, cursorPosition);
- | v when v == Key.Keycode.escape =>
- onKeyDown(event);
- Focus.loseFocus();
+ | v when Key.Keycode.escape == v => Focus.loseFocus()
- | _ => onKeyDown(event)
+ | _ => ()
};
};
- /*
- computed styles
- */
+ let handleClick = (event: NodeEvents.mouseButtonEventParams) => {
+ let rec offsetLeft = node => {
+ let Dimensions.{left, _} = node#measurements();
+ switch (node#getParent()) {
+ | Some(parent) => left + offsetLeft(parent)
+ | None => left
+ };
+ };
- let allStyles =
- Style.(
- merge(
- ~source=[
- flexDirection(`Row),
- alignItems(`Center),
- justifyContent(`FlexStart),
- overflow(`Hidden),
- cursor(MouseCursors.text),
- ...defaultStyles,
- ],
- ~target=style,
- )
- );
+ let indexNearestOffset = offset => {
+ let rec loop = (i, last) =>
+ if (i > String.length(value)) {
+ i - 1;
+ } else {
+ let width = measureTextWidth(String.sub(value, 0, i));
+
+ if (width > offset) {
+ let isCurrentNearest = width - offset < offset - last;
+ isCurrentNearest ? i : i - 1;
+ } else {
+ loop(i + 1, width);
+ };
+ };
+
+ loop(1, 0);
+ };
- let viewStyles = Style.extractViewStyles(allStyles);
- let inputFontSize = fontSize;
- let inputColor = Selector.select(style, Color, Colors.black);
- let inputFontFamily =
- Selector.select(style, FontFamily, "Roboto-Regular.ttf");
+ switch (textRef) {
+ | Some(node) =>
+ let offset =
+ int_of_float(event.mouseX) - offsetLeft(node) + scrollOffset^;
+ let cursorPosition = indexNearestOffset(offset);
+ resetCursor();
+ update(value, cursorPosition);
- let cursorOpacity = 1.0;
+ | None => ()
+ };
+ };
- let cursor = {
+ let cursor = () => {
let (startStr, _) =
- getStringParts(cursorPosition + String.length(prefix), valueToDisplay);
-
- Revery.UI.getActiveWindow()
- |> Option.map(window => {
- let dimension =
- Revery.Draw.Text.measure(
- ~window,
- ~fontFamily=inputFontFamily,
- ~fontSize=inputFontSize,
- startStr,
- );
-
-
-
-
- ;
- })
- |> Option.value(~default=React.empty);
+ getStringParts(cursorPosition + String.length(prefix), value);
+ let textWidth = measureTextWidth(startStr);
+
+ let offset = textWidth - scrollOffset^;
+
+
+
+
+
+ ;
};
- let makeTextComponent = content =>
+ let text = () =>
setTextRef(Some(node))}
+ text={showPlaceholder ? placeholder : value}
+ style=Styles.text
/>;
- let textView =
- makeTextComponent(showPlaceholder ? placeholder : valueToDisplay);
-
- cursor textView
+
+
+
+
+
+
;
};
diff --git a/src/UI/QuickmenuView.re b/src/UI/QuickmenuView.re
index 411c88a74e..d6df5d69b9 100644
--- a/src/UI/QuickmenuView.re
+++ b/src/UI/QuickmenuView.re
@@ -19,18 +19,15 @@ module Styles = {
Style.[
border(~width=2, ~color=Color.rgba(0., 0., 0., 0.1)),
backgroundColor(Color.rgba(0., 0., 0., 0.3)),
- width(Constants.menuWidth - 10),
color(Colors.white),
fontFamily(font),
- ];
-
- let menuItem =
- Style.[
fontSize(14),
- width(Constants.menuWidth - 50),
- cursor(Revery.MouseCursors.pointer),
];
+ let dropdown = Style.[height(Constants.menuHeight), overflow(`Hidden)];
+
+ let menuItem = Style.[fontSize(14), cursor(Revery.MouseCursors.pointer)];
+
let label =
(~font: Types.UiFont.t, ~theme: Theme.t, ~highlighted, ~isFocused) =>
Style.[
@@ -46,8 +43,7 @@ module Styles = {
textWrap(TextWrapping.NoWrap),
];
- let progressBarTrack =
- Style.[height(2), width(Constants.menuWidth), overflow(`Hidden)];
+ let progressBarTrack = Style.[height(2), overflow(`Hidden)];
let progressBarIndicator = (~width as barWidth, ~offset, ~theme: Theme.t) =>
Style.[
@@ -193,15 +189,13 @@ let make =
cursorColor=Colors.white
style={Styles.input(font.fontFile)}
onChange=onInput
- text=query
+ value=query
cursorPosition
/>
-
+
- Style.[
- backgroundColor(background),
- color(foreground),
- position(`Absolute),
- top(0),
- left(0),
- right(0),
- bottom(0),
- justifyContent(`Center),
- alignItems(`Center),
- ];
+module Styles = {
+ let root = (background, foreground) =>
+ Style.[
+ backgroundColor(background),
+ color(foreground),
+ position(`Absolute),
+ top(0),
+ left(0),
+ right(0),
+ bottom(0),
+ justifyContent(`Center),
+ alignItems(`Stretch),
+ ];
-let surfaceStyle = statusBarHeight =>
- Style.[
- position(`Absolute),
- top(0),
- left(0),
- right(0),
- bottom(statusBarHeight),
- ];
+ let surface = Style.[flexGrow(1)];
-let statusBarStyle = statusBarHeight =>
- Style.[
- backgroundColor(Color.hex("#21252b")),
- position(`Absolute),
- left(0),
- right(0),
- bottom(0),
- height(statusBarHeight),
- justifyContent(`Center),
- alignItems(`Center),
- ];
+ let statusBar = statusBarHeight =>
+ Style.[
+ backgroundColor(Color.hex("#21252b")),
+ height(statusBarHeight),
+ justifyContent(`Center),
+ alignItems(`Center),
+ ];
+};
let make = (~state: State.t, ()) => {
- let theme = state.theme;
- let configuration = state.configuration;
- let style = rootStyle(theme.background, theme.foreground);
+ let State.{theme, configuration, uiFont, editorFont, _} = state;
let statusBarVisible =
Selectors.getActiveConfigurationValue(state, c =>
@@ -55,13 +44,21 @@ let make = (~state: State.t, ()) => {
let statusBarHeight = statusBarVisible ? 25 : 0;
let statusBar =
statusBarVisible
- ?
+ ?
: React.empty;
-
-
+ let searchPane =
+ switch (state.searchPane) {
+ | Some(searchPane) =>
+
+
+ | None => React.empty
+ };
+
+
+ searchPane
{switch (state.quickmenu) {
| None => React.empty
@@ -70,12 +67,7 @@ let make = (~state: State.t, ()) => {
| Wildmenu(_) =>
| _ =>
-
+
}
}}
diff --git a/src/UI/SearchPane.re b/src/UI/SearchPane.re
new file mode 100644
index 0000000000..fb3655eb90
--- /dev/null
+++ b/src/UI/SearchPane.re
@@ -0,0 +1,95 @@
+open Revery;
+open Revery.UI;
+open Oni_Core;
+open Types;
+open Oni_Model;
+
+module Styles = {
+ let searchPane = (~theme: Theme.t) =>
+ Style.[
+ flexDirection(`Row),
+ height(200),
+ borderTop(~color=theme.sideBarBackground, ~width=1),
+ ];
+
+ let queryPane = (~theme: Theme.t) =>
+ Style.[
+ width(300),
+ borderRight(~color=theme.sideBarBackground, ~width=1),
+ ];
+
+ let resultsPane = Style.[flexGrow(1)];
+
+ let row =
+ Style.[flexDirection(`Row), alignItems(`Center), marginHorizontal(8)];
+
+ let title = (~font: Types.UiFont.t) =>
+ Style.[
+ fontFamily(font.fontFile),
+ fontSize(font.fontSize),
+ color(Colors.white),
+ marginVertical(8),
+ marginHorizontal(8),
+ ];
+
+ let input = (~font: Types.UiFont.t) =>
+ Style.[
+ border(~width=2, ~color=Color.rgba(0., 0., 0., 0.1)),
+ backgroundColor(Color.rgba(0., 0., 0., 0.3)),
+ color(Colors.white),
+ fontFamily(font.fontFile),
+ fontSize(font.fontSize),
+ flexGrow(1),
+ ];
+
+ let clickable = Style.[cursor(Revery.MouseCursors.pointer)];
+};
+
+let matchToLocListItem = (hit: Ripgrep.Match.t) =>
+ LocationListItem.{
+ file: hit.file,
+ location:
+ Position.create(
+ Index.ofInt1(hit.lineNumber),
+ Index.ofInt0(hit.charStart),
+ ),
+ text: hit.text,
+ highlight:
+ Some((Index.ofInt1(hit.charStart), Index.ofInt1(hit.charEnd))),
+ };
+
+let make = (~theme, ~uiFont, ~editorFont, ~state: Search.t, ()) => {
+ let items = state.hits |> List.map(matchToLocListItem) |> Array.of_list;
+
+
+
+
+
+
+
+
+ if (event.keycode == 13) {
+ GlobalContext.current().dispatch(Actions.SearchStart);
+ }
+ }
+ onChange={(text, pos) =>
+ GlobalContext.current().dispatch(Actions.SearchInput(text, pos))
+ }
+ />
+
+
+
+
+
+
+ ;
+};
diff --git a/test/Core/UtilityTests.re b/test/Core/UtilityTests.re
index 2b89deba37..1a8c4fe86c 100644
--- a/test/Core/UtilityTests.re
+++ b/test/Core/UtilityTests.re
@@ -30,3 +30,190 @@ describe("dropLast", ({test, _}) => {
expect.list(dropLast([1, 2, 3])).toEqual([1, 2]);
});
});
+
+describe("StringUtil", ({describe, _}) => {
+ open StringUtil;
+
+ describe("trimLeft", ({test, _}) => {
+ test("empty", ({expect}) =>
+ expect.string(trimLeft("")).toEqual("")
+ );
+
+ test("all whitespace", ({expect}) =>
+ expect.string(trimLeft(" ")).toEqual("")
+ );
+
+ test("no whitespace", ({expect}) =>
+ expect.string(trimLeft("foo")).toEqual("foo")
+ );
+
+ test("whitespace beginning, middle and end", ({expect}) =>
+ expect.string(trimLeft(" foo bar ")).toEqual("foo bar ")
+ );
+ });
+
+ describe("trimRight", ({test, _}) => {
+ test("empty", ({expect}) =>
+ expect.string(trimRight("")).toEqual("")
+ );
+
+ test("all whitespace", ({expect}) =>
+ expect.string(trimRight(" ")).toEqual("")
+ );
+
+ test("no whitespace", ({expect}) =>
+ expect.string(trimRight("foo")).toEqual("foo")
+ );
+
+ test("whitespace beginning, middle and end", ({expect}) =>
+ expect.string(trimRight(" foo bar ")).toEqual(" foo bar")
+ );
+ });
+
+ describe("extractSnippet", ({test, _}) => {
+ let text = " 123456789";
+
+ test("empty", ({expect}) => {
+ let text = "";
+ let (snippet, charStart, charEnd) =
+ extractSnippet(~maxLength=10, ~charStart=0, ~charEnd=0, text);
+
+ expect.string(snippet).toEqual("");
+ expect.int(charStart).toBe(0);
+ expect.int(charEnd).toBe(0);
+ });
+
+ test("maxLength == 0", ({expect}) => {
+ let (snippet, charStart, charEnd) =
+ extractSnippet(~maxLength=0, ~charStart=0, ~charEnd=0, text);
+
+ expect.string(snippet).toEqual("");
+ expect.int(charStart).toBe(0);
+ expect.int(charEnd).toBe(0);
+ });
+
+ test(
+ "maxLength > length && charStart < indent | ~maxLength=10, ~charStart=0, ~charEnd=1",
+ ({expect}) => {
+ let (snippet, charStart, charEnd) =
+ extractSnippet(~maxLength=10, ~charStart=0, ~charEnd=1, text);
+
+ expect.string(snippet).toEqual(" 123456789");
+ expect.int(charStart).toBe(0);
+ expect.int(charEnd).toBe(1);
+ },
+ );
+
+ test(
+ "maxLength > length && charStart > indent | ~maxLength=10, ~charStart=1, ~charEnd=2",
+ ({expect}) => {
+ let (snippet, charStart, charEnd) =
+ extractSnippet(~maxLength=10, ~charStart=1, ~charEnd=2, text);
+
+ expect.string(snippet).toEqual("123456789");
+ expect.int(charStart).toBe(0);
+ expect.int(charEnd).toBe(1);
+ },
+ );
+
+ test(
+ "maxLength > length && charStart > charEnd | ~maxLength=10, ~charStart=1, ~charEnd=0",
+ ({expect}) => {
+ let (snippet, charStart, charEnd) =
+ extractSnippet(~maxLength=10, ~charStart=1, ~charEnd=0, text);
+
+ expect.string(snippet).toEqual("123456789");
+ expect.int(charStart).toBe(0);
+ expect.int(charEnd).toBe(-1);
+ },
+ );
+
+ test(
+ "maxLength < length && charStart > charEnd | ~maxLength=2, ~charStart=1, ~charEnd=0",
+ ({expect}) => {
+ let (snippet, charStart, charEnd) =
+ extractSnippet(~maxLength=2, ~charStart=1, ~charEnd=0, text);
+
+ expect.string(snippet).toEqual("12");
+ expect.int(charStart).toBe(0);
+ expect.int(charEnd).toBe(-1);
+ },
+ );
+
+ test(
+ "charStart > charEnd | ~maxLength=1, ~charStart=1, ~charEnd=0",
+ ({expect}) => {
+ let (snippet, charStart, charEnd) =
+ extractSnippet(~maxLength=1, ~charStart=1, ~charEnd=0, text);
+
+ expect.string(snippet).toEqual("1");
+ expect.int(charStart).toBe(0);
+ expect.int(charEnd).toBe(-1);
+ });
+
+ test(
+ "charEnd < maxLength | ~maxLength=4, ~charStart=1, ~charEnd=3",
+ ({expect}) => {
+ let (snippet, charStart, charEnd) =
+ extractSnippet(~maxLength=4, ~charStart=1, ~charEnd=3, text);
+
+ expect.string(snippet).toEqual("1234");
+ expect.int(charStart).toBe(0);
+ expect.int(charEnd).toBe(2);
+ });
+
+ test(
+ "charEnd > maxLength | ~maxLength=2, ~charStart=1, ~charEnd=3",
+ ({expect}) => {
+ let (snippet, charStart, charEnd) =
+ extractSnippet(~maxLength=2, ~charStart=1, ~charEnd=3, text);
+
+ expect.string(snippet).toEqual("12");
+ expect.int(charStart).toBe(0);
+ expect.int(charEnd).toBe(2);
+ });
+
+ test("match fits | ~maxLength=7, ~charStart=6, ~charEnd=9", ({expect}) => {
+ let (snippet, charStart, charEnd) =
+ extractSnippet(~maxLength=7, ~charStart=6, ~charEnd=9, text);
+
+ expect.string(snippet).toEqual("...5678");
+ expect.int(charStart).toBe(4);
+ expect.int(charEnd).toBe(7);
+ });
+
+ test(
+ "match fits, but not ellipsis | ~maxLength=4, ~charStart=3, ~charEnd=6",
+ ({expect}) => {
+ let (snippet, charStart, charEnd) =
+ extractSnippet(~maxLength=4, ~charStart=3, ~charEnd=6, text);
+
+ expect.string(snippet).toEqual("...2");
+ expect.int(charStart).toBe(4);
+ expect.int(charEnd).toBe(4);
+ });
+
+ test(
+ "match does not fit | ~maxLength=4, ~charStart=3, ~charEnd=6",
+ ({expect}) => {
+ let (snippet, charStart, charEnd) =
+ extractSnippet(~maxLength=4, ~charStart=3, ~charEnd=6, text);
+
+ expect.string(snippet).toEqual("...2");
+ expect.int(charStart).toBe(4);
+ expect.int(charEnd).toBe(4);
+ });
+
+ test("real world case 1", ({expect}) => {
+ let text = "// than any JS-based solution and consumes fewer resources. Repeated testing to fine tune the";
+ let (snippet, charStart, charEnd) =
+ extractSnippet(~maxLength=68, ~charStart=69, ~charEnd=76, text);
+
+ expect.string(snippet).toEqual(
+ "... JS-based solution and consumes fewer resources. Repeated testing",
+ );
+ expect.int(charStart).toBe(61);
+ expect.int(charEnd).toBe(68);
+ });
+ });
+});