From fc3f71170f4c18d2c081e3efe0ee5f91922e8bbb Mon Sep 17 00:00:00 2001 From: Dave MacFarlane Date: Mon, 16 May 2016 14:31:08 -0400 Subject: [PATCH] Initial release --- LICENSE | 21 +++ README.md | 38 +++++ USAGE.md | 70 ++++++++++ actions/deletecursor.go | 55 ++++++++ actions/execute.go | 110 +++++++++++++++ actions/find.go | 84 +++++++++++ actions/insertcursor.go | 43 ++++++ actions/movecursor.go | 25 ++++ actions/register.go | 49 +++++++ actions/savefile.go | 21 +++ demodel/model.go | 36 +++++ kbmap/deletemode.go | 75 ++++++++++ kbmap/insertmode.go | 116 +++++++++++++++ kbmap/kbmap.go | 42 ++++++ kbmap/normalmode.go | 189 +++++++++++++++++++++++++ main.go | 295 +++++++++++++++++++++++++++++++++++++++ position/positions.go | 202 +++++++++++++++++++++++++++ renderer/colors.go | 18 +++ renderer/gosyntax.go | 302 ++++++++++++++++++++++++++++++++++++++++ renderer/init.go | 48 +++++++ renderer/nosyntax.go | 115 +++++++++++++++ 21 files changed, 1954 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 USAGE.md create mode 100644 actions/deletecursor.go create mode 100644 actions/execute.go create mode 100644 actions/find.go create mode 100644 actions/insertcursor.go create mode 100644 actions/movecursor.go create mode 100644 actions/register.go create mode 100644 actions/savefile.go create mode 100644 demodel/model.go create mode 100644 kbmap/deletemode.go create mode 100644 kbmap/insertmode.go create mode 100644 kbmap/kbmap.go create mode 100644 kbmap/normalmode.go create mode 100644 main.go create mode 100644 position/positions.go create mode 100644 renderer/colors.go create mode 100644 renderer/gosyntax.go create mode 100644 renderer/init.go create mode 100644 renderer/nosyntax.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dda8336 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Dave MacFarlane + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7cfd124 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# The de Editor + +de is a programmer's editor, where that programmer happens to be [driusan](https://github.com/driusan/). + +It's kind of like a bastard child of vim and Plan 9's ACME editor, because vim feels inadequate on a +computer with a mouse after using acme, and acme feels inadequate on a computer with a keyboard after +using vi. + +Like vim, it's a modal editor with syntax highlighting that uses hjkl for movement. +Like acme, it attempts to exploit your current environment instead of replacing it and tries to +make the mouse useful. + +See [USAGE.md](USAGE.md) for usage instructions. + +## Features + +* Syntax highlighting (currently Go only) +* vi-like keybindings and philosophy +* acme-like mouse bindings and philosophy + + +## Limitations and Bugs + +* vi-like functionality not fully implemented (most notably repeating a command by prefixing it + with a number, and some movement verbs like '%' are missing.) +* Can not open multiple files/windows at a time. (if your workflow is like mine, it means you often + save and quit, do something in the shell, and then relaunch your editor. The startup time should + be fast enough to support this style of workflow.) +* Missing acme style window tag to use as a scratch space. + +# Installation + +It should be installable with the standard go tools: + +``` +go get github.com/driusan/de +``` + diff --git a/USAGE.md b/USAGE.md new file mode 100644 index 0000000..0dc63cf --- /dev/null +++ b/USAGE.md @@ -0,0 +1,70 @@ +# de Usage Instructions + +## Basic Usage +de is a modal editor that starts in normal mode. The three modes currently implemented are +Normal (biege background), Insert (light green background), and Delete mode (light red/pink +background). The keybindings are inspired by, but not identical to, vi. + +In Normal and Delete modes, the movement commands are similar to vi. hjkl move the cursor. +Shift-6 (^) moves the cursor to the start of the line, and Shift-4 ($) moves the cursor to the +end of the line, G moves to end of file (or a line number if you type the line number before hitting +G). w moves the cursor to the next word. In Delete mode it will delete from the current cursor up +to that point, and in Normal mode it will either move to, or select up to that point depending on if +the ctrl key is pressed. Other movement commands will be implemented, but that's all I've done up to now. +Unlike in vi, the h and l keys do not stop at line boundaries. p will insert the most recently +delete text, replacing the currently selected text. + +Generally, when I find myself typing a keystroke out of muscle-memory enough times as a long +time vi-user, I implement it, or a close enough approximation to it, here. Ranges with a repeat +(ie 3dw) are not yet implemented, but will be eventually. + +In Normal mode you can enter Insert mode by pressing 'i' or Delete mode by pressing 'd' and +the background colour should change to indicate the current mode. + +In Insert mode, the arrow keys take on the same meaning as hjkl in Normal mode, and Escape will +return to normal mode. Any other key combination that results in a printable unicode character +being sent to de will insert the utf8 encoding of that character at the current location of the +file. In all other modes, the arrow keys scroll the viewport without adjusting the cursor. + +In all modes, backspace will delete the currently selected text (or previous character if nothing +is selected) without changing the mode. + +Pressing the Escape key will save the current file and exit. *NOTE TO VI USERS: RE-READ THE LAST +SENTENCE* + +## Mouse Usage + +While the keyboard usage is inspired by vim, the mouse usage is inspired by acme. +The mouse works the same way regardless of keyboard mode. + +Clicking anywhere with the left mouse button will move the text cursor or select text. + +Clicking with the right mouse button will search for the next instance of the word clicked on +and select the next instance found. If the word is a filename, changes to the current file will be +discarded and the clicked file will be opened in the current window. The keyboard equivalent +for the currently selected text is the slash key (although slash will not open a file.) + +Clicking with the middle mouse button will "execute" the word clicked on. (see below.) + +Chording will probably eventually work similarly to acme, but isn't yet implemented. + +## Executing Words + +Words that are selected or clicked on can be "executed" to control the editor, either by +selecting the word and then pressing the Enter key, or by clicking with the middle mouse button. +(When executing with the keyboard, it will first check if the file exists and open it if applicable, +similarly to searching with the mouse.) + +If executing a point in a word instead of a selection, that word will be executed. + +If the word is an internal editor command, it will perform that command. Otherwise, the shell command +will be executed and the output will replace the currently selected text. + +Currently understood commands: +Get (or Discard): Reload the current file from disk and discard changes +Put (or Save): Save the current character buffer to disk, overwriting the existing file. +Exit (or Quit): Quit de, discarding any changes. + +To be implemented: +Copy (or Snarf), Cut, Paste, +ACME style tag/Scratch line diff --git a/actions/deletecursor.go b/actions/deletecursor.go new file mode 100644 index 0000000..0a49755 --- /dev/null +++ b/actions/deletecursor.go @@ -0,0 +1,55 @@ +package actions + +import ( + "github.com/driusan/de/demodel" +) + +func DeleteCursor(From, To demodel.Position, buff *demodel.CharBuffer) { + if buff == nil { + return + } + + dot := demodel.Dot{} + i, err := From(*buff) + if err != nil { + return + } + dot.Start = i + + i, err = To(*buff) + if err != nil { + return + } + dot.End = i + if dot.Start == dot.End { + // nothing selected, so delete the previous character + + if dot.Start == 0 { + // can't delete past the beginning + return + } + + buff.Buffer = append( + buff.Buffer[:dot.Start-1], buff.Buffer[dot.Start:]..., + ) + + // now adjust dot if it was inside the deleted range.. + if buff.Dot.Start == dot.Start { + buff.Dot.Start -= 1 + buff.Dot.End = buff.Dot.Start + } + return + } else { + // delete the selected text. + buff.SnarfBuffer = make([]byte, dot.End-dot.Start) + copy(buff.SnarfBuffer, buff.Buffer[dot.Start:dot.End]) + + buff.Buffer = append(buff.Buffer[:dot.Start], buff.Buffer[dot.End:]...) + + // now adjust dot if it was inside the deleted range.. + if buff.Dot.Start >= dot.Start && buff.Dot.End <= dot.End { + buff.Dot.Start = dot.Start + buff.Dot.End = buff.Dot.Start + } + } +} diff --git a/actions/execute.go b/actions/execute.go new file mode 100644 index 0000000..a77e3cb --- /dev/null +++ b/actions/execute.go @@ -0,0 +1,110 @@ +package actions + +import ( + "fmt" + "github.com/driusan/de/demodel" + "io/ioutil" + "os" + "os/exec" +) + +func PerformAction(From, To demodel.Position, buff *demodel.CharBuffer) { + if buff == nil { + return + } + + dot := demodel.Dot{} + i, err := From(*buff) + if err != nil { + return + } + + dot.Start = i + i, err = To(*buff) + if err != nil { + return + } + dot.End = i + + cmd := string(buff.Buffer[dot.Start : dot.End+1]) + runOrExec(cmd, buff) +} + +func OpenOrPerformAction(From, To demodel.Position, buff *demodel.CharBuffer) { + if buff == nil { + return + } + + dot := demodel.Dot{} + i, err := From(*buff) + if err != nil { + return + } + + dot.Start = i + i, err = To(*buff) + if err != nil { + return + } + dot.End = i + + var cmd string + if dot.End+1 >= uint(len(buff.Buffer)) { + cmd = string(buff.Buffer[dot.Start:]) + } else { + cmd = string(buff.Buffer[dot.Start : dot.End+1]) + } + + if _, err := os.Stat(cmd); err == nil { + // the file exists, so open it + b, ferr := ioutil.ReadFile(cmd) + if ferr != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + return + } + buff.Buffer = b + buff.Filename = cmd + buff.Dot.Start = 0 + buff.Dot.End = 0 + return + } + runOrExec(cmd, buff) +} + +func runOrExec(cmd string, buff *demodel.CharBuffer) { + + if f, ok := actions[cmd]; ok { + // it was an internal command, so run it. + f("", buff) + return + } + + // it wasn't an internal command, so run the shell command + gocmd := exec.Command(cmd) + stdout, err := gocmd.StdoutPipe() + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + return + } + if err := gocmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + return + } + output, err := ioutil.ReadAll(stdout) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + return + } + if err := gocmd.Wait(); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + } + // nothing selected, so insert at dot.Start + newBuffer := make([]byte, len(buff.Buffer)-int((buff.Dot.End-buff.Dot.Start))+len(output)) + copy(newBuffer, buff.Buffer) + copy(newBuffer[buff.Dot.Start:], output) + copy(newBuffer[int(buff.Dot.Start)+len(output):], buff.Buffer[buff.Dot.End:]) + + buff.Buffer = newBuffer + + fmt.Printf("Output: %s\n", string(output)) +} diff --git a/actions/find.go b/actions/find.go new file mode 100644 index 0000000..6f0629c --- /dev/null +++ b/actions/find.go @@ -0,0 +1,84 @@ +package actions + +import ( + "fmt" + "github.com/driusan/de/demodel" + "io/ioutil" + "os" +) + +// Changes Dot to next instance of the character sequence between From +// and To +func FindNext(From, To demodel.Position, buff *demodel.CharBuffer) { + if buff == nil { + return + } + dot := demodel.Dot{} + i, err := From(*buff) + if err != nil { + return + } + dot.Start = i + + i, err = To(*buff) + if err != nil { + return + } + dot.End = i + 1 + + word := string(buff.Buffer[dot.Start:dot.End]) + lenword := dot.End - dot.Start + for i := dot.End; i < uint(len(buff.Buffer))-lenword; i++ { + if string(buff.Buffer[i:i+lenword]) == word { + buff.Dot.Start = i + buff.Dot.End = i + lenword - 1 + return + } + } + +} + +func FindNextOrOpen(From, To demodel.Position, buff *demodel.CharBuffer) { + if buff == nil { + return + } + dot := demodel.Dot{} + i, err := From(*buff) + if err != nil { + return + } + dot.Start = i + + i, err = To(*buff) + if err != nil { + return + } + dot.End = i + 1 + + word := string(buff.Buffer[dot.Start:dot.End]) + + fmt.Printf("Word: %s\n", word) + if _, err := os.Stat(word); err == nil { + // the file exists, so open it + b, ferr := ioutil.ReadFile(word) + if ferr != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + return + } + buff.Buffer = b + buff.Filename = word + buff.Dot.Start = 0 + buff.Dot.End = 0 + return + } + + // the file doesn't exist, so find the next instance of word. + lenword := dot.End - dot.Start + for i := dot.End; i < uint(len(buff.Buffer))-lenword; i++ { + if string(buff.Buffer[i:i+lenword]) == word { + buff.Dot.Start = i + buff.Dot.End = i + lenword - 1 + return + } + } +} diff --git a/actions/insertcursor.go b/actions/insertcursor.go new file mode 100644 index 0000000..e49df1c --- /dev/null +++ b/actions/insertcursor.go @@ -0,0 +1,43 @@ +package actions + +import ( + "github.com/driusan/de/demodel" +) + +func InsertSnarfBuffer(From, To demodel.Position, buff *demodel.CharBuffer) { + if buff == nil { + return + } + + dot := demodel.Dot{} + i, err := From(*buff) + if err != nil { + return + } + dot.Start = i + + i, err = To(*buff) + if err != nil { + return + } + dot.End = i + // inserting at the start of the file. + if dot.End == 0 { + newBuffer := make([]byte, len(buff.Buffer)+len(buff.SnarfBuffer)) + copy(newBuffer, buff.SnarfBuffer) + copy(newBuffer[len(buff.SnarfBuffer):], buff.Buffer) + + buff.Buffer = newBuffer + buff.Dot.Start = 0 + buff.Dot.End = buff.Dot.Start + } else { + newBuffer := make([]byte, len(buff.Buffer)+len(buff.SnarfBuffer)-int(buff.Dot.End-buff.Dot.Start)) + copy(newBuffer, buff.Buffer) + copy(newBuffer[buff.Dot.Start:], buff.SnarfBuffer) + copy(newBuffer[buff.Dot.Start+uint(len(buff.SnarfBuffer)):], buff.Buffer[buff.Dot.End:]) + + buff.Buffer = newBuffer + buff.Dot.End = buff.Dot.Start + uint(len(buff.SnarfBuffer)) + buff.Dot.Start = buff.Dot.End + } +} diff --git a/actions/movecursor.go b/actions/movecursor.go new file mode 100644 index 0000000..51fbeab --- /dev/null +++ b/actions/movecursor.go @@ -0,0 +1,25 @@ +package actions + +import ( + "github.com/driusan/de/demodel" +) + +func MoveCursor(From, To demodel.Position, buff *demodel.CharBuffer) { + if buff == nil { + return + } + dot := demodel.Dot{} + i, err := From(*buff) + if err != nil { + return + } + dot.Start = i + + i, err = To(*buff) + if err != nil { + return + } + dot.End = i + buff.Dot = dot + +} diff --git a/actions/register.go b/actions/register.go new file mode 100644 index 0000000..023ad68 --- /dev/null +++ b/actions/register.go @@ -0,0 +1,49 @@ +package actions + +import ( + "fmt" + "github.com/driusan/de/demodel" + "io/ioutil" + "os" +) + +func init() { + actions = make(map[string]func(string, *demodel.CharBuffer)) + + RegisterAction("Put", Put) + RegisterAction("Save", Put) + + RegisterAction("Get", Get) + RegisterAction("Discard", Get) + + RegisterAction("Exit", Quit) + RegisterAction("Quit", Quit) +} + +var actions map[string]func(string, *demodel.CharBuffer) + +func RegisterAction(cmd string, f func(string, *demodel.CharBuffer)) { + actions[cmd] = f + +} + +// Some default actions. +func Put(args string, buff *demodel.CharBuffer) { + SaveFile(nil, nil, buff) +} + +func Get(args string, buff *demodel.CharBuffer) { + if buff == nil { + return + } + b, err := ioutil.ReadFile(buff.Filename) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + return + } + buff.Buffer = b +} + +func Quit(args string, buff *demodel.CharBuffer) { + os.Exit(0) +} diff --git a/actions/savefile.go b/actions/savefile.go new file mode 100644 index 0000000..2566ddf --- /dev/null +++ b/actions/savefile.go @@ -0,0 +1,21 @@ +package actions + +import ( + "fmt" + "github.com/driusan/de/demodel" + "io/ioutil" + "os" +) + +func SaveFile(From, To demodel.Position, buff *demodel.CharBuffer) { + if buff == nil || buff.Filename == "" { + return + } + + // we don't care about positions, just write the file + err := ioutil.WriteFile(buff.Filename, buff.Buffer, 0644) + if err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + } + +} diff --git a/demodel/model.go b/demodel/model.go new file mode 100644 index 0000000..25d9485 --- /dev/null +++ b/demodel/model.go @@ -0,0 +1,36 @@ +package demodel + +// Dot generally represents a selection or position in a buffer. +// It holds the start and the end of the selection. +// If Start==End, it's a position. +type Dot struct { + Start, End uint +} + +// A CharBuffer is a set of bytes being manipulated. Generally, +// a text file. +type CharBuffer struct { + Buffer []byte + Dot Dot + Filename string + + // The most recently deleted text, which can be pasted + SnarfBuffer []byte +} + +// An action performs some sort of action to a character buffer, +// which would generally involve modifying it in some way. +type Action func(From, To Position, buf *CharBuffer) error + +// A position calculates the index into a CharBuffer for +// something to use as a reference, generally to perform +// an action on it. For instance, the position of the +// start of the previous word, or the next paragraph, +// or the containing block of the cursor. +type Position func(buf CharBuffer) (uint, error) + +// Renders the character buffer to a string which can +// be displayed on a non-graphical terminal. +type TermRender interface { + Render(CharBuffer) (string, error) +} diff --git a/kbmap/deletemode.go b/kbmap/deletemode.go new file mode 100644 index 0000000..c5e3e1a --- /dev/null +++ b/kbmap/deletemode.go @@ -0,0 +1,75 @@ +package kbmap + +import ( + "github.com/driusan/de/actions" + "github.com/driusan/de/demodel" + "github.com/driusan/de/position" + "golang.org/x/mobile/event/key" +) + +func deleteMap(e key.Event, buff *demodel.CharBuffer) (Map, error) { + // things only happen on key press in normal mode, if it's a release + // or a repeat, ignore it. It's not an error + if e.Direction != key.DirPress { + return DeleteMode, nil + } + if buff == nil { + return NormalMode, Invalid + } + switch e.Code { + case key.CodeEscape: + return NormalMode, nil + case key.CodeDeleteBackspace: + if e.Direction == key.DirPress { + actions.DeleteCursor(position.DotStart, position.DotEnd, buff) + } + return NormalMode, nil + case key.CodeK: + actions.DeleteCursor(position.PrevLine, position.DotEnd, buff) + return NormalMode, nil + case key.CodeH: + actions.DeleteCursor(position.PrevChar, position.DotEnd, buff) + return NormalMode, nil + case key.CodeL: + actions.DeleteCursor(position.DotStart, position.NextChar, buff) + return NormalMode, nil + case key.CodeJ: + actions.DeleteCursor(position.DotStart, position.NextLine, buff) + return NormalMode, nil + case key.CodeX: + actions.DeleteCursor(position.DotStart, position.NextChar, buff) + return NormalMode, nil + case key.CodeW: + actions.DeleteCursor(position.DotStart, position.NextWordStart, buff) + case key.CodeRightArrow: + return DeleteMode, ScrollRight + case key.CodeLeftArrow: + return DeleteMode, ScrollLeft + case key.CodeDownArrow: + return DeleteMode, ScrollDown + case key.CodeUpArrow: + return DeleteMode, ScrollUp + case key.Code4: + // $ is pressed, in most key maps.. + if e.Modifiers&key.ModShift != 0 { + actions.DeleteCursor(position.DotStart, position.EndOfLine, buff) + } + return NormalMode, nil + case key.Code6: + // ^ is pressed, on most keyboards.. + if e.Modifiers&key.ModShift != 0 { + actions.DeleteCursor(position.StartOfLine, position.DotEnd, buff) + } + return NormalMode, nil + case key.CodeG: + // capital G + if e.Modifiers&key.ModShift != 0 { + actions.MoveCursor(position.DotStart, position.BuffEnd, buff) + } + return NormalMode, nil + case key.CodeD: + actions.DeleteCursor(position.StartOfLine, position.EndOfLine, buff) + return NormalMode, nil + } + return DeleteMode, Invalid +} diff --git a/kbmap/insertmode.go b/kbmap/insertmode.go new file mode 100644 index 0000000..296e025 --- /dev/null +++ b/kbmap/insertmode.go @@ -0,0 +1,116 @@ +package kbmap + +import ( + "github.com/driusan/de/actions" + "github.com/driusan/de/demodel" + "github.com/driusan/de/position" + "golang.org/x/mobile/event/key" + "unicode" + "unicode/utf8" +) + +func insertMap(e key.Event, buff *demodel.CharBuffer) (Map, error) { + // special cases for Insert Mode + switch e.Code { + case key.CodeEscape: + if e.Direction == key.DirPress { + return NormalMode, nil + } + case key.CodeDeleteBackspace: + if e.Direction == key.DirPress { + if e.Direction == key.DirPress { + actions.DeleteCursor(position.DotStart, position.DotEnd, buff) + } + return InsertMode, nil + } + case key.CodeLeftArrow: + if e.Direction == key.DirPress { + if e.Modifiers&key.ModControl != 0 { + // ctrl is pressed, so just move the start and expand dot + actions.MoveCursor(position.PrevChar, position.DotEnd, buff) + } else { + // ctrl is not pressed, so move the cursor without selecting + actions.MoveCursor(position.PrevChar, position.PrevChar, buff) + } + } + return InsertMode, nil + case key.CodeRightArrow: + if e.Direction == key.DirPress { + if e.Modifiers&key.ModControl != 0 { + // ctrl is pressed, so just move the end and expand dot + actions.MoveCursor(position.DotStart, position.NextChar, buff) + } else { + // ctrl is not pressed, so move the cursor without selecting + actions.MoveCursor(position.NextChar, position.NextChar, buff) + } + return InsertMode, nil + } + case key.CodeDownArrow: + if e.Direction == key.DirPress { + if e.Modifiers&key.ModControl != 0 { + // ctrl is pressed, so just move the end and expand dot + actions.MoveCursor(position.DotStart, position.NextLine, buff) + } else { + // ctrl is not pressed, so move the cursor without selecting + actions.MoveCursor(position.NextLine, position.NextLine, buff) + } + } + return InsertMode, nil + + case key.CodeUpArrow: + if e.Direction == key.DirPress { + if e.Modifiers&key.ModControl != 0 { + // ctrl is pressed, so just move the start and expand dot + actions.MoveCursor(position.PrevLine, position.DotEnd, buff) + } else { + // ctrl is not pressed, so move the cursor without selecting + actions.MoveCursor(position.PrevLine, position.PrevLine, buff) + } + } + return InsertMode, nil + } + + // These events don't seem to have the rune set properly, so add it as a hack. + if e.Code == key.CodeReturnEnter { + e.Rune = '\n' + } + if e.Code == key.CodeTab { + e.Rune = '\t' + } + + if e.Direction != key.DirPress { + // add the character if it's a key release or a repeat, but not + // if it's being released. For some reason, release seems more reliable + // than press when typing fast. + return InsertMode, nil + } + + // unicode.IsPrint is selective about what whitespace it considers printable. + if !unicode.IsPrint(e.Rune) && e.Rune != '\n' && e.Rune != '\t' { + // if it's not a printable character, don't insert it. We also + // receive key events on things like shift being pressed.. + return InsertMode, nil + } + // in insert the rune at the current position, overwriting Dot if applicable + + runeBytes := make([]byte, 4) + i := utf8.EncodeRune(runeBytes, e.Rune) + + // inserting at the start of the file. + if buff.Dot.End == 0 { + buff.Buffer = append(runeBytes[:i], buff.Buffer...) + buff.Dot.Start = uint(i) + buff.Dot.End = buff.Dot.Start + } else { + newBuffer := make([]byte, len(buff.Buffer)+i) + copy(newBuffer, buff.Buffer) + copy(newBuffer[buff.Dot.Start:], runeBytes[:i]) + copy(newBuffer[buff.Dot.Start+uint(i):], buff.Buffer[buff.Dot.End:]) + //copy(newBuffer[:buff.Dot.Start+uint(i)+1], buff.Buffer[buff.Dot.End:]) + + buff.Buffer = newBuffer + buff.Dot.Start += uint(i) + buff.Dot.End = buff.Dot.Start + } + return InsertMode, nil +} diff --git a/kbmap/kbmap.go b/kbmap/kbmap.go new file mode 100644 index 0000000..ecec3c6 --- /dev/null +++ b/kbmap/kbmap.go @@ -0,0 +1,42 @@ +package kbmap + +import ( + "errors" + "github.com/driusan/de/demodel" + "golang.org/x/mobile/event/key" +) + +// A Map maps a keystroke to a command. It performs a command, and then +// returns a new map which represents the keyboard mapping to be used +// for the next keystroke. +type Map interface { + HandleKey(key.Event, *demodel.CharBuffer) (Map, error) +} + +var Invalid error = errors.New("Invalid keyboard map.") +var ExitProgram error = errors.New("Keystroke wants to exit the program.") +var ScrollDown error = errors.New("Keystroke wants to scroll the window down.") +var ScrollUp error = errors.New("Keystroke wants to scroll the window up.") +var ScrollLeft error = errors.New("Keystroke wants to scroll the window left.") +var ScrollRight error = errors.New("Keystroke wants to scroll the window right.") + +type defaultMaps uint + +const ( + NormalMode = defaultMaps(iota) + InsertMode + DeleteMode +) + +func (m defaultMaps) HandleKey(e key.Event, buff *demodel.CharBuffer) (Map, error) { + switch m { + case NormalMode: + return normalMap(e, buff) + case InsertMode: + return insertMap(e, buff) + case DeleteMode: + return deleteMap(e, buff) + } + return nil, Invalid + +} diff --git a/kbmap/normalmode.go b/kbmap/normalmode.go new file mode 100644 index 0000000..9531c4f --- /dev/null +++ b/kbmap/normalmode.go @@ -0,0 +1,189 @@ +package kbmap + +import ( + "fmt" + "github.com/driusan/de/actions" + "github.com/driusan/de/demodel" + "github.com/driusan/de/position" + "golang.org/x/mobile/event/key" +) + +var Repeat uint + +func normalMap(e key.Event, buff *demodel.CharBuffer) (Map, error) { + // things only happen on key press in normal mode, if it's a release + // or a repeat, ignore it. It's not an error + if e.Direction != key.DirPress { + return NormalMode, nil + } + if buff == nil { + return NormalMode, Invalid + } + switch e.Code { + case key.CodeEscape: + actions.SaveFile(position.BuffStart, position.BuffEnd, buff) + return NormalMode, ExitProgram + case key.CodeDeleteBackspace: + if e.Direction == key.DirPress { + actions.DeleteCursor(position.DotStart, position.DotEnd, buff) + } + return NormalMode, nil + case key.CodeI: + return InsertMode, nil + case key.CodeK: + if e.Modifiers&key.ModControl != 0 { + // ctrl is pressed, so just move the start and expand dot + actions.MoveCursor(position.PrevLine, position.DotEnd, buff) + } else { + // ctrl is not pressed, so move the cursor without selecting + actions.MoveCursor(position.PrevLine, position.PrevLine, buff) + } + return NormalMode, nil + case key.CodeH: + if e.Modifiers&key.ModControl != 0 { + // ctrl is pressed, so just move the start and expand dot + actions.MoveCursor(position.PrevChar, position.DotEnd, buff) + } else { + // ctrl is not pressed, so move the cursor without selecting + actions.MoveCursor(position.PrevChar, position.PrevChar, buff) + } + return NormalMode, nil + + case key.CodeL: + if e.Modifiers&key.ModControl != 0 { + // ctrl is pressed, so just move the end and expand dot + actions.MoveCursor(position.DotStart, position.NextChar, buff) + } else { + // ctrl is not pressed, so move the cursor without selecting + actions.MoveCursor(position.NextChar, position.NextChar, buff) + } + return NormalMode, nil + case key.CodeJ: + if e.Modifiers&key.ModControl != 0 { + // ctrl is pressed, so just move the end and expand dot + actions.MoveCursor(position.DotStart, position.NextLine, buff) + } else { + // ctrl is not pressed, so move the cursor without selecting + actions.MoveCursor(position.NextLine, position.NextLine, buff) + } + return NormalMode, nil + case key.CodeX: + actions.DeleteCursor(position.NextChar, position.NextChar, buff) + case key.CodeW: + if e.Modifiers&key.ModControl != 0 { + actions.MoveCursor(position.DotStart, position.NextWordStart, buff) + } else { + actions.MoveCursor(position.NextWordStart, position.NextWordStart, buff) + } + case key.CodeP: + fmt.Printf("Snarf buffer: \"%s\"\n", buff.SnarfBuffer) + actions.InsertSnarfBuffer(position.DotEnd, position.DotEnd, buff) + case key.CodeRightArrow: + return NormalMode, ScrollRight + case key.CodeLeftArrow: + return NormalMode, ScrollLeft + case key.CodeDownArrow: + return NormalMode, ScrollDown + case key.CodeUpArrow: + return NormalMode, ScrollUp + case key.Code0: + if e.Modifiers == 0 { + Repeat *= 10 + } + return NormalMode, nil + case key.Code1: + if e.Modifiers == 0 { + Repeat = Repeat*10 + 1 + } + return NormalMode, nil + case key.Code2: + if e.Modifiers == 0 { + Repeat = Repeat*10 + 2 + + } + return NormalMode, nil + case key.Code3: + if e.Modifiers == 0 { + Repeat = Repeat*10 + 3 + + } + return NormalMode, nil + case key.Code4: + if e.Modifiers == 0 { + Repeat = Repeat*10 + 4 + + } else if e.Modifiers&key.ModShift != 0 { + // $ is pressed, in most key maps.. + if e.Modifiers&key.ModControl != 0 { + // ctrl is pressed, so just move the end and expand dot + // BUG: This doesn't seem to work. Probably a bug in the shiny driver. + actions.MoveCursor(position.DotStart, position.EndOfLine, buff) + } else { + actions.MoveCursor(position.EndOfLine, position.EndOfLine, buff) + } + } + return NormalMode, nil + case key.Code5: + if e.Modifiers == 0 { + Repeat = Repeat*10 + 5 + } + return NormalMode, nil + case key.Code6: + if e.Modifiers == 0 { + Repeat = Repeat*10 + 6 + } else if e.Modifiers&key.ModShift != 0 { + // ^ is pressed, on most keyboards.. + if e.Modifiers&key.ModControl != 0 { + // BUG: This doesn't seem to work. Probably a bug in the shiny driver. + actions.MoveCursor(position.StartOfLine, position.DotEnd, buff) + } else { + actions.MoveCursor(position.StartOfLine, position.StartOfLine, buff) + } + } + return NormalMode, nil + case key.Code7: + if e.Modifiers == 0 { + Repeat = Repeat*10 + 7 + } + return NormalMode, nil + case key.Code8: + if e.Modifiers == 0 { + Repeat = Repeat*10 + 8 + } + return NormalMode, nil + case key.Code9: + if e.Modifiers == 0 { + Repeat = Repeat*10 + 9 + } + return NormalMode, nil + case key.CodeG: + if e.Modifiers&key.ModShift != 0 { + if Repeat > 0 { + actions.MoveCursor(position.BuffStart, position.BuffStart, buff) + for ; Repeat > 0; Repeat-- { + actions.MoveCursor(position.NextLine, position.NextLine, buff) + } + } else { + actions.MoveCursor(position.BuffEnd, position.BuffEnd, buff) + } + } + case key.CodeD: + return DeleteMode, nil + case key.CodeSlash: + if buff.Dot.Start == buff.Dot.End { + actions.FindNext(position.CurWordStart, position.CurWordEnd, buff) + + } else { + actions.FindNext(position.DotStart, position.DotEnd, buff) + } + return NormalMode, nil + case key.CodeReturnEnter: + if buff.Dot.Start == buff.Dot.End { + actions.OpenOrPerformAction(position.CurWordStart, position.CurWordEnd, buff) + } else { + actions.OpenOrPerformAction(position.DotStart, position.DotEnd, buff) + } + return NormalMode, nil + } + return NormalMode, Invalid +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..430efe8 --- /dev/null +++ b/main.go @@ -0,0 +1,295 @@ +package main + +import ( + "fmt" + "github.com/driusan/de/actions" + "github.com/driusan/de/demodel" + "github.com/driusan/de/kbmap" + "github.com/driusan/de/position" + "github.com/driusan/de/renderer" + "golang.org/x/exp/shiny/driver" + "golang.org/x/exp/shiny/screen" + "golang.org/x/mobile/event/key" + "golang.org/x/mobile/event/lifecycle" + "golang.org/x/mobile/event/mouse" + "golang.org/x/mobile/event/paint" + "golang.org/x/mobile/event/size" + "image" + "image/draw" + "io/ioutil" + "os" + "strings" +) + +var viewport struct { + Location image.Point + KeyboardMode kbmap.Map +} + +const ( + ButtonLeft = iota + ButtonMiddle + ButtonRight + MouseWheelUp + MouseWheelDown +) + +func paintWindow(s screen.Screen, w screen.Window, sz size.Event, buf image.Image) { + b, err := s.NewBuffer(sz.Size()) + defer b.Release() + if err != nil { + panic(err) + } + dst := b.RGBA() + + // Fill the buffer with the window background colour before + // drawing the web page on top of it. + switch viewport.KeyboardMode { + case kbmap.InsertMode: + draw.Draw(dst, dst.Bounds(), &image.Uniform{renderer.InsertBackground}, image.ZP, draw.Src) + case kbmap.DeleteMode: + draw.Draw(dst, dst.Bounds(), &image.Uniform{renderer.DeleteBackground}, image.ZP, draw.Src) + default: + draw.Draw(dst, dst.Bounds(), &image.Uniform{renderer.NormalBackground}, image.ZP, draw.Src) + } + + draw.Draw(dst, dst.Bounds(), buf, viewport.Location, draw.Over) + + w.Upload(image.Point{0, 0}, b, dst.Bounds()) + w.Publish() + return +} + +func main() { + var sz size.Event + if len(os.Args) <= 1 { + fmt.Fprintf(os.Stderr, "Need filename to open.\n") + return + } + filename := os.Args[1] + b, err := ioutil.ReadFile(filename) + buff := demodel.CharBuffer{} + if err != nil { + // An unhandled error occured + if !os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "%s\n", err) + return + } + // the error was just that the file doesn't exist, it'll be created on + // save + buff.Buffer = make([]byte, 0) + } else { + buff.Buffer = b + } + + buff.Filename = filename + + var imap renderer.ImageMap + var MouseButtonMask [6]bool + + // hack so that things don't get confused on DirRelease when a button transitions keyboard modes + var lastKeyboardMode kbmap.Map = kbmap.NormalMode + viewport.KeyboardMode = kbmap.NormalMode + + var render renderer.Renderer + if strings.HasSuffix(filename, ".go") { + render = &renderer.GoSyntax{} + } else { + render = &renderer.NoSyntaxRenderer{} + } + img, imap, err := render.Render(buff) + if err != nil { + panic(err) + } + driver.Main(func(s screen.Screen) { + w, err := s.NewWindow(nil) + if err != nil { + return + } + defer w.Release() + + for { + switch e := w.NextEvent().(type) { + case lifecycle.Event: + if e.To == lifecycle.StageDead { + return + } + case key.Event: + if lastKeyboardMode != viewport.KeyboardMode && e.Direction == key.DirRelease { + lastKeyboardMode = viewport.KeyboardMode + // don't repeat the same key in a different direction if a keystroke changed + // modes. + continue + } + + newKbmap, err := viewport.KeyboardMode.HandleKey(e, &buff) + + wSize := sz.Size() + imgSize := img.Bounds().Size() + // special error codes that a keystroke can send to control the + // program + switch err { + case kbmap.ExitProgram: + return + case kbmap.ScrollUp: + scrollSize := sz.Size().Y / 2 + viewport.Location.Y -= scrollSize + if viewport.Location.Y < 0 { + viewport.Location.Y = 0 + } + case kbmap.ScrollDown: + scrollSize := sz.Size().Y / 2 + viewport.Location.Y += scrollSize + if viewport.Location.Y+wSize.Y > imgSize.Y+50 { + // we can scroll a *little* past the end, so that it's easier to read + // the last + viewport.Location.Y = imgSize.Y - wSize.Y + 50 + } + case kbmap.ScrollLeft: + scrollSize := sz.Size().X / 2 + viewport.Location.X -= scrollSize + if viewport.Location.X < 0 { + viewport.Location.X = 0 + } + case kbmap.ScrollRight: + scrollSize := sz.Size().X / 2 + viewport.Location.X += scrollSize + // we've scrolled too far down + if viewport.Location.X+wSize.X > imgSize.X+50 { + // we can scroll a *little* past the end, so that it's easier to read + // the longest line + viewport.Location.X = imgSize.X - wSize.X + 50 + } + } + + if wSize.X >= imgSize.X { + viewport.Location.X = 0 + } + if wSize.Y >= imgSize.Y { + viewport.Location.Y = 0 + } + + // TODO: Autoscroll if the cursor has moved past the end of the window. + + // now apply the new map and repaint the window to incorporate + // whatever changes the keystroke may have changed. + lastKeyboardMode = viewport.KeyboardMode + viewport.KeyboardMode = newKbmap + + img, imap, _ = render.Render(buff) + paintWindow(s, w, sz, img) + case mouse.Event: + charIdx, err := imap.At(int(e.X)+viewport.Location.X, int(e.Y)+viewport.Location.Y) + if err != nil { + continue + } + var pressed bool + + switch e.Direction { + case mouse.DirPress: + pressed = true + case mouse.DirRelease: + pressed = false + } + + if e.Direction != mouse.DirNone { + switch e.Button { + case mouse.ButtonLeft: + // this is the start of a mouse click. Reset Dot + // to whatever was clicked on. + if pressed && MouseButtonMask[ButtonLeft] == false { + buff.Dot.Start = charIdx + buff.Dot.End = charIdx + } + MouseButtonMask[ButtonLeft] = pressed + case mouse.ButtonMiddle: + if pressed && MouseButtonMask[ButtonMiddle] == false { + buff.Dot.Start = charIdx + buff.Dot.End = charIdx + } + MouseButtonMask[ButtonMiddle] = pressed + case mouse.ButtonRight: + if pressed && MouseButtonMask[ButtonRight] == false { + buff.Dot.Start = charIdx + buff.Dot.End = charIdx + } + MouseButtonMask[ButtonRight] = pressed + case mouse.ButtonWheelUp: + viewport.Location.Y -= 50 + if viewport.Location.Y < 0 { + viewport.Location.Y = 0 + } + img, imap, _ = render.Render(buff) + paintWindow(s, w, sz, img) + case mouse.ButtonWheelDown: + viewport.Location.Y += 50 + wSize := sz.Size() + imgSize := img.Bounds().Size() + if viewport.Location.Y+wSize.Y > imgSize.Y+50 { + // we can scroll a *little* past the end, so that it's easier to read + // the last + viewport.Location.Y = imgSize.Y - wSize.Y + 50 + } + img, imap, _ = render.Render(buff) + paintWindow(s, w, sz, img) + } + } + + if MouseButtonMask[ButtonLeft] == true || MouseButtonMask[ButtonRight] == true || MouseButtonMask[ButtonMiddle] == true { + // if it's outside the current selection, expand the selection. + if charIdx < buff.Dot.Start { + buff.Dot.Start = charIdx + } + if charIdx > buff.Dot.End { + buff.Dot.End = charIdx + } + + // if it's inside the current selection, shrink the selection. + if charIdx < buff.Dot.End && charIdx > buff.Dot.Start { + if buff.Dot.End-charIdx < buff.Dot.Start-charIdx { + // it's slower to the end + buff.Dot.End = charIdx + } else { + // it's slower to the start + buff.Dot.Start = charIdx + } + } + img, imap, _ = render.Render(buff) + paintWindow(s, w, sz, img) + } + if e.Direction == mouse.DirRelease && e.Button == mouse.ButtonRight { + if buff.Dot.Start == buff.Dot.End { + actions.FindNextOrOpen(position.CurWordStart, position.CurWordEnd, &buff) + + } else { + actions.FindNextOrOpen(position.DotStart, position.DotEnd, &buff) + } + img, imap, _ = render.Render(buff) + paintWindow(s, w, sz, img) + } + if e.Direction == mouse.DirRelease && e.Button == mouse.ButtonMiddle { + if buff.Dot.Start == buff.Dot.End { + actions.PerformAction(position.CurWordStart, position.CurWordEnd, &buff) + } else { + actions.PerformAction(position.DotStart, position.DotEnd, &buff) + } + } + //paintWindow(s, w, sz, buff) + case paint.Event: + paintWindow(s, w, sz, img) + case size.Event: + sz = e + wSize := e.Size() + img, imap, _ = render.Render(buff) + imgSize := img.Bounds().Size() + if wSize.X >= imgSize.X { + viewport.Location.X = 0 + } + if wSize.Y >= imgSize.Y { + viewport.Location.Y = 0 + } + paintWindow(s, w, sz, img) + } + } + }) +} diff --git a/position/positions.go b/position/positions.go new file mode 100644 index 0000000..8bec3bb --- /dev/null +++ b/position/positions.go @@ -0,0 +1,202 @@ +package position + +import ( + "errors" + "github.com/driusan/de/demodel" + "unicode" +) + +var Invalid error = errors.New("Invalid Position") + +// DotStart can be used as a demodel.Position argument +// to actions +func DotStart(buff demodel.CharBuffer) (uint, error) { + return buff.Dot.Start, nil +} + +// DotEnd can be used as a demodel.Position argument +// to actions +func DotEnd(buff demodel.CharBuffer) (uint, error) { + return buff.Dot.End, nil +} + +func BuffStart(buff demodel.CharBuffer) (uint, error) { + if len(buff.Buffer) > 0 { + return 0, nil + } + return 0, Invalid +} +func BuffEnd(buff demodel.CharBuffer) (uint, error) { + if len(buff.Buffer) > 0 { + return uint(len(buff.Buffer) - 1), nil + } + return 0, Invalid +} + +func PrevChar(buff demodel.CharBuffer) (uint, error) { + if buff.Dot.Start == 0 { + return 0, Invalid + } + + // BUG: This doesn't deal with multibyte UTF-8 runes. + return buff.Dot.Start - 1, nil +} +func NextChar(buff demodel.CharBuffer) (uint, error) { + if buff.Dot.End >= uint(len(buff.Buffer)) { + return uint(len(buff.Buffer)) - 1, Invalid + } + return buff.Dot.End + 1, nil +} + +func PrevLine(buff demodel.CharBuffer) (uint, error) { + // how far into the current line is the current character? + var lineIdx int = -1 + + // where does the previous line start, so that we can index + // lineIdx into it at the end? + var prevLineStart, curLineStart int = -1, -1 + for i := buff.Dot.Start; i > 0; i-- { + if uint(len(buff.Buffer)) <= i { + return buff.Dot.End, Invalid + } + if buff.Buffer[i] == '\n' { + if lineIdx == -1 { + lineIdx = int(buff.Dot.Start) - int(i) + curLineStart = int(i) + } else { + prevLineStart = int(i) + // the current line was shorter than lineIdx, so move to the + // end of the line instead of lineIdx into it. + if prevLineStart+lineIdx > curLineStart { + return uint(curLineStart), nil + } + break + } + } + } + + // it was the first line, so return the first character + if prevLineStart < 0 || lineIdx < 0 { + return 0, nil + } + + return uint(prevLineStart + lineIdx), nil +} + +func NextLine(buff demodel.CharBuffer) (uint, error) { + // how far into the current line is the current character? + var lineIdx int = -1 + + // calculate how far we are into the current line. + var nextLineStart, subsequentLine int = -1, -1 + for i := buff.Dot.End; i > 0; i-- { + if uint(len(buff.Buffer)) <= i { + return buff.Dot.End, Invalid + } + if buff.Buffer[i] == '\n' { + if lineIdx == -1 { + lineIdx = int(buff.Dot.End - i) + break + } + } + } + if lineIdx < 0 { + // must be the on the first line, which means Dot + // is actually the index. + lineIdx = int(buff.Dot.End) + } + + // now find the next line start, so we can add to it, and the line + // after that, so we can check if nextLine+idx goes too far. + for i := buff.Dot.End + 1; int(i) < len(buff.Buffer); i++ { + if buff.Buffer[i] == '\n' { + if nextLineStart == -1 { + nextLineStart = int(i) + } else { + subsequentLine = int(i) + break + } + } + } + + // must be on the last line, so go to the end of the buffer + if nextLineStart < 0 { + return uint(len(buff.Buffer)) - 1, nil + } + + // calculate the position + pos := uint(nextLineStart + lineIdx) + if subsequentLine >= 0 && pos > uint(subsequentLine) { + // went too far, so return the end of the line + return uint(subsequentLine) - 1, nil + } + + if pos >= uint(len(buff.Buffer)) { + // overflowed the buffer somehow, so return the end of the buffer + return uint(len(buff.Buffer)) - 1, Invalid + } + + return pos, nil +} + +func EndOfLine(buff demodel.CharBuffer) (uint, error) { + for i := buff.Dot.End; i < uint(len(buff.Buffer)); i++ { + if buff.Buffer[i] == '\n' { + return i, nil + } + } + return uint(len(buff.Buffer) - 1), nil +} +func StartOfLine(buff demodel.CharBuffer) (uint, error) { + for i := buff.Dot.Start - 1; i > 0; i-- { + if buff.Buffer[i] == '\n' { + return i + 1, nil + } + } + return 0, nil +} + +func CurWordStart(buff demodel.CharBuffer) (uint, error) { + for i := buff.Dot.Start - 1; i > 0; i-- { + if unicode.IsSpace(rune(buff.Buffer[i])) { + return i + 1, nil + } + // some non-space word borders. Note that '.' + // isn't considered a word border so that opening + // files by right clicking or hitting enter works. + switch buff.Buffer[i] { + case '(', ')', '"', '\'', ',', '/': + return i + 1, nil + } + + } + return buff.Dot.Start, Invalid +} +func CurWordEnd(buff demodel.CharBuffer) (uint, error) { + for i := buff.Dot.End; i < uint(len(buff.Buffer)); i++ { + if unicode.IsSpace(rune(buff.Buffer[i])) { + return i - 1, nil + } + switch buff.Buffer[i] { + case '(', ')', '"', '\'', ',', '/': + return i - 1, nil + } + } + return buff.Dot.End, Invalid + +} + +func NextWordStart(buff demodel.CharBuffer) (uint, error) { + foundSpace := false + for i := buff.Dot.End; i < uint(len(buff.Buffer)); i++ { + if unicode.IsSpace(rune(buff.Buffer[i])) { + foundSpace = true + continue + } else { + if foundSpace == true { + return i, nil + } + } + } + return buff.Dot.End, Invalid +} diff --git a/renderer/colors.go b/renderer/colors.go new file mode 100644 index 0000000..adf5488 --- /dev/null +++ b/renderer/colors.go @@ -0,0 +1,18 @@ +package renderer + +import ( + "image/color" +) + +// Background colours +var NormalBackground = color.RGBA{0xFF, 0xFF, 0xDC, 0xFF} +var InsertBackground = color.RGBA{0xEF, 0xFF, 0xEF, 0xFF} +var DeleteBackground = color.RGBA{0xFF, 0xDC, 0xDC, 0xFF} + +// Text colours +var TextHighlight = color.RGBA{0xBC, 0xFC, 0xF9, 0xFF} +var TextColour = color.RGBA{0, 0, 0, 0xFF} +var CommentColour = color.RGBA{0, 0, 0xFF, 0xFF} +var KeywordColour = color.RGBA{0x6D, 0x6D, 0, 0xFF} +var BuiltinTypeColour = color.RGBA{0x00, 0x6D, 0, 0xFF} +var StringColour = color.RGBA{0xFF, 0, 0, 0xFF} diff --git a/renderer/gosyntax.go b/renderer/gosyntax.go new file mode 100644 index 0000000..56820ec --- /dev/null +++ b/renderer/gosyntax.go @@ -0,0 +1,302 @@ +package renderer + +import ( + "bytes" + "github.com/driusan/de/demodel" + "unicode" + //"fmt" + "golang.org/x/image/font" + "golang.org/x/image/math/fixed" + "image" + // "image/color" + "image/draw" +) + +type GoSyntax struct{} + +func (rd *GoSyntax) calcImageSize(buf demodel.CharBuffer) image.Rectangle { + metrics := MonoFontFace.Metrics() + runes := bytes.Runes(buf.Buffer) + _, MglyphWidth, _ := MonoFontFace.GlyphBounds('M') + rt := image.ZR + var lineSize fixed.Int26_6 + for _, r := range runes { + _, glyphWidth, _ := MonoFontFace.GlyphBounds(r) + switch r { + case '\t': + lineSize += MglyphWidth * 8 + case '\n': + rt.Max.Y += metrics.Height.Ceil() + lineSize = 0 + default: + lineSize += glyphWidth + } + if lineSize.Ceil() > rt.Max.X { + rt.Max.X = lineSize.Ceil() + } + } + rt.Max.Y += metrics.Height.Ceil() + 1 + return rt +} + +func (rd *GoSyntax) Render(buf demodel.CharBuffer) (image.Image, ImageMap, error) { + dstSize := rd.calcImageSize(buf) + dst := image.NewRGBA(dstSize) + metrics := MonoFontFace.Metrics() + writer := font.Drawer{ + Dst: dst, + Src: &image.Uniform{TextColour}, + Dot: fixed.P(0, metrics.Ascent.Floor()), + Face: MonoFontFace, + } + runes := bytes.Runes(buf.Buffer) + + // it's a monospace font, so only do this once outside of the for loop.. + // use an M so that space characters are based on an em-quad if we change + // to a non-monospace font. + //writer.Src = &image.Uniform{TextColour} + im := make(ImageMap, 0) + + var inLineComment, inMultilineComment, inString, inCharString bool + + _, MglyphWidth, _ := MonoFontFace.GlyphBounds('M') + var nextColor image.Image + for i, r := range runes { + _, glyphWidth, _ := MonoFontFace.GlyphBounds(r) + switch r { + case '\n': + if inLineComment && !inMultilineComment && !inString { + inLineComment = false + writer.Src = &image.Uniform{TextColour} + } + case '\'': + if !IsEscaped(i, runes) { + if inCharString { + // end of a string, colourize the quote too. + nextColor = &image.Uniform{TextColour} + inCharString = false + } else if !inLineComment && !inMultilineComment && !inString { + inCharString = true + writer.Src = &image.Uniform{StringColour} + } + } + case '"': + if !IsEscaped(i, runes) { + if inString { + inString = false + nextColor = &image.Uniform{TextColour} + } else if !inLineComment && !inMultilineComment && !inCharString { + inString = true + writer.Src = &image.Uniform{StringColour} + } + } + case '/': + if string(runes[i:i+2]) == "//" { + if !inCharString && !inMultilineComment && !inString { + inLineComment = true + writer.Src = &image.Uniform{CommentColour} + } + } + case ' ', '\t': + if !inCharString && !inMultilineComment && !inString && !inLineComment { + writer.Src = &image.Uniform{TextColour} + } + default: + if !inCharString && !inMultilineComment && !inString && !inLineComment { + if IsLanguageKeyword(i, runes) { + writer.Src = &image.Uniform{KeywordColour} + } else if IsLanguageType(i, runes) { + writer.Src = &image.Uniform{BuiltinTypeColour} + } else if StartsLanguageDeliminator(r) { + writer.Src = &image.Uniform{TextColour} + } + } + } + + runeRectangle := image.Rectangle{} + runeRectangle.Min.X = writer.Dot.X.Ceil() + runeRectangle.Min.Y = writer.Dot.Y.Ceil() - metrics.Ascent.Floor() + switch r { + case '\t': + runeRectangle.Max.X = runeRectangle.Min.X + 8*MglyphWidth.Ceil() + case '\n': + runeRectangle.Max.X = dstSize.Max.X + default: + runeRectangle.Max.X = runeRectangle.Min.X + glyphWidth.Ceil() + } + runeRectangle.Max.Y = runeRectangle.Min.Y + metrics.Height.Ceil() + 1 + + im = append(im, ImageLoc{runeRectangle, uint(i)}) + if uint(i) >= buf.Dot.Start && uint(i) <= buf.Dot.End { + // it's in dot, so highlight the background + draw.Draw( + dst, + runeRectangle, + &image.Uniform{TextHighlight}, + image.ZP, + draw.Src, + ) + } + + switch r { + case '\t': + writer.Dot.X += glyphWidth * 8 + continue + case '\n': + writer.Dot.Y += metrics.Height + writer.Dot.X = 0 + continue + } + writer.DrawString(string(r)) + if nextColor != nil { + writer.Src = nextColor + nextColor = nil + } + } + + return dst, im, nil +} + +func StartsLanguageDeliminator(r rune) bool { + switch r { + case '+', '-', '*', '/', '%', + '&', '|', '^', + '<', '>', '=', '!', + ':', '.', + '(', ')', '[', ']', '{', '}', + ',', ';': + return true + } + if unicode.IsSpace(r) { + return true + } + return false +} +func IsLanguageKeyword(pos int, runes []rune) bool { + if pos > 0 { + prev := runes[pos-1] + if !unicode.IsSpace(prev) && !StartsLanguageDeliminator(prev) { + return false + } + } + if len(runes) > pos+12 { + if unicode.IsSpace(runes[pos+11]) || StartsLanguageDeliminator(runes[pos+11]) { + switch string(runes[pos : pos+11]) { + case "fallthrough": + return true + } + } + } + if len(runes) > pos+9 { + if unicode.IsSpace(runes[pos+8]) || StartsLanguageDeliminator(runes[pos+8]) { + switch string(runes[pos : pos+8]) { + case "continue": + return true + } + } + } + if len(runes) > pos+8 { + if unicode.IsSpace(runes[pos+7]) || StartsLanguageDeliminator(runes[pos+7]) { + switch string(runes[pos : pos+7]) { + case "default", "package": + return true + } + + } + } + if len(runes) > pos+7 { + if unicode.IsSpace(runes[pos+6]) || StartsLanguageDeliminator(runes[pos+6]) { + switch string(runes[pos : pos+6]) { + case "import", "return", "select", "struct", "switch": + return true + } + } + } + if len(runes) > pos+6 { + if unicode.IsSpace(runes[pos+5]) || StartsLanguageDeliminator(runes[pos+5]) { + switch string(runes[pos : pos+5]) { + case "break", "const", "defer", "range", "false": + return true + } + } + } + if len(runes) > pos+5 { + if unicode.IsSpace(runes[pos+4]) || StartsLanguageDeliminator(runes[pos+4]) { + switch string(runes[pos : pos+4]) { + case "case", "chan", "else", "func", "goto", "type", "true", "iota": + return true + } + } + } + if len(runes) > pos+4 { + if unicode.IsSpace(runes[pos+3]) || StartsLanguageDeliminator(runes[pos+3]) { + switch string(runes[pos : pos+3]) { + case "for", "map", "var": + return true + } + } + } + if len(runes) > pos+3 { + if unicode.IsSpace(runes[pos+2]) || StartsLanguageDeliminator(runes[pos+2]) { + switch string(runes[pos : pos+2]) { + case "if", "go": + return true + } + } + } + return false +} +func IsLanguageType(pos int, runes []rune) bool { + if pos < 3 { + return false + + } + if !StartsLanguageDeliminator(runes[pos-1]) { + return false + } + if len(runes) > pos+4 { + if StartsLanguageDeliminator(runes[pos+3]) { + switch string(runes[pos : pos+3]) { + case "int": + return true + } + } + } + if len(runes) > pos+5 { + if StartsLanguageDeliminator(runes[pos+4]) { + switch string(runes[pos : pos+4]) { + case "int8", "bool", "byte", "rune", "uint": + return true + } + } + + } + if len(runes) > pos+6 { + if unicode.IsSpace(runes[pos+5]) { + switch string(runes[pos : pos+5]) { + case "uint8", "int16", "int32", "int64": + return true + } + } + } + if len(runes) > pos+7 { + if unicode.IsSpace(runes[pos+6]) { + switch string(runes[pos : pos+6]) { + case "uint16", "uint32", "uint64": + return true + } + } + } + return false +} +func IsEscaped(pos int, runes []rune) bool { + if pos == 0 { + return false + } + + isEscaped := false + for i := pos - 1; i >= 0 && runes[i] == '\\'; i-- { + isEscaped = !isEscaped + } + return isEscaped +} diff --git a/renderer/init.go b/renderer/init.go new file mode 100644 index 0000000..3ee50de --- /dev/null +++ b/renderer/init.go @@ -0,0 +1,48 @@ +package renderer + +import ( + "fmt" + "github.com/driusan/de/demodel" + "github.com/driusan/fonts" + "github.com/golang/freetype/truetype" + "golang.org/x/image/font" + // "golang.org/x/image/font/basicfont" + "image" + "os" +) + +var MonoFontFace font.Face + +func init() { + ff, err := fonts.Asset("DejaVuSansMono.ttf") + if err != nil { + fmt.Fprintf(os.Stderr, "Could not retrieve font: %s\n", err) + os.Exit(2) + return + } + ft, err := truetype.Parse(ff) + if err != nil { + fmt.Fprintf(os.Stderr, "Could not parse font: %s\n", err) + os.Exit(3) + } + + MonoFontFace = truetype.NewFace(ft, + &truetype.Options{ + Size: float64(16), + DPI: 72, + Hinting: font.HintingNone}) + if MonoFontFace == nil { + panic("Could not get font face.") + } + // There seems to be a bug where DejaVuSansMono with hinting won't render + // the character '2', so for now just use the built in basicfont, even though + // it's not as pretty and doesn't have as many runes. + //MonoFontFace = basicfont.Face7x13 +} + +// Renders the character buffer to an image which can +// be displayed on a screen. For instance, to a shiny +// window. +type Renderer interface { + Render(demodel.CharBuffer) (image.Image, ImageMap, error) +} diff --git a/renderer/nosyntax.go b/renderer/nosyntax.go new file mode 100644 index 0000000..993e2b0 --- /dev/null +++ b/renderer/nosyntax.go @@ -0,0 +1,115 @@ +package renderer + +import ( + "bytes" + "errors" + "github.com/driusan/de/demodel" + "golang.org/x/image/font" + "golang.org/x/image/math/fixed" + "image" + "image/color" + "image/draw" +) + +type NoSyntaxRenderer struct{} + +func (r NoSyntaxRenderer) calcImageSize(buf demodel.CharBuffer) image.Rectangle { + metrics := MonoFontFace.Metrics() + runes := bytes.Runes(buf.Buffer) + _, glyphWidth, _ := MonoFontFace.GlyphBounds('a') + rt := image.ZR + var lineSize fixed.Int26_6 + for _, r := range runes { + switch r { + case '\t': + lineSize += glyphWidth * 8 + case '\n': + rt.Max.Y += metrics.Height.Ceil() + lineSize = 0 + default: + lineSize += glyphWidth + } + if lineSize.Ceil() > rt.Max.X { + rt.Max.X = lineSize.Ceil() + } + } + rt.Max.Y += metrics.Height.Ceil() + 1 + return rt +} + +var NoCharacter error = errors.New("No character under the mouse cursor.") + +type ImageLoc struct { + Loc image.Rectangle + Idx uint +} +type ImageMap []ImageLoc + +func (im ImageMap) At(x, y int) (uint, error) { + for _, m := range im { + if m.Loc.At(x, y) == color.Opaque { + return m.Idx, nil + } + } + return 0, NoCharacter +} + +func (r NoSyntaxRenderer) Render(buf demodel.CharBuffer) (image.Image, ImageMap, error) { + dstSize := r.calcImageSize(buf) + dst := image.NewRGBA(dstSize) + metrics := MonoFontFace.Metrics() + writer := font.Drawer{ + Dst: dst, + Src: &image.Uniform{color.Black}, + Face: MonoFontFace, + Dot: fixed.P(0, metrics.Ascent.Floor()), + } + runes := bytes.Runes(buf.Buffer) + + // it's a monospace font, so only do this once outside of the for loop.. + // use an M so that space characters are based on an em-quad if we change + // to a non-monospace font. + _, glyphWidth, _ := MonoFontFace.GlyphBounds('M') + im := make(ImageMap, 0) + for i, r := range runes { + runeRectangle := image.Rectangle{} + runeRectangle.Min.X = writer.Dot.X.Ceil() + runeRectangle.Min.Y = writer.Dot.Y.Ceil() - metrics.Ascent.Floor() + switch r { + case '\t': + runeRectangle.Max.X = runeRectangle.Min.X + 8*glyphWidth.Ceil() + case '\n': + runeRectangle.Max.X = dstSize.Max.X + default: + runeRectangle.Max.X = runeRectangle.Min.X + glyphWidth.Ceil() + } + runeRectangle.Max.Y = runeRectangle.Min.Y + metrics.Height.Ceil() + 1 + + im = append(im, ImageLoc{runeRectangle, uint(i)}) + if uint(i) >= buf.Dot.Start && uint(i) <= buf.Dot.End { + writer.Src = &image.Uniform{color.Black} + draw.Draw( + dst, + runeRectangle, + &image.Uniform{TextHighlight}, + image.ZP, + draw.Src, + ) + } else { + // it's not within dot, so use a black font on no background. + writer.Src = &image.Uniform{color.Black} + } + switch r { + case '\t': + writer.Dot.X += glyphWidth * 8 + continue + case '\n': + writer.Dot.Y += metrics.Height + writer.Dot.X = 0 + continue + } + writer.DrawString(string(r)) + } + + return dst, im, nil +}