├── .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 | A screenshot of the editor running. It looks like nano. It has a text file called 'A Bird, came down the Walk' opened. 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 --------------------------------------------------------------------------------