├── .gitignore
├── A Bird, came down the Walk.txt
├── LICENSE
├── README.md
├── cmd
└── noter
│ └── main.go
├── editor.go
├── editor_test.go
├── go.mod
├── go.sum
└── preview.png
/.gitignore:
--------------------------------------------------------------------------------
1 | /noter
2 |
3 | # If you prefer the allow list template instead of the deny list, see community template:
4 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
5 | #
6 | # Binaries for programs and plugins
7 | *.exe
8 | *.exe~
9 | *.dll
10 | *.so
11 | *.dylib
12 |
13 | # Test binary, built with `go test -c`
14 | *.test
15 |
16 | # Output of the go coverage tool, specifically when used with LiteIDE
17 | *.out
18 |
19 | # Dependency directories (remove the comment below to include it)
20 | # vendor/
21 |
22 | # Go workspace file
23 | go.work
24 |
--------------------------------------------------------------------------------
/A Bird, came down the Walk.txt:
--------------------------------------------------------------------------------
1 | A Bird, came down the Walk -
2 | He did not know I saw -
3 | He bit an Angle Worm in halves
4 | And ate the fellow, raw,
5 |
6 | And then, he drank a Dew
7 | From a convenient Grass -
8 | And then hopped sidewise to the Wall
9 | To let a Beetle pass -
10 |
11 | He glanced with rapid eyes,
12 | That hurried all abroad -
13 | They looked like frightened Beads, I thought,
14 | He stirred his Velvet Head. -
15 |
16 | Like one in danger, Cautious,
17 | I offered him a Crumb,
18 | And he unrolled his feathers,
19 | And rowed him softer Home -
20 |
21 | Than Oars divide the Ocean,
22 | Too silver for a seam,
23 | Or Butterflies, off Banks of Noon,
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Andrew Healey
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 📝 noter
2 | > My blog posts:
3 | > - [Making a Text Editor with a Game Engine](https://healeycodes.com/making-a-text-editor-with-a-game-engine)
4 | > - [Implementing Highlighting, Search, and Undo](https://healeycodes.com/implementing-highlighting-search-and-undo)
5 |
6 |
7 |
8 | A text editor for macOS. Built using the [Ebitengine](https://github.com/hajimehoshi/ebiten) game engine.
9 |
10 | It's a little bit like `nano`.
11 |
12 |
13 |
14 |
15 | ## Shortcuts
16 |
17 | Highlight with (shift + arrow key).
18 |
19 | Swap lines with option + (up)/(down).
20 |
21 | Command +
22 | - (z) undo
23 | - (f) search
24 | - (a) select all
25 | - (c) copy
26 | - (x) cut
27 | - (v) paste
28 | - (x) save
29 | - (q) quit without saving
30 | - (left)/(right) skips to start/end of line
31 | - (up)/(down) skip to start/end of document
32 |
33 | ## Development
34 |
35 | Run the editor `go run github.com/healeycodes/noter/cmd/noter -- "A Bird, came down the Walk.txt"`
36 |
37 | ## Build
38 |
39 | Build `go build ./cmd/noter`
40 |
41 | Run the editor `./noter "A Bird, came down the Walk.txt"`
42 |
43 | ## Tests
44 |
45 | `go test .`
46 |
47 | ## Roadmap
48 |
49 | - More tests
50 | - Implement redo?
51 |
--------------------------------------------------------------------------------
/cmd/noter/main.go:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2024 Andrew Healey
2 | //
3 | // Example of using Editor in an ebiten application.
4 |
5 | package main
6 |
7 | import (
8 | "flag"
9 | "fmt"
10 | "io"
11 | "io/ioutil"
12 | "os"
13 | "path"
14 |
15 | "github.com/flopp/go-findfont"
16 | "github.com/hajimehoshi/ebiten/v2"
17 | "github.com/healeycodes/noter"
18 | "golang.design/x/clipboard"
19 | "golang.org/x/image/font"
20 | "golang.org/x/image/font/opentype"
21 | )
22 |
23 | type clipBoard struct {
24 | }
25 |
26 | func (cb *clipBoard) ReadText() []byte {
27 | return clipboard.Read(clipboard.FmtText)
28 | }
29 |
30 | func (cb *clipBoard) WriteText(content []byte) {
31 | clipboard.Write(clipboard.FmtText, content)
32 | }
33 |
34 | type fileContent struct {
35 | FilePath string
36 | }
37 |
38 | func (fc *fileContent) FileName() (name string) {
39 | return path.Base(fc.FilePath)
40 | }
41 |
42 | func (fc *fileContent) ReadText() (content []byte) {
43 | file, err := os.Open(fc.FilePath)
44 | if err != nil {
45 | // It's ok if the file does not (yet) exist.
46 | return
47 | }
48 | defer file.Close()
49 |
50 | content, err = io.ReadAll(file)
51 | if err != nil {
52 | panic(err)
53 | }
54 |
55 | return
56 | }
57 |
58 | func (fc *fileContent) WriteText(content []byte) {
59 | file, err := os.Create(fc.FilePath)
60 | if err != nil {
61 | panic(err)
62 | }
63 | defer file.Close()
64 |
65 | _, err = file.Write(content)
66 | if err != nil {
67 | panic(err)
68 | }
69 | }
70 |
71 | type options struct {
72 | font_name string
73 | font_size float64
74 | font_dpi float64
75 | }
76 |
77 | func init() {
78 | flag.Usage = func() {
79 | fmt.Fprintf(flag.CommandLine.Output(), "Usage of noter:\n\n")
80 | fmt.Fprintf(flag.CommandLine.Output(), "noter [flags] \n")
81 | flag.PrintDefaults()
82 | }
83 | }
84 |
85 | func execute(file_path string, opts *options) (err error) {
86 | var font_face font.Face
87 |
88 | if len(opts.font_name) > 0 {
89 | var font_path string
90 | font_path, err = findfont.Find(opts.font_name)
91 | if err != nil {
92 | return
93 | }
94 |
95 | var font_data []byte
96 | font_data, err = ioutil.ReadFile(font_path)
97 | if err != nil {
98 | return
99 | }
100 |
101 | var font_sfnt *opentype.Font
102 | font_sfnt, err = opentype.Parse(font_data)
103 | if err != nil {
104 | return
105 | }
106 |
107 | font_opts := opentype.FaceOptions{
108 | Size: opts.font_size,
109 | DPI: opts.font_dpi,
110 | }
111 | font_face, err = opentype.NewFace(font_sfnt, &font_opts)
112 | if err != nil {
113 | return
114 | }
115 | defer font_face.Close()
116 | }
117 |
118 | content := &fileContent{FilePath: file_path}
119 |
120 | editor := noter.NewEditor(
121 | noter.WithClipboard(&clipBoard{}),
122 | noter.WithContent(content),
123 | noter.WithContentName(content.FileName()),
124 | noter.WithTopBar(true),
125 | noter.WithBottomBar(true),
126 | noter.WithFontFace(font_face),
127 | noter.WithQuit(func() { os.Exit(0) }),
128 | )
129 |
130 | width, height := editor.Size()
131 | ebiten.SetWindowSize(width, height)
132 | ebiten.SetWindowTitle("noter")
133 | if err = ebiten.RunGame(editor); err != nil {
134 | return
135 | }
136 |
137 | return
138 | }
139 |
140 | func main() {
141 | var opts options
142 |
143 | flag.StringVar(&opts.font_name, "font", "", "TrueType font name")
144 | flag.Float64Var(&opts.font_size, "fontsize", 12.0, "Font size")
145 | flag.Float64Var(&opts.font_dpi, "fontdpi", 96.0, "Font DPI")
146 |
147 | flag.Parse()
148 |
149 | var filePath string
150 | if flag.NArg() < 1 {
151 | flag.Usage()
152 | os.Exit(1)
153 | } else {
154 | // This is the way
155 | filePath = flag.Arg(0)
156 | }
157 |
158 | err := execute(filePath, &opts)
159 |
160 | if err != nil {
161 | panic(err)
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/editor.go:
--------------------------------------------------------------------------------
1 | // MIT License
2 | //
3 | // Copyright (c) 2024 Andrew Healey
4 | //
5 | // Permission is hereby granted, free of charge, to any person obtaining a copy
6 | // of this software and associated documentation files (the "Software"), to deal
7 | // in the Software without restriction, including without limitation the rights
8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | // copies of the Software, and to permit persons to whom the Software is
10 | // furnished to do so, subject to the following conditions:
11 | //
12 | // The above copyright notice and this permission notice shall be included in all
13 | // copies or substantial portions of the Software.
14 | //
15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | // SOFTWARE.
22 |
23 | package noter
24 |
25 | import (
26 | "fmt"
27 | "image/color"
28 | "log"
29 | "sort"
30 | "unicode"
31 |
32 | "github.com/hajimehoshi/bitmapfont/v3"
33 | "github.com/hajimehoshi/ebiten/v2"
34 | "github.com/hajimehoshi/ebiten/v2/ebitenutil"
35 | "github.com/hajimehoshi/ebiten/v2/inpututil"
36 | "github.com/hajimehoshi/ebiten/v2/text"
37 | "golang.org/x/image/font"
38 | )
39 |
40 | const (
41 | EDITOR_DEFAULT_ROWS = 25
42 | EDITOR_DEFAULT_COLS = 80
43 | )
44 |
45 | type editorLine struct {
46 | prev *editorLine
47 | next *editorLine
48 | values []rune
49 | }
50 |
51 | type editorCursor struct {
52 | line *editorLine
53 | x int
54 | }
55 |
56 | func (c *editorCursor) FixPosition() {
57 | limit := len(c.line.values) - 1
58 | if c.x > limit {
59 | c.x = limit
60 | }
61 | }
62 |
63 | // Content is an interface to a clipboard or file to read/write data.
64 | // We use this instead of io.ReadWriter as we do not want to handle
65 | // errors or buffered reads in the Editor; we force that to the caller
66 | // of the editor.
67 | type Content interface {
68 | ReadText() []byte // Read the entire content of the text clipboard.
69 | WriteText([]byte) // Write replaces the entire content of the text clipboard.
70 | }
71 |
72 | // dummyContent provides a trivial text storage implementation.
73 | type dummyContent struct {
74 | content string
75 | }
76 |
77 | func (cb *dummyContent) ReadText() []byte {
78 | return []byte(cb.content)
79 | }
80 |
81 | func (cb *dummyContent) WriteText(content []byte) {
82 | // 'string' cast will make a duplicate of the content.
83 | cb.content = string(content)
84 | }
85 |
86 | type fontInfo struct {
87 | face font.Face // Font itself.
88 | ascent int // ascent of the font above the baseline's origin.
89 | xUnit int // xUnit is the text advance of the '0' glyph.
90 | yUnit int // yUnit is the line height of the font.
91 | }
92 |
93 | // Create a new fontInfo
94 | func newfontInfo(font_face font.Face) (fi *fontInfo) {
95 | metrics := font_face.Metrics()
96 | advance, _ := font_face.GlyphAdvance('0')
97 |
98 | fi = &fontInfo{
99 | face: font_face,
100 | ascent: metrics.Ascent.Ceil(),
101 | xUnit: advance.Ceil(),
102 | yUnit: metrics.Height.Ceil(),
103 | }
104 |
105 | return fi
106 | }
107 |
108 | const (
109 | EDIT_MODE = iota
110 | SEARCH_MODE
111 | )
112 |
113 | var noop = func() bool { return false }
114 |
115 | // Editor is a simple text editor, compliant to the ebiten.Game interface.
116 | //
117 | // The Meta or Control key can be used with the following command keys:
118 | //
119 | // | Keystroke | Action |
120 | // | --- | --- |
121 | // | COMMAND-S | Save the content. |
122 | // | COMMAND-L | Load the content. |
123 | // | COMMAND-C | Copy the selection to clipboard. |
124 | // | COMMAND-V | Paste clipboard into the selection/current cursor. |
125 | // | COMMAND-X | Cut the selection, saving a copy into the clipboard. |
126 | // | COMMAND-F | Find text in the content. |
127 | // | COMMAND-Q | Quit the editor. |
128 | type Editor struct {
129 | // Settable options
130 | font_info *fontInfo
131 | font_color color.Color
132 | select_color color.Color
133 | search_color color.Color
134 | cursor_color color.Color
135 | background_image *ebiten.Image
136 | clipboard Content
137 | content Content
138 | content_name string
139 | rows int
140 | cols int
141 | width int
142 | height int
143 | width_padding int
144 | bot_bar bool
145 | top_bar bool
146 |
147 | // Internal state
148 | screen *ebiten.Image
149 | top_padding int
150 | bot_padding int
151 | mode uint
152 | searchIndex int
153 | searchTerm []rune
154 | start *editorLine
155 | firstVisible int
156 | cursor *editorCursor
157 | modified bool
158 | highlighted map[*editorLine]map[int]bool
159 | searchHighlights map[*editorLine]map[int]bool
160 | undoStack []func() bool
161 | quit func()
162 | }
163 |
164 | // EditorOption is an option that can be sent to NewEditor()
165 | type EditorOption func(e *Editor)
166 |
167 | // WithQuit sets the function to call when ^Q is pressed,
168 | // nominally to quit the editor. The default is no action.
169 | func WithQuit(opt func()) EditorOption {
170 | return func(e *Editor) {
171 | if opt == nil {
172 | opt = func() {}
173 | }
174 | e.quit = opt
175 | }
176 | }
177 |
178 | // WithContent sets the content accessor, and permits saving and loading.
179 | // If set to nil, an in-memory content manager is used.
180 | func WithContent(opt Content) EditorOption {
181 | return func(e *Editor) {
182 | if opt == nil {
183 | opt = &dummyContent{}
184 | }
185 | e.content = opt
186 | }
187 | }
188 |
189 | // WithContentName sets the name of the content
190 | func WithContentName(opt string) EditorOption {
191 | return func(e *Editor) {
192 | e.content_name = opt
193 | }
194 | }
195 |
196 | // WithTopBar enables the display of the first row as a top bar.
197 | func WithTopBar(enabled bool) EditorOption {
198 | return func(e *Editor) {
199 | e.top_bar = enabled
200 | }
201 | }
202 |
203 | // WithBottomBar enables the display of the last row as a help display.
204 | func WithBottomBar(enabled bool) EditorOption {
205 | return func(e *Editor) {
206 | e.bot_bar = enabled
207 | }
208 | }
209 |
210 | // WithClipboard sets the clipboard accessor.
211 | // If set to nil, an in-memory content manager is used.
212 | func WithClipboard(opt Content) EditorOption {
213 | return func(e *Editor) {
214 | if opt == nil {
215 | opt = &dummyContent{}
216 | }
217 | e.clipboard = opt
218 | }
219 | }
220 |
221 | // WithFontFace set the default font.
222 | // If set to nil, the monospace font `github.com/hajimehoshi/bitmapfont/v3`
223 | // is used.
224 | func WithFontFace(opt font.Face) EditorOption {
225 | return func(e *Editor) {
226 | if opt == nil {
227 | opt = bitmapfont.Face
228 | }
229 | e.font_info = newfontInfo(opt)
230 | }
231 | }
232 |
233 | // WithFontColor sets the color of the text.
234 | // It is recommended to have an Alpha component of 255.
235 | func WithFontColor(opt color.Color) EditorOption {
236 | return func(e *Editor) {
237 | e.font_color = opt
238 | }
239 | }
240 |
241 | // WithHighlightColor sets the color of the select highlight over the text.
242 | // It is recommended to have an Alpha component of 70.
243 | func WithHighlightColor(opt color.Color) EditorOption {
244 | return func(e *Editor) {
245 | e.select_color = opt
246 | }
247 | }
248 |
249 | // WithSearchColor sets the color of the search highlight over the text.
250 | // It is recommended to have an Alpha component of 70.
251 | func WithSearchColor(opt color.Color) EditorOption {
252 | return func(e *Editor) {
253 | e.search_color = opt
254 | }
255 | }
256 |
257 | // WithCursorColor sets the color of the cursor over the text.
258 | // It is recommended to have an Alpha component of 90.
259 | func WithCursorColor(opt color.Color) EditorOption {
260 | return func(e *Editor) {
261 | e.cursor_color = opt
262 | }
263 | }
264 |
265 | // WithBackgroundColor sets the color of the background.
266 | func WithBackgroundColor(opt color.Color) EditorOption {
267 | return func(e *Editor) {
268 | // Make a single pixel image with the background color.
269 | // We will scale it to fit.
270 | img := ebiten.NewImage(1, 1)
271 | img.Fill(opt)
272 | WithBackgroundImage(img)(e)
273 | }
274 | }
275 |
276 | // WithBackgroundImage sets the ebiten.Image in the background.
277 | // It will be scaled to fit the entire background of the editor.
278 | func WithBackgroundImage(opt *ebiten.Image) EditorOption {
279 | return func(e *Editor) {
280 | e.background_image = opt
281 | }
282 | }
283 |
284 | // WithRows sets the total number of rows in the editor, including
285 | // the top bar and bottom bar, if enabled. If set to < 0, then:
286 | // - if WithHeight is set, then the maximum number of rows that would
287 | // fit, based on font height, is used.
288 | // - if WithHeight is not set, then the number of rows defaults to 25.
289 | func WithRows(opt int) EditorOption {
290 | return func(e *Editor) {
291 | e.rows = opt
292 | }
293 | }
294 |
295 | // WidthHeight sets the image height of the editor.
296 | // If WithRows is set, the font is scaled appropriately to the height.
297 | // If WithRows is not set, the maximum number of rows that would fit
298 | // are used, with any additional padding to the bottom of the editor.
299 | // If not set, see the 'WithRows()' option for the calculation.
300 | func WithHeight(opt int) EditorOption {
301 | return func(e *Editor) {
302 | e.height = opt
303 | }
304 | }
305 |
306 | // WithColumns sets the total number of columns in the editor, including
307 | // the line-number area, if enabled. If set to < 0, then:
308 | // - if WithWidth is set, then the maximum number of columns that would
309 | // fit, based on font advance of the glyph '0', is used.
310 | // - if WithWidth is not set, then the number of columns defaults to 80.
311 | func WithColumns(opt int) EditorOption {
312 | return func(e *Editor) {
313 | e.cols = opt
314 | }
315 | }
316 |
317 | // WidthWidth sets the image width of the editor.
318 | // If WithColumns is set, the font is scaled appropriately to the width.
319 | // If WithColumns is not set, the maximum number of columns that would fit
320 | // are used, with any additional padding to the bottom of the editor.
321 | // If not set, see the 'WithColumns()' option for the calculation.
322 | func WithWidth(opt int) EditorOption {
323 | return func(e *Editor) {
324 | e.width = opt
325 | }
326 | }
327 |
328 | // WithWidthPadding sets the left and right side padding, in pixels.
329 | // If not set, the default is 1/2 of the width of the text advance
330 | // of the font's rune '0'.
331 | func WithWithPadding(opt int) EditorOption {
332 | return func(e *Editor) {
333 | e.width_padding = opt
334 | }
335 | }
336 |
337 | // NewEditor creates a new editor. See the EditorOption type for
338 | // available options that can be passed to change its defaults.
339 | //
340 | // If neither the WithHeight nor WithRows options are set, the editor
341 | // defaults to 25 rows.
342 | // The resulting image width is `rows * font.Face.Metrics().Height`
343 | //
344 | // If neither the WithWidth nor the WithCols options are set, the
345 | // editor defaults to 80 columns. The resulting image width
346 | // is `cols * font.Face.GlyphAdvance('0')`
347 | func NewEditor(options ...EditorOption) (e *Editor) {
348 | e = &Editor{
349 | rows: -1,
350 | cols: -1,
351 | width: -1,
352 | height: -1,
353 | width_padding: -1,
354 | }
355 |
356 | WithQuit(nil)(e)
357 | WithContent(nil)(e)
358 | WithClipboard(nil)(e)
359 | WithFontFace(nil)(e)
360 | WithFontColor(color.Black)(e)
361 | WithBackgroundColor(color.White)(e)
362 | WithCursorColor(color.RGBA{0, 0, 0, 90})(e)
363 | WithHighlightColor(color.RGBA{0, 0, 200, 70})(e)
364 | WithSearchColor(color.RGBA{0, 200, 0, 70})(e)
365 |
366 | for _, opt := range options {
367 | opt(e)
368 | }
369 |
370 | // Determine padding.
371 | if e.width_padding < 0 {
372 | e.width_padding = e.font_info.xUnit / 2
373 | }
374 |
375 | if e.top_bar {
376 | e.top_padding = int(float64(e.font_info.yUnit) * 1.25)
377 | }
378 |
379 | if e.bot_bar {
380 | e.bot_padding = int(float64(e.font_info.yUnit) * 1.25)
381 | }
382 |
383 | // Set geometry defaults.
384 | if e.rows < 0 {
385 | if e.height < 0 {
386 | e.rows = EDITOR_DEFAULT_ROWS
387 | } else {
388 | e.rows = (e.height - (e.top_padding + e.bot_padding)) / e.font_info.yUnit
389 | }
390 | }
391 |
392 | if e.cols < 0 {
393 | if e.width < 0 {
394 | e.cols = EDITOR_DEFAULT_COLS
395 | } else {
396 | e.cols = (e.width - e.width_padding*2) / e.font_info.xUnit
397 | }
398 | }
399 |
400 | if e.width < 0 {
401 | e.width = e.font_info.xUnit*e.cols + e.width_padding*2
402 | }
403 |
404 | if e.height < 0 {
405 | e.height = e.font_info.yUnit*e.rows + e.top_padding + e.bot_padding
406 | }
407 |
408 | text_height := e.height - (e.top_padding + e.bot_padding)
409 | text_width := e.width - (e.width_padding * 2)
410 |
411 | // Clamp rows and cols to fit.
412 | if e.rows > text_height/e.font_info.yUnit {
413 | e.rows = text_height / e.font_info.yUnit
414 | }
415 |
416 | if e.cols > text_width/e.font_info.xUnit {
417 | e.cols = text_width / e.font_info.xUnit
418 | }
419 |
420 | // Create the internal image
421 | e.screen = ebiten.NewImage(e.width, e.height)
422 |
423 | // Load content.
424 | e.Load()
425 |
426 | return e
427 | }
428 |
429 | func (e *Editor) searchMode() {
430 | e.resetHighlight()
431 | e.mode = SEARCH_MODE
432 | e.searchHighlights = make(map[*editorLine]map[int]bool)
433 | }
434 |
435 | func (e *Editor) editMode() {
436 | e.mode = EDIT_MODE
437 | e.searchTerm = make([]rune, 0)
438 | e.searchHighlights = make(map[*editorLine]map[int]bool)
439 | }
440 |
441 | func (e *Editor) fnDeleteHighlighted() func() bool {
442 | highlightCount := 0
443 | lastHighlightedLine := e.start
444 | lastHighlightedX := 0
445 | curLine := e.start
446 | for curLine != nil {
447 | if lineWithHighlights, ok := e.highlighted[curLine]; ok {
448 | lastHighlightedLine = curLine
449 | lastHighlightedX = 0
450 | for index := range lineWithHighlights {
451 | if lastHighlightedX < index {
452 | lastHighlightedX = index
453 | }
454 | highlightCount++
455 | }
456 | }
457 | curLine = curLine.next
458 | }
459 | e.cursor.line = lastHighlightedLine
460 | e.cursor.x = lastHighlightedX + 1
461 |
462 | // When a single new line character is highlighted
463 | // we need to start deleting from the start of the
464 | // next line so we can re-use existing deletion logic
465 | if e.cursor.x == len(e.cursor.line.values) && e.cursor.line.next != nil {
466 | e.cursor.line = e.cursor.line.next
467 | e.cursor.x = 0
468 | }
469 |
470 | highlightedRunes := e.getHighlightedRunes()
471 |
472 | for i := 0; i < highlightCount; i++ {
473 | e.deletePrevious()
474 | }
475 |
476 | lineNum := e.getLineNumber()
477 | curX := e.cursor.x
478 |
479 | return func() bool {
480 | e.MoveCursor(lineNum, curX)
481 | for _, r := range highlightedRunes {
482 | e.handleRune(r)
483 | }
484 | return true
485 | }
486 | }
487 |
488 | func (e *Editor) resetHighlight() {
489 | e.highlighted = make(map[*editorLine]map[int]bool)
490 | }
491 |
492 | func (e *Editor) setModified() {
493 | e.modified = true
494 | }
495 |
496 | // IsModified returns true if the editor is in modified state.
497 | func (e *Editor) IsModified() bool {
498 | return e.modified
499 | }
500 |
501 | // Save saves the text to the Content assigned to the editor.
502 | // This clears the 'modified' bit also.
503 | func (e *Editor) Save() {
504 | if e.content != nil {
505 | e.content.WriteText(e.ReadText())
506 | }
507 |
508 | e.modified = false
509 | }
510 |
511 | // Load loads the text from the Content assigned to the editor.
512 | func (e *Editor) Load() {
513 | if e.content != nil {
514 | e.WriteText(e.content.ReadText())
515 | }
516 | }
517 |
518 | // ReadText returns all of the text in the editor.
519 | // Note that this does not clear the 'modified' state of the editor.
520 | func (e *Editor) ReadText() []byte {
521 | allRunes := e.getAllRunes()
522 |
523 | return []byte(string(allRunes))
524 | }
525 |
526 | // WriteText replaces all of the text in the editor.
527 | // Note that this clears the 'modified' state of the editor, and disables
528 | // all selection highlighting.
529 | func (e *Editor) WriteText(text []byte) {
530 | source := string(text)
531 |
532 | e.editMode()
533 | e.undoStack = make([]func() bool, 0)
534 | e.searchTerm = make([]rune, 0)
535 | e.highlighted = make(map[*editorLine]map[int]bool)
536 | e.start = &editorLine{values: make([]rune, 0)}
537 | e.cursor = &editorCursor{line: e.start, x: 0}
538 | currentLine := e.start
539 |
540 | if len(source) == 0 {
541 | currentLine.values = append(currentLine.values, '\n')
542 | } else {
543 | for _, char := range source {
544 | currentLine.values = append(currentLine.values, char)
545 | if char == '\n' {
546 | nextLine := &editorLine{values: make([]rune, 0)}
547 | currentLine.next = nextLine
548 | nextLine.prev = currentLine
549 | currentLine = nextLine
550 | }
551 | }
552 | }
553 |
554 | // Ensure the final line ends with `\n`
555 | if len(currentLine.values) > 0 && currentLine.values[len(currentLine.values)-1] != '\n' {
556 | currentLine.values = append(currentLine.values, '\n')
557 | }
558 |
559 | // Remove dangling line
560 | if currentLine.prev != nil {
561 | currentLine.prev.next = nil
562 | }
563 |
564 | // Refresh the internal image.
565 | e.updateImage()
566 | }
567 |
568 | func (e *Editor) search() {
569 | // Always reset search highlights (for empty searches)
570 | e.searchHighlights = make(map[*editorLine]map[int]bool)
571 |
572 | if len(e.searchTerm) == 0 {
573 | return
574 | }
575 |
576 | curLine := e.start
577 | searchTermIndex := 0
578 |
579 | // Store the location of all runes that are part of a result
580 | // this will be used render search highlights
581 | possibleMatches := make(map[*editorLine]map[int]bool, 0)
582 |
583 | // Store the starting lines and line indexes of every match
584 | // this will be used to tab between results
585 | possibleLines := make([]*editorLine, 0)
586 | possibleXs := make([]int, 0)
587 |
588 | for curLine != nil {
589 | for index, r := range curLine.values {
590 | if unicode.ToLower(e.searchTerm[searchTermIndex]) == unicode.ToLower(r) {
591 |
592 | // We've found the possible start of a match
593 | if searchTermIndex == 0 {
594 | possibleLines = append(possibleLines, curLine)
595 | possibleXs = append(possibleXs, index)
596 | }
597 | searchTermIndex++
598 |
599 | // We've found part of a possible match
600 | if _, ok := possibleMatches[curLine]; !ok {
601 | possibleMatches[curLine] = make(map[int]bool)
602 | }
603 | possibleMatches[curLine][index] = true
604 | } else {
605 | // Clear up the incorrect possible start
606 | if searchTermIndex > 0 {
607 | possibleLines = possibleLines[:len(possibleLines)-1]
608 | possibleXs = possibleXs[:len(possibleXs)-1]
609 | }
610 |
611 | searchTermIndex = 0
612 |
613 | // Clear up the incorrect possible match parts
614 | possibleMatches = make(map[*editorLine]map[int]bool, 0)
615 | }
616 |
617 | // We found a full match. Save the match parts for highlighting
618 | // and reset all state to check for more matches
619 | if searchTermIndex == len(e.searchTerm) {
620 | for line := range possibleMatches {
621 | for x := range possibleMatches[line] {
622 | if _, ok := e.searchHighlights[line]; !ok {
623 | e.searchHighlights[line] = make(map[int]bool)
624 | }
625 | e.searchHighlights[line][x] = true
626 | }
627 | }
628 |
629 | searchTermIndex = 0
630 | possibleMatches = make(map[*editorLine]map[int]bool, 0)
631 | }
632 | }
633 | curLine = curLine.next
634 | }
635 |
636 | // Were there any full matches?
637 | if len(possibleLines) > 0 {
638 |
639 | // Have we tabbed before the first full match?
640 | if e.searchIndex == -1 {
641 | e.cursor.line = possibleLines[len(possibleLines)-1]
642 | e.cursor.x = possibleXs[len(possibleXs)-1]
643 | e.searchIndex = len(possibleLines) - 1
644 | return
645 | }
646 |
647 | // Have we tabbed beyond the final full match?
648 | if e.searchIndex > len(possibleLines)-1 {
649 | e.searchIndex = 0
650 | }
651 |
652 | // Move to the desired match
653 | e.cursor.line = possibleLines[e.searchIndex]
654 | e.cursor.x = possibleXs[e.searchIndex]
655 | return
656 | }
657 |
658 | // There were no matches, reset so that the next search can hit the first match it finds
659 | e.searchIndex = 0
660 | }
661 |
662 | func (e *Editor) fnHandleRuneSingle(r rune) func() bool {
663 | undoDeleteHighlighted := func() bool { return false }
664 | if len(e.highlighted) != 0 {
665 | undoDeleteHighlighted = e.fnDeleteHighlighted()
666 | }
667 |
668 | e.handleRune(r)
669 |
670 | lineNum := e.getLineNumber()
671 | curX := e.cursor.x
672 | return func() bool {
673 | e.MoveCursor(lineNum, curX)
674 | e.deletePrevious()
675 | undoDeleteHighlighted()
676 | return true
677 | }
678 | }
679 |
680 | func (e *Editor) fnHandleRuneMulti(rs []rune) func() bool {
681 | undoDeleteHighlighted := func() bool { return false }
682 | if len(e.highlighted) != 0 {
683 | undoDeleteHighlighted = e.fnDeleteHighlighted()
684 | }
685 |
686 | for _, r := range rs {
687 | e.handleRune(r)
688 | }
689 |
690 | lineNum := e.getLineNumber()
691 | curX := e.cursor.x
692 | return func() bool {
693 | e.MoveCursor(lineNum, curX)
694 | for i := 0; i < len(rs); i++ {
695 | e.deletePrevious()
696 | }
697 | undoDeleteHighlighted()
698 | return true
699 | }
700 | }
701 |
702 | func (e *Editor) handleRune(r rune) {
703 | if e.mode == SEARCH_MODE {
704 | e.searchTerm = append(e.searchTerm, r)
705 | e.search()
706 | return
707 | }
708 |
709 | if len(e.highlighted) != 0 {
710 | e.resetHighlight()
711 | }
712 |
713 | if r == '\n' {
714 | before := e.cursor.line
715 | after := e.cursor.line.next
716 |
717 | shiftedValues := make([]rune, 0)
718 | leftBehindValues := make([]rune, 0)
719 | shiftedValues = append(shiftedValues, e.cursor.line.values[e.cursor.x:]...)
720 | leftBehindValues = append(leftBehindValues, e.cursor.line.values[:e.cursor.x]...)
721 | leftBehindValues = append(leftBehindValues, '\n')
722 | e.cursor.line.values = leftBehindValues
723 |
724 | e.cursor.line = &editorLine{
725 | values: shiftedValues,
726 | prev: before,
727 | next: after,
728 | }
729 | e.cursor.x = 0
730 |
731 | if before != nil {
732 | before.next = e.cursor.line
733 | }
734 | if after != nil {
735 | after.prev = e.cursor.line
736 | }
737 | } else {
738 | modifiedLine := make([]rune, 0)
739 | modifiedLine = append(modifiedLine, e.cursor.line.values[:e.cursor.x]...)
740 | modifiedLine = append(modifiedLine, r)
741 | modifiedLine = append(modifiedLine, e.cursor.line.values[e.cursor.x:]...)
742 | e.cursor.line.values = modifiedLine
743 | e.cursor.x++
744 | }
745 |
746 | e.setModified()
747 | }
748 |
749 | // Determine if the key has just been pressed, or is repeating
750 | func isKeyJustPressedOrRepeating(key ebiten.Key) bool {
751 | tps := ebiten.ActualTPS()
752 | delay_ticks := int(0.500 /*sec*/ * tps)
753 | interval_ticks := int(0.050 /*sec*/ * tps)
754 |
755 | // If tps is 0 or very small, provide reasonable defaults
756 | if interval_ticks == 0 {
757 | delay_ticks = 30
758 | interval_ticks = 3
759 | }
760 |
761 | // Down for one tick? Then just pressed.
762 | d := inpututil.KeyPressDuration(key)
763 | if d == 1 {
764 | return true
765 | }
766 |
767 | // Wait until after the delay to start repeating.
768 | if d >= delay_ticks {
769 | if (d-delay_ticks)%interval_ticks == 0 {
770 | return true
771 | }
772 | }
773 | return false
774 | }
775 |
776 | // fixPosition fixes the cursor position, and ensure the cursor is in the view.
777 | func (e *Editor) fixPosition() {
778 | e.cursor.FixPosition()
779 |
780 | lineno := e.getLineNumberFromLine(e.cursor.line) - 1
781 | switch {
782 | case lineno < e.firstVisible:
783 | e.firstVisible = lineno
784 | case lineno > (e.firstVisible + e.rows - 1):
785 | e.firstVisible = lineno - (e.rows - 1)
786 | }
787 | }
788 |
789 | // Update the editor state.
790 | func (e *Editor) Update() error {
791 | // Update the internal image when complete.
792 | defer e.updateImage()
793 |
794 | // // Log key number
795 | // for i := 0; i < int(ebiten.KeyMax); i++ {
796 | // if inpututil.IsKeyJustPressed(ebiten.Key(i)) {
797 | // println(i)
798 | // return nil
799 | // }
800 | // }
801 |
802 | // Modifiers
803 | command := ebiten.IsKeyPressed(ebiten.KeyMeta) || ebiten.IsKeyPressed(ebiten.KeyControl)
804 | shift := ebiten.IsKeyPressed(ebiten.KeyShift)
805 | option := ebiten.IsKeyPressed(ebiten.KeyAlt)
806 |
807 | isCommand := command && !(shift || option)
808 | isOnly := !(command || shift || option)
809 |
810 | // Although ebiten.AppendInputChars() would seem to be a better
811 | // solution, it 'eats' the CONTROL meta character on Linux, and
812 | // does not return a rune.
813 | for _, key := range inpututil.PressedKeys() {
814 | if !isKeyJustPressedOrRepeating(key) {
815 | continue
816 | }
817 |
818 | // Get the active keyboard map name (keycap) for the US QUERTY scancode
819 | // that was pressed.
820 | letter := ebiten.KeyName(key)
821 | if len(letter) == 0 && key >= ebiten.KeyA && key <= ebiten.KeyZ {
822 | // KeyName not supported? Use a reasonable default 1:1 mapping.
823 | letter = string([]rune{rune('a') + rune(key-ebiten.KeyA)})
824 | }
825 |
826 | // Command-KEY codes.
827 | if isCommand {
828 | switch letter {
829 | case "f":
830 | // Enter search mode
831 | if e.mode == SEARCH_MODE {
832 | e.editMode()
833 | } else {
834 | e.searchMode()
835 | }
836 | case "z":
837 | // Undo (may repeat)
838 | e.editMode()
839 | e.resetHighlight()
840 |
841 | for len(e.undoStack) > 0 {
842 | notNoop := e.undoStack[len(e.undoStack)-1]()
843 | e.undoStack = e.undoStack[:len(e.undoStack)-1]
844 | if notNoop {
845 | break
846 | }
847 | }
848 | case "q":
849 | // Quit
850 | e.quit()
851 | case "s":
852 | // Save
853 | e.Save()
854 | case "a":
855 | // Highlight all
856 | e.editMode()
857 | e.fnSelectAll()
858 | case "v":
859 | // Paste (may repeat)
860 | pasteBytes := e.clipboard.ReadText()
861 | rs := []rune{}
862 | for _, r := range string(pasteBytes) {
863 | rs = append(rs, r)
864 | }
865 | e.storeUndoAction(e.fnHandleRuneMulti(rs))
866 | e.setModified()
867 | case "x":
868 | // Cut highlight
869 | copyRunes := e.getHighlightedRunes()
870 | if len(copyRunes) == 0 {
871 | break
872 | }
873 |
874 | e.clipboard.WriteText([]byte(string(copyRunes)))
875 |
876 | e.storeUndoAction(e.fnDeleteHighlighted())
877 | e.resetHighlight()
878 |
879 | e.setModified()
880 | case "c":
881 | // Copy highlight
882 | if len(e.highlighted) == 0 {
883 | break
884 | }
885 | copyRunes := e.getHighlightedRunes()
886 | copyBytes := []byte(string(copyRunes))
887 | e.clipboard.WriteText(copyBytes)
888 | default:
889 | // Ignored key
890 | }
891 | }
892 | }
893 |
894 | // All other keys that can be converted into runes.
895 | // Even handles emoji input!
896 | if !(command || option) {
897 | // Keys which are valid input
898 | letters := ebiten.AppendInputChars(nil)
899 | for _, letter := range letters {
900 | e.storeUndoAction(e.fnHandleRuneSingle(letter))
901 | }
902 | }
903 |
904 | // Arrows
905 | right := isKeyJustPressedOrRepeating(ebiten.KeyArrowRight)
906 | left := isKeyJustPressedOrRepeating(ebiten.KeyArrowLeft)
907 | up := isKeyJustPressedOrRepeating(ebiten.KeyArrowUp)
908 | down := isKeyJustPressedOrRepeating(ebiten.KeyArrowDown)
909 | pageup := isKeyJustPressedOrRepeating(ebiten.KeyPageUp)
910 | pagedown := isKeyJustPressedOrRepeating(ebiten.KeyPageDown)
911 | home := isKeyJustPressedOrRepeating(ebiten.KeyHome)
912 | end := isKeyJustPressedOrRepeating(ebiten.KeyEnd)
913 |
914 | // Exit search mode
915 | if isOnly && inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
916 | e.editMode()
917 | return nil
918 | }
919 |
920 | // Next/previous search match
921 | if isOnly && (up || down) && e.mode == SEARCH_MODE {
922 | if up {
923 | if e.searchIndex > -1 {
924 | e.searchIndex--
925 | }
926 | } else if down {
927 | e.searchIndex++
928 | }
929 | e.search()
930 | return nil
931 | }
932 |
933 | // Handle movement
934 | if right || left || up || down || home || end || pageup || pagedown {
935 | e.editMode()
936 |
937 | // Clear up old highlighting
938 | if !shift {
939 | e.resetHighlight()
940 | }
941 |
942 | // Option scanning finds the next emptyType after hitting a non-emptyType
943 | // TODO: the characters that we filter for needs improving
944 | emptyTypes := map[rune]bool{' ': true, '.': true, ',': true}
945 |
946 | switch {
947 | case end:
948 | switch {
949 | case !option && !command:
950 | for e.cursor.x < len(e.cursor.line.values)-1 {
951 | if shift {
952 | e.highlight(e.cursor.line, e.cursor.x)
953 | }
954 | e.cursor.x++
955 | }
956 | }
957 | case home:
958 | switch {
959 | case !option && !command:
960 | for e.cursor.x > 0 {
961 | e.cursor.x--
962 | if shift {
963 | e.highlight(e.cursor.line, e.cursor.x)
964 | }
965 | }
966 | }
967 | case pagedown:
968 | switch {
969 | case !option && !command:
970 | for rows := e.rows; e.cursor.line.next != nil && rows > 0; rows-- {
971 | e.cursor.line = e.cursor.line.next
972 | e.firstVisible++
973 | }
974 | e.fixPosition()
975 | }
976 | case pageup:
977 | switch {
978 | case !option && !command:
979 | for rows := e.rows; e.cursor.line.prev != nil && rows > 0; rows-- {
980 | e.cursor.line = e.cursor.line.prev
981 | e.firstVisible--
982 | }
983 | e.fixPosition()
984 | }
985 | case right:
986 | switch {
987 | case option && !command:
988 | // Find the next empty
989 | for e.cursor.x < len(e.cursor.line.values)-2 {
990 | if shift {
991 | e.highlight(e.cursor.line, e.cursor.x)
992 | }
993 | e.cursor.x++
994 | if ok := emptyTypes[e.cursor.line.values[e.cursor.x]]; !ok {
995 | } else {
996 | break
997 | }
998 | if shift {
999 | e.highlight(e.cursor.line, e.cursor.x)
1000 | }
1001 | }
1002 | case !option && command:
1003 | for e.cursor.x < len(e.cursor.line.values)-1 {
1004 | if shift {
1005 | e.highlight(e.cursor.line, e.cursor.x)
1006 | }
1007 | e.cursor.x++
1008 | }
1009 | case !option && !command:
1010 | if e.cursor.x < len(e.cursor.line.values)-1 {
1011 | if shift {
1012 | e.highlight(e.cursor.line, e.cursor.x)
1013 | }
1014 | e.cursor.x++
1015 | } else if e.cursor.line.next != nil {
1016 | if shift {
1017 | e.highlight(e.cursor.line, len(e.cursor.line.values)-1)
1018 | }
1019 | e.cursor.line = e.cursor.line.next
1020 | e.cursor.x = 0
1021 | }
1022 | }
1023 | case left:
1024 | switch {
1025 | case option && !command:
1026 | // Find the next non-empty
1027 | for e.cursor.x > 0 {
1028 | e.cursor.x--
1029 | if shift {
1030 | e.highlight(e.cursor.line, e.cursor.x)
1031 | }
1032 | if ok := emptyTypes[e.cursor.line.values[e.cursor.x]]; !ok {
1033 | break
1034 | }
1035 | }
1036 |
1037 | // Find the next empty
1038 | for e.cursor.x > 0 {
1039 | if ok := emptyTypes[e.cursor.line.values[e.cursor.x-1]]; !ok {
1040 | if shift {
1041 | e.highlight(e.cursor.line, e.cursor.x)
1042 | }
1043 | } else {
1044 | break
1045 | }
1046 | e.cursor.x--
1047 | if shift {
1048 | e.highlight(e.cursor.line, e.cursor.x)
1049 | }
1050 | }
1051 | case !option && command:
1052 | for e.cursor.x > 0 {
1053 | e.cursor.x--
1054 | if shift {
1055 | e.highlight(e.cursor.line, e.cursor.x)
1056 | }
1057 | }
1058 | case !option && !command:
1059 | if e.cursor.x > 0 {
1060 | e.cursor.x--
1061 | if shift {
1062 | e.highlight(e.cursor.line, e.cursor.x)
1063 | }
1064 | } else if e.cursor.line.prev != nil {
1065 | e.cursor.line = e.cursor.line.prev
1066 | e.cursor.x = len(e.cursor.line.values) - 1
1067 | if shift {
1068 | e.highlight(e.cursor.line, e.cursor.x)
1069 | }
1070 | }
1071 | }
1072 | case up:
1073 | switch {
1074 | case option && !command:
1075 | e.storeUndoAction(e.fnSwapUp())
1076 | case !option && command:
1077 | if shift {
1078 | e.highlightLineToLeft()
1079 | }
1080 | for e.cursor.line.prev != nil {
1081 | if shift {
1082 | e.highlightLine()
1083 | }
1084 | e.cursor.line = e.cursor.line.prev
1085 | e.cursor.x = 0
1086 | if shift {
1087 | e.highlightLineToRight()
1088 | }
1089 | }
1090 | e.fixPosition()
1091 | case !option && !command:
1092 | for x := e.cursor.x - 1; shift && x >= 0; x-- {
1093 | e.highlight(e.cursor.line, x)
1094 | }
1095 | if e.cursor.line.prev != nil {
1096 | e.cursor.line = e.cursor.line.prev
1097 | for x := e.cursor.x; shift && x < len(e.cursor.line.values); x++ {
1098 | e.highlight(e.cursor.line, x)
1099 | }
1100 | } else {
1101 | e.cursor.x = 0
1102 | }
1103 | e.fixPosition()
1104 | }
1105 | case down:
1106 | switch {
1107 | case option && !command && !shift:
1108 | e.storeUndoAction(e.fnSwapDown())
1109 | case !option && command:
1110 | for e.cursor.line.next != nil {
1111 | if shift {
1112 | e.highlightLineToRight()
1113 | }
1114 | e.cursor.line = e.cursor.line.next
1115 | if shift {
1116 | e.highlightLineToLeft()
1117 | }
1118 | }
1119 | // instead of fixing position, we actually want the document end
1120 | if shift {
1121 | e.highlightLineToRight()
1122 | }
1123 | e.cursor.x = len(e.cursor.line.values) - 1
1124 | e.fixPosition()
1125 | case !option && !command:
1126 | if e.cursor.line.next != nil {
1127 | if shift {
1128 | e.highlightLineToRight()
1129 | }
1130 | e.cursor.line = e.cursor.line.next
1131 | e.fixPosition()
1132 | if shift {
1133 | e.highlightLineToLeft()
1134 | }
1135 | }
1136 | }
1137 | }
1138 |
1139 | return nil
1140 | }
1141 |
1142 | // Enter
1143 | if isOnly && isKeyJustPressedOrRepeating(ebiten.KeyEnter) {
1144 | if e.mode == SEARCH_MODE {
1145 | e.searchIndex++
1146 | e.search()
1147 | } else {
1148 | e.storeUndoAction(e.fnHandleRuneSingle('\n'))
1149 | e.fixPosition()
1150 | }
1151 | return nil
1152 | }
1153 |
1154 | // Tab
1155 | if isOnly && isKeyJustPressedOrRepeating(ebiten.KeyTab) {
1156 | if e.mode == SEARCH_MODE {
1157 | e.searchIndex++
1158 | e.search()
1159 | return nil
1160 | }
1161 | // Just insert four spaces
1162 | for i := 0; i < 4; i++ {
1163 | e.storeUndoAction(e.fnHandleRuneSingle(' '))
1164 | }
1165 | return nil
1166 | }
1167 |
1168 | // Backspace
1169 | if isOnly && isKeyJustPressedOrRepeating(ebiten.KeyBackspace) {
1170 | if e.mode == SEARCH_MODE {
1171 | if len(e.searchTerm) > 0 {
1172 | e.searchTerm = e.searchTerm[:len(e.searchTerm)-1]
1173 | }
1174 | e.search()
1175 | return nil
1176 | }
1177 | // Delete all highlighted content
1178 | if len(e.highlighted) != 0 {
1179 | e.storeUndoAction(e.fnDeleteHighlighted())
1180 | } else {
1181 | // Or..
1182 | e.storeUndoAction(e.fnDeleteSinglePrevious())
1183 | }
1184 |
1185 | e.resetHighlight()
1186 | e.setModified()
1187 | return nil
1188 | }
1189 |
1190 | return nil
1191 | }
1192 |
1193 | func (e *Editor) storeUndoAction(fun func() bool) {
1194 | if e.mode == EDIT_MODE {
1195 | e.undoStack = append(e.undoStack, fun)
1196 | }
1197 | }
1198 |
1199 | func (e *Editor) fnReturnToCursor(line *editorLine, startingX int) func() {
1200 | destination := e.getLineNumberFromLine(line)
1201 | return func() {
1202 | i := 1
1203 | e.cursor.line = e.start
1204 | for i != destination {
1205 | i++
1206 | e.cursor.line = e.cursor.line.next
1207 | }
1208 | e.cursor.x = startingX
1209 | }
1210 | }
1211 |
1212 | func (e *Editor) fnSwapDown() func() bool {
1213 | if e.cursor.line.next != nil {
1214 | tempValues := e.cursor.line.values
1215 | e.cursor.line.values = e.cursor.line.next.values
1216 | e.cursor.line.next.values = tempValues
1217 | e.cursor.line = e.cursor.line.next
1218 | e.fixPosition()
1219 |
1220 | lineNum := e.getLineNumber()
1221 | curX := e.cursor.x
1222 | return func() bool {
1223 | e.MoveCursor(lineNum, curX)
1224 | tempValues := e.cursor.line.values
1225 | e.cursor.line.values = e.cursor.line.prev.values
1226 | e.cursor.line.prev.values = tempValues
1227 | e.cursor.line = e.cursor.line.prev
1228 | return true
1229 | }
1230 | }
1231 | return noop
1232 | }
1233 |
1234 | func (e *Editor) fnSwapUp() func() bool {
1235 | if e.cursor.line.prev != nil {
1236 | tempValues := e.cursor.line.values
1237 | e.cursor.line.values = e.cursor.line.prev.values
1238 | e.cursor.line.prev.values = tempValues
1239 | e.cursor.line = e.cursor.line.prev
1240 | e.fixPosition()
1241 |
1242 | lineNum := e.getLineNumber()
1243 | curX := e.cursor.x
1244 | return func() bool {
1245 | e.MoveCursor(lineNum, curX)
1246 | tempValues := e.cursor.line.values
1247 | e.cursor.line.values = e.cursor.line.next.values
1248 | e.cursor.line.next.values = tempValues
1249 | e.cursor.line = e.cursor.line.next
1250 | return true
1251 | }
1252 | }
1253 | return noop
1254 | }
1255 |
1256 | func (e *Editor) fnSelectAll() {
1257 | e.cursor.line = e.start
1258 | e.highlightLine()
1259 |
1260 | for e.cursor.line.next != nil {
1261 | e.cursor.line = e.cursor.line.next
1262 | e.cursor.x = len(e.cursor.line.values) - 1
1263 | e.highlightLine()
1264 | }
1265 | }
1266 |
1267 | func (e *Editor) fnDeleteSinglePrevious() func() bool {
1268 | if e.cursor.line == e.start && e.cursor.x == 0 {
1269 | return noop
1270 | }
1271 |
1272 | if e.cursor.x-1 < 0 {
1273 | e.deletePrevious()
1274 | lineNum := e.getLineNumber()
1275 | curX := e.cursor.x
1276 | return func() bool {
1277 | e.MoveCursor(lineNum, curX)
1278 | e.handleRune('\n')
1279 | return true
1280 | }
1281 | } else {
1282 | curRune := e.cursor.line.values[e.cursor.x-1]
1283 | e.deletePrevious()
1284 | lineNum := e.getLineNumber()
1285 | curX := e.cursor.x
1286 | return func() bool {
1287 | e.MoveCursor(lineNum, curX)
1288 | e.handleRune(curRune)
1289 | return true
1290 | }
1291 | }
1292 | }
1293 |
1294 | func (e *Editor) deletePrevious() {
1295 | // Instead of allowing an empty document, "clear it" by writing a new line character
1296 | if e.cursor.line == e.start && len(e.cursor.line.values) == 1 {
1297 | e.cursor.line.values = []rune{'\n'}
1298 | e.fixPosition()
1299 | return
1300 | }
1301 |
1302 | if e.cursor.x == 0 {
1303 | if e.cursor.line.prev != nil {
1304 | e.cursor.x = len(e.cursor.line.prev.values) - 1
1305 | e.cursor.line.prev.values = e.cursor.line.prev.values[:len(e.cursor.line.prev.values)-1]
1306 | e.cursor.line.prev.values = append(e.cursor.line.prev.values, e.cursor.line.values...)
1307 | e.cursor.line.prev.next = e.cursor.line.next
1308 | if e.cursor.line.next != nil {
1309 | e.cursor.line.next.prev = e.cursor.line.prev
1310 | }
1311 | e.cursor.line = e.cursor.line.prev
1312 | }
1313 | } else {
1314 | e.cursor.x--
1315 | e.cursor.line.values = append(e.cursor.line.values[:e.cursor.x], e.cursor.line.values[e.cursor.x+1:]...)
1316 | }
1317 | }
1318 |
1319 | func (e *Editor) getHighlightedRunes() []rune {
1320 | copyRunes := make([]rune, 0)
1321 | curLine := e.start
1322 | for curLine != nil {
1323 | if highlightedLine, ok := e.highlighted[curLine]; ok {
1324 | highlightedIndexes := make([]int, 0)
1325 | for index := range highlightedLine {
1326 | highlightedIndexes = append(highlightedIndexes, index)
1327 | }
1328 | sort.Ints(highlightedIndexes)
1329 | for _, i := range highlightedIndexes {
1330 | copyRunes = append(copyRunes, curLine.values[i])
1331 | }
1332 | }
1333 | curLine = curLine.next
1334 | }
1335 | return copyRunes
1336 | }
1337 |
1338 | func (e *Editor) highlightLine() {
1339 | for x := range e.cursor.line.values {
1340 | e.highlight(e.cursor.line, x)
1341 | }
1342 | }
1343 |
1344 | func (e *Editor) highlightLineToRight() {
1345 | for x := e.cursor.x; x < len(e.cursor.line.values); x++ {
1346 | e.highlight(e.cursor.line, x)
1347 | }
1348 | }
1349 |
1350 | func (e *Editor) highlightLineToLeft() {
1351 | for x := e.cursor.x - 1; x > -1; x-- {
1352 | e.highlight(e.cursor.line, x)
1353 | }
1354 | }
1355 |
1356 | func (e *Editor) highlight(line *editorLine, x int) {
1357 | if _, ok := e.highlighted[line]; ok {
1358 | e.highlighted[line][x] = true
1359 | } else {
1360 | e.highlighted[line] = map[int]bool{x: true}
1361 | }
1362 | }
1363 |
1364 | func (e *Editor) getAllRunes() []rune {
1365 | all := make([]rune, 0)
1366 | cur := e.start
1367 | for cur != nil {
1368 | all = append(all, cur.values...)
1369 | cur = cur.next
1370 | }
1371 | return all
1372 | }
1373 |
1374 | // Cursor returns the current cursor position.
1375 | func (e *Editor) Cursor() (row int, col int) {
1376 | return e.getLineNumberFromLine(e.cursor.line) - 1, e.cursor.x
1377 | }
1378 |
1379 | // MoveCursor moves the cursor to the specified location.
1380 | // If `row` is `-1` then the cursor will be on the final row.
1381 | // If `col` is `-1` then the cursor is moved to the final rune in the row.
1382 | func (e *Editor) MoveCursor(row int, col int) {
1383 | e.cursor.line = e.start
1384 | i := 0
1385 | for i != row {
1386 | if e.cursor.line.next == nil {
1387 | if row < 0 {
1388 | // We're moving to the last line.
1389 | break
1390 | }
1391 | log.Fatalf("attempted illegal move to %v %v", row, col)
1392 | }
1393 | e.cursor.line = e.cursor.line.next
1394 | i++
1395 | }
1396 | if col == -1 {
1397 | e.cursor.x = len(e.cursor.line.values) - 1
1398 | } else {
1399 | e.cursor.x = col
1400 | }
1401 |
1402 | e.fixPosition()
1403 | }
1404 |
1405 | // Get the cursor's current line number
1406 | func (e *Editor) getLineNumber() int {
1407 | return e.getLineNumberFromLine(e.cursor.line) - 1
1408 | }
1409 |
1410 | func (e *Editor) getLineNumberFromLine(line *editorLine) int {
1411 | cur := e.start
1412 | count := 1
1413 | for cur != line && cur != e.cursor.line {
1414 | count++
1415 | cur = cur.next
1416 | }
1417 | return count
1418 | }
1419 |
1420 | // Return the size in pixels of the editor.
1421 | func (e *Editor) Size() (width, height int) {
1422 | return e.width, e.height
1423 | }
1424 |
1425 | // copyIntoImageStretched copies the src image into dst,
1426 | // such that src is stretched to fit dst.
1427 | func copyIntoImageStretched(dst, src *ebiten.Image) {
1428 | src_width, src_height := src.Size()
1429 | dst_width, dst_height := dst.Size()
1430 | scale_width := float64(dst_width) / float64(src_width)
1431 | scale_height := float64(dst_height) / float64(src_height)
1432 | opts := ebiten.DrawImageOptions{}
1433 | opts.GeoM.Scale(scale_width, scale_height)
1434 | dst.DrawImage(src, &opts)
1435 | }
1436 |
1437 | // Draw the editor onto the screen, scaled to full size.
1438 | func (e *Editor) Draw(screen *ebiten.Image) {
1439 | // Scale editor to the screen region we want to draw into.
1440 | copyIntoImageStretched(screen, e.screen)
1441 | }
1442 |
1443 | // Color a line based on a selection highlighing map.
1444 | func (e *Editor) colorSelected(col, row int, runes []rune, selected map[int]bool, selected_color color.Color) {
1445 | start := -1
1446 | fontFace := e.font_info.face
1447 |
1448 | draw_highlight := func(start, end int) {
1449 | // End of a selection - highlight it!
1450 | x_offset := e.width_padding
1451 | x_offset += font.MeasureString(fontFace, string(runes[col:col+start])).Floor()
1452 | x_advance := font.MeasureString(fontFace, string(runes[col+start:col+end])).Ceil()
1453 |
1454 | // Draw the selection highlight background
1455 | ebitenutil.DrawRect(
1456 | e.screen,
1457 | float64(x_offset),
1458 | float64(row*e.font_info.yUnit+e.top_padding),
1459 | float64(x_advance),
1460 | float64(e.font_info.yUnit),
1461 | selected_color,
1462 | )
1463 | }
1464 |
1465 | for x, _ := range runes[col:] {
1466 | _, ok := selected[col+x]
1467 | if ok {
1468 | if start < 0 {
1469 | // Beginning of a selection
1470 | start = x
1471 | }
1472 | } else {
1473 | if start >= 0 {
1474 | draw_highlight(start, x)
1475 | start = -1
1476 | }
1477 | }
1478 | }
1479 |
1480 | if start >= 0 {
1481 | draw_highlight(start, len(runes)-1)
1482 | }
1483 | }
1484 |
1485 | // Content() returns the current content manager.
1486 | func (e *Editor) Content() Content {
1487 | return e.content
1488 | }
1489 |
1490 | // SetContent() sets the content manager.
1491 | // NOTE: This does _not_ modify the editor until a Load()
1492 | func (e *Editor) SetContent(content Content) {
1493 | e.content = content
1494 | }
1495 |
1496 | // ContentName() returns the current content name.
1497 | func (e *Editor) ContentName() string {
1498 | return e.content_name
1499 | }
1500 |
1501 | // SetContentName updates the top bar's content name.
1502 | func (e *Editor) SetContentName(content_name string) {
1503 | e.content_name = content_name
1504 |
1505 | // Update the backing image.
1506 | e.updateImage()
1507 | }
1508 |
1509 | // Return the internal image of the editor.
1510 | func (e *Editor) Image() (img *ebiten.Image) {
1511 | return e.screen
1512 | }
1513 |
1514 | // updateImage updates the internal image.
1515 | func (e *Editor) updateImage() {
1516 | screen := e.screen
1517 |
1518 | // Draw the background
1519 | if e.background_image != nil {
1520 | copyIntoImageStretched(e.screen, e.background_image)
1521 | }
1522 |
1523 | // Collect font metrics.
1524 | xUnit := e.font_info.xUnit
1525 | yUnit := e.font_info.yUnit
1526 | fontAscent := e.font_info.ascent
1527 | textColor := e.font_color
1528 | fontFace := e.font_info.face
1529 |
1530 | // Handle top bar
1531 | if e.top_bar {
1532 | modifiedText := ""
1533 | if e.modified {
1534 | modifiedText = "(modified)"
1535 | }
1536 |
1537 | topBar := ">"
1538 | if e.mode == SEARCH_MODE {
1539 | topBar = string(append([]rune(topBar), e.searchTerm...))
1540 | } else {
1541 | topBar = fmt.Sprintf("%s %s", e.content_name, modifiedText)
1542 | }
1543 |
1544 | text.Draw(screen, string(topBar), e.font_info.face,
1545 | e.width_padding, fontAscent,
1546 | textColor)
1547 | ebitenutil.DrawLine(e.screen, 0, float64(yUnit+1), float64(e.width), float64(yUnit+1), textColor)
1548 | }
1549 |
1550 | if e.bot_bar {
1551 | // Handle bottom bar
1552 | botBar := fmt.Sprintf("(x)cut (c)opy (v)paste (s)ave (q)uit (f)search [%v:%v:%v] ", e.getLineNumber()+1, e.cursor.x+1, e.cursor.line.values[e.cursor.x])
1553 | text.Draw(screen, string(botBar), e.font_info.face,
1554 | e.width_padding, e.height-yUnit+fontAscent,
1555 | textColor)
1556 |
1557 | ebitenutil.DrawLine(screen, 0, float64(e.height-yUnit-2), float64(e.width), float64(e.height-yUnit-2), textColor)
1558 | }
1559 |
1560 | // Handle all lines
1561 | y := 0
1562 |
1563 | // Find the first visible line.
1564 | curLine := e.start
1565 | for line := 0; curLine.next != nil && line != e.firstVisible; line++ {
1566 | // Skip to first visible
1567 | curLine = curLine.next
1568 | }
1569 |
1570 | for curLine != nil {
1571 | // Don't render outside the line area
1572 | if y == e.rows {
1573 | break
1574 | }
1575 |
1576 | // Handle each line (only render the visible section)
1577 | xStart := 0
1578 | charactersPerScreen := int(float64(e.width-e.width_padding*2) / float64(xUnit))
1579 | if e.cursor.line == curLine && e.cursor.x > charactersPerScreen {
1580 | xStart = ((e.cursor.x / charactersPerScreen) * charactersPerScreen) + 1
1581 | }
1582 |
1583 | // Render highlighting (if any)
1584 | if highlight, ok := e.highlighted[curLine]; ok {
1585 | e.colorSelected(xStart, y, curLine.values, highlight, e.select_color)
1586 | }
1587 |
1588 | // Render search highlighting (if any)
1589 | if searchHighlight, ok := e.searchHighlights[curLine]; ok {
1590 | e.colorSelected(xStart, y, curLine.values, searchHighlight, e.search_color)
1591 | }
1592 |
1593 | // Render cursor
1594 | if e.cursor.line == curLine {
1595 | // We append a '0' to the line to highlight, so that a
1596 | // cursor at the end of a line actually is a non-zero width.
1597 | runes := append(curLine.values, '0')
1598 |
1599 | cursorHighlight := map[int]bool{e.cursor.x: true}
1600 |
1601 | e.colorSelected(xStart, y, runes, cursorHighlight, e.cursor_color)
1602 | }
1603 |
1604 | // Render the text.
1605 | text.Draw(screen, string(curLine.values[xStart:]), fontFace,
1606 | e.width_padding, e.top_padding+y*yUnit+fontAscent,
1607 | textColor)
1608 |
1609 | curLine = curLine.next
1610 | y++
1611 | }
1612 | }
1613 |
1614 | func (e *Editor) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
1615 | return e.width, e.height
1616 | }
1617 |
--------------------------------------------------------------------------------
/editor_test.go:
--------------------------------------------------------------------------------
1 | package noter
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | )
7 |
8 | func TestGetLineNumber(t *testing.T) {
9 | line1 := &editorLine{}
10 | line2 := &editorLine{}
11 | line1.next = line2
12 | line2.prev = line1
13 | editor := &Editor{
14 | start: line1,
15 | cursor: &editorCursor{
16 | line2,
17 | 0,
18 | },
19 | }
20 |
21 | lineNum := editor.getLineNumber()
22 | want := 1
23 | if lineNum != want {
24 | t.Fatalf(`Expected current line number to be %v, got: %v`, want, lineNum)
25 | }
26 | }
27 |
28 | func TestGetAllRunes(t *testing.T) {
29 | line1 := &editorLine{values: []rune{'a', '\n'}}
30 | line2 := &editorLine{values: []rune{'b', '\n'}}
31 | line1.next = line2
32 | line2.prev = line1
33 | editor := &Editor{
34 | start: line1,
35 | cursor: &editorCursor{
36 | line2,
37 | 0,
38 | },
39 | }
40 |
41 | allRunes := editor.getAllRunes()
42 | if reflect.DeepEqual(allRunes, []rune{'a', '\n', 'b', '\n'}) != true {
43 | t.Fatalf(`Expected allRunes to return document runes, got: %v`, allRunes)
44 | }
45 | }
46 |
47 | func TestDeleteRune(t *testing.T) {
48 | line1 := &editorLine{values: []rune{'a', '\n'}}
49 | editor := &Editor{
50 | start: line1,
51 | cursor: &editorCursor{
52 | line1,
53 | 1,
54 | },
55 | }
56 |
57 | editor.fnDeleteSinglePrevious()
58 | if len(line1.values) != 0 && line1.values[0] != '\n' {
59 | t.Fatalf("Delete operation did not work correctly, got: %v", line1.values)
60 | }
61 | }
62 |
63 | func TestDeleteLine(t *testing.T) {
64 | line1 := &editorLine{values: []rune{'a', '\n'}}
65 | line2 := &editorLine{values: []rune{'b', '\n'}}
66 | line1.next = line2
67 | line2.prev = line1
68 | editor := &Editor{
69 | start: line1,
70 | cursor: &editorCursor{
71 | line2,
72 | 1,
73 | },
74 | }
75 |
76 | editor.fnDeleteSinglePrevious()
77 | editor.fnDeleteSinglePrevious()
78 | if len(line1.values) != 0 && line1.next != nil {
79 | t.Fatalf("Delete operation did not work correctly, got: %v, %v", line1.values, line1.next)
80 | }
81 | }
82 |
83 | func TestHighlightLineAndGetHighlightedRunes(t *testing.T) {
84 | line1 := &editorLine{values: []rune{'a', '\n'}}
85 | line2 := &editorLine{values: []rune{'b', '\n'}}
86 | line1.next = line2
87 | line2.prev = line1
88 | editor := &Editor{
89 | start: line1,
90 | cursor: &editorCursor{
91 | line2,
92 | 1,
93 | },
94 | // This would normally happen in editor.Load()
95 | highlighted: make(map[*editorLine]map[int]bool),
96 | }
97 |
98 | editor.highlightLine()
99 | if reflect.DeepEqual(editor.getHighlightedRunes(), []rune{'b', '\n'}) != true {
100 | t.Fatalf(`Expected GetHighlightedRunes to return line2's runes, got: %v`, editor.getHighlightedRunes())
101 | }
102 | }
103 |
104 | func TestSearch(t *testing.T) {
105 | line1 := &editorLine{values: []rune{'a', '\n'}}
106 | line2 := &editorLine{values: []rune{'b', '\n'}}
107 | line1.next = line2
108 | line2.prev = line1
109 | editor := &Editor{
110 | start: line1,
111 | cursor: &editorCursor{
112 | line2,
113 | 1,
114 | },
115 | }
116 |
117 | editor.mode = SEARCH_MODE
118 | // This would normally happen in editor.Load()
119 | editor.searchHighlights = map[*editorLine]map[int]bool{}
120 | editor.searchTerm = []rune{'b'}
121 | editor.search()
122 |
123 | if _, ok := editor.searchHighlights[line2]; !ok {
124 | t.Fatalf("Incorrect search highlights: line2 wasn't highlighted")
125 | }
126 | if _, ok := editor.searchHighlights[line2][0]; !ok {
127 | t.Fatalf("Incorrect search highlights: line index wasn't highlighted")
128 | }
129 |
130 | if editor.searchHighlights[line2][0] != true {
131 | t.Fatalf("Incorrect search highlights: boolean was false instead of true")
132 | }
133 | }
134 |
135 | func TestLayout(t *testing.T) {
136 | editor := NewEditor(
137 | WithWidth(123),
138 | WithHeight(456),
139 | )
140 |
141 | table := [](struct{ screen_w, screen_h, layout_w, layout_h int }){
142 | {123, 456, 123, 456},
143 | {0, 0, 123, 456},
144 | {1024, 768, 123, 456},
145 | }
146 |
147 | for _, entry := range table {
148 | w, h := editor.Layout(entry.screen_w, entry.screen_h)
149 | if w != entry.layout_w || h != entry.layout_h {
150 | t.Fatalf("Incorrect result (%v,%v) from Editor.Layout(%v,%v); expected (%v,%v)",
151 | w, h, entry.screen_w, entry.screen_h, entry.layout_w, entry.layout_h)
152 | }
153 | }
154 | }
155 |
156 | func TestMoveCursorScroll(t *testing.T) {
157 | editor := NewEditor(
158 | WithRows(3),
159 | WithColumns(4),
160 | )
161 |
162 | editor.WriteText([]byte("1\n2\n3\n4\n5\n6\n7\n8\n"))
163 |
164 | table := [](struct{ row, first_visible int }){
165 | {0, 0}, // Move to first line.
166 | {1, 0}, // Move to middle line.
167 | {2, 0}, // Move to end of first page.
168 | {3, 1}, // Scrolls down by one.
169 | {4, 2}, // Scrolls down by one mode.
170 | {2, 2}, // Back to top.
171 | {7, 5}, // Move to end.
172 | {5, 5}, // Move to top view of end.
173 | {4, 4}, // Move up one line moves one line up.
174 | {1, 1}, // Move up one line moves one line up.
175 | {0, 0}, // Move up to first page.
176 | }
177 |
178 | for _, entry := range table {
179 | editor.MoveCursor(entry.row, 0)
180 | if entry.first_visible != editor.firstVisible {
181 | t.Fatalf("Incorrect move to row %v, expected first visible to %v, was %v", entry.row, entry.first_visible, editor.firstVisible)
182 | }
183 | }
184 | }
185 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/healeycodes/noter
2 |
3 | go 1.19
4 |
5 | require (
6 | github.com/hajimehoshi/bitmapfont/v3 v3.0.0
7 | github.com/hajimehoshi/ebiten/v2 v2.6.6
8 | golang.org/x/image v0.15.0
9 | )
10 |
11 | require (
12 | github.com/ebitengine/purego v0.6.0 // indirect
13 | github.com/flopp/go-findfont v0.1.0 // indirect
14 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20220806181222-55e207c401ad // indirect
15 | github.com/hajimehoshi/file2byteslice v0.0.0-20210813153925-5340248a8f41 // indirect
16 | github.com/jezek/xgb v1.1.0 // indirect
17 | golang.design/x/clipboard v0.7.0 // indirect
18 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 // indirect
19 | golang.org/x/mobile v0.0.0-20230922142353-e2f452493d57 // indirect
20 | golang.org/x/sync v0.3.0 // indirect
21 | golang.org/x/sys v0.12.0 // indirect
22 | golang.org/x/text v0.14.0 // indirect
23 | )
24 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
2 | github.com/ebitengine/purego v0.0.0-20220905075623-aeed57cda744 h1:A8UnJ/5OKzki4HBDwoRQz7I6sxKsokpMXcGh+fUxpfc=
3 | github.com/ebitengine/purego v0.0.0-20220905075623-aeed57cda744/go.mod h1:Eh8I3yvknDYZeCuXH9kRNaPuHEwvXDCk378o9xszmHg=
4 | github.com/ebitengine/purego v0.6.0 h1:Yo9uBc1x+ETQbfEaf6wcBsjrQfCEnh/gaGUg7lguEJY=
5 | github.com/ebitengine/purego v0.6.0/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ=
6 | github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU=
7 | github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw=
8 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20220806181222-55e207c401ad h1:kX51IjbsJPCvzV9jUoVQG9GEUqIq5hjfYzXTqQ52Rh8=
9 | github.com/go-gl/glfw/v3.3/glfw v0.0.0-20220806181222-55e207c401ad/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
10 | github.com/hajimehoshi/bitmapfont/v2 v2.2.2 h1:4z08Fk1m3pjtlO7BdoP48u5bp/Y8xmKshf44aCXgYpE=
11 | github.com/hajimehoshi/bitmapfont/v2 v2.2.2/go.mod h1:Ua/x9Dkz7M9CU4zr1VHWOqGwjKdXbOTRsH7lWfb1Co0=
12 | github.com/hajimehoshi/bitmapfont/v3 v3.0.0 h1:r2+6gYK38nfztS/et50gHAswb9hXgxXECYgE8Nczmi4=
13 | github.com/hajimehoshi/bitmapfont/v3 v3.0.0/go.mod h1:+CxxG+uMmgU4mI2poq944i3uZ6UYFfAkj9V6WqmuvZA=
14 | github.com/hajimehoshi/ebiten/v2 v2.4.16 h1:vhuMtaB78N2HlNMfImV/SZkDPNJhOxgFrEIm1uh838o=
15 | github.com/hajimehoshi/ebiten/v2 v2.4.16/go.mod h1:BZcqCU4XHmScUi+lsKexocWcf4offMFwfp8dVGIB/G4=
16 | github.com/hajimehoshi/ebiten/v2 v2.6.6 h1:E5X87Or4VwKZIKjeC9+Vr4ComhZAz9h839myF4Q21kc=
17 | github.com/hajimehoshi/ebiten/v2 v2.6.6/go.mod h1:gKgQI26zfoSb6j5QbrEz2L6nuHMbAYwrsXa5qsGrQKo=
18 | github.com/hajimehoshi/file2byteslice v0.0.0-20210813153925-5340248a8f41 h1:s01qIIRG7vN/5ndLwkDktjx44ulFk6apvAjVBYR50Yo=
19 | github.com/hajimehoshi/file2byteslice v0.0.0-20210813153925-5340248a8f41/go.mod h1:CqqAHp7Dk/AqQiwuhV1yT2334qbA/tFWQW0MD2dGqUE=
20 | github.com/hajimehoshi/go-mp3 v0.3.3/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM=
21 | github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI=
22 | github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
23 | github.com/jakecoffman/cp v1.2.1/go.mod h1:JjY/Fp6d8E1CHnu74gWNnU0+b9VzEdUVPoJxg2PsTQg=
24 | github.com/jezek/xgb v1.0.1 h1:YUGhxps0aR7J2Xplbs23OHnV1mWaxFVcOl9b+1RQkt8=
25 | github.com/jezek/xgb v1.0.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
26 | github.com/jezek/xgb v1.1.0 h1:wnpxJzP1+rkbGclEkmwpVFQWpuE2PUGNUzP8SbfFobk=
27 | github.com/jezek/xgb v1.1.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
28 | github.com/jfreymuth/oggvorbis v1.0.4/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
29 | github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
30 | github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
31 | github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
32 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
33 | golang.design/x/clipboard v0.7.0 h1:4Je8M/ys9AJumVnl8m+rZnIvstSnYj1fvzqYrU3TXvo=
34 | golang.design/x/clipboard v0.7.0/go.mod h1:PQIvqYO9GP29yINEfsEn5zSQKAz3UgXmZKzDA6dnq2E=
35 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
36 | golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
37 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
38 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
39 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
40 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56 h1:estk1glOnSVeJ9tdEZZc5mAMDZk5lNJNyJ6DvrBkTEU=
41 | golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
42 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
43 | golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
44 | golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c=
45 | golang.org/x/image v0.3.0 h1:HTDXbdK9bjfSWkPzDJIw89W8CAtfFGduujWs33NLLsg=
46 | golang.org/x/image v0.3.0/go.mod h1:fXd9211C/0VTlYuAcOhW8dY/RtEJqODXOWBDpmYBf+A=
47 | golang.org/x/image v0.6.0 h1:bR8b5okrPI3g/gyZakLZHeWxAR8Dn5CyxXv1hLH5g/4=
48 | golang.org/x/image v0.6.0/go.mod h1:MXLdDR43H7cDJq5GEGXEVeeNhPgi+YYEQ2pC1byI1x0=
49 | golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
50 | golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
51 | golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
52 | golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
53 | golang.org/x/mobile v0.0.0-20220722155234-aaac322e2105 h1:3vUV5x5+3LfQbgk7paCM6INOaJG9xXQbn79xoNkwfIk=
54 | golang.org/x/mobile v0.0.0-20220722155234-aaac322e2105/go.mod h1:pe2sM7Uk+2Su1y7u/6Z8KJ24D7lepUjFZbhFOrmDfuQ=
55 | golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c h1:Gk61ECugwEHL6IiyyNLXNzmu8XslmRP2dS0xjIYhbb4=
56 | golang.org/x/mobile v0.0.0-20230301163155-e0f57694e12c/go.mod h1:aAjjkJNdrh3PMckS4B10TGS2nag27cbKR1y2BpUxsiY=
57 | golang.org/x/mobile v0.0.0-20230922142353-e2f452493d57 h1:Q6NT8ckDYNcwmi/bmxe+XbiDMXqMRW1xFBtJ+bIpie4=
58 | golang.org/x/mobile v0.0.0-20230922142353-e2f452493d57/go.mod h1:wEyOn6VvNW7tcf+bW/wBz1sehi2s2BZ4TimyR7qZen4=
59 | golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
60 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
61 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
62 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
63 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
64 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
65 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
66 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
67 | golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
68 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
69 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
70 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
71 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
72 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
73 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
74 | golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
75 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
76 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
77 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
78 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
79 | golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
80 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
81 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
82 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
83 | golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
84 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
85 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
86 | golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
87 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
88 | golang.org/x/sys v0.0.0-20220818161305-2296e01440c6 h1:Sx/u41w+OwrInGdEckYmEuU5gHoGSL4QbDz3S9s6j4U=
89 | golang.org/x/sys v0.0.0-20220818161305-2296e01440c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
90 | golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
91 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
92 | golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
93 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
94 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
95 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
96 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
97 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
98 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
99 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
100 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
101 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
102 | golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k=
103 | golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
104 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
105 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
106 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
107 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
108 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
109 | golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
110 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
111 | golang.org/x/tools v0.1.8-0.20211022200916-316ba0b74098/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
112 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
113 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
114 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
115 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
116 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
117 |
--------------------------------------------------------------------------------
/preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/healeycodes/noter/d233239fc9421af90d730e4afaa354b61a83e134/preview.png
--------------------------------------------------------------------------------