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); + }); + }); +});