diff --git a/browser/src/Editor/BufferManager.ts b/browser/src/Editor/BufferManager.ts index 6ae98f1c47..f5419aba2d 100644 --- a/browser/src/Editor/BufferManager.ts +++ b/browser/src/Editor/BufferManager.ts @@ -47,8 +47,11 @@ import { IBufferLayer } from "./NeovimEditor/BufferLayerManager" * Candidate API methods */ export interface IBuffer extends Oni.Buffer { - setLanguage(lang: string): Promise + tabstop: number + shiftwidth: number + comment: ICommentFormats + setLanguage(lang: string): Promise getLayerById(id: string): T getCursorPosition(): Promise @@ -59,6 +62,13 @@ export interface IBuffer extends Oni.Buffer { type NvimError = [1, string] +interface ICommentFormats { + start: string + middle: string + end: string + defaults: string[] +} + const isStringArray = (value: NvimError | string[]): value is string[] => { if (value && Array.isArray(value)) { return typeof value[0] === "string" @@ -100,10 +110,26 @@ export class Buffer implements IBuffer { private _version: number private _modified: boolean private _lineCount: number + private _tabstop: number + private _shiftwidth: number + private _comment: ICommentFormats + private _bufferHighlightId: BufferHighlightId = null private _promiseQueue = new PromiseQueue() + public get shiftwidth(): number { + return this._shiftwidth + } + + public get tabstop(): number { + return this._tabstop + } + + public get comment(): ICommentFormats { + return this._comment + } + public get filePath(): string { return this._filePath } @@ -435,12 +461,54 @@ export class Buffer implements IBuffer { this._modified = evt.modified this._lineCount = evt.bufferTotalLines this._cursorOffset = evt.byte + this._tabstop = evt.tabstop + this._shiftwidth = evt.shiftwidth + this._comment = this.formatCommentOption(evt.comments) this._cursor = { line: evt.line - 1, column: evt.column - 1, } } + + public formatCommentOption(comments: string): ICommentFormats { + if (!comments) { + return null + } + try { + const commentsArray = comments.split(",") + const commentFormats = commentsArray.reduce( + (acc, str) => { + const [flag, character] = str.split(":") + switch (true) { + case flag.includes("s"): + acc.start = character + return acc + case flag.includes("m"): + acc.middle = character + return acc + case flag.includes("e"): + acc.end = character + return acc + default: + acc.defaults.push(character) + return acc + } + }, + { + start: null, + middle: null, + end: null, + defaults: [], + }, + ) + + return commentFormats + } catch (e) { + Log.warn(`Error formatting neovim comment options due to ${e.message}`) + return null + } + } } // Helper for managing buffer state diff --git a/browser/src/Editor/OniEditor/IndentGuideBufferLayer.tsx b/browser/src/Editor/OniEditor/IndentGuideBufferLayer.tsx new file mode 100644 index 0000000000..5ed44595b9 --- /dev/null +++ b/browser/src/Editor/OniEditor/IndentGuideBufferLayer.tsx @@ -0,0 +1,234 @@ +import * as React from "react" + +import * as detectIndent from "detect-indent" +import * as flatten from "lodash/flatten" +import * as last from "lodash/last" +import * as memoize from "lodash/memoize" +import * as Oni from "oni-api" + +import { IBuffer } from "../BufferManager" +import { NeovimEditor } from "../NeovimEditor/NeovimEditor" +import styled, { pixel, withProps } from "./../../UI/components/common" + +interface IWrappedLine { + start: number + end: number + line: string +} + +interface IProps { + height: number + left: number + top: number + color?: string +} + +interface IndentLinesProps { + top: number + left: number + height: number + line: string + indentBy: number + characterWidth: number +} + +const Container = styled.div`` + +const IndentLine = withProps(styled.span).attrs({ + style: ({ height, left, top }: IProps) => ({ + height: pixel(height), + left: pixel(left), + top: pixel(top), + }), +})` + border-left: 1px solid ${p => p.color || "rgba(100, 100, 100, 0.4)"}; + position: absolute; +` + +interface IndentLayerArgs { + buffer: IBuffer + neovimEditor: NeovimEditor + configuration: Oni.Configuration +} + +class IndentGuideBufferLayer implements Oni.BufferLayer { + public render = memoize((bufferLayerContext: Oni.BufferLayerRenderContext) => { + return {this._renderIndentLines(bufferLayerContext)} + }) + + private _checkForComment = memoize((line: string) => { + const trimmedLine = line.trim() + const isMultiLine = Object.entries(this._comments).reduce( + (acc, [key, comment]) => { + const match = Array.isArray(comment) + ? comment.some(char => trimmedLine.startsWith(char)) + : trimmedLine.startsWith(comment) + + if (match) { + return { + isMultiline: key !== "default", + position: key, + isComment: true, + } + } + return acc + }, + { isComment: false, isMultiline: null, position: null }, + ) + return isMultiLine + }) + + private _buffer: IBuffer + private _comments: IBuffer["comment"] + private _userSpacing: number + private _configuration: Oni.Configuration + + constructor({ buffer, neovimEditor, configuration }: IndentLayerArgs) { + this._buffer = buffer + this._configuration = configuration + this._comments = this._buffer.comment + this._userSpacing = this._buffer.shiftwidth || this._buffer.tabstop + + neovimEditor.onBufferEnter.subscribe(buf => { + this._comments = (buf as IBuffer).comment + }) + } + get id() { + return "indent-guides" + } + + get friendlyName() { + return "Indent Guide Lines" + } + + private _getIndentLines = (guidePositions: IndentLinesProps[], color?: string) => { + return flatten( + guidePositions.map(({ line, height, characterWidth, indentBy, left, top }, lineNo) => { + const indentation = characterWidth * this._userSpacing + return Array.from({ length: indentBy }, (_, level) => { + const adjustedLeft = left - level * indentation - characterWidth + return ( + + ) + }) + }), + ) + } + + private _getWrappedLines(context: Oni.BufferLayerRenderContext): IWrappedLine[] { + const { lines } = context.visibleLines.reduce( + (acc, line, index) => { + const currentLine = context.topBufferLine + index + const bufferInfo = context.bufferToScreen({ line: currentLine, character: 0 }) + + if (bufferInfo && bufferInfo.screenY) { + const { screenY: screenLine } = bufferInfo + if (acc.expectedLine !== screenLine) { + acc.lines.push({ + start: acc.expectedLine, + end: screenLine, + line, + }) + acc.expectedLine = screenLine + 1 + } else { + acc.expectedLine += 1 + } + } + return acc + }, + { lines: [], expectedLine: 1 }, + ) + return lines + } + + /** + * Calculates the position of each indent guide element using shiftwidth or tabstop if no + * shift width available + * @name _renderIndentLines + * @function + * @param {Oni.BufferLayerRenderContext} bufferLayerContext The buffer layer context + * @returns {JSX.Element[]} An array of react elements + */ + private _renderIndentLines = (bufferLayerContext: Oni.BufferLayerRenderContext) => { + // FIXME: Outstanding issues - + // 1. If the beginning of the visible lines is wrapping no lines are drawn + // 2. If a line wraps but the wrapped line has no content line positions are off by one + + const wrappedScreenLines = this._getWrappedLines(bufferLayerContext) + const color = this._configuration.getValue("experimental.indentLines.color") + const { visibleLines, fontPixelHeight, fontPixelWidth, topBufferLine } = bufferLayerContext + + const { allIndentations } = visibleLines.reduce( + (acc, line, currenLineNumber) => { + const indentation = detectIndent(line) + + const previous = last(acc.allIndentations) + const height = Math.ceil(fontPixelHeight) + + // start position helps determine the initial indent offset + const startPosition = bufferLayerContext.bufferToScreen({ + line: topBufferLine, + character: indentation.amount, + }) + + const wrappedLine = wrappedScreenLines.find(wrapped => wrapped.line === line) + const levelsOfWrapping = wrappedLine ? wrappedLine.end - wrappedLine.start : 1 + const adjustedHeight = height * levelsOfWrapping + + if (!startPosition) { + return acc + } + + const { pixelX: left, pixelY: top } = bufferLayerContext.screenToPixel({ + screenX: startPosition.screenX, + screenY: currenLineNumber, + }) + + const adjustedTop = top + acc.wrappedHeightAdjustment + + // Only adjust height for Subsequent lines! + if (wrappedLine) { + acc.wrappedHeightAdjustment += adjustedHeight + } + + const { isComment, position } = this._checkForComment(line) + const inMultiLineComment = + isComment && (position === "middle" || position === "end") + + if ((!line && previous) || inMultiLineComment) { + acc.allIndentations.push({ + ...previous, + line, + top: adjustedTop, + }) + return acc + } + + const indent = { + left, + line, + top: adjustedTop, + height: adjustedHeight, + indentBy: indentation.amount / this._userSpacing, + characterWidth: fontPixelWidth, + } + + acc.allIndentations.push(indent) + + return acc + }, + { allIndentations: [], wrappedHeightAdjustment: 0 }, + ) + + return this._getIndentLines(allIndentations, color) + } +} + +export default IndentGuideBufferLayer diff --git a/browser/src/Editor/OniEditor/OniEditor.tsx b/browser/src/Editor/OniEditor/OniEditor.tsx index a3e1e76666..50ff0364d6 100644 --- a/browser/src/Editor/OniEditor/OniEditor.tsx +++ b/browser/src/Editor/OniEditor/OniEditor.tsx @@ -51,7 +51,9 @@ import { NeovimEditor } from "./../NeovimEditor" import { SplitDirection, windowManager } from "./../../Services/WindowManager" +import { IBuffer } from "../BufferManager" import { ImageBufferLayer } from "./ImageBufferLayer" +import IndentLineBufferLayer from "./IndentGuideBufferLayer" // Helper method to wrap a react component into a layer const wrapReactComponentWithLayer = (id: string, component: JSX.Element): Oni.BufferLayer => { @@ -170,11 +172,24 @@ export class OniEditor extends Utility.Disposable implements IEditor { wrapReactComponentWithLayer("oni.layer.errors", ), ) - const extensions = this._configuration.getValue("editor.imageLayerExtensions") + const imageExtensions = this._configuration.getValue("editor.imageLayerExtensions") + const indentExtensions = this._configuration.getValue("experimental.indentLines.filetypes") this._neovimEditor.bufferLayers.addBufferLayer( - buf => extensions.includes(path.extname(buf.filePath)), + buf => imageExtensions.includes(path.extname(buf.filePath)), buf => new ImageBufferLayer(buf), ) + + if (this._configuration.getValue("experimental.indentLines.enabled")) { + this._neovimEditor.bufferLayers.addBufferLayer( + buf => indentExtensions.includes(path.extname(buf.filePath)), + buffer => + new IndentLineBufferLayer({ + buffer: buffer as IBuffer, + configuration: this._configuration, + neovimEditor: this._neovimEditor, + }), + ) + } } public dispose(): void { diff --git a/browser/src/Services/Configuration/DefaultConfiguration.ts b/browser/src/Services/Configuration/DefaultConfiguration.ts index 2b9dc36174..fe31e082d9 100644 --- a/browser/src/Services/Configuration/DefaultConfiguration.ts +++ b/browser/src/Services/Configuration/DefaultConfiguration.ts @@ -57,6 +57,21 @@ const BaseConfiguration: IConfigurationValues = { "experimental.preview.enabled": false, "experimental.welcome.enabled": false, + "experimental.indentLines.enabled": false, + "experimental.indentLines.color": null, + "experimental.indentLines.filetypes": [ + ".tsx", + ".ts", + ".jsx", + ".js", + ".go", + ".re", + ".py", + ".c", + ".cc", + ".lua", + ".java", + ], "experimental.markdownPreview.enabled": false, "experimental.markdownPreview.autoScroll": true, diff --git a/browser/src/Services/Configuration/IConfigurationValues.ts b/browser/src/Services/Configuration/IConfigurationValues.ts index b760f827c0..c9b0e04b5e 100644 --- a/browser/src/Services/Configuration/IConfigurationValues.ts +++ b/browser/src/Services/Configuration/IConfigurationValues.ts @@ -49,6 +49,11 @@ export interface IConfigurationValues { // Whether or not the learning pane is available "experimental.particles.enabled": boolean + // Whether the indent lines should be shown + "experimental.indentLines.enabled": boolean + "experimental.indentLines.color": string + // Filetypes the indent lines are shown for + "experimental.indentLines.filetypes": string[] // Whether the markdown preview pane should be shown "experimental.markdownPreview.enabled": boolean "experimental.markdownPreview.autoScroll": boolean diff --git a/browser/src/neovim/EventContext.ts b/browser/src/neovim/EventContext.ts index a58629942a..a9a4329d97 100644 --- a/browser/src/neovim/EventContext.ts +++ b/browser/src/neovim/EventContext.ts @@ -28,6 +28,9 @@ export interface EventContext { windowBottomLine: number windowWidth: number windowHeight: number + tabstop: number + shiftwidth: number + comments: string } export interface InactiveBufferContext { diff --git a/browser/test/Editor/NeovimEditor/BufferManagerTest.ts b/browser/test/Editor/NeovimEditor/BufferManagerTest.ts new file mode 100644 index 0000000000..cf2e72684e --- /dev/null +++ b/browser/test/Editor/NeovimEditor/BufferManagerTest.ts @@ -0,0 +1,80 @@ +import * as assert from "assert" + +import { BufferManager, InactiveBuffer } from "./../../../src/Editor/BufferManager" + +describe("Buffer Manager Tests", () => { + const neovim = {} as any + const actions = {} as any + const store = {} as any + const manager = new BufferManager(neovim, actions, store) + const event = { + bufferFullPath: "/test/file", + bufferTotalLines: 2, + bufferNumber: 1, + modified: false, + hidden: false, + listed: true, + version: 1, + line: 0, + column: 0, + byte: 8, + filetype: "js", + tabNumber: 1, + windowNumber: 1, + wincol: 10, + winline: 25, + windowTopLine: 0, + windowBottomLine: 200, + windowWidth: 100, + windowHeight: 100, + tabstop: 8, + shiftwidth: 2, + comments: "://,ex:*/", + } + + const inactive1 = { + bufferNumber: 2, + bufferFullPath: "/test/two", + filetype: "js", + buftype: "", + modified: false, + hidden: false, + listed: true, + version: 1, + } + + it("Should correctly set buffer variables", () => { + manager.updateBufferFromEvent(event) + const buffer = manager.getBufferById("1") + assert(buffer.tabstop === 8, "tabstop is set correctly") + assert(buffer.shiftwidth === 2, "shiftwidth is set correctly") + assert(buffer.comment.defaults.includes("//"), "comments are set correctly") + assert(buffer.comment.end.includes("*/"), "comments are set correctly") + }) + + it("Should correctly populate the buffer list", () => { + manager.updateBufferFromEvent(event) + manager.populateBufferList({ + current: event, + existingBuffers: [inactive1], + }) + + const buffers = manager.getBuffers() + assert(buffers.length === 2, "Two buffers were added") + assert( + buffers.find(buffer => buffer instanceof InactiveBuffer), + "One of the buffers is an inactive buffer", + ) + }) + + it("Should correctly format a comment string (based on neovim &comment option)", () => { + manager.updateBufferFromEvent(event) + const buffer = manager.getBufferById("1") + const comment = "s1:/*,ex:*/,://,b:#,:%" + const formatted = buffer.formatCommentOption(comment) + assert(formatted.start.includes("/*"), "Correctly parses a comment string") + assert(formatted.end.includes("*/"), "Correctly parses a comment string") + assert(formatted.defaults.includes("//"), "Correctly parses a comment string") + assert(formatted.defaults.includes("#"), "Correctly parses a comment string") + }) +}) diff --git a/test/CiTests.ts b/test/CiTests.ts index e125f713bb..c4a74aeed9 100644 --- a/test/CiTests.ts +++ b/test/CiTests.ts @@ -57,6 +57,7 @@ const CiTests = [ "TextmateHighlighting.DebugScopesTest", "TextmateHighlighting.ScopesOnEnterTest", "TextmateHighlighting.TokenColorOverrideTest", + "IndentGuide.BufferLayerTest", "Theming.LightAndDarkColorsTest", diff --git a/test/ci/IndentGuide.BufferLayerTest.tsx b/test/ci/IndentGuide.BufferLayerTest.tsx new file mode 100644 index 0000000000..fdbe82234f --- /dev/null +++ b/test/ci/IndentGuide.BufferLayerTest.tsx @@ -0,0 +1,63 @@ +/** + * Test script for the Indent Lines Layer. + */ + +import * as assert from "assert" +import * as os from "os" +import * as path from "path" + +import * as Oni from "oni-api" + +import { createNewFile } from "./Common" + +const testStr = ` + function doThing() { + thingOne(); + thingTwo(); + } + + const X = a + b + const Y = c - d +` + +const getIndentLines = () => document.querySelectorAll("[data-id='indent-line']") + +export const test = async (oni: Oni.Plugin.Api) => { + await oni.automation.waitForEditors() + + await createNewFile("js", oni, testStr) + + const elements = getIndentLines() + + const element = elements[0] + + assert.ok(element, "Validate an indent line is present in the layer") + + // Render the same test string in an incompatible buffer and plugin should not render + await createNewFile("md", oni, testStr) + const markdownIndents = getIndentLines() + + assert.ok(markdownIndents.length === 0, "No indents are rendered in an incompatible file") +} + +export const settings = { + config: { + "oni.useDefaultConfig": true, + "oni.loadInitVim": false, + "experimental.indentLines.enabled": true, + "experimental.indentLines.filetypes": [ + ".tsx", + ".ts", + ".jsx", + ".js", + ".go", + ".re", + ".py", + ".c", + ".cc", + ".lua", + ".java", + ], + "_internal.hasCheckedInitVim": true, + }, +} diff --git a/vim/core/oni-core-interop/plugin/init.vim b/vim/core/oni-core-interop/plugin/init.vim index 1631a77040..c18598c37f 100644 --- a/vim/core/oni-core-interop/plugin/init.vim +++ b/vim/core/oni-core-interop/plugin/init.vim @@ -172,6 +172,9 @@ let context.filetype = eval("&filetype") let context.modified = &modified let context.hidden = &hidden let context.listed = &buflisted +let context.tabstop = &tabstop +let context.shiftwidth = shiftwidth() +let context.comments = &comments if exists("b:last_change_tick") let context.version = b:last_change_tick