From a5fe8fcf42b3ba28c20de6e7104b9de51f1a295b Mon Sep 17 00:00:00 2001 From: ivan Date: Sun, 22 Jan 2023 15:50:16 -0600 Subject: [PATCH] Saving & Creating Notes ; UI tweaks (#2) - removes prefix/suffix from filename in list box - changes mapping of from search box - saves content box when modified - adds 'scrolltoview' behavior - up/down keys in search box navigate through list - allows creation of new notes - shows snippets in list box - updates readme --- .gitignore | 1 + README.md | 17 +++++------ cmd/main.go | 7 +++-- content_box.go | 77 ++++++++++++++++++++++++++++++++++++++++++++++++-- database.go | 2 +- files.go | 9 ++++++ go.mod | 1 + go.sum | 14 ++------- list_box.go | 37 ++++++++++++++++++++++-- notes.go | 37 +++++++++++++++++++++++- search_box.go | 44 ++++++++++++++++++++--------- 11 files changed, 203 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index 2287d1a..3b4f2f8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .vscode dist/ *.db +*.log diff --git a/README.md b/README.md index eecf6e0..5b22b5a 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,17 @@ The goal is to point 'nve' to a directory of plain-text files, and quickly searc ## Current Status -- 2023/01 - Navigation, search and viewing. +- 2023/01/16 - Navigation, search and viewing. +- 2023/02/22 - Saving, creating & displaying snippets ## TODO -- Saving edits -- Creating new notes from search box -- Monitor FS changes to incrementally update DB -- Display snippet in search results -- Support renaming of notes (modal) -- Colorize matching search term in content -- Syntax highlighting for Markdown files +- [x] ✅ Saving edits +- [x] ✅ Creating new notes from search box +- [x] ✅ Display snippet in search results +- [ ] Monitor FS changes to incrementally update DB +- [ ] Support renaming of notes (modal) +- [ ] Colorize matching search term in content +- [ ] Syntax highlighting for Markdown files diff --git a/cmd/main.go b/cmd/main.go index 697be56..ed2597e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -16,10 +16,10 @@ func main() { // View hierarchy contentBox = nve.NewContentBox() listBox = nve.NewListBox(contentBox, notes) - searchBox = nve.NewSearchBox(listBox, notes) + searchBox = nve.NewSearchBox(listBox, contentBox, notes) ) - notes.RegisterObservers(contentBox, listBox) + notes.RegisterObservers(listBox) notes.Notify() // global input events @@ -35,7 +35,8 @@ func main() { } return &tcell.EventKey{} case tcell.KeyEscape: - if contentBox.HasFocus() { + if !contentBox.HasFocus() { + listBox.SetCurrentItem(0) app.SetFocus(searchBox) return &tcell.EventKey{} } diff --git a/content_box.go b/content_box.go index 0292579..febe813 100644 --- a/content_box.go +++ b/content_box.go @@ -1,17 +1,27 @@ package nve import ( + "log" + "time" + "unicode" + "unicode/utf8" + + "github.com/bep/debounce" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) type ContentBox struct { *tview.TextArea + debounce func(func()) currentFile *FileRef } func NewContentBox() *ContentBox { - textArea := ContentBox{TextArea: tview.NewTextArea()} + textArea := ContentBox{ + TextArea: tview.NewTextArea(), + debounce: debounce.New(300 * time.Millisecond), + } textArea.SetBorder(true). SetTitle("Content"). @@ -20,6 +30,12 @@ func NewContentBox() *ContentBox { SetBorderPadding(1, 0, 1, 1). SetTitleAlign(tview.AlignLeft) + textArea.SetFocusFunc(func() { + // ignore edits if there is no current file + if textArea.currentFile == nil { + textArea.Blur() + } + }) return &textArea } @@ -33,6 +49,61 @@ func (b *ContentBox) SetFile(f *FileRef) { b.SetText(GetContent(f.Filename), false) } -func (b *ContentBox) SearchResultsUpdate(_ *Notes) { - // TODO: if selected note changes, update content. +// InputHandler overrides default handling to switch focus away from search box when necessary. +func (b *ContentBox) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + return b.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { + event = b.mapSpecialKeys(event) + + before := b.GetText() + + if handler := b.TextArea.InputHandler(); handler != nil { + handler(event, setFocus) + } + + if after := b.GetText(); before != after { + b.queueSave(after) + } + }) +} + +func (b *ContentBox) mapSpecialKeys(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + // navigate up + case tcell.KeyCtrlP: + event = tcell.NewEventKey(tcell.KeyUp, event.Rune(), event.Modifiers()) + + // navigate down + case tcell.KeyCtrlN: + event = tcell.NewEventKey(tcell.KeyDown, event.Rune(), event.Modifiers()) + + // navigate forward + case tcell.KeyCtrlF: + event = tcell.NewEventKey(tcell.KeyRight, event.Rune(), event.Modifiers()) + + // delete empty line + case tcell.KeyCtrlK: + fromRow, fromCol, toRow, toCol := b.GetCursor() + + if fromRow == toRow && fromCol == toCol && fromCol == 0 { + if _, start, end := b.GetSelection(); start == end { + r, _ := utf8.DecodeRuneInString(b.GetText()[start:]) + if !unicode.IsLetter(r) { + event = tcell.NewEventKey(tcell.KeyDelete, event.Rune(), event.Modifiers()) + } + } + } + + } + + return event +} + +func (b *ContentBox) queueSave(content string) { + b.debounce(func() { + err := SaveContent(b.currentFile.Filename, content) + + if err != nil { + log.Println("Error saving content:", err) + } + }) } diff --git a/database.go b/database.go index 8bc36f8..0694f7a 100644 --- a/database.go +++ b/database.go @@ -132,7 +132,7 @@ func (db *DB) Recent(limit int) ([]*SearchResult, error) { err = db.Select(&res, ` SELECT docs.id, docs.filename, docs.md5, docs.modified_at, - substr(cti.text, 0, 10) as snippet + substr(cti.text, 0, 120) as snippet FROM documents docs INNER JOIN diff --git a/files.go b/files.go index bde4733..bc6fd63 100644 --- a/files.go +++ b/files.go @@ -7,6 +7,7 @@ import ( "io/fs" "os" "path/filepath" + "strings" "time" ) @@ -24,6 +25,10 @@ type FileRef struct { ModifiedAt time.Time `db:"modified_at"` } +func (f *FileRef) DisplayName() string { + return strings.TrimSuffix(filepath.Base(f.Filename), filepath.Ext(f.Filename)) +} + func GetContent(filename string) string { bytes, err := os.ReadFile(filename) @@ -34,6 +39,10 @@ func GetContent(filename string) string { return string(bytes) } +func SaveContent(filename string, content string) error { + return os.WriteFile(filename, []byte(content), 0644) +} + func scanDirectory(dirname string) ([]string, error) { var files []string diff --git a/go.mod b/go.mod index 4c6b28a..4e8c75a 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/ivan3bx/nve go 1.19 require ( + github.com/bep/debounce v1.2.1 github.com/gdamore/tcell/v2 v2.5.4 github.com/jmoiron/sqlx v1.3.5 github.com/mattn/go-sqlite3 v1.14.16 diff --git a/go.sum b/go.sum index 350ef2c..e37ba14 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,10 @@ +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= -github.com/gdamore/tcell/v2 v2.5.3 h1:b9XQrT6QGbgI7JvZOJXFNczOQeIYbo8BfeSMzt2sAV0= -github.com/gdamore/tcell/v2 v2.5.3/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo= github.com/gdamore/tcell/v2 v2.5.4 h1:TGU4tSjD3sCL788vFNeJnTdzpNKIw1H5dgLnJRQVv/k= github.com/gdamore/tcell/v2 v2.5.4/go.mod h1:dZgRy5v4iMobMEcWNYBtREnDZAT9DYmfqIkrgEMxLyw= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= @@ -15,8 +15,6 @@ github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= -github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= @@ -29,8 +27,6 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/tview v0.0.0-20230104153304-892d1a2eb0da h1:3Mh+tcC2KqetuHpWMurDeF+yOgyt4w4qtLIpwSQ3uqo= github.com/rivo/tview v0.0.0-20230104153304-892d1a2eb0da/go.mod h1:lBUy/T5kyMudFzWUH/C2moN+NlU5qF505vzOyINXuUQ= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= -github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -52,22 +48,16 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220318055525-2edf467146b5 h1:saXMvIOKvRFwbOMicHXr0B1uwoxq9dGmLe5ExMES6c4= -golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0 h1:Zr2JFtRQNX3BCZ8YtxRE9hNJYC8J6I1MVbMg6owUp18= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE= -golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.4.0 h1:O7UWfv5+A2qiuulQk30kVinPoMtoIPeVaKLEgLpVkvg= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0 h1:3XmdazWV+ubf7QgHSTWeykHOci5oeekaGJBLkrkaw4k= diff --git a/list_box.go b/list_box.go index 07d0504..71e4e60 100644 --- a/list_box.go +++ b/list_box.go @@ -1,6 +1,10 @@ package nve import ( + "fmt" + "math" + "strings" + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) @@ -65,8 +69,37 @@ func (b *ListBox) SearchResultsUpdate(notes *Notes) { b.contentView.Clear() } - for _, result := range lastResult { - b.AddItem(result.Filename, "", 0, nil) + selectedIndex := -1 + + for index, result := range lastResult { + displayName := result.DisplayName() + var formattedName string + if len(displayName) > 14 { + formattedName = fmt.Sprintf("%-20.20s..", displayName) + } else { + formattedName = fmt.Sprintf("%-22.22s", displayName) + } + + b.AddItem(strings.Join([]string{formattedName, result.Snippet}, " : "), "", 0, nil) + + if selectedIndex == -1 && strings.HasPrefix(displayName, notes.LastQuery) { + selectedIndex = index + } + } + + _, _, _, height := b.GetInnerRect() + + if selectedIndex >= 0 { + // highlights row with exact prefix match to search query. + b.SetCurrentItem(selectedIndex) + + // scroll to view; use height of list box + b.SetOffset(int(math.Max(float64(selectedIndex-height+1), 0)), 0) + } else { + // highlight any selected row if not in visible rect + if !b.InRect(b.GetCurrentItem(), 0) { + b.SetOffset(b.GetCurrentItem(), 0) + } } } diff --git a/notes.go b/notes.go index 8ef4c98..318bec8 100644 --- a/notes.go +++ b/notes.go @@ -1,8 +1,10 @@ package nve import ( + "fmt" "log" "os" + "path/filepath" _ "github.com/mattn/go-sqlite3" // sqlite driver ) @@ -56,7 +58,7 @@ func (n *Notes) Search(text string) ([]string, error) { n.LastQuery = text if text == "" { - searchResults, err = n.db.Recent(10) + searchResults, err = n.db.Recent(20) } else { searchResults, err = n.db.Search(text) } @@ -81,6 +83,39 @@ func (n *Notes) Search(text string) ([]string, error) { return res, nil } +func (n *Notes) CreateNote(name string) (*FileRef, error) { + path := filepath.Join(n.config.Filepath, fmt.Sprintf("%s.%s", name, "md")) + newFile, err := os.OpenFile(path, os.O_CREATE, 0644) + + if err != nil { + return nil, err + } + + md5, err := calculateMD5(path) + + if err != nil { + return nil, err + } + + stat, err := newFile.Stat() + + if err != nil { + return nil, err + } + + fileRef := FileRef{ + Filename: newFile.Name(), + MD5: md5, + ModifiedAt: stat.ModTime(), + } + + if err := n.db.Insert(&fileRef, []byte{}); err != nil { + return nil, err + } + + return &fileRef, nil +} + func (n *Notes) RegisterObservers(obs ...Observer) { if n.observers != nil { n.observers = obs diff --git a/search_box.go b/search_box.go index 9f272a5..54ffa07 100644 --- a/search_box.go +++ b/search_box.go @@ -1,19 +1,23 @@ package nve import ( + "log" + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) type SearchBox struct { *tview.InputField - listView *ListBox + listView *ListBox + contentView *ContentBox } -func NewSearchBox(listView *ListBox, notes *Notes) *SearchBox { +func NewSearchBox(listView *ListBox, contentView *ContentBox, notes *Notes) *SearchBox { res := SearchBox{ - InputField: tview.NewInputField(), - listView: listView, + InputField: tview.NewInputField(), + listView: listView, + contentView: contentView, } // input field attributes @@ -37,10 +41,16 @@ func NewSearchBox(listView *ListBox, notes *Notes) *SearchBox { res.SetDoneFunc(func(key tcell.Key) { switch key { case tcell.KeyEnter: - notes.Search(res.GetText()) - case tcell.KeyEsc: - notes.Search("") - res.SetText("") + if len(notes.LastSearchResults) == 0 { + newNote, err := notes.CreateNote(res.GetText()) + + if err != nil { + log.Println("Error creating new note") + break + } + + notes.Search(newNote.DisplayName()) + } } }) @@ -50,12 +60,20 @@ func NewSearchBox(listView *ListBox, notes *Notes) *SearchBox { // InputHandler overrides default handling to switch focus away from search box when necessary. func (sb *SearchBox) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { return sb.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { - if event.Key() == tcell.KeyDown || event.Key() == tcell.KeyEnter { - setFocus(sb.listView) - } else { - if handler := sb.InputField.InputHandler(); handler != nil { - handler(event, setFocus) + if event.Key() == tcell.KeyEnter { + setFocus(sb.contentView) + } else if event.Key() == tcell.KeyDown || event.Key() == tcell.KeyCtrlN { + if handler := sb.listView.InputHandler(); handler != nil { + handler(tcell.NewEventKey(tcell.KeyDown, event.Rune(), event.Modifiers()), setFocus) } + } else if event.Key() == tcell.KeyUp || event.Key() == tcell.KeyCtrlP { + if handler := sb.listView.InputHandler(); handler != nil { + handler(tcell.NewEventKey(tcell.KeyUp, event.Rune(), event.Modifiers()), setFocus) + } + } + + if handler := sb.InputField.InputHandler(); handler != nil { + handler(event, setFocus) } }) }