diff --git a/integration_test/lib/Oni_IntegrationTestLib.re b/integration_test/lib/Oni_IntegrationTestLib.re index f8f4befde3..a2f093df89 100644 --- a/integration_test/lib/Oni_IntegrationTestLib.re +++ b/integration_test/lib/Oni_IntegrationTestLib.re @@ -14,6 +14,7 @@ type testCallback = let _currentClipboard: ref(option(string)) = ref(None); let _currentTime: ref(float) = ref(0.0); let _currentZoom: ref(float) = ref(1.0); +let _currentTitle: ref(string) = ref(""); let setClipboard = v => _currentClipboard := v; let getClipboard = () => _currentClipboard^; @@ -21,6 +22,9 @@ let getClipboard = () => _currentClipboard^; let setTime = v => _currentTime := v; let getTime = () => _currentTime^; +let setTitle = title => _currentTitle := title; +let getTitle = () => _currentTitle^; + let setZoom = v => _currentZoom := v; let getZoom = () => _currentZoom^; @@ -70,6 +74,7 @@ let runTest = ~setClipboardText=text => setClipboard(Some(text)), ~getScaleFactor, ~getTime, + ~setTitle, ~getZoom, ~setZoom, ~executingDirectory=Revery.Environment.getExecutingDirectory(), diff --git a/src/Core/ConfigurationParser.re b/src/Core/ConfigurationParser.re index 99d8a2a12d..06eeed0e98 100644 --- a/src/Core/ConfigurationParser.re +++ b/src/Core/ConfigurationParser.re @@ -187,6 +187,7 @@ let configurationParsers: list(configurationTuple) = [ ), ("editor.rulers", (s, v) => {...s, editorRulers: parseIntList(v)}), ("files.exclude", (s, v) => {...s, filesExclude: parseStringList(v)}), + ("window.title", (s, v) => {...s, windowTitle: parseString(v)}), ( "workbench.activityBar.visible", (s, v) => {...s, workbenchActivityBarVisible: parseBool(v)}, diff --git a/src/Core/ConfigurationValues.re b/src/Core/ConfigurationValues.re index 3ebb45a2cc..0308810c44 100644 --- a/src/Core/ConfigurationValues.re +++ b/src/Core/ConfigurationValues.re @@ -48,6 +48,7 @@ type t = { vimUseSystemClipboard, uiShadows: bool, uiZoom: float, + windowTitle: string, zenModeHideTabs: bool, zenModeSingleFile: bool, // Experimental feature flags @@ -96,6 +97,7 @@ let default = { delete: false, paste: false, }, + windowTitle: "${dirty}${activeEditorShort}${separator}${rootName}${separator}${appName}", zenModeHideTabs: true, zenModeSingleFile: true, diff --git a/src/Model/Actions.re b/src/Model/Actions.re index 9b5f17d930..4d7249b15c 100644 --- a/src/Model/Actions.re +++ b/src/Model/Actions.re @@ -53,6 +53,7 @@ type t = | WildmenuSelect | WildmenuHide | WindowSetActive(int, int) + | WindowTitleSet(string) | WindowTreeSetSize(int, int) | EditorGroupAdd(editorGroup) | EditorGroupSetSize(int, EditorSize.t) diff --git a/src/Model/Oni_Model.re b/src/Model/Oni_Model.re index 998c4f1a9f..54c6d78f76 100644 --- a/src/Model/Oni_Model.re +++ b/src/Model/Oni_Model.re @@ -42,6 +42,7 @@ module StatusBarModel = StatusBarModel; module State = State; module SyntaxHighlighting = SyntaxHighlighting; module ThemeInfo = ThemeInfo; +module Title = Title; module WhitespaceTokenFilter = WhitespaceTokenFilter; module Wildmenu = Wildmenu; module WindowManager = WindowManager; diff --git a/src/Model/Reducer.re b/src/Model/Reducer.re index 88bd40d1ea..e0464a8f67 100644 --- a/src/Model/Reducer.re +++ b/src/Model/Reducer.re @@ -23,6 +23,7 @@ let reduce: (State.t, Actions.t) => State.t = statusBar: StatusBarReducer.reduce(s.statusBar, a), fileExplorer: FileExplorer.reduce(s.fileExplorer, a), notifications: Notifications.reduce(s.notifications, a), + workspace: Workspace.reduce(s.workspace, a), }; switch (a) { diff --git a/src/Model/State.re b/src/Model/State.re index ed951c26f9..22b7f06143 100644 --- a/src/Model/State.re +++ b/src/Model/State.re @@ -37,6 +37,9 @@ type t = { statusBar: StatusBarModel.t, windowManager: WindowManager.t, fileExplorer: FileExplorer.t, + // [windowTitle] is the title of the window + windowTitle: string, + workspace: Workspace.t, zenMode: bool, // [darkMode] describes if the UI is in 'dark' or 'light' mode. // Generally controlled by the theme. @@ -76,6 +79,8 @@ let create: unit => t = searchHighlights: SearchHighlights.create(), statusBar: StatusBarModel.create(), windowManager: WindowManager.create(), + windowTitle: "", + workspace: Workspace.empty, fileExplorer: FileExplorer.create(), zenMode: false, darkMode: true, diff --git a/src/Model/Title.re b/src/Model/Title.re new file mode 100644 index 0000000000..f3ea3758b4 --- /dev/null +++ b/src/Model/Title.re @@ -0,0 +1,78 @@ +/* + * Title.re + * + * Model for working with the window title + */ + +open Oni_Core; + +type titleSections = + | Text(string) + | Separator + | Variable(string); + +type t = list(titleSections); + +let regexp = Str.regexp("\\${\\([a-zA-Z0-9]+\\)}"); + +let ofString = str => { + let idx = ref(0); + let len = String.length(str); + let result = ref([]); + while (idx^ < len) { + switch (Str.search_forward(regexp, str, idx^)) { + | exception Not_found => + result := [Text(String.sub(str, idx^, len - idx^)), ...result^]; + idx := len; + | v => + let prev = v - idx^; + + if (prev > 0) { + result := [Text(String.sub(str, idx^, prev)), ...result^]; + }; + + let group = Str.matched_group(1, str); + idx := v + String.length(group) + 3; + if (String.equal(group, "separator")) { + result := [Separator, ...result^]; + } else { + result := [Variable(group), ...result^]; + }; + }; + }; + + result^ |> List.rev; +}; + +let _resolve = (v: t, items: StringMap.t(string)) => { + let f = section => { + switch (section) { + | Text(sz) => Some(Text(sz)) + | Separator => Some(Separator) + | Variable(sz) => + switch (StringMap.find_opt(sz, items)) { + | Some(v) => Some(Text(v)) + | None => None + } + }; + }; + + v |> List.map(f) |> Utility.filterMap(v => v); +}; + +let toString = (v: t, items: StringMap.t(string)) => { + let resolvedItems = _resolve(v, items); + + let rec f = (v: t, hadText) => { + switch (v) { + | [Separator, Text(t2), ...tail] when hadText => + " - " ++ t2 ++ f(tail, true) + | [Text(t), ...tail] => t ++ f(tail, true) + | [Separator, ...tail] => f(tail, false) + | [Variable(_v), ...tail] => f(tail, false) + | [] => "" + }; + }; + + f(resolvedItems, false); +}; diff --git a/src/Model/Workspace.re b/src/Model/Workspace.re new file mode 100644 index 0000000000..2556fff62c --- /dev/null +++ b/src/Model/Workspace.re @@ -0,0 +1,26 @@ +/* + * A workspace models the current 'ambient environment' of the editor, in particular: + * - Current directory + * + * ...and eventually + * - Open editors + * - Local modifications (hot exit) + * - Per-workspace configuration + */ + +type workspace = { + workingDirectory: string, + rootName: string, +}; + +type t = option(workspace); + +let empty: t = None; + +let reduce = (v: t, a) => { + switch (a) { + | Actions.OpenExplorer(dir) => + Some({workingDirectory: dir, rootName: Filename.basename(dir)}) + | _ => v + }; +}; diff --git a/src/Store/StoreThread.re b/src/Store/StoreThread.re index 0615e66346..da8dba2471 100644 --- a/src/Store/StoreThread.re +++ b/src/Store/StoreThread.re @@ -62,6 +62,7 @@ let start = ~setZoom, ~quit, ~getTime, + ~setTitle, ~window: option(Revery.Window.t), ~cliOptions: option(Oni_Core.Cli.t), ~getScaleFactor, @@ -143,6 +144,8 @@ let start = let inputStream = InputStoreConnector.start(getState, window, runRunEffects); + let titleUpdater = TitleStoreConnector.start(setTitle); + let (storeDispatch, storeStream) = Isolinear.Store.create( ~initialState=state, @@ -167,6 +170,7 @@ let start = acpUpdater, hoverUpdater, completionUpdater, + titleUpdater, ]), (), ); diff --git a/src/Store/TitleStoreConnector.re b/src/Store/TitleStoreConnector.re new file mode 100644 index 0000000000..b275348899 --- /dev/null +++ b/src/Store/TitleStoreConnector.re @@ -0,0 +1,77 @@ +/* + * TitleStoreConnector + * + * This implements an updater (reducer + side effects) for the window title + */ + +module Core = Oni_Core; +module Model = Oni_Model; + +module Actions = Model.Actions; + +let start = setTitle => { + let _lastTitle = ref(""); + + let getTemplateVariables: Model.State.t => Core.StringMap.t(string) = + state => { + let initialValues = [("appName", "Onivim 2")]; + + let initialValues = + switch (Model.Selectors.getActiveBuffer(state)) { + | None => initialValues + | Some(buf) => + let fp = Model.Buffer.getFilePath(buf); + let ret = + switch (fp) { + | None => initialValues + | Some(fp) => + let activeEditorShort = Filename.basename(fp); + [("activeEditorShort", activeEditorShort), ...initialValues]; + }; + switch (Model.Buffer.isModified(buf)) { + | false => ret + | true => [("dirty", "*"), ...ret] + }; + }; + + let initialValues = + switch (state.workspace) { + | None => initialValues + | Some(workspace) => [ + ("rootName", workspace.rootName), + ...initialValues, + ] + }; + + initialValues |> List.to_seq |> Core.StringMap.of_seq; + }; + + let updateTitleEffect = state => + Isolinear.Effect.createWithDispatch(~name="title.update", _dispatch => { + let templateVariables = getTemplateVariables(state); + let titleTemplate = + Core.Configuration.getValue(c => c.windowTitle, state.configuration); + + let titleModel = Model.Title.ofString(titleTemplate); + let title = Model.Title.toString(titleModel, templateVariables); + print_endline("!!! got title: " ++ title); + + if (!String.equal(_lastTitle^, title)) { + print_endline("!!! TITLE: " ++ title); + _lastTitle := title; + setTitle(title); + }; + }); + + let updater = (state: Model.State.t, action: Actions.t) => { + switch (action) { + | Init => (state, updateTitleEffect(state)) + | BufferEnter(_) => (state, updateTitleEffect(state)) + | BufferSetModified(_) => (state, updateTitleEffect(state)) + // Catch directory changes + | OpenExplorer(_) => (state, updateTitleEffect(state)) + | _ => (state, Isolinear.Effect.none) + }; + }; + updater; +}; diff --git a/src/bin_editor/Oni2_editor.re b/src/bin_editor/Oni2_editor.re index 9fce5195c5..89bd50596a 100644 --- a/src/bin_editor/Oni2_editor.re +++ b/src/bin_editor/Oni2_editor.re @@ -74,6 +74,10 @@ let init = app => { let setZoom = zoomFactor => Window.setZoom(w, zoomFactor); + let setTitle = title => { + Window.setTitle(w, title); + }; + let quit = code => { App.quit(~code, app); }; @@ -90,6 +94,7 @@ let init = app => { ~getScaleFactor, ~getZoom, ~setZoom, + ~setTitle, ~window=Some(w), ~cliOptions=Some(cliOptions), ~quit, diff --git a/test/Model/TitleTests.re b/test/Model/TitleTests.re new file mode 100644 index 0000000000..848d21770e --- /dev/null +++ b/test/Model/TitleTests.re @@ -0,0 +1,46 @@ +open TestFramework; + +open Oni_Core; + +module Title = Oni_Model.Title; + +describe("Title", ({describe, _}) => { + describe("parse", ({test, _}) => { + test("plain string", ({expect}) => { + let title = Title.ofString("abc"); + expect.equal(title, [Title.Text("abc")]); + }); + test("string with separator", ({expect}) => { + let title = Title.ofString("abc${separator}def"); + expect.equal( + title, + [Title.Text("abc"), Title.Separator, Title.Text("def")], + ); + }); + test("string with variables", ({expect}) => { + let title = Title.ofString("${variable1}${separator}${variable2}"); + expect.equal( + title, + [ + Title.Variable("variable1"), + Title.Separator, + Title.Variable("variable2"), + ], + ); + }); + }); + + describe("toString", ({test, _}) => { + let simpleMap = + [("variable1", "rv1"), ("variable2", "rv2")] + |> List.to_seq + |> StringMap.of_seq; + + test("basic case", ({expect}) => { + let title = + Title.ofString("prefix${variable1}${separator}${variable2}postfix"); + let result = Title.toString(title, simpleMap); + expect.string(result).toEqual("prefixrv1 - rv2postfix"); + }); + }); +});