chore: refactor TUIs to use bubbletea standards
d7e56ea6
24 file(s) · +1348 −1024
| 51 | 51 | github.com/ncruces/go-strftime v0.1.9 // indirect |
|
| 52 | 52 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
|
| 53 | 53 | github.com/rivo/uniseg v0.4.7 // indirect |
|
| 54 | + | github.com/sahilm/fuzzy v0.1.1 // indirect |
|
| 54 | 55 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect |
|
| 55 | 56 | github.com/yuin/goldmark-emoji v1.0.6 // indirect |
|
| 56 | 57 | golang.org/x/crypto v0.39.0 // indirect |
| 54 | 54 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= |
|
| 55 | 55 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= |
|
| 56 | 56 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= |
|
| 57 | + | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= |
|
| 58 | + | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= |
|
| 57 | 59 | github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= |
|
| 58 | 60 | github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= |
|
| 59 | 61 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
|
| 72 | 74 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
|
| 73 | 75 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= |
|
| 74 | 76 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= |
|
| 77 | + | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= |
|
| 78 | + | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= |
|
| 75 | 79 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= |
|
| 76 | 80 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= |
|
| 77 | 81 | github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= |
|
| 1 | + | package tui |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "strings" |
|
| 5 | + | ||
| 6 | + | tea "charm.land/bubbletea/v2" |
|
| 7 | + | "github.com/atotto/clipboard" |
|
| 8 | + | ) |
|
| 9 | + | ||
| 10 | + | func loadNotesCmd(b Backend) tea.Cmd { |
|
| 11 | + | return func() tea.Msg { |
|
| 12 | + | notes, err := b.List() |
|
| 13 | + | return notesLoadedMsg{notes: notes, err: err} |
|
| 14 | + | } |
|
| 15 | + | } |
|
| 16 | + | ||
| 17 | + | func saveNoteCmd(b Backend, shortID, title, content string) tea.Cmd { |
|
| 18 | + | return func() tea.Msg { |
|
| 19 | + | var ( |
|
| 20 | + | note *Note |
|
| 21 | + | err error |
|
| 22 | + | ) |
|
| 23 | + | if shortID == "" { |
|
| 24 | + | note, err = b.Create(title, content) |
|
| 25 | + | } else { |
|
| 26 | + | note, err = b.Update(shortID, title, content) |
|
| 27 | + | } |
|
| 28 | + | return noteSavedMsg{note: note, err: err} |
|
| 29 | + | } |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | func deleteNoteCmd(b Backend, shortID string) tea.Cmd { |
|
| 33 | + | return func() tea.Msg { |
|
| 34 | + | _, err := b.Delete(shortID) |
|
| 35 | + | return noteDeletedMsg{shortID: shortID, err: err} |
|
| 36 | + | } |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | func copyToClipboardCmd(text, okStatus string) tea.Cmd { |
|
| 40 | + | return func() tea.Msg { |
|
| 41 | + | if err := clipboard.WriteAll(text); err != nil { |
|
| 42 | + | return statusMsg{text: "clipboard: " + err.Error(), ok: false} |
|
| 43 | + | } |
|
| 44 | + | return statusMsg{text: okStatus, ok: true} |
|
| 45 | + | } |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | func openURLCmd(url string) tea.Cmd { |
|
| 49 | + | return func() tea.Msg { |
|
| 50 | + | if err := openURL(url); err != nil { |
|
| 51 | + | return statusMsg{text: "open: " + err.Error(), ok: false} |
|
| 52 | + | } |
|
| 53 | + | return statusMsg{text: "opened " + url, ok: true} |
|
| 54 | + | } |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | func noteLinkURL(remoteBase, shortID string) string { |
|
| 58 | + | if remoteBase == "" { |
|
| 59 | + | return "" |
|
| 60 | + | } |
|
| 61 | + | return strings.TrimRight(remoteBase, "/") + "/notes/" + shortID |
|
| 62 | + | } |
| 1 | + | package tui |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "charm.land/bubbles/v2/viewport" |
|
| 5 | + | tea "charm.land/bubbletea/v2" |
|
| 6 | + | ) |
|
| 7 | + | ||
| 8 | + | type contentModel struct { |
|
| 9 | + | vp viewport.Model |
|
| 10 | + | renderer *mdRenderer |
|
| 11 | + | wrap bool |
|
| 12 | + | ||
| 13 | + | shortID string |
|
| 14 | + | title string |
|
| 15 | + | body string |
|
| 16 | + | } |
|
| 17 | + | ||
| 18 | + | func newContentModel() contentModel { |
|
| 19 | + | return contentModel{vp: viewport.New(), wrap: true} |
|
| 20 | + | } |
|
| 21 | + | ||
| 22 | + | func (c contentModel) Update(msg tea.Msg) (contentModel, tea.Cmd) { |
|
| 23 | + | var cmd tea.Cmd |
|
| 24 | + | c.vp, cmd = c.vp.Update(msg) |
|
| 25 | + | return c, cmd |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | func (c *contentModel) SetSize(w, h int) { |
|
| 29 | + | c.vp.SetWidth(w) |
|
| 30 | + | c.vp.SetHeight(h) |
|
| 31 | + | if c.renderer == nil { |
|
| 32 | + | c.renderer = newRenderer(w) |
|
| 33 | + | } else { |
|
| 34 | + | c.renderer.resize(w) |
|
| 35 | + | } |
|
| 36 | + | c.refresh() |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | func (c *contentModel) SetNote(n *Note) { |
|
| 40 | + | if n == nil { |
|
| 41 | + | c.shortID, c.title, c.body = "", "", "" |
|
| 42 | + | c.vp.SetContent("") |
|
| 43 | + | c.vp.GotoTop() |
|
| 44 | + | return |
|
| 45 | + | } |
|
| 46 | + | c.shortID = n.ShortID |
|
| 47 | + | c.title = n.Title |
|
| 48 | + | c.body = n.Content |
|
| 49 | + | c.vp.GotoTop() |
|
| 50 | + | c.refresh() |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | func (c *contentModel) ToggleWrap() { |
|
| 54 | + | c.wrap = !c.wrap |
|
| 55 | + | c.refresh() |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | func (c *contentModel) Wrap() bool { return c.wrap } |
|
| 59 | + | ||
| 60 | + | func (c *contentModel) Invalidate(shortID string) { |
|
| 61 | + | if c.renderer != nil { |
|
| 62 | + | c.renderer.invalidate(shortID) |
|
| 63 | + | } |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | func (c *contentModel) refresh() { |
|
| 67 | + | if c.body == "" { |
|
| 68 | + | c.vp.SetContent("") |
|
| 69 | + | return |
|
| 70 | + | } |
|
| 71 | + | if !c.wrap || c.renderer == nil { |
|
| 72 | + | c.vp.SetContent(c.body) |
|
| 73 | + | return |
|
| 74 | + | } |
|
| 75 | + | c.vp.SetContent(c.renderer.render(c.shortID, c.body)) |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | func (c contentModel) Title() string { return c.title } |
|
| 79 | + | func (c contentModel) View() string { return c.vp.View() } |
|
| 80 | + | ||
| 81 | + | func (c contentModel) ScrollUp(n int) contentModel { |
|
| 82 | + | c.vp.ScrollUp(n) |
|
| 83 | + | return c |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | func (c contentModel) ScrollDown(n int) contentModel { |
|
| 87 | + | c.vp.ScrollDown(n) |
|
| 88 | + | return c |
|
| 89 | + | } |
| 1 | + | package tui |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "strings" |
|
| 5 | + | ||
| 6 | + | "charm.land/bubbles/v2/key" |
|
| 7 | + | "charm.land/bubbles/v2/textarea" |
|
| 8 | + | "charm.land/bubbles/v2/textinput" |
|
| 9 | + | tea "charm.land/bubbletea/v2" |
|
| 10 | + | ) |
|
| 11 | + | ||
| 12 | + | type formField uint8 |
|
| 13 | + | ||
| 14 | + | const ( |
|
| 15 | + | formFieldTitle formField = iota |
|
| 16 | + | formFieldContent |
|
| 17 | + | ) |
|
| 18 | + | ||
| 19 | + | type formModel struct { |
|
| 20 | + | title textinput.Model |
|
| 21 | + | content textarea.Model |
|
| 22 | + | ||
| 23 | + | field formField |
|
| 24 | + | shortID string |
|
| 25 | + | isCreate bool |
|
| 26 | + | ||
| 27 | + | keys formKeys |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | type formKeys struct { |
|
| 31 | + | Save key.Binding |
|
| 32 | + | Cancel key.Binding |
|
| 33 | + | SwitchField key.Binding |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | func defaultFormKeys() formKeys { |
|
| 37 | + | return formKeys{ |
|
| 38 | + | Save: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("⌃s", "save")), |
|
| 39 | + | Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), |
|
| 40 | + | SwitchField: key.NewBinding(key.WithKeys("tab"), key.WithHelp("⇥", "switch field")), |
|
| 41 | + | } |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | func newFormModel() formModel { |
|
| 45 | + | ti := textinput.New() |
|
| 46 | + | ti.Placeholder = "Title" |
|
| 47 | + | ti.Prompt = "" |
|
| 48 | + | ti.CharLimit = 200 |
|
| 49 | + | ||
| 50 | + | ta := textarea.New() |
|
| 51 | + | ta.Placeholder = "Write markdown..." |
|
| 52 | + | ta.ShowLineNumbers = false |
|
| 53 | + | ta.Prompt = "" |
|
| 54 | + | ||
| 55 | + | return formModel{title: ti, content: ta, keys: defaultFormKeys()} |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | func (f *formModel) StartCreate() { |
|
| 59 | + | f.shortID = "" |
|
| 60 | + | f.isCreate = true |
|
| 61 | + | f.title.SetValue("") |
|
| 62 | + | f.content.SetValue("") |
|
| 63 | + | f.field = formFieldTitle |
|
| 64 | + | f.applyFocus() |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | func (f *formModel) StartEdit(n Note) { |
|
| 68 | + | f.shortID = n.ShortID |
|
| 69 | + | f.isCreate = false |
|
| 70 | + | f.title.SetValue(n.Title) |
|
| 71 | + | f.content.SetValue(n.Content) |
|
| 72 | + | f.field = formFieldTitle |
|
| 73 | + | f.applyFocus() |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | func (f *formModel) SetContent(s string) { f.content.SetValue(s) } |
|
| 77 | + | ||
| 78 | + | func (f *formModel) Blur() { |
|
| 79 | + | f.title.Blur() |
|
| 80 | + | f.content.Blur() |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | func (f *formModel) applyFocus() { |
|
| 84 | + | switch f.field { |
|
| 85 | + | case formFieldTitle: |
|
| 86 | + | f.title.Focus() |
|
| 87 | + | f.content.Blur() |
|
| 88 | + | case formFieldContent: |
|
| 89 | + | f.content.Focus() |
|
| 90 | + | f.title.Blur() |
|
| 91 | + | } |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | func (f *formModel) SetSize(w, h int) { |
|
| 95 | + | f.title.SetWidth(max(w-4, 1)) |
|
| 96 | + | f.content.SetWidth(max(w-2, 1)) |
|
| 97 | + | f.content.SetHeight(max(h-6, 1)) |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | func (f formModel) Update(msg tea.Msg) (formModel, tea.Cmd) { |
|
| 101 | + | if km, ok := msg.(tea.KeyPressMsg); ok { |
|
| 102 | + | switch { |
|
| 103 | + | case key.Matches(km, f.keys.Cancel): |
|
| 104 | + | f.Blur() |
|
| 105 | + | return f, func() tea.Msg { return cancelFormMsg{} } |
|
| 106 | + | case key.Matches(km, f.keys.Save): |
|
| 107 | + | title := strings.TrimSpace(f.title.Value()) |
|
| 108 | + | if title == "" { |
|
| 109 | + | return f, func() tea.Msg { return statusMsg{text: "title required", ok: false} } |
|
| 110 | + | } |
|
| 111 | + | return f, func() tea.Msg { |
|
| 112 | + | return submitFormMsg{shortID: f.shortID, title: title, content: f.content.Value()} |
|
| 113 | + | } |
|
| 114 | + | case key.Matches(km, f.keys.SwitchField): |
|
| 115 | + | if f.field == formFieldTitle { |
|
| 116 | + | f.field = formFieldContent |
|
| 117 | + | } else { |
|
| 118 | + | f.field = formFieldTitle |
|
| 119 | + | } |
|
| 120 | + | f.applyFocus() |
|
| 121 | + | return f, nil |
|
| 122 | + | } |
|
| 123 | + | } |
|
| 124 | + | ||
| 125 | + | var cmd tea.Cmd |
|
| 126 | + | switch f.field { |
|
| 127 | + | case formFieldTitle: |
|
| 128 | + | f.title, cmd = f.title.Update(msg) |
|
| 129 | + | case formFieldContent: |
|
| 130 | + | f.content, cmd = f.content.Update(msg) |
|
| 131 | + | } |
|
| 132 | + | return f, cmd |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | func (f formModel) ActiveField() formField { return f.field } |
|
| 136 | + | func (f formModel) IsCreate() bool { return f.isCreate } |
| 3 | 3 | import "charm.land/bubbles/v2/key" |
|
| 4 | 4 | ||
| 5 | 5 | type keyMap struct { |
|
| 6 | - | Up key.Binding |
|
| 7 | - | Down key.Binding |
|
| 8 | 6 | Open key.Binding |
|
| 9 | 7 | Back key.Binding |
|
| 10 | 8 | Quit key.Binding |
|
| 15 | 13 | Copy key.Binding |
|
| 16 | 14 | CopyLink key.Binding |
|
| 17 | 15 | OpenBrowser key.Binding |
|
| 18 | - | Search key.Binding |
|
| 19 | 16 | Refresh key.Binding |
|
| 20 | 17 | Help key.Binding |
|
| 21 | - | Save key.Binding |
|
| 22 | 18 | ToggleWrap key.Binding |
|
| 23 | - | SwitchField key.Binding |
|
| 24 | - | Cancel key.Binding |
|
| 19 | + | ScrollUp key.Binding |
|
| 20 | + | ScrollDown key.Binding |
|
| 25 | 21 | } |
|
| 26 | 22 | ||
| 27 | 23 | func defaultKeys() keyMap { |
|
| 28 | 24 | return keyMap{ |
|
| 29 | - | Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), |
|
| 30 | - | Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), |
|
| 31 | 25 | Open: key.NewBinding(key.WithKeys("enter", "l"), key.WithHelp("⏎/l", "open")), |
|
| 32 | - | Back: key.NewBinding(key.WithKeys("h", "esc", " "), key.WithHelp("h/␣", "back")), |
|
| 33 | - | Quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")), |
|
| 26 | + | Back: key.NewBinding(key.WithKeys("h", "esc"), key.WithHelp("h/esc", "back")), |
|
| 27 | + | Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), |
|
| 34 | 28 | Create: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "create")), |
|
| 35 | 29 | Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), |
|
| 36 | 30 | ExtEdit: key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "$EDITOR")), |
|
| 38 | 32 | Copy: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy text")), |
|
| 39 | 33 | CopyLink: key.NewBinding(key.WithKeys("Y"), key.WithHelp("Y", "copy link")), |
|
| 40 | 34 | OpenBrowser: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "browser")), |
|
| 41 | - | Search: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")), |
|
| 42 | 35 | Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), |
|
| 43 | 36 | Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), |
|
| 44 | - | Save: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("⌃s", "save")), |
|
| 45 | 37 | ToggleWrap: key.NewBinding(key.WithKeys("ctrl+w"), key.WithHelp("⌃w", "wrap")), |
|
| 46 | - | SwitchField: key.NewBinding(key.WithKeys("tab"), key.WithHelp("⇥", "switch field")), |
|
| 47 | - | Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), |
|
| 38 | + | ScrollUp: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), |
|
| 39 | + | ScrollDown: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), |
|
| 48 | 40 | } |
|
| 49 | 41 | } |
|
| 50 | 42 | ||
| 51 | 43 | func (k keyMap) ShortHelp() []key.Binding { |
|
| 52 | - | return []key.Binding{k.Open, k.Create, k.Edit, k.Delete, k.Search, k.Help, k.Quit} |
|
| 44 | + | return []key.Binding{k.Open, k.Create, k.Edit, k.Delete, k.Help, k.Quit} |
|
| 53 | 45 | } |
|
| 54 | 46 | ||
| 55 | 47 | func (k keyMap) FullHelp() [][]key.Binding { |
|
| 56 | 48 | return [][]key.Binding{ |
|
| 57 | - | {k.Up, k.Down, k.Open, k.Back}, |
|
| 58 | - | {k.Create, k.Edit, k.ExtEdit, k.Delete}, |
|
| 59 | - | {k.Copy, k.CopyLink, k.OpenBrowser, k.Search}, |
|
| 60 | - | {k.Refresh, k.Help, k.Save, k.ToggleWrap}, |
|
| 61 | - | {k.SwitchField, k.Cancel, k.Quit}, |
|
| 49 | + | {k.Open, k.Back, k.Create, k.Edit}, |
|
| 50 | + | {k.ExtEdit, k.Delete, k.Copy, k.CopyLink}, |
|
| 51 | + | {k.OpenBrowser, k.Refresh, k.ToggleWrap, k.Help}, |
|
| 52 | + | {k.ScrollUp, k.ScrollDown, k.Quit}, |
|
| 62 | 53 | } |
|
| 63 | 54 | } |
|
| 1 | + | package tui |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "charm.land/bubbles/v2/list" |
|
| 5 | + | tea "charm.land/bubbletea/v2" |
|
| 6 | + | "charm.land/lipgloss/v2" |
|
| 7 | + | ) |
|
| 8 | + | ||
| 9 | + | func ansiListDelegate() list.DefaultDelegate { |
|
| 10 | + | d := list.NewDefaultDelegate() |
|
| 11 | + | d.ShowDescription = false |
|
| 12 | + | d.SetSpacing(0) |
|
| 13 | + | d.Styles.NormalTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("7")).Padding(0, 0, 0, 2) |
|
| 14 | + | d.Styles.SelectedTitle = lipgloss.NewStyle(). |
|
| 15 | + | Foreground(lipgloss.Color("3")). |
|
| 16 | + | Bold(true). |
|
| 17 | + | Border(lipgloss.NormalBorder(), false, false, false, true). |
|
| 18 | + | BorderForeground(lipgloss.Color("3")). |
|
| 19 | + | Padding(0, 0, 0, 1) |
|
| 20 | + | d.Styles.DimmedTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Padding(0, 0, 0, 2) |
|
| 21 | + | d.Styles.FilterMatch = lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("3")) |
|
| 22 | + | return d |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | func ansiListStyles() list.Styles { |
|
| 26 | + | s := list.DefaultStyles(true) |
|
| 27 | + | s.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("3")).Padding(0, 1) |
|
| 28 | + | s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 0) |
|
| 29 | + | s.NoItems = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Padding(0, 0, 0, 2) |
|
| 30 | + | s.DefaultFilterCharacterMatch = lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("3")) |
|
| 31 | + | return s |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | type noteItem struct { |
|
| 35 | + | note Note |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | func (n noteItem) Title() string { return n.note.Title } |
|
| 39 | + | func (n noteItem) Description() string { return "" } |
|
| 40 | + | func (n noteItem) FilterValue() string { return n.note.Title } |
|
| 41 | + | ||
| 42 | + | type listModel struct { |
|
| 43 | + | inner list.Model |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | func newListModel(notes []Note) listModel { |
|
| 47 | + | items := make([]list.Item, 0, len(notes)) |
|
| 48 | + | for _, n := range notes { |
|
| 49 | + | items = append(items, noteItem{note: n}) |
|
| 50 | + | } |
|
| 51 | + | ||
| 52 | + | l := list.New(items, ansiListDelegate(), 0, 0) |
|
| 53 | + | l.Title = "notes" |
|
| 54 | + | l.Styles = ansiListStyles() |
|
| 55 | + | l.SetShowStatusBar(false) |
|
| 56 | + | l.SetShowPagination(false) |
|
| 57 | + | l.SetShowHelp(false) |
|
| 58 | + | l.SetFilteringEnabled(true) |
|
| 59 | + | l.DisableQuitKeybindings() |
|
| 60 | + | ||
| 61 | + | return listModel{inner: l} |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | func (l listModel) Update(msg tea.Msg) (listModel, tea.Cmd) { |
|
| 65 | + | var cmd tea.Cmd |
|
| 66 | + | l.inner, cmd = l.inner.Update(msg) |
|
| 67 | + | return l, cmd |
|
| 68 | + | } |
|
| 69 | + | ||
| 70 | + | func (l listModel) View() string { return l.inner.View() } |
|
| 71 | + | ||
| 72 | + | func (l *listModel) SetSize(w, h int) { l.inner.SetSize(w, h) } |
|
| 73 | + | ||
| 74 | + | func (l *listModel) SetNotes(notes []Note) tea.Cmd { |
|
| 75 | + | items := make([]list.Item, 0, len(notes)) |
|
| 76 | + | for _, n := range notes { |
|
| 77 | + | items = append(items, noteItem{note: n}) |
|
| 78 | + | } |
|
| 79 | + | return l.inner.SetItems(items) |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | func (l listModel) Selected() (Note, bool) { |
|
| 83 | + | it := l.inner.SelectedItem() |
|
| 84 | + | if it == nil { |
|
| 85 | + | return Note{}, false |
|
| 86 | + | } |
|
| 87 | + | n, ok := it.(noteItem) |
|
| 88 | + | if !ok { |
|
| 89 | + | return Note{}, false |
|
| 90 | + | } |
|
| 91 | + | return n.note, true |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | func (l listModel) IsFiltering() bool { |
|
| 95 | + | return l.inner.SettingFilter() |
|
| 96 | + | } |
| 27 | 27 | } |
|
| 28 | 28 | ||
| 29 | 29 | type clearStatusMsg struct{} |
|
| 30 | + | ||
| 31 | + | type submitFormMsg struct { |
|
| 32 | + | shortID string |
|
| 33 | + | title string |
|
| 34 | + | content string |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | type cancelFormMsg struct{} |
| 1 | 1 | package tui |
|
| 2 | 2 | ||
| 3 | 3 | import ( |
|
| 4 | - | "strings" |
|
| 5 | 4 | "time" |
|
| 6 | 5 | ||
| 7 | 6 | "charm.land/bubbles/v2/help" |
|
| 8 | - | "charm.land/bubbles/v2/textarea" |
|
| 9 | - | "charm.land/bubbles/v2/textinput" |
|
| 10 | - | "charm.land/bubbles/v2/viewport" |
|
| 11 | 7 | tea "charm.land/bubbletea/v2" |
|
| 12 | 8 | ) |
|
| 13 | 9 | ||
| 14 | - | type Focus int |
|
| 10 | + | type sessionState uint8 |
|
| 15 | 11 | ||
| 16 | 12 | const ( |
|
| 17 | - | FocusList Focus = iota |
|
| 18 | - | FocusContent |
|
| 19 | - | FocusCreateTitle |
|
| 20 | - | FocusCreateContent |
|
| 21 | - | FocusEditTitle |
|
| 22 | - | FocusEditContent |
|
| 23 | - | FocusSearch |
|
| 13 | + | stateList sessionState = iota |
|
| 14 | + | stateContent |
|
| 15 | + | stateForm |
|
| 24 | 16 | ) |
|
| 25 | 17 | ||
| 26 | 18 | type Model struct { |
|
| 27 | 19 | backend Backend |
|
| 28 | 20 | isRemote bool |
|
| 29 | 21 | ||
| 30 | - | notes []Note |
|
| 31 | - | filtered []int |
|
| 32 | - | cursor int |
|
| 22 | + | state sessionState |
|
| 23 | + | list listModel |
|
| 24 | + | cont contentModel |
|
| 25 | + | form formModel |
|
| 26 | + | ||
| 27 | + | width, height int |
|
| 28 | + | ready bool |
|
| 33 | 29 | ||
| 34 | - | focus Focus |
|
| 35 | 30 | showHelp bool |
|
| 36 | 31 | confirmDelete bool |
|
| 37 | 32 | ||
| 38 | - | titleInput textinput.Model |
|
| 39 | - | contentArea textarea.Model |
|
| 40 | - | searchInput textinput.Model |
|
| 41 | - | contentVP viewport.Model |
|
| 42 | - | help help.Model |
|
| 43 | - | keys keyMap |
|
| 44 | - | ||
| 45 | - | renderer *mdRenderer |
|
| 46 | - | wrap bool |
|
| 47 | - | ||
| 48 | - | editShortID string |
|
| 49 | - | ||
| 50 | 33 | status string |
|
| 51 | 34 | statusOK bool |
|
| 52 | 35 | statusUntil time.Time |
|
| 53 | 36 | ||
| 54 | - | width, height int |
|
| 55 | - | ready bool |
|
| 56 | - | err error |
|
| 37 | + | help help.Model |
|
| 38 | + | keys keyMap |
|
| 57 | 39 | } |
|
| 58 | 40 | ||
| 59 | 41 | func newModel(backend Backend, notes []Note, width, height int) Model { |
|
| 60 | - | ti := textinput.New() |
|
| 61 | - | ti.Placeholder = "Title" |
|
| 62 | - | ti.Prompt = "" |
|
| 63 | - | ti.CharLimit = 200 |
|
| 64 | - | ||
| 65 | - | ta := textarea.New() |
|
| 66 | - | ta.Placeholder = "Write markdown..." |
|
| 67 | - | ta.ShowLineNumbers = false |
|
| 68 | - | ta.Prompt = "" |
|
| 69 | - | ||
| 70 | - | si := textinput.New() |
|
| 71 | - | si.Placeholder = "search titles" |
|
| 72 | - | si.Prompt = "/ " |
|
| 73 | - | ||
| 74 | - | vp := viewport.New() |
|
| 75 | - | ||
| 76 | 42 | m := Model{ |
|
| 77 | - | backend: backend, |
|
| 78 | - | isRemote: backend.RemoteURL() != "", |
|
| 79 | - | notes: notes, |
|
| 80 | - | focus: FocusList, |
|
| 81 | - | titleInput: ti, |
|
| 82 | - | contentArea: ta, |
|
| 83 | - | searchInput: si, |
|
| 84 | - | contentVP: vp, |
|
| 85 | - | help: help.New(), |
|
| 86 | - | keys: defaultKeys(), |
|
| 87 | - | wrap: true, |
|
| 88 | - | width: width, |
|
| 89 | - | height: height, |
|
| 90 | - | ready: true, |
|
| 43 | + | backend: backend, |
|
| 44 | + | isRemote: backend.RemoteURL() != "", |
|
| 45 | + | state: stateList, |
|
| 46 | + | list: newListModel(notes), |
|
| 47 | + | cont: newContentModel(), |
|
| 48 | + | form: newFormModel(), |
|
| 49 | + | help: help.New(), |
|
| 50 | + | keys: defaultKeys(), |
|
| 51 | + | width: width, |
|
| 52 | + | height: height, |
|
| 53 | + | ready: true, |
|
| 91 | 54 | } |
|
| 92 | - | m.resizePanes() |
|
| 55 | + | m.applyLayout() |
|
| 93 | 56 | return m |
|
| 94 | 57 | } |
|
| 95 | 58 | ||
| 96 | 59 | func (m Model) Init() tea.Cmd { |
|
| 97 | 60 | return tea.RequestWindowSize |
|
| 98 | 61 | } |
|
| 99 | - | ||
| 100 | - | func (m *Model) visibleNotes() []Note { |
|
| 101 | - | if m.filtered == nil { |
|
| 102 | - | return m.notes |
|
| 103 | - | } |
|
| 104 | - | out := make([]Note, 0, len(m.filtered)) |
|
| 105 | - | for _, i := range m.filtered { |
|
| 106 | - | out = append(out, m.notes[i]) |
|
| 107 | - | } |
|
| 108 | - | return out |
|
| 109 | - | } |
|
| 110 | - | ||
| 111 | - | func (m *Model) currentNote() *Note { |
|
| 112 | - | notes := m.visibleNotes() |
|
| 113 | - | if m.cursor < 0 || m.cursor >= len(notes) { |
|
| 114 | - | return nil |
|
| 115 | - | } |
|
| 116 | - | return ¬es[m.cursor] |
|
| 117 | - | } |
|
| 118 | - | ||
| 119 | - | func (m *Model) applyFilter(q string) { |
|
| 120 | - | q = strings.TrimSpace(strings.ToLower(q)) |
|
| 121 | - | if q == "" { |
|
| 122 | - | m.filtered = nil |
|
| 123 | - | if m.cursor >= len(m.notes) { |
|
| 124 | - | m.cursor = 0 |
|
| 125 | - | } |
|
| 126 | - | return |
|
| 127 | - | } |
|
| 128 | - | idx := []int{} |
|
| 129 | - | for i, n := range m.notes { |
|
| 130 | - | if strings.Contains(strings.ToLower(n.Title), q) { |
|
| 131 | - | idx = append(idx, i) |
|
| 132 | - | } |
|
| 133 | - | } |
|
| 134 | - | m.filtered = idx |
|
| 135 | - | if m.cursor >= len(idx) { |
|
| 136 | - | m.cursor = 0 |
|
| 137 | - | } |
|
| 138 | - | } |
|
| 139 | - | ||
| 140 | - | func (m *Model) setStatus(text string, ok bool) tea.Cmd { |
|
| 141 | - | m.status = text |
|
| 142 | - | m.statusOK = ok |
|
| 143 | - | m.statusUntil = time.Now().Add(2 * time.Second) |
|
| 144 | - | return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearStatusMsg{} }) |
|
| 145 | - | } |
| 7 | 7 | "charm.land/glamour/v2/ansi" |
|
| 8 | 8 | ) |
|
| 9 | 9 | ||
| 10 | + | const mdCacheMax = 64 |
|
| 11 | + | ||
| 10 | 12 | type mdRenderer struct { |
|
| 11 | 13 | r *glamour.TermRenderer |
|
| 12 | 14 | width int |
|
| 13 | 15 | cache map[string]string |
|
| 16 | + | order []string |
|
| 14 | 17 | } |
|
| 15 | 18 | ||
| 16 | 19 | func sp(s string) *string { return &s } |
|
| 92 | 95 | return &mdRenderer{r: r, width: width, cache: map[string]string{}} |
|
| 93 | 96 | } |
|
| 94 | 97 | ||
| 98 | + | func (m *mdRenderer) store(key, value string) { |
|
| 99 | + | if _, ok := m.cache[key]; !ok { |
|
| 100 | + | m.order = append(m.order, key) |
|
| 101 | + | if len(m.order) > mdCacheMax { |
|
| 102 | + | drop := m.order[0] |
|
| 103 | + | m.order = m.order[1:] |
|
| 104 | + | delete(m.cache, drop) |
|
| 105 | + | } |
|
| 106 | + | } |
|
| 107 | + | m.cache[key] = value |
|
| 108 | + | } |
|
| 109 | + | ||
| 95 | 110 | func (m *mdRenderer) resize(width int) { |
|
| 96 | 111 | if width == m.width || width < 20 { |
|
| 97 | 112 | return |
|
| 104 | 119 | m.r = r |
|
| 105 | 120 | m.width = width |
|
| 106 | 121 | m.cache = map[string]string{} |
|
| 122 | + | m.order = nil |
|
| 107 | 123 | } |
|
| 108 | 124 | ||
| 109 | 125 | func (m *mdRenderer) render(key, body string) string { |
|
| 117 | 133 | if err != nil { |
|
| 118 | 134 | out = fmt.Sprintf("render error: %v\n\n%s", err, body) |
|
| 119 | 135 | } |
|
| 120 | - | m.cache[key] = out |
|
| 136 | + | m.store(key, out) |
|
| 121 | 137 | return out |
|
| 122 | 138 | } |
|
| 123 | 139 | ||
| 124 | 140 | func (m *mdRenderer) invalidate(key string) { |
|
| 141 | + | if _, ok := m.cache[key]; !ok { |
|
| 142 | + | return |
|
| 143 | + | } |
|
| 125 | 144 | delete(m.cache, key) |
|
| 145 | + | for i, k := range m.order { |
|
| 146 | + | if k == key { |
|
| 147 | + | m.order = append(m.order[:i], m.order[i+1:]...) |
|
| 148 | + | break |
|
| 149 | + | } |
|
| 150 | + | } |
|
| 126 | 151 | } |
|
| 2 | 2 | ||
| 3 | 3 | import ( |
|
| 4 | 4 | "strings" |
|
| 5 | + | "time" |
|
| 5 | 6 | ||
| 6 | - | "github.com/atotto/clipboard" |
|
| 7 | 7 | "charm.land/bubbles/v2/key" |
|
| 8 | 8 | tea "charm.land/bubbletea/v2" |
|
| 9 | 9 | ) |
|
| 10 | 10 | ||
| 11 | - | func loadNotesCmd(b Backend) tea.Cmd { |
|
| 12 | - | return func() tea.Msg { |
|
| 13 | - | notes, err := b.List() |
|
| 14 | - | return notesLoadedMsg{notes: notes, err: err} |
|
| 15 | - | } |
|
| 16 | - | } |
|
| 17 | - | ||
| 18 | - | func saveNoteCmd(b Backend, shortID, title, content string) tea.Cmd { |
|
| 19 | - | return func() tea.Msg { |
|
| 20 | - | var ( |
|
| 21 | - | note *Note |
|
| 22 | - | err error |
|
| 23 | - | ) |
|
| 24 | - | if shortID == "" { |
|
| 25 | - | note, err = b.Create(title, content) |
|
| 26 | - | } else { |
|
| 27 | - | note, err = b.Update(shortID, title, content) |
|
| 28 | - | } |
|
| 29 | - | return noteSavedMsg{note: note, err: err} |
|
| 30 | - | } |
|
| 31 | - | } |
|
| 32 | - | ||
| 33 | - | func deleteNoteCmd(b Backend, shortID string) tea.Cmd { |
|
| 34 | - | return func() tea.Msg { |
|
| 35 | - | _, err := b.Delete(shortID) |
|
| 36 | - | return noteDeletedMsg{shortID: shortID, err: err} |
|
| 37 | - | } |
|
| 38 | - | } |
|
| 39 | - | ||
| 40 | 11 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { |
|
| 41 | 12 | switch msg := msg.(type) { |
|
| 42 | 13 | ||
| 43 | 14 | case tea.WindowSizeMsg: |
|
| 44 | 15 | m.width, m.height = msg.Width, msg.Height |
|
| 45 | 16 | m.ready = true |
|
| 46 | - | m.resizePanes() |
|
| 17 | + | m.applyLayout() |
|
| 47 | 18 | return m, nil |
|
| 48 | 19 | ||
| 49 | 20 | case notesLoadedMsg: |
|
| 50 | 21 | if msg.err != nil { |
|
| 51 | - | cmd := m.setStatus("load: "+msg.err.Error(), false) |
|
| 52 | - | return m, cmd |
|
| 22 | + | return m, m.setStatus("load: "+msg.err.Error(), false) |
|
| 53 | 23 | } |
|
| 54 | - | m.notes = msg.notes |
|
| 55 | - | m.applyFilter(m.searchInput.Value()) |
|
| 56 | - | m.refreshPreview() |
|
| 57 | - | return m, nil |
|
| 24 | + | cmd := m.list.SetNotes(msg.notes) |
|
| 25 | + | if n, ok := m.list.Selected(); ok { |
|
| 26 | + | m.cont.SetNote(&n) |
|
| 27 | + | } else { |
|
| 28 | + | m.cont.SetNote(nil) |
|
| 29 | + | } |
|
| 30 | + | return m, cmd |
|
| 58 | 31 | ||
| 59 | 32 | case noteSavedMsg: |
|
| 60 | 33 | if msg.err != nil { |
|
| 61 | 34 | return m, m.setStatus("save: "+msg.err.Error(), false) |
|
| 62 | 35 | } |
|
| 63 | - | if m.renderer != nil && msg.note != nil { |
|
| 64 | - | m.renderer.invalidate(msg.note.ShortID) |
|
| 36 | + | if msg.note != nil { |
|
| 37 | + | m.cont.Invalidate(msg.note.ShortID) |
|
| 65 | 38 | } |
|
| 66 | - | m.focus = FocusList |
|
| 67 | - | m.titleInput.Reset() |
|
| 68 | - | m.contentArea.Reset() |
|
| 69 | - | m.editShortID = "" |
|
| 70 | - | return m, loadNotesCmd(m.backend) |
|
| 39 | + | m.state = stateList |
|
| 40 | + | m.form.Blur() |
|
| 41 | + | return m, tea.Batch(loadNotesCmd(m.backend), m.setStatus("saved", true)) |
|
| 71 | 42 | ||
| 72 | 43 | case noteDeletedMsg: |
|
| 73 | 44 | if msg.err != nil { |
|
| 74 | 45 | return m, m.setStatus("delete: "+msg.err.Error(), false) |
|
| 75 | 46 | } |
|
| 76 | - | if m.renderer != nil { |
|
| 77 | - | m.renderer.invalidate(msg.shortID) |
|
| 78 | - | } |
|
| 47 | + | m.cont.Invalidate(msg.shortID) |
|
| 48 | + | m.state = stateList |
|
| 79 | 49 | return m, tea.Batch(loadNotesCmd(m.backend), m.setStatus("deleted", true)) |
|
| 80 | 50 | ||
| 81 | 51 | case editorFinishedMsg: |
|
| 83 | 53 | return m, m.setStatus("editor: "+msg.err.Error(), false) |
|
| 84 | 54 | } |
|
| 85 | 55 | if msg.shortID == "" { |
|
| 86 | - | m.contentArea.SetValue(msg.content) |
|
| 56 | + | m.form.SetContent(msg.content) |
|
| 87 | 57 | return m, nil |
|
| 88 | 58 | } |
|
| 89 | 59 | var orig *Note |
|
| 90 | - | for i := range m.notes { |
|
| 91 | - | if m.notes[i].ShortID == msg.shortID { |
|
| 92 | - | orig = &m.notes[i] |
|
| 60 | + | for _, it := range m.list.inner.Items() { |
|
| 61 | + | ni, ok := it.(noteItem) |
|
| 62 | + | if ok && ni.note.ShortID == msg.shortID { |
|
| 63 | + | n := ni.note |
|
| 64 | + | orig = &n |
|
| 93 | 65 | break |
|
| 94 | 66 | } |
|
| 95 | 67 | } |
|
| 98 | 70 | } |
|
| 99 | 71 | return m, saveNoteCmd(m.backend, msg.shortID, orig.Title, msg.content) |
|
| 100 | 72 | ||
| 73 | + | case submitFormMsg: |
|
| 74 | + | return m, saveNoteCmd(m.backend, msg.shortID, msg.title, msg.content) |
|
| 75 | + | ||
| 76 | + | case cancelFormMsg: |
|
| 77 | + | m.state = stateList |
|
| 78 | + | return m, nil |
|
| 79 | + | ||
| 101 | 80 | case statusMsg: |
|
| 102 | 81 | return m, m.setStatus(msg.text, msg.ok) |
|
| 103 | 82 | ||
| 104 | 83 | case clearStatusMsg: |
|
| 84 | + | if time.Now().Before(m.statusUntil) { |
|
| 85 | + | return m, nil |
|
| 86 | + | } |
|
| 105 | 87 | m.status = "" |
|
| 106 | 88 | return m, nil |
|
| 107 | 89 | ||
| 112 | 94 | return m, nil |
|
| 113 | 95 | } |
|
| 114 | 96 | ||
| 115 | - | func (m *Model) resizePanes() { |
|
| 116 | - | if !m.ready { |
|
| 117 | - | return |
|
| 118 | - | } |
|
| 119 | - | ||
| 120 | - | _, contentOuterW := splitWidths(m.width) |
|
| 121 | - | bodyOuterH := splitBodyHeight(m.height) |
|
| 122 | - | contentInnerW := maxInt(contentOuterW-paneFrameWidth(), 20) |
|
| 123 | - | contentInnerH := maxInt(bodyOuterH-paneFrameHeight(), 3) |
|
| 124 | - | ||
| 125 | - | m.contentVP.SetWidth(maxInt(contentInnerW, 1)) |
|
| 126 | - | m.contentVP.SetHeight(maxInt(contentInnerH-1, 1)) |
|
| 127 | - | ||
| 128 | - | m.titleInput.SetWidth(maxInt(contentInnerW-4, 1)) |
|
| 129 | - | m.contentArea.SetWidth(maxInt(contentInnerW-2, 1)) |
|
| 130 | - | m.contentArea.SetHeight(maxInt(contentInnerH-6, 1)) |
|
| 131 | - | ||
| 132 | - | listOuterW, _ := splitWidths(m.width) |
|
| 133 | - | listInnerW := maxInt(listOuterW-paneFrameWidth(), 1) |
|
| 134 | - | m.searchInput.SetWidth(maxInt(listInnerW-2, 1)) |
|
| 135 | - | ||
| 136 | - | if m.renderer == nil { |
|
| 137 | - | m.renderer = newRenderer(contentInnerW) |
|
| 138 | - | } else { |
|
| 139 | - | m.renderer.resize(contentInnerW) |
|
| 140 | - | } |
|
| 141 | - | m.refreshPreview() |
|
| 142 | - | } |
|
| 143 | - | ||
| 144 | - | func (m *Model) refreshPreview() { |
|
| 145 | - | if m.renderer == nil { |
|
| 146 | - | return |
|
| 147 | - | } |
|
| 148 | - | n := m.currentNote() |
|
| 149 | - | if n == nil { |
|
| 150 | - | m.contentVP.SetContent("") |
|
| 151 | - | return |
|
| 152 | - | } |
|
| 153 | - | body := n.Content |
|
| 154 | - | if !m.wrap { |
|
| 155 | - | // raw view: no rendering |
|
| 156 | - | m.contentVP.SetContent(body) |
|
| 157 | - | return |
|
| 158 | - | } |
|
| 159 | - | m.contentVP.SetContent(m.renderer.render(n.ShortID, body)) |
|
| 160 | - | } |
|
| 161 | - | ||
| 162 | 97 | func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { |
|
| 163 | 98 | if m.confirmDelete { |
|
| 164 | 99 | switch msg.String() { |
|
| 165 | 100 | case "y", "Y": |
|
| 166 | - | n := m.currentNote() |
|
| 167 | 101 | m.confirmDelete = false |
|
| 168 | - | if n == nil { |
|
| 102 | + | n, ok := m.list.Selected() |
|
| 103 | + | if !ok { |
|
| 169 | 104 | return m, nil |
|
| 170 | 105 | } |
|
| 171 | 106 | return m, deleteNoteCmd(m.backend, n.ShortID) |
|
| 172 | 107 | case "n", "N", "esc", "q": |
|
| 173 | 108 | m.confirmDelete = false |
|
| 174 | - | return m, nil |
|
| 175 | 109 | } |
|
| 176 | 110 | return m, nil |
|
| 177 | 111 | } |
|
| 183 | 117 | return m, nil |
|
| 184 | 118 | } |
|
| 185 | 119 | ||
| 186 | - | switch m.focus { |
|
| 187 | - | case FocusList: |
|
| 188 | - | return m.keyList(msg) |
|
| 189 | - | case FocusContent: |
|
| 190 | - | return m.keyContent(msg) |
|
| 191 | - | case FocusCreateTitle, FocusCreateContent, FocusEditTitle, FocusEditContent: |
|
| 192 | - | return m.keyForm(msg) |
|
| 193 | - | case FocusSearch: |
|
| 194 | - | return m.keySearch(msg) |
|
| 120 | + | switch m.state { |
|
| 121 | + | case stateList: |
|
| 122 | + | return m.handleListKey(msg) |
|
| 123 | + | case stateContent: |
|
| 124 | + | return m.handleContentKey(msg) |
|
| 125 | + | case stateForm: |
|
| 126 | + | var cmd tea.Cmd |
|
| 127 | + | m.form, cmd = m.form.Update(msg) |
|
| 128 | + | return m, cmd |
|
| 195 | 129 | } |
|
| 196 | 130 | return m, nil |
|
| 197 | 131 | } |
|
| 198 | 132 | ||
| 199 | - | func (m Model) keyList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { |
|
| 200 | - | notes := m.visibleNotes() |
|
| 133 | + | func (m Model) handleListKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { |
|
| 134 | + | // While the list is in filter-entry mode, route every key to it |
|
| 135 | + | // so typing and esc/enter behave as expected. |
|
| 136 | + | if m.list.IsFiltering() { |
|
| 137 | + | var cmd tea.Cmd |
|
| 138 | + | m.list, cmd = m.list.Update(msg) |
|
| 139 | + | m.refreshContentFromSelection() |
|
| 140 | + | return m, cmd |
|
| 141 | + | } |
|
| 142 | + | ||
| 201 | 143 | switch { |
|
| 202 | 144 | case key.Matches(msg, m.keys.Quit): |
|
| 203 | 145 | return m, tea.Quit |
|
| 204 | - | case key.Matches(msg, m.keys.Down): |
|
| 205 | - | if m.cursor < len(notes)-1 { |
|
| 206 | - | m.cursor++ |
|
| 207 | - | m.refreshPreview() |
|
| 208 | - | } |
|
| 209 | - | case key.Matches(msg, m.keys.Up): |
|
| 210 | - | if m.cursor > 0 { |
|
| 211 | - | m.cursor-- |
|
| 212 | - | m.refreshPreview() |
|
| 213 | - | } |
|
| 214 | 146 | case key.Matches(msg, m.keys.Open): |
|
| 215 | - | if len(notes) > 0 { |
|
| 216 | - | m.focus = FocusContent |
|
| 217 | - | m.contentVP.GotoTop() |
|
| 147 | + | if n, ok := m.list.Selected(); ok { |
|
| 148 | + | m.cont.SetNote(&n) |
|
| 149 | + | m.state = stateContent |
|
| 218 | 150 | } |
|
| 151 | + | return m, nil |
|
| 219 | 152 | case key.Matches(msg, m.keys.Create): |
|
| 220 | - | m.focus = FocusCreateTitle |
|
| 221 | - | m.editShortID = "" |
|
| 222 | - | m.titleInput.SetValue("") |
|
| 223 | - | m.contentArea.SetValue("") |
|
| 224 | - | m.titleInput.Focus() |
|
| 225 | - | m.contentArea.Blur() |
|
| 153 | + | m.form.StartCreate() |
|
| 154 | + | m.state = stateForm |
|
| 155 | + | return m, nil |
|
| 226 | 156 | case key.Matches(msg, m.keys.Edit): |
|
| 227 | - | n := m.currentNote() |
|
| 228 | - | if n != nil { |
|
| 229 | - | m.focus = FocusEditTitle |
|
| 230 | - | m.editShortID = n.ShortID |
|
| 231 | - | m.titleInput.SetValue(n.Title) |
|
| 232 | - | m.contentArea.SetValue(n.Content) |
|
| 233 | - | m.titleInput.Focus() |
|
| 234 | - | m.contentArea.Blur() |
|
| 157 | + | if n, ok := m.list.Selected(); ok { |
|
| 158 | + | m.form.StartEdit(n) |
|
| 159 | + | m.state = stateForm |
|
| 235 | 160 | } |
|
| 161 | + | return m, nil |
|
| 236 | 162 | case key.Matches(msg, m.keys.ExtEdit): |
|
| 237 | - | n := m.currentNote() |
|
| 238 | - | if n != nil { |
|
| 163 | + | if n, ok := m.list.Selected(); ok { |
|
| 239 | 164 | return m, openExternalEditor(n.ShortID, n.Content) |
|
| 240 | 165 | } |
|
| 166 | + | return m, nil |
|
| 241 | 167 | case key.Matches(msg, m.keys.Delete): |
|
| 242 | - | if m.currentNote() != nil { |
|
| 168 | + | if _, ok := m.list.Selected(); ok { |
|
| 243 | 169 | m.confirmDelete = true |
|
| 244 | 170 | } |
|
| 171 | + | return m, nil |
|
| 245 | 172 | case key.Matches(msg, m.keys.Copy): |
|
| 246 | - | n := m.currentNote() |
|
| 247 | - | if n != nil { |
|
| 248 | - | if err := clipboard.WriteAll(n.Content); err != nil { |
|
| 249 | - | return m, m.setStatus("clipboard: "+err.Error(), false) |
|
| 250 | - | } |
|
| 251 | - | return m, m.setStatus("copied text", true) |
|
| 173 | + | if n, ok := m.list.Selected(); ok { |
|
| 174 | + | return m, copyToClipboardCmd(n.Content, "copied text") |
|
| 252 | 175 | } |
|
| 176 | + | return m, nil |
|
| 253 | 177 | case key.Matches(msg, m.keys.CopyLink): |
|
| 254 | - | n := m.currentNote() |
|
| 255 | - | if n != nil && m.isRemote { |
|
| 256 | - | link := strings.TrimRight(m.backend.RemoteURL(), "/") + "/notes/" + n.ShortID |
|
| 257 | - | if err := clipboard.WriteAll(link); err != nil { |
|
| 258 | - | return m, m.setStatus("clipboard: "+err.Error(), false) |
|
| 259 | - | } |
|
| 260 | - | return m, m.setStatus("copied link", true) |
|
| 178 | + | if !m.isRemote { |
|
| 179 | + | return m, m.setStatus("local mode: no link", false) |
|
| 180 | + | } |
|
| 181 | + | if n, ok := m.list.Selected(); ok { |
|
| 182 | + | return m, copyToClipboardCmd(noteLinkURL(m.backend.RemoteURL(), n.ShortID), "copied link") |
|
| 261 | 183 | } |
|
| 262 | - | return m, m.setStatus("local mode: no link", false) |
|
| 184 | + | return m, nil |
|
| 263 | 185 | case key.Matches(msg, m.keys.OpenBrowser): |
|
| 264 | - | n := m.currentNote() |
|
| 265 | - | if n != nil && m.isRemote { |
|
| 266 | - | link := strings.TrimRight(m.backend.RemoteURL(), "/") + "/notes/" + n.ShortID |
|
| 267 | - | if err := openURL(link); err != nil { |
|
| 268 | - | return m, m.setStatus("open: "+err.Error(), false) |
|
| 269 | - | } |
|
| 270 | - | return m, m.setStatus("opened "+link, true) |
|
| 186 | + | if !m.isRemote { |
|
| 187 | + | return m, nil |
|
| 188 | + | } |
|
| 189 | + | if n, ok := m.list.Selected(); ok { |
|
| 190 | + | return m, openURLCmd(noteLinkURL(m.backend.RemoteURL(), n.ShortID)) |
|
| 271 | 191 | } |
|
| 272 | - | case key.Matches(msg, m.keys.Search): |
|
| 273 | - | m.focus = FocusSearch |
|
| 274 | - | m.searchInput.Focus() |
|
| 192 | + | return m, nil |
|
| 275 | 193 | case key.Matches(msg, m.keys.Refresh): |
|
| 276 | 194 | if m.isRemote { |
|
| 277 | 195 | return m, loadNotesCmd(m.backend) |
|
| 278 | 196 | } |
|
| 197 | + | return m, nil |
|
| 279 | 198 | case key.Matches(msg, m.keys.Help): |
|
| 280 | 199 | m.showHelp = true |
|
| 200 | + | return m, nil |
|
| 281 | 201 | } |
|
| 282 | - | return m, nil |
|
| 202 | + | ||
| 203 | + | var cmd tea.Cmd |
|
| 204 | + | m.list, cmd = m.list.Update(msg) |
|
| 205 | + | m.refreshContentFromSelection() |
|
| 206 | + | return m, cmd |
|
| 283 | 207 | } |
|
| 284 | 208 | ||
| 285 | - | func (m Model) keyContent(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { |
|
| 209 | + | func (m Model) handleContentKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { |
|
| 286 | 210 | switch { |
|
| 287 | 211 | case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.Back): |
|
| 288 | - | m.focus = FocusList |
|
| 212 | + | m.state = stateList |
|
| 213 | + | return m, nil |
|
| 214 | + | case key.Matches(msg, m.keys.ScrollDown): |
|
| 215 | + | m.cont = m.cont.ScrollDown(1) |
|
| 289 | 216 | return m, nil |
|
| 290 | - | case key.Matches(msg, m.keys.Down): |
|
| 291 | - | m.contentVP.ScrollDown(1) |
|
| 292 | - | case key.Matches(msg, m.keys.Up): |
|
| 293 | - | m.contentVP.ScrollUp(1) |
|
| 217 | + | case key.Matches(msg, m.keys.ScrollUp): |
|
| 218 | + | m.cont = m.cont.ScrollUp(1) |
|
| 219 | + | return m, nil |
|
| 294 | 220 | case key.Matches(msg, m.keys.Edit): |
|
| 295 | - | n := m.currentNote() |
|
| 296 | - | if n != nil { |
|
| 297 | - | m.focus = FocusEditTitle |
|
| 298 | - | m.editShortID = n.ShortID |
|
| 299 | - | m.titleInput.SetValue(n.Title) |
|
| 300 | - | m.contentArea.SetValue(n.Content) |
|
| 301 | - | m.titleInput.Focus() |
|
| 221 | + | if n, ok := m.list.Selected(); ok { |
|
| 222 | + | m.form.StartEdit(n) |
|
| 223 | + | m.state = stateForm |
|
| 302 | 224 | } |
|
| 225 | + | return m, nil |
|
| 303 | 226 | case key.Matches(msg, m.keys.ExtEdit): |
|
| 304 | - | n := m.currentNote() |
|
| 305 | - | if n != nil { |
|
| 227 | + | if n, ok := m.list.Selected(); ok { |
|
| 306 | 228 | return m, openExternalEditor(n.ShortID, n.Content) |
|
| 307 | 229 | } |
|
| 230 | + | return m, nil |
|
| 308 | 231 | case key.Matches(msg, m.keys.Copy): |
|
| 309 | - | n := m.currentNote() |
|
| 310 | - | if n != nil { |
|
| 311 | - | clipboard.WriteAll(n.Content) |
|
| 312 | - | return m, m.setStatus("copied text", true) |
|
| 232 | + | if n, ok := m.list.Selected(); ok { |
|
| 233 | + | return m, copyToClipboardCmd(n.Content, "copied text") |
|
| 313 | 234 | } |
|
| 235 | + | return m, nil |
|
| 314 | 236 | case key.Matches(msg, m.keys.CopyLink): |
|
| 315 | - | n := m.currentNote() |
|
| 316 | - | if n != nil && m.isRemote { |
|
| 317 | - | link := strings.TrimRight(m.backend.RemoteURL(), "/") + "/notes/" + n.ShortID |
|
| 318 | - | clipboard.WriteAll(link) |
|
| 319 | - | return m, m.setStatus("copied link", true) |
|
| 237 | + | if !m.isRemote { |
|
| 238 | + | return m, m.setStatus("local mode: no link", false) |
|
| 320 | 239 | } |
|
| 321 | - | case key.Matches(msg, m.keys.OpenBrowser): |
|
| 322 | - | n := m.currentNote() |
|
| 323 | - | if n != nil && m.isRemote { |
|
| 324 | - | openURL(strings.TrimRight(m.backend.RemoteURL(), "/") + "/notes/" + n.ShortID) |
|
| 240 | + | if n, ok := m.list.Selected(); ok { |
|
| 241 | + | return m, copyToClipboardCmd(noteLinkURL(m.backend.RemoteURL(), n.ShortID), "copied link") |
|
| 325 | 242 | } |
|
| 326 | - | case key.Matches(msg, m.keys.Help): |
|
| 327 | - | m.showHelp = true |
|
| 328 | - | case key.Matches(msg, m.keys.ToggleWrap): |
|
| 329 | - | m.wrap = !m.wrap |
|
| 330 | - | m.refreshPreview() |
|
| 331 | - | } |
|
| 332 | - | var cmd tea.Cmd |
|
| 333 | - | m.contentVP, cmd = m.contentVP.Update(msg) |
|
| 334 | - | return m, cmd |
|
| 335 | - | } |
|
| 336 | - | ||
| 337 | - | func (m Model) keyForm(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { |
|
| 338 | - | switch { |
|
| 339 | - | case key.Matches(msg, m.keys.Cancel): |
|
| 340 | - | m.focus = FocusList |
|
| 341 | - | m.titleInput.Blur() |
|
| 342 | - | m.contentArea.Blur() |
|
| 343 | 243 | return m, nil |
|
| 344 | - | case key.Matches(msg, m.keys.Save): |
|
| 345 | - | title := strings.TrimSpace(m.titleInput.Value()) |
|
| 346 | - | if title == "" { |
|
| 347 | - | return m, m.setStatus("title required", false) |
|
| 244 | + | case key.Matches(msg, m.keys.OpenBrowser): |
|
| 245 | + | if !m.isRemote { |
|
| 246 | + | return m, nil |
|
| 348 | 247 | } |
|
| 349 | - | return m, saveNoteCmd(m.backend, m.editShortID, title, m.contentArea.Value()) |
|
| 350 | - | case key.Matches(msg, m.keys.SwitchField): |
|
| 351 | - | switch m.focus { |
|
| 352 | - | case FocusCreateTitle: |
|
| 353 | - | m.focus = FocusCreateContent |
|
| 354 | - | case FocusCreateContent: |
|
| 355 | - | m.focus = FocusCreateTitle |
|
| 356 | - | case FocusEditTitle: |
|
| 357 | - | m.focus = FocusEditContent |
|
| 358 | - | case FocusEditContent: |
|
| 359 | - | m.focus = FocusEditTitle |
|
| 248 | + | if n, ok := m.list.Selected(); ok { |
|
| 249 | + | return m, openURLCmd(noteLinkURL(m.backend.RemoteURL(), n.ShortID)) |
|
| 360 | 250 | } |
|
| 361 | - | m.applyFormFocus() |
|
| 362 | 251 | return m, nil |
|
| 363 | 252 | case key.Matches(msg, m.keys.ToggleWrap): |
|
| 364 | - | m.wrap = !m.wrap |
|
| 253 | + | m.cont.ToggleWrap() |
|
| 254 | + | return m, nil |
|
| 255 | + | case key.Matches(msg, m.keys.Help): |
|
| 256 | + | m.showHelp = true |
|
| 365 | 257 | return m, nil |
|
| 366 | 258 | } |
|
| 367 | 259 | ||
| 368 | 260 | var cmd tea.Cmd |
|
| 369 | - | switch m.focus { |
|
| 370 | - | case FocusCreateTitle, FocusEditTitle: |
|
| 371 | - | m.titleInput, cmd = m.titleInput.Update(msg) |
|
| 372 | - | case FocusCreateContent, FocusEditContent: |
|
| 373 | - | m.contentArea, cmd = m.contentArea.Update(msg) |
|
| 374 | - | } |
|
| 261 | + | m.cont, cmd = m.cont.Update(msg) |
|
| 375 | 262 | return m, cmd |
|
| 376 | 263 | } |
|
| 377 | 264 | ||
| 378 | - | func (m *Model) applyFormFocus() { |
|
| 379 | - | switch m.focus { |
|
| 380 | - | case FocusCreateTitle, FocusEditTitle: |
|
| 381 | - | m.titleInput.Focus() |
|
| 382 | - | m.contentArea.Blur() |
|
| 383 | - | case FocusCreateContent, FocusEditContent: |
|
| 384 | - | m.contentArea.Focus() |
|
| 385 | - | m.titleInput.Blur() |
|
| 265 | + | func (m *Model) refreshContentFromSelection() { |
|
| 266 | + | if n, ok := m.list.Selected(); ok { |
|
| 267 | + | m.cont.SetNote(&n) |
|
| 268 | + | } else { |
|
| 269 | + | m.cont.SetNote(nil) |
|
| 386 | 270 | } |
|
| 387 | 271 | } |
|
| 388 | 272 | ||
| 389 | - | func (m Model) keySearch(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { |
|
| 390 | - | switch msg.String() { |
|
| 391 | - | case "esc": |
|
| 392 | - | m.searchInput.SetValue("") |
|
| 393 | - | m.searchInput.Blur() |
|
| 394 | - | m.focus = FocusList |
|
| 395 | - | m.applyFilter("") |
|
| 396 | - | m.refreshPreview() |
|
| 397 | - | return m, nil |
|
| 398 | - | case "enter": |
|
| 399 | - | m.searchInput.Blur() |
|
| 400 | - | m.focus = FocusList |
|
| 401 | - | return m, nil |
|
| 273 | + | func (m *Model) applyLayout() { |
|
| 274 | + | if !m.ready { |
|
| 275 | + | return |
|
| 402 | 276 | } |
|
| 403 | - | var cmd tea.Cmd |
|
| 404 | - | m.searchInput, cmd = m.searchInput.Update(msg) |
|
| 405 | - | m.applyFilter(m.searchInput.Value()) |
|
| 406 | - | m.refreshPreview() |
|
| 407 | - | return m, cmd |
|
| 277 | + | listW, contentW := splitWidths(m.width) |
|
| 278 | + | bodyH := splitBodyHeight(m.height) |
|
| 279 | + | ||
| 280 | + | listInnerW := max(listW-paneFrameWidth(), 1) |
|
| 281 | + | listInnerH := max(bodyH-paneFrameHeight(), 1) |
|
| 282 | + | m.list.SetSize(listInnerW, listInnerH) |
|
| 283 | + | ||
| 284 | + | contentInnerW := max(contentW-paneFrameWidth(), 20) |
|
| 285 | + | contentInnerH := max(bodyH-paneFrameHeight(), 3) |
|
| 286 | + | m.cont.SetSize(contentInnerW, max(contentInnerH-1, 1)) |
|
| 287 | + | m.form.SetSize(contentInnerW, contentInnerH) |
|
| 288 | + | } |
|
| 289 | + | ||
| 290 | + | func (m *Model) setStatus(text string, ok bool) tea.Cmd { |
|
| 291 | + | m.status = text |
|
| 292 | + | m.statusOK = ok |
|
| 293 | + | m.statusUntil = time.Now().Add(2 * time.Second) |
|
| 294 | + | return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearStatusMsg{} }) |
|
| 408 | 295 | } |
|
| 2 | 2 | ||
| 3 | 3 | import ( |
|
| 4 | 4 | "fmt" |
|
| 5 | - | "strings" |
|
| 6 | 5 | ||
| 7 | 6 | tea "charm.land/bubbletea/v2" |
|
| 8 | 7 | "charm.land/lipgloss/v2" |
|
| 19 | 18 | Bold(true). |
|
| 20 | 19 | Foreground(lipgloss.Color("3")). |
|
| 21 | 20 | Padding(0, 1) |
|
| 22 | - | itemStyle = lipgloss.NewStyle().Padding(0, 1) |
|
| 23 | - | itemSelected = lipgloss.NewStyle(). |
|
| 24 | - | Padding(0, 1). |
|
| 25 | - | Bold(true). |
|
| 26 | - | Foreground(lipgloss.Color("3")) |
|
| 27 | - | statusOK = lipgloss.NewStyle(). |
|
| 21 | + | statusOKStyle = lipgloss.NewStyle(). |
|
| 28 | 22 | Foreground(lipgloss.Color("2")). |
|
| 29 | 23 | Bold(true) |
|
| 30 | - | statusErr = lipgloss.NewStyle(). |
|
| 24 | + | statusErrStyle = lipgloss.NewStyle(). |
|
| 31 | 25 | Foreground(lipgloss.Color("1")). |
|
| 32 | 26 | Bold(true) |
|
| 33 | - | hintStyle = lipgloss.NewStyle(). |
|
| 34 | - | Foreground(lipgloss.Color("8")) |
|
| 35 | 27 | modalStyle = lipgloss.NewStyle(). |
|
| 36 | 28 | Border(lipgloss.RoundedBorder()). |
|
| 37 | 29 | BorderForeground(lipgloss.Color("3")). |
|
| 42 | 34 | listW, contentW := splitWidths(m.width) |
|
| 43 | 35 | bodyH := splitBodyHeight(m.height) |
|
| 44 | 36 | ||
| 45 | - | left := m.renderList(listW, bodyH) |
|
| 46 | - | right := m.renderRight(contentW, bodyH) |
|
| 37 | + | left := m.renderListPane(listW, bodyH) |
|
| 38 | + | right := m.renderRightPane(contentW, bodyH) |
|
| 47 | 39 | ||
| 48 | 40 | base := lipgloss.JoinHorizontal(lipgloss.Top, left, right) |
|
| 49 | 41 | ||
| 53 | 45 | modalStyle.Render(m.help.FullHelpView(m.keys.FullHelp())), 1)) |
|
| 54 | 46 | } |
|
| 55 | 47 | if m.confirmDelete { |
|
| 56 | - | n := m.currentNote() |
|
| 57 | 48 | title := "" |
|
| 58 | - | if n != nil { |
|
| 49 | + | if n, ok := m.list.Selected(); ok { |
|
| 59 | 50 | title = n.Title |
|
| 60 | 51 | } |
|
| 61 | 52 | overlays = append(overlays, centerLayer(m.width, m.height, |
|
| 62 | 53 | modalStyle.Render(fmt.Sprintf("Delete %q?\n\ny / n", title)), 2)) |
|
| 63 | 54 | } |
|
| 64 | 55 | if m.status != "" { |
|
| 65 | - | st := statusOK |
|
| 56 | + | st := statusOKStyle |
|
| 66 | 57 | if !m.statusOK { |
|
| 67 | - | st = statusErr |
|
| 58 | + | st = statusErrStyle |
|
| 68 | 59 | } |
|
| 69 | 60 | overlays = append(overlays, bottomCenterLayer(m.width, m.height, |
|
| 70 | 61 | modalStyle.Render(st.Render(m.status)), 3)) |
|
| 81 | 72 | return tea.View{Content: content, AltScreen: true} |
|
| 82 | 73 | } |
|
| 83 | 74 | ||
| 84 | - | func centerLayer(w, h int, content string, z int) *lipgloss.Layer { |
|
| 85 | - | cw, ch := lipgloss.Width(content), lipgloss.Height(content) |
|
| 86 | - | x := (w - cw) / 2 |
|
| 87 | - | y := (h - ch) / 2 |
|
| 88 | - | if x < 0 { |
|
| 89 | - | x = 0 |
|
| 90 | - | } |
|
| 91 | - | if y < 0 { |
|
| 92 | - | y = 0 |
|
| 93 | - | } |
|
| 94 | - | return lipgloss.NewLayer(content).X(x).Y(y).Z(z) |
|
| 95 | - | } |
|
| 96 | - | ||
| 97 | - | func bottomCenterLayer(w, h int, content string, z int) *lipgloss.Layer { |
|
| 98 | - | cw, ch := lipgloss.Width(content), lipgloss.Height(content) |
|
| 99 | - | x := (w - cw) / 2 |
|
| 100 | - | y := h - ch - 1 |
|
| 101 | - | if x < 0 { |
|
| 102 | - | x = 0 |
|
| 103 | - | } |
|
| 104 | - | if y < 0 { |
|
| 105 | - | y = 0 |
|
| 106 | - | } |
|
| 107 | - | return lipgloss.NewLayer(content).X(x).Y(y).Z(z) |
|
| 108 | - | } |
|
| 109 | - | ||
| 110 | - | func (m Model) renderList(w, h int) string { |
|
| 75 | + | func (m Model) renderListPane(w, h int) string { |
|
| 111 | 76 | style := borderStyle |
|
| 112 | - | if m.focus == FocusList || m.focus == FocusSearch { |
|
| 77 | + | if m.state == stateList { |
|
| 113 | 78 | style = borderActive |
|
| 114 | 79 | } |
|
| 115 | - | ||
| 116 | - | notes := m.visibleNotes() |
|
| 117 | - | rows := make([]string, 0, len(notes)+2) |
|
| 118 | - | rows = append(rows, titleStyle.Render("notes")) |
|
| 119 | - | if len(notes) == 0 { |
|
| 120 | - | rows = append(rows, hintStyle.Render(" (empty — press c)")) |
|
| 121 | - | } |
|
| 122 | - | for i, n := range notes { |
|
| 123 | - | line := truncate(n.Title, maxInt(w-paneFrameWidth()-4, 1)) |
|
| 124 | - | if i == m.cursor { |
|
| 125 | - | rows = append(rows, itemSelected.Render("▶ "+line)) |
|
| 126 | - | } else { |
|
| 127 | - | rows = append(rows, itemStyle.Render(" "+line)) |
|
| 128 | - | } |
|
| 129 | - | } |
|
| 130 | - | ||
| 131 | - | if m.focus == FocusSearch || m.searchInput.Value() != "" { |
|
| 132 | - | rows = append(rows, "", hintStyle.Render(m.searchInput.View())) |
|
| 133 | - | } |
|
| 134 | - | ||
| 135 | - | content := strings.Join(rows, "\n") |
|
| 136 | 80 | return style. |
|
| 137 | - | Width(maxInt(w-paneFrameWidth(), 1)). |
|
| 138 | - | Height(maxInt(h-paneFrameHeight(), 1)). |
|
| 139 | - | Render(content) |
|
| 81 | + | Width(max(w-paneFrameWidth(), 1)). |
|
| 82 | + | Height(max(h-paneFrameHeight(), 1)). |
|
| 83 | + | Render(m.list.View()) |
|
| 140 | 84 | } |
|
| 141 | 85 | ||
| 142 | - | func (m Model) renderRight(w, h int) string { |
|
| 143 | - | switch m.focus { |
|
| 144 | - | case FocusCreateTitle, FocusCreateContent, FocusEditTitle, FocusEditContent: |
|
| 86 | + | func (m Model) renderRightPane(w, h int) string { |
|
| 87 | + | if m.state == stateForm { |
|
| 145 | 88 | return m.renderForm(w, h) |
|
| 146 | 89 | } |
|
| 147 | 90 | return m.renderContent(w, h) |
|
| 149 | 92 | ||
| 150 | 93 | func (m Model) renderContent(w, h int) string { |
|
| 151 | 94 | style := borderStyle |
|
| 152 | - | if m.focus == FocusContent { |
|
| 95 | + | if m.state == stateContent { |
|
| 153 | 96 | style = borderActive |
|
| 154 | 97 | } |
|
| 155 | 98 | header := "preview" |
|
| 156 | - | n := m.currentNote() |
|
| 157 | - | if n != nil { |
|
| 158 | - | header = n.Title |
|
| 99 | + | if t := m.cont.Title(); t != "" { |
|
| 100 | + | header = t |
|
| 159 | 101 | } |
|
| 160 | - | body := m.contentVP.View() |
|
| 161 | - | inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), body) |
|
| 102 | + | inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), m.cont.View()) |
|
| 162 | 103 | return style. |
|
| 163 | - | Width(maxInt(w-paneFrameWidth(), 1)). |
|
| 164 | - | Height(maxInt(h-paneFrameHeight(), 1)). |
|
| 104 | + | Width(max(w-paneFrameWidth(), 1)). |
|
| 105 | + | Height(max(h-paneFrameHeight(), 1)). |
|
| 165 | 106 | Render(inner) |
|
| 166 | 107 | } |
|
| 167 | 108 | ||
| 168 | 109 | func (m Model) renderForm(w, h int) string { |
|
| 169 | 110 | header := "new note" |
|
| 170 | - | if m.editShortID != "" { |
|
| 111 | + | if !m.form.IsCreate() { |
|
| 171 | 112 | header = "edit" |
|
| 172 | 113 | } |
|
| 173 | - | title := m.titleInput.View() |
|
| 174 | - | if m.focus == FocusCreateTitle || m.focus == FocusEditTitle { |
|
| 175 | - | title = borderActive.Render(title) |
|
| 114 | + | ||
| 115 | + | titleField := m.form.title.View() |
|
| 116 | + | if m.form.ActiveField() == formFieldTitle { |
|
| 117 | + | titleField = borderActive.Render(titleField) |
|
| 176 | 118 | } else { |
|
| 177 | - | title = borderStyle.Render(title) |
|
| 119 | + | titleField = borderStyle.Render(titleField) |
|
| 178 | 120 | } |
|
| 179 | 121 | ||
| 180 | - | body := m.contentArea.View() |
|
| 181 | - | if m.focus == FocusCreateContent || m.focus == FocusEditContent { |
|
| 122 | + | body := m.form.content.View() |
|
| 123 | + | if m.form.ActiveField() == formFieldContent { |
|
| 182 | 124 | body = borderActive.Render(body) |
|
| 183 | 125 | } else { |
|
| 184 | 126 | body = borderStyle.Render(body) |
|
| 185 | 127 | } |
|
| 186 | 128 | ||
| 187 | - | inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), title, body) |
|
| 188 | - | return borderStyle. |
|
| 189 | - | Width(maxInt(w-paneFrameWidth(), 1)). |
|
| 190 | - | Height(maxInt(h-paneFrameHeight(), 1)). |
|
| 129 | + | inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), titleField, body) |
|
| 130 | + | return borderActive. |
|
| 131 | + | Width(max(w-paneFrameWidth(), 1)). |
|
| 132 | + | Height(max(h-paneFrameHeight(), 1)). |
|
| 191 | 133 | Render(inner) |
|
| 192 | 134 | } |
|
| 193 | 135 | ||
| 136 | + | func centerLayer(w, h int, content string, z int) *lipgloss.Layer { |
|
| 137 | + | cw, ch := lipgloss.Width(content), lipgloss.Height(content) |
|
| 138 | + | x := (w - cw) / 2 |
|
| 139 | + | y := (h - ch) / 2 |
|
| 140 | + | if x < 0 { |
|
| 141 | + | x = 0 |
|
| 142 | + | } |
|
| 143 | + | if y < 0 { |
|
| 144 | + | y = 0 |
|
| 145 | + | } |
|
| 146 | + | return lipgloss.NewLayer(content).X(x).Y(y).Z(z) |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | func bottomCenterLayer(w, h int, content string, z int) *lipgloss.Layer { |
|
| 150 | + | cw, ch := lipgloss.Width(content), lipgloss.Height(content) |
|
| 151 | + | x := (w - cw) / 2 |
|
| 152 | + | y := h - ch - 1 |
|
| 153 | + | if x < 0 { |
|
| 154 | + | x = 0 |
|
| 155 | + | } |
|
| 156 | + | if y < 0 { |
|
| 157 | + | y = 0 |
|
| 158 | + | } |
|
| 159 | + | return lipgloss.NewLayer(content).X(x).Y(y).Z(z) |
|
| 160 | + | } |
|
| 161 | + | ||
| 194 | 162 | func splitWidths(total int) (int, int) { |
|
| 195 | 163 | if total < 44 { |
|
| 196 | 164 | return total / 2, total - (total / 2) |
|
| 215 | 183 | return total |
|
| 216 | 184 | } |
|
| 217 | 185 | ||
| 218 | - | func paneFrameWidth() int { |
|
| 219 | - | return borderStyle.GetHorizontalFrameSize() |
|
| 220 | - | } |
|
| 221 | - | ||
| 222 | - | func paneFrameHeight() int { |
|
| 223 | - | return borderStyle.GetVerticalFrameSize() |
|
| 224 | - | } |
|
| 225 | - | ||
| 226 | - | func maxInt(a, b int) int { |
|
| 227 | - | if a > b { |
|
| 228 | - | return a |
|
| 229 | - | } |
|
| 230 | - | return b |
|
| 231 | - | } |
|
| 232 | - | ||
| 233 | - | func truncate(s string, n int) string { |
|
| 234 | - | if n < 1 { |
|
| 235 | - | return "" |
|
| 236 | - | } |
|
| 237 | - | if len(s) <= n { |
|
| 238 | - | return s |
|
| 239 | - | } |
|
| 240 | - | if n <= 1 { |
|
| 241 | - | return "…" |
|
| 242 | - | } |
|
| 243 | - | return s[:n-1] + "…" |
|
| 244 | - | } |
|
| 186 | + | func paneFrameWidth() int { return borderStyle.GetHorizontalFrameSize() } |
|
| 187 | + | func paneFrameHeight() int { return borderStyle.GetVerticalFrameSize() } |
|
| 35 | 35 | github.com/ncruces/go-strftime v0.1.9 // indirect |
|
| 36 | 36 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
|
| 37 | 37 | github.com/rivo/uniseg v0.4.7 // indirect |
|
| 38 | + | github.com/sahilm/fuzzy v0.1.1 // indirect |
|
| 38 | 39 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect |
|
| 39 | 40 | golang.org/x/crypto v0.39.0 // indirect |
|
| 40 | 41 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect |
| 46 | 46 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
|
| 47 | 47 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= |
|
| 48 | 48 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= |
|
| 49 | + | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= |
|
| 50 | + | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= |
|
| 49 | 51 | github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= |
|
| 50 | 52 | github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= |
|
| 51 | 53 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
|
| 60 | 62 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
|
| 61 | 63 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= |
|
| 62 | 64 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= |
|
| 65 | + | github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= |
|
| 66 | + | github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= |
|
| 63 | 67 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= |
|
| 64 | 68 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= |
|
| 65 | 69 | golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= |
|
| 1 | + | package tui |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "strings" |
|
| 5 | + | ||
| 6 | + | tea "charm.land/bubbletea/v2" |
|
| 7 | + | "github.com/atotto/clipboard" |
|
| 8 | + | ) |
|
| 9 | + | ||
| 10 | + | func loadSnippetsCmd(b Backend) tea.Cmd { |
|
| 11 | + | return func() tea.Msg { |
|
| 12 | + | list, err := b.List() |
|
| 13 | + | return snippetsLoadedMsg{snippets: list, err: err} |
|
| 14 | + | } |
|
| 15 | + | } |
|
| 16 | + | ||
| 17 | + | func saveSnippetCmd(b Backend, shortID, name, content string) tea.Cmd { |
|
| 18 | + | return func() tea.Msg { |
|
| 19 | + | var ( |
|
| 20 | + | s *Snippet |
|
| 21 | + | err error |
|
| 22 | + | ) |
|
| 23 | + | if shortID == "" { |
|
| 24 | + | s, err = b.Create(name, content) |
|
| 25 | + | } else { |
|
| 26 | + | s, err = b.Update(shortID, name, content) |
|
| 27 | + | } |
|
| 28 | + | return snippetSavedMsg{snippet: s, err: err} |
|
| 29 | + | } |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | func deleteSnippetCmd(b Backend, shortID string) tea.Cmd { |
|
| 33 | + | return func() tea.Msg { |
|
| 34 | + | _, err := b.Delete(shortID) |
|
| 35 | + | return snippetDeletedMsg{shortID: shortID, err: err} |
|
| 36 | + | } |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | func copyToClipboardCmd(text, okStatus string) tea.Cmd { |
|
| 40 | + | return func() tea.Msg { |
|
| 41 | + | if err := clipboard.WriteAll(text); err != nil { |
|
| 42 | + | return statusMsg{text: "clipboard: " + err.Error(), ok: false} |
|
| 43 | + | } |
|
| 44 | + | return statusMsg{text: okStatus, ok: true} |
|
| 45 | + | } |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | func openURLCmd(url string) tea.Cmd { |
|
| 49 | + | return func() tea.Msg { |
|
| 50 | + | if err := openURL(url); err != nil { |
|
| 51 | + | return statusMsg{text: "open: " + err.Error(), ok: false} |
|
| 52 | + | } |
|
| 53 | + | return statusMsg{text: "opened " + url, ok: true} |
|
| 54 | + | } |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | func shareLinkURL(remoteBase, shortID string) string { |
|
| 58 | + | if remoteBase == "" { |
|
| 59 | + | return "" |
|
| 60 | + | } |
|
| 61 | + | return strings.TrimRight(remoteBase, "/") + "/s/" + shortID |
|
| 62 | + | } |
| 1 | + | package tui |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "charm.land/bubbles/v2/viewport" |
|
| 5 | + | tea "charm.land/bubbletea/v2" |
|
| 6 | + | "charm.land/lipgloss/v2" |
|
| 7 | + | ) |
|
| 8 | + | ||
| 9 | + | type contentModel struct { |
|
| 10 | + | vp viewport.Model |
|
| 11 | + | highlighter *highlighter |
|
| 12 | + | wrap bool |
|
| 13 | + | ||
| 14 | + | shortID string |
|
| 15 | + | name string |
|
| 16 | + | body string |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | func newContentModel() contentModel { |
|
| 20 | + | return contentModel{vp: viewport.New(), highlighter: newHighlighter(), wrap: true} |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | func (c contentModel) Update(msg tea.Msg) (contentModel, tea.Cmd) { |
|
| 24 | + | var cmd tea.Cmd |
|
| 25 | + | c.vp, cmd = c.vp.Update(msg) |
|
| 26 | + | return c, cmd |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | func (c *contentModel) SetSize(w, h int) { |
|
| 30 | + | c.vp.SetWidth(w) |
|
| 31 | + | c.vp.SetHeight(h) |
|
| 32 | + | c.refresh() |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | func (c *contentModel) SetSnippet(s *Snippet) { |
|
| 36 | + | if s == nil { |
|
| 37 | + | c.shortID, c.name, c.body = "", "", "" |
|
| 38 | + | c.vp.SetContent("") |
|
| 39 | + | c.vp.GotoTop() |
|
| 40 | + | return |
|
| 41 | + | } |
|
| 42 | + | c.shortID = s.ShortID |
|
| 43 | + | c.name = s.Name |
|
| 44 | + | c.body = s.Content |
|
| 45 | + | c.vp.GotoTop() |
|
| 46 | + | c.refresh() |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | func (c *contentModel) ToggleWrap() { |
|
| 50 | + | c.wrap = !c.wrap |
|
| 51 | + | c.vp.GotoTop() |
|
| 52 | + | c.refresh() |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | func (c *contentModel) Wrap() bool { return c.wrap } |
|
| 56 | + | ||
| 57 | + | func (c *contentModel) Invalidate(shortID string) { |
|
| 58 | + | if c.highlighter != nil { |
|
| 59 | + | c.highlighter.invalidate(shortID) |
|
| 60 | + | } |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | func (c *contentModel) refresh() { |
|
| 64 | + | if c.body == "" { |
|
| 65 | + | c.vp.SetContent("") |
|
| 66 | + | return |
|
| 67 | + | } |
|
| 68 | + | body := c.body |
|
| 69 | + | if c.highlighter != nil { |
|
| 70 | + | body = c.highlighter.render(c.shortID, c.name, c.body) |
|
| 71 | + | } |
|
| 72 | + | if c.wrap && c.vp.Width() > 0 { |
|
| 73 | + | body = lipgloss.NewStyle().Width(c.vp.Width()).Render(body) |
|
| 74 | + | } |
|
| 75 | + | c.vp.SetContent(body) |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | func (c contentModel) Header() string { |
|
| 79 | + | if c.name != "" { |
|
| 80 | + | return c.name |
|
| 81 | + | } |
|
| 82 | + | return c.shortID |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | func (c contentModel) View() string { return c.vp.View() } |
|
| 86 | + | ||
| 87 | + | func (c contentModel) ScrollUp(n int) contentModel { |
|
| 88 | + | c.vp.ScrollUp(n) |
|
| 89 | + | return c |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | func (c contentModel) ScrollDown(n int) contentModel { |
|
| 93 | + | c.vp.ScrollDown(n) |
|
| 94 | + | return c |
|
| 95 | + | } |
| 1 | + | package tui |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "strings" |
|
| 5 | + | ||
| 6 | + | "charm.land/bubbles/v2/key" |
|
| 7 | + | "charm.land/bubbles/v2/textarea" |
|
| 8 | + | "charm.land/bubbles/v2/textinput" |
|
| 9 | + | tea "charm.land/bubbletea/v2" |
|
| 10 | + | ) |
|
| 11 | + | ||
| 12 | + | type formField uint8 |
|
| 13 | + | ||
| 14 | + | const ( |
|
| 15 | + | formFieldName formField = iota |
|
| 16 | + | formFieldContent |
|
| 17 | + | ) |
|
| 18 | + | ||
| 19 | + | type formModel struct { |
|
| 20 | + | name textinput.Model |
|
| 21 | + | content textarea.Model |
|
| 22 | + | ||
| 23 | + | field formField |
|
| 24 | + | shortID string |
|
| 25 | + | isCreate bool |
|
| 26 | + | ||
| 27 | + | keys formKeys |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | type formKeys struct { |
|
| 31 | + | Save key.Binding |
|
| 32 | + | Cancel key.Binding |
|
| 33 | + | SwitchField key.Binding |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | func defaultFormKeys() formKeys { |
|
| 37 | + | return formKeys{ |
|
| 38 | + | Save: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("⌃s", "save")), |
|
| 39 | + | Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), |
|
| 40 | + | SwitchField: key.NewBinding(key.WithKeys("tab"), key.WithHelp("⇥", "switch field")), |
|
| 41 | + | } |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | func newFormModel() formModel { |
|
| 45 | + | ti := textinput.New() |
|
| 46 | + | ti.Placeholder = "name.ext" |
|
| 47 | + | ti.Prompt = "" |
|
| 48 | + | ti.CharLimit = 200 |
|
| 49 | + | ||
| 50 | + | ta := textarea.New() |
|
| 51 | + | ta.Placeholder = "Paste code..." |
|
| 52 | + | ta.ShowLineNumbers = true |
|
| 53 | + | ta.Prompt = "" |
|
| 54 | + | ||
| 55 | + | return formModel{name: ti, content: ta, keys: defaultFormKeys()} |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | func (f *formModel) StartCreate() { |
|
| 59 | + | f.shortID = "" |
|
| 60 | + | f.isCreate = true |
|
| 61 | + | f.name.SetValue("") |
|
| 62 | + | f.content.SetValue("") |
|
| 63 | + | f.field = formFieldName |
|
| 64 | + | f.applyFocus() |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | func (f *formModel) StartEdit(s Snippet) { |
|
| 68 | + | f.shortID = s.ShortID |
|
| 69 | + | f.isCreate = false |
|
| 70 | + | f.name.SetValue(s.Name) |
|
| 71 | + | f.content.SetValue(s.Content) |
|
| 72 | + | f.field = formFieldName |
|
| 73 | + | f.applyFocus() |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | func (f *formModel) SetContent(s string) { f.content.SetValue(s) } |
|
| 77 | + | ||
| 78 | + | func (f *formModel) Blur() { |
|
| 79 | + | f.name.Blur() |
|
| 80 | + | f.content.Blur() |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | func (f *formModel) applyFocus() { |
|
| 84 | + | switch f.field { |
|
| 85 | + | case formFieldName: |
|
| 86 | + | f.name.Focus() |
|
| 87 | + | f.content.Blur() |
|
| 88 | + | case formFieldContent: |
|
| 89 | + | f.content.Focus() |
|
| 90 | + | f.name.Blur() |
|
| 91 | + | } |
|
| 92 | + | } |
|
| 93 | + | ||
| 94 | + | func (f *formModel) SetSize(w, h int) { |
|
| 95 | + | f.name.SetWidth(max(w-4, 1)) |
|
| 96 | + | f.content.SetWidth(max(w-2, 1)) |
|
| 97 | + | f.content.SetHeight(max(h-5, 1)) |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | func (f formModel) Update(msg tea.Msg) (formModel, tea.Cmd) { |
|
| 101 | + | if km, ok := msg.(tea.KeyPressMsg); ok { |
|
| 102 | + | switch { |
|
| 103 | + | case key.Matches(km, f.keys.Cancel): |
|
| 104 | + | f.Blur() |
|
| 105 | + | return f, func() tea.Msg { return cancelFormMsg{} } |
|
| 106 | + | case key.Matches(km, f.keys.Save): |
|
| 107 | + | name := strings.TrimSpace(f.name.Value()) |
|
| 108 | + | if name == "" { |
|
| 109 | + | return f, func() tea.Msg { return statusMsg{text: "name required", ok: false} } |
|
| 110 | + | } |
|
| 111 | + | content := f.content.Value() |
|
| 112 | + | if strings.TrimSpace(content) == "" { |
|
| 113 | + | return f, func() tea.Msg { return statusMsg{text: "content required", ok: false} } |
|
| 114 | + | } |
|
| 115 | + | return f, func() tea.Msg { |
|
| 116 | + | return submitFormMsg{shortID: f.shortID, name: name, content: content} |
|
| 117 | + | } |
|
| 118 | + | case key.Matches(km, f.keys.SwitchField): |
|
| 119 | + | if f.field == formFieldName { |
|
| 120 | + | f.field = formFieldContent |
|
| 121 | + | } else { |
|
| 122 | + | f.field = formFieldName |
|
| 123 | + | } |
|
| 124 | + | f.applyFocus() |
|
| 125 | + | return f, nil |
|
| 126 | + | } |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | var cmd tea.Cmd |
|
| 130 | + | switch f.field { |
|
| 131 | + | case formFieldName: |
|
| 132 | + | f.name, cmd = f.name.Update(msg) |
|
| 133 | + | case formFieldContent: |
|
| 134 | + | f.content, cmd = f.content.Update(msg) |
|
| 135 | + | } |
|
| 136 | + | return f, cmd |
|
| 137 | + | } |
|
| 138 | + | ||
| 139 | + | func (f formModel) ActiveField() formField { return f.field } |
|
| 140 | + | func (f formModel) IsCreate() bool { return f.isCreate } |
| 10 | 10 | "github.com/alecthomas/chroma/v2/styles" |
|
| 11 | 11 | ) |
|
| 12 | 12 | ||
| 13 | + | const highlightCacheMax = 64 |
|
| 14 | + | ||
| 13 | 15 | type highlighter struct { |
|
| 14 | 16 | cache map[string]string |
|
| 17 | + | order []string |
|
| 15 | 18 | } |
|
| 16 | 19 | ||
| 17 | 20 | func newHighlighter() *highlighter { |
|
| 24 | 27 | } |
|
| 25 | 28 | out := highlightCode(name, content) |
|
| 26 | 29 | h.cache[shortID] = out |
|
| 30 | + | h.order = append(h.order, shortID) |
|
| 31 | + | if len(h.order) > highlightCacheMax { |
|
| 32 | + | drop := h.order[0] |
|
| 33 | + | h.order = h.order[1:] |
|
| 34 | + | delete(h.cache, drop) |
|
| 35 | + | } |
|
| 27 | 36 | return out |
|
| 28 | 37 | } |
|
| 29 | 38 | ||
| 30 | 39 | func (h *highlighter) invalidate(shortID string) { |
|
| 40 | + | if _, ok := h.cache[shortID]; !ok { |
|
| 41 | + | return |
|
| 42 | + | } |
|
| 31 | 43 | delete(h.cache, shortID) |
|
| 44 | + | for i, k := range h.order { |
|
| 45 | + | if k == shortID { |
|
| 46 | + | h.order = append(h.order[:i], h.order[i+1:]...) |
|
| 47 | + | break |
|
| 48 | + | } |
|
| 49 | + | } |
|
| 32 | 50 | } |
|
| 33 | 51 | ||
| 34 | 52 | func highlightCode(name, content string) string { |
|
| 42 | 60 | if lexer == nil { |
|
| 43 | 61 | lexer = lexers.Fallback |
|
| 44 | 62 | } |
|
| 45 | - | style := styles.Get("monokai") |
|
| 63 | + | style := styles.Get("vim") |
|
| 46 | 64 | if style == nil { |
|
| 47 | 65 | style = styles.Fallback |
|
| 48 | 66 | } |
|
| 49 | - | formatter := formatters.Get("terminal256") |
|
| 67 | + | formatter := formatters.Get("terminal16") |
|
| 50 | 68 | if formatter == nil { |
|
| 51 | 69 | formatter = formatters.Fallback |
|
| 52 | 70 | } |
|
| 3 | 3 | import "charm.land/bubbles/v2/key" |
|
| 4 | 4 | ||
| 5 | 5 | type keyMap struct { |
|
| 6 | - | Up key.Binding |
|
| 7 | - | Down key.Binding |
|
| 8 | 6 | Open key.Binding |
|
| 9 | 7 | Back key.Binding |
|
| 10 | 8 | Quit key.Binding |
|
| 15 | 13 | Copy key.Binding |
|
| 16 | 14 | CopyLink key.Binding |
|
| 17 | 15 | OpenBrowser key.Binding |
|
| 18 | - | Search key.Binding |
|
| 19 | 16 | Refresh key.Binding |
|
| 20 | 17 | WrapToggle key.Binding |
|
| 21 | 18 | Help key.Binding |
|
| 22 | - | Save key.Binding |
|
| 23 | - | SwitchField key.Binding |
|
| 24 | - | Cancel key.Binding |
|
| 19 | + | ScrollUp key.Binding |
|
| 20 | + | ScrollDown key.Binding |
|
| 25 | 21 | } |
|
| 26 | 22 | ||
| 27 | 23 | func defaultKeys() keyMap { |
|
| 28 | 24 | return keyMap{ |
|
| 29 | - | Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), |
|
| 30 | - | Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), |
|
| 31 | 25 | Open: key.NewBinding(key.WithKeys("enter", "l"), key.WithHelp("⏎/l", "open")), |
|
| 32 | 26 | Back: key.NewBinding(key.WithKeys("h", "esc"), key.WithHelp("h/esc", "back")), |
|
| 33 | - | Quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")), |
|
| 27 | + | Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), |
|
| 34 | 28 | Create: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "create")), |
|
| 35 | 29 | Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), |
|
| 36 | 30 | ExtEdit: key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "$EDITOR")), |
|
| 38 | 32 | Copy: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy text")), |
|
| 39 | 33 | CopyLink: key.NewBinding(key.WithKeys("Y"), key.WithHelp("Y", "copy link")), |
|
| 40 | 34 | OpenBrowser: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "browser")), |
|
| 41 | - | Search: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")), |
|
| 42 | 35 | Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), |
|
| 43 | 36 | WrapToggle: key.NewBinding(key.WithKeys("ctrl+w"), key.WithHelp("⌃w", "wrap")), |
|
| 44 | 37 | Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), |
|
| 45 | - | Save: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("⌃s", "save")), |
|
| 46 | - | SwitchField: key.NewBinding(key.WithKeys("tab"), key.WithHelp("⇥", "switch field")), |
|
| 47 | - | Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")), |
|
| 38 | + | ScrollUp: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), |
|
| 39 | + | ScrollDown: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), |
|
| 48 | 40 | } |
|
| 49 | 41 | } |
|
| 50 | 42 | ||
| 51 | 43 | func (k keyMap) ShortHelp() []key.Binding { |
|
| 52 | - | return []key.Binding{k.Open, k.Create, k.Edit, k.Delete, k.Search, k.Help, k.Quit} |
|
| 44 | + | return []key.Binding{k.Open, k.Create, k.Edit, k.Delete, k.Help, k.Quit} |
|
| 53 | 45 | } |
|
| 54 | 46 | ||
| 55 | 47 | func (k keyMap) FullHelp() [][]key.Binding { |
|
| 56 | 48 | return [][]key.Binding{ |
|
| 57 | - | {k.Up, k.Down, k.Open, k.Back}, |
|
| 58 | - | {k.Create, k.Edit, k.ExtEdit, k.Delete}, |
|
| 59 | - | {k.Copy, k.CopyLink, k.OpenBrowser, k.Search}, |
|
| 60 | - | {k.Refresh, k.WrapToggle, k.Help, k.Save, k.SwitchField}, |
|
| 61 | - | {k.Cancel, k.Quit}, |
|
| 49 | + | {k.Open, k.Back, k.Create, k.Edit}, |
|
| 50 | + | {k.ExtEdit, k.Delete, k.Copy, k.CopyLink}, |
|
| 51 | + | {k.OpenBrowser, k.Refresh, k.WrapToggle, k.Help}, |
|
| 52 | + | {k.ScrollUp, k.ScrollDown, k.Quit}, |
|
| 62 | 53 | } |
|
| 63 | 54 | } |
|
| 1 | + | package tui |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "charm.land/bubbles/v2/list" |
|
| 5 | + | tea "charm.land/bubbletea/v2" |
|
| 6 | + | "charm.land/lipgloss/v2" |
|
| 7 | + | ) |
|
| 8 | + | ||
| 9 | + | var listIDStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) |
|
| 10 | + | ||
| 11 | + | func ansiListDelegate() list.DefaultDelegate { |
|
| 12 | + | d := list.NewDefaultDelegate() |
|
| 13 | + | d.ShowDescription = false |
|
| 14 | + | d.SetSpacing(0) |
|
| 15 | + | d.Styles.NormalTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("7")).Padding(0, 0, 0, 2) |
|
| 16 | + | d.Styles.SelectedTitle = lipgloss.NewStyle(). |
|
| 17 | + | Foreground(lipgloss.Color("3")). |
|
| 18 | + | Bold(true). |
|
| 19 | + | Border(lipgloss.NormalBorder(), false, false, false, true). |
|
| 20 | + | BorderForeground(lipgloss.Color("3")). |
|
| 21 | + | Padding(0, 0, 0, 1) |
|
| 22 | + | d.Styles.DimmedTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Padding(0, 0, 0, 2) |
|
| 23 | + | d.Styles.FilterMatch = lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("3")) |
|
| 24 | + | return d |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | func ansiListStyles() list.Styles { |
|
| 28 | + | s := list.DefaultStyles(true) |
|
| 29 | + | s.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("3")).Padding(0, 1) |
|
| 30 | + | s.TitleBar = lipgloss.NewStyle().Padding(0, 0, 1, 0) |
|
| 31 | + | s.NoItems = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Padding(0, 0, 0, 2) |
|
| 32 | + | s.DefaultFilterCharacterMatch = lipgloss.NewStyle().Underline(true).Foreground(lipgloss.Color("3")) |
|
| 33 | + | return s |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | type snippetItem struct { |
|
| 37 | + | snippet Snippet |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | func (s snippetItem) Title() string { |
|
| 41 | + | label := s.snippet.Name |
|
| 42 | + | if label == "" { |
|
| 43 | + | label = s.snippet.ShortID |
|
| 44 | + | return label |
|
| 45 | + | } |
|
| 46 | + | return label + listIDStyle.Render(" "+s.snippet.ShortID) |
|
| 47 | + | } |
|
| 48 | + | func (s snippetItem) Description() string { return "" } |
|
| 49 | + | func (s snippetItem) FilterValue() string { |
|
| 50 | + | if s.snippet.Name != "" { |
|
| 51 | + | return s.snippet.Name + " " + s.snippet.ShortID |
|
| 52 | + | } |
|
| 53 | + | return s.snippet.ShortID |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | type listModel struct { |
|
| 57 | + | inner list.Model |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | func newListModel(snippets []Snippet) listModel { |
|
| 61 | + | items := make([]list.Item, 0, len(snippets)) |
|
| 62 | + | for _, s := range snippets { |
|
| 63 | + | items = append(items, snippetItem{snippet: s}) |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | l := list.New(items, ansiListDelegate(), 0, 0) |
|
| 67 | + | l.Title = "snippets" |
|
| 68 | + | l.Styles = ansiListStyles() |
|
| 69 | + | l.SetShowStatusBar(false) |
|
| 70 | + | l.SetShowPagination(false) |
|
| 71 | + | l.SetShowHelp(false) |
|
| 72 | + | l.SetFilteringEnabled(true) |
|
| 73 | + | l.DisableQuitKeybindings() |
|
| 74 | + | ||
| 75 | + | return listModel{inner: l} |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | func (l listModel) Update(msg tea.Msg) (listModel, tea.Cmd) { |
|
| 79 | + | var cmd tea.Cmd |
|
| 80 | + | l.inner, cmd = l.inner.Update(msg) |
|
| 81 | + | return l, cmd |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | func (l listModel) View() string { return l.inner.View() } |
|
| 85 | + | ||
| 86 | + | func (l *listModel) SetSize(w, h int) { l.inner.SetSize(w, h) } |
|
| 87 | + | ||
| 88 | + | func (l *listModel) SetSnippets(snippets []Snippet) tea.Cmd { |
|
| 89 | + | items := make([]list.Item, 0, len(snippets)) |
|
| 90 | + | for _, s := range snippets { |
|
| 91 | + | items = append(items, snippetItem{snippet: s}) |
|
| 92 | + | } |
|
| 93 | + | return l.inner.SetItems(items) |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | func (l listModel) Selected() (Snippet, bool) { |
|
| 97 | + | it := l.inner.SelectedItem() |
|
| 98 | + | if it == nil { |
|
| 99 | + | return Snippet{}, false |
|
| 100 | + | } |
|
| 101 | + | s, ok := it.(snippetItem) |
|
| 102 | + | if !ok { |
|
| 103 | + | return Snippet{}, false |
|
| 104 | + | } |
|
| 105 | + | return s.snippet, true |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | func (l listModel) IsFiltering() bool { |
|
| 109 | + | return l.inner.SettingFilter() |
|
| 110 | + | } |
| 27 | 27 | } |
|
| 28 | 28 | ||
| 29 | 29 | type clearStatusMsg struct{} |
|
| 30 | + | ||
| 31 | + | type submitFormMsg struct { |
|
| 32 | + | shortID string |
|
| 33 | + | name string |
|
| 34 | + | content string |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | type cancelFormMsg struct{} |
| 1 | 1 | package tui |
|
| 2 | 2 | ||
| 3 | 3 | import ( |
|
| 4 | - | "strings" |
|
| 5 | 4 | "time" |
|
| 6 | 5 | ||
| 7 | 6 | "charm.land/bubbles/v2/help" |
|
| 8 | - | "charm.land/bubbles/v2/textarea" |
|
| 9 | - | "charm.land/bubbles/v2/textinput" |
|
| 10 | - | "charm.land/bubbles/v2/viewport" |
|
| 11 | 7 | tea "charm.land/bubbletea/v2" |
|
| 12 | 8 | ) |
|
| 13 | 9 | ||
| 14 | - | type Focus int |
|
| 10 | + | type sessionState uint8 |
|
| 15 | 11 | ||
| 16 | 12 | const ( |
|
| 17 | - | FocusList Focus = iota |
|
| 18 | - | FocusContent |
|
| 19 | - | FocusCreateName |
|
| 20 | - | FocusCreateContent |
|
| 21 | - | FocusEditName |
|
| 22 | - | FocusEditContent |
|
| 23 | - | FocusSearch |
|
| 13 | + | stateList sessionState = iota |
|
| 14 | + | stateContent |
|
| 15 | + | stateForm |
|
| 24 | 16 | ) |
|
| 25 | 17 | ||
| 26 | 18 | type Model struct { |
|
| 27 | 19 | backend Backend |
|
| 28 | 20 | isRemote bool |
|
| 29 | 21 | ||
| 30 | - | snippets []Snippet |
|
| 31 | - | filtered []int |
|
| 32 | - | cursor int |
|
| 22 | + | state sessionState |
|
| 23 | + | list listModel |
|
| 24 | + | cont contentModel |
|
| 25 | + | form formModel |
|
| 26 | + | ||
| 27 | + | width, height int |
|
| 28 | + | ready bool |
|
| 29 | + | loading bool |
|
| 33 | 30 | ||
| 34 | - | focus Focus |
|
| 35 | 31 | showHelp bool |
|
| 36 | 32 | confirmDelete bool |
|
| 37 | - | wrapContent bool |
|
| 38 | - | ||
| 39 | - | nameInput textinput.Model |
|
| 40 | - | contentArea textarea.Model |
|
| 41 | - | searchInput textinput.Model |
|
| 42 | - | contentVP viewport.Model |
|
| 43 | - | help help.Model |
|
| 44 | - | keys keyMap |
|
| 45 | - | ||
| 46 | - | highlighter *highlighter |
|
| 47 | - | ||
| 48 | - | editShortID string |
|
| 49 | 33 | ||
| 50 | 34 | status string |
|
| 51 | 35 | statusOK bool |
|
| 52 | 36 | statusUntil time.Time |
|
| 53 | 37 | ||
| 54 | - | width, height int |
|
| 55 | - | ready bool |
|
| 56 | - | loading bool |
|
| 38 | + | help help.Model |
|
| 39 | + | keys keyMap |
|
| 57 | 40 | } |
|
| 58 | 41 | ||
| 59 | 42 | func newModel(backend Backend) Model { |
|
| 60 | - | ti := textinput.New() |
|
| 61 | - | ti.Placeholder = "name.ext" |
|
| 62 | - | ti.Prompt = "" |
|
| 63 | - | ti.CharLimit = 200 |
|
| 64 | - | ||
| 65 | - | ta := textarea.New() |
|
| 66 | - | ta.Placeholder = "Paste code..." |
|
| 67 | - | ta.ShowLineNumbers = true |
|
| 68 | - | ta.Prompt = "" |
|
| 69 | - | ||
| 70 | - | si := textinput.New() |
|
| 71 | - | si.Placeholder = "search names" |
|
| 72 | - | si.Prompt = "/ " |
|
| 73 | - | ||
| 74 | - | vp := viewport.New() |
|
| 75 | - | ||
| 76 | 43 | return Model{ |
|
| 77 | - | backend: backend, |
|
| 78 | - | isRemote: backend.RemoteURL() != "", |
|
| 79 | - | focus: FocusList, |
|
| 80 | - | nameInput: ti, |
|
| 81 | - | contentArea: ta, |
|
| 82 | - | searchInput: si, |
|
| 83 | - | contentVP: vp, |
|
| 84 | - | help: help.New(), |
|
| 85 | - | keys: defaultKeys(), |
|
| 86 | - | highlighter: newHighlighter(), |
|
| 44 | + | backend: backend, |
|
| 45 | + | isRemote: backend.RemoteURL() != "", |
|
| 46 | + | state: stateList, |
|
| 47 | + | list: newListModel(nil), |
|
| 48 | + | cont: newContentModel(), |
|
| 49 | + | form: newFormModel(), |
|
| 50 | + | help: help.New(), |
|
| 51 | + | keys: defaultKeys(), |
|
| 52 | + | loading: true, |
|
| 87 | 53 | } |
|
| 88 | 54 | } |
|
| 89 | 55 | ||
| 90 | 56 | func (m Model) Init() tea.Cmd { |
|
| 91 | 57 | return tea.Batch(tea.RequestWindowSize, loadSnippetsCmd(m.backend)) |
|
| 92 | 58 | } |
|
| 93 | - | ||
| 94 | - | func (m *Model) visible() []Snippet { |
|
| 95 | - | if m.filtered == nil { |
|
| 96 | - | return m.snippets |
|
| 97 | - | } |
|
| 98 | - | out := make([]Snippet, 0, len(m.filtered)) |
|
| 99 | - | for _, i := range m.filtered { |
|
| 100 | - | out = append(out, m.snippets[i]) |
|
| 101 | - | } |
|
| 102 | - | return out |
|
| 103 | - | } |
|
| 104 | - | ||
| 105 | - | func (m *Model) current() *Snippet { |
|
| 106 | - | list := m.visible() |
|
| 107 | - | if m.cursor < 0 || m.cursor >= len(list) { |
|
| 108 | - | return nil |
|
| 109 | - | } |
|
| 110 | - | return &list[m.cursor] |
|
| 111 | - | } |
|
| 112 | - | ||
| 113 | - | func (m *Model) applyFilter(q string) { |
|
| 114 | - | q = strings.TrimSpace(strings.ToLower(q)) |
|
| 115 | - | if q == "" { |
|
| 116 | - | m.filtered = nil |
|
| 117 | - | if m.cursor >= len(m.snippets) { |
|
| 118 | - | m.cursor = 0 |
|
| 119 | - | } |
|
| 120 | - | return |
|
| 121 | - | } |
|
| 122 | - | idx := []int{} |
|
| 123 | - | for i, s := range m.snippets { |
|
| 124 | - | if strings.Contains(strings.ToLower(s.Name), q) || strings.Contains(strings.ToLower(s.ShortID), q) { |
|
| 125 | - | idx = append(idx, i) |
|
| 126 | - | } |
|
| 127 | - | } |
|
| 128 | - | m.filtered = idx |
|
| 129 | - | if m.cursor >= len(idx) { |
|
| 130 | - | m.cursor = 0 |
|
| 131 | - | } |
|
| 132 | - | } |
|
| 133 | - | ||
| 134 | - | func (m *Model) setStatus(text string, ok bool) tea.Cmd { |
|
| 135 | - | m.status = text |
|
| 136 | - | m.statusOK = ok |
|
| 137 | - | m.statusUntil = time.Now().Add(2 * time.Second) |
|
| 138 | - | return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearStatusMsg{} }) |
|
| 139 | - | } |
|
| 140 | - | ||
| 141 | - | func (m *Model) shareURL(shortID string) string { |
|
| 142 | - | if m.backend.RemoteURL() == "" { |
|
| 143 | - | return "" |
|
| 144 | - | } |
|
| 145 | - | return strings.TrimRight(m.backend.RemoteURL(), "/") + "/s/" + shortID |
|
| 146 | - | } |
| 2 | 2 | ||
| 3 | 3 | import ( |
|
| 4 | 4 | "strings" |
|
| 5 | + | "time" |
|
| 5 | 6 | ||
| 6 | - | "github.com/atotto/clipboard" |
|
| 7 | 7 | "charm.land/bubbles/v2/key" |
|
| 8 | 8 | tea "charm.land/bubbletea/v2" |
|
| 9 | - | "charm.land/lipgloss/v2" |
|
| 10 | 9 | ) |
|
| 11 | 10 | ||
| 12 | - | func loadSnippetsCmd(b Backend) tea.Cmd { |
|
| 13 | - | return func() tea.Msg { |
|
| 14 | - | list, err := b.List() |
|
| 15 | - | return snippetsLoadedMsg{snippets: list, err: err} |
|
| 16 | - | } |
|
| 17 | - | } |
|
| 18 | - | ||
| 19 | - | func saveSnippetCmd(b Backend, shortID, name, content string) tea.Cmd { |
|
| 20 | - | return func() tea.Msg { |
|
| 21 | - | var ( |
|
| 22 | - | s *Snippet |
|
| 23 | - | err error |
|
| 24 | - | ) |
|
| 25 | - | if shortID == "" { |
|
| 26 | - | s, err = b.Create(name, content) |
|
| 27 | - | } else { |
|
| 28 | - | s, err = b.Update(shortID, name, content) |
|
| 29 | - | } |
|
| 30 | - | return snippetSavedMsg{snippet: s, err: err} |
|
| 31 | - | } |
|
| 32 | - | } |
|
| 33 | - | ||
| 34 | - | func deleteSnippetCmd(b Backend, shortID string) tea.Cmd { |
|
| 35 | - | return func() tea.Msg { |
|
| 36 | - | _, err := b.Delete(shortID) |
|
| 37 | - | return snippetDeletedMsg{shortID: shortID, err: err} |
|
| 38 | - | } |
|
| 39 | - | } |
|
| 40 | - | ||
| 41 | 11 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { |
|
| 42 | 12 | switch msg := msg.(type) { |
|
| 43 | 13 | ||
| 44 | 14 | case tea.WindowSizeMsg: |
|
| 45 | 15 | m.width, m.height = msg.Width, msg.Height |
|
| 46 | 16 | m.ready = true |
|
| 47 | - | m.resizePanes() |
|
| 17 | + | m.applyLayout() |
|
| 48 | 18 | return m, nil |
|
| 49 | 19 | ||
| 50 | 20 | case snippetsLoadedMsg: |
|
| 52 | 22 | if msg.err != nil { |
|
| 53 | 23 | return m, m.setStatus("load: "+msg.err.Error(), false) |
|
| 54 | 24 | } |
|
| 55 | - | m.snippets = msg.snippets |
|
| 56 | - | m.applyFilter(m.searchInput.Value()) |
|
| 57 | - | m.refreshPreview() |
|
| 58 | - | return m, nil |
|
| 25 | + | cmd := m.list.SetSnippets(msg.snippets) |
|
| 26 | + | m.refreshContentFromSelection() |
|
| 27 | + | return m, cmd |
|
| 59 | 28 | ||
| 60 | 29 | case snippetSavedMsg: |
|
| 61 | 30 | if msg.err != nil { |
|
| 62 | 31 | return m, m.setStatus("save: "+msg.err.Error(), false) |
|
| 63 | 32 | } |
|
| 64 | - | if msg.snippet != nil && m.highlighter != nil { |
|
| 65 | - | m.highlighter.invalidate(msg.snippet.ShortID) |
|
| 33 | + | if msg.snippet != nil { |
|
| 34 | + | m.cont.Invalidate(msg.snippet.ShortID) |
|
| 66 | 35 | } |
|
| 67 | - | m.focus = FocusList |
|
| 68 | - | m.nameInput.Reset() |
|
| 69 | - | m.contentArea.Reset() |
|
| 70 | - | m.editShortID = "" |
|
| 36 | + | m.state = stateList |
|
| 37 | + | m.form.Blur() |
|
| 71 | 38 | return m, tea.Batch(loadSnippetsCmd(m.backend), m.setStatus("saved", true)) |
|
| 72 | 39 | ||
| 73 | 40 | case snippetDeletedMsg: |
|
| 74 | 41 | if msg.err != nil { |
|
| 75 | 42 | return m, m.setStatus("delete: "+msg.err.Error(), false) |
|
| 76 | 43 | } |
|
| 77 | - | if m.highlighter != nil { |
|
| 78 | - | m.highlighter.invalidate(msg.shortID) |
|
| 79 | - | } |
|
| 44 | + | m.cont.Invalidate(msg.shortID) |
|
| 45 | + | m.state = stateList |
|
| 80 | 46 | return m, tea.Batch(loadSnippetsCmd(m.backend), m.setStatus("deleted", true)) |
|
| 81 | 47 | ||
| 82 | 48 | case editorFinishedMsg: |
|
| 84 | 50 | return m, m.setStatus("editor: "+msg.err.Error(), false) |
|
| 85 | 51 | } |
|
| 86 | 52 | if msg.shortID == "" { |
|
| 87 | - | m.contentArea.SetValue(msg.content) |
|
| 53 | + | m.form.SetContent(msg.content) |
|
| 88 | 54 | return m, nil |
|
| 89 | 55 | } |
|
| 90 | 56 | var orig *Snippet |
|
| 91 | - | for i := range m.snippets { |
|
| 92 | - | if m.snippets[i].ShortID == msg.shortID { |
|
| 93 | - | orig = &m.snippets[i] |
|
| 57 | + | for _, it := range m.list.inner.Items() { |
|
| 58 | + | si, ok := it.(snippetItem) |
|
| 59 | + | if ok && si.snippet.ShortID == msg.shortID { |
|
| 60 | + | s := si.snippet |
|
| 61 | + | orig = &s |
|
| 94 | 62 | break |
|
| 95 | 63 | } |
|
| 96 | 64 | } |
|
| 99 | 67 | } |
|
| 100 | 68 | return m, saveSnippetCmd(m.backend, msg.shortID, orig.Name, msg.content) |
|
| 101 | 69 | ||
| 70 | + | case submitFormMsg: |
|
| 71 | + | return m, saveSnippetCmd(m.backend, msg.shortID, msg.name, msg.content) |
|
| 72 | + | ||
| 73 | + | case cancelFormMsg: |
|
| 74 | + | m.state = stateList |
|
| 75 | + | return m, nil |
|
| 76 | + | ||
| 102 | 77 | case statusMsg: |
|
| 103 | 78 | return m, m.setStatus(msg.text, msg.ok) |
|
| 104 | 79 | ||
| 105 | 80 | case clearStatusMsg: |
|
| 81 | + | if time.Now().Before(m.statusUntil) { |
|
| 82 | + | return m, nil |
|
| 83 | + | } |
|
| 106 | 84 | m.status = "" |
|
| 107 | 85 | return m, nil |
|
| 108 | 86 | ||
| 113 | 91 | return m, nil |
|
| 114 | 92 | } |
|
| 115 | 93 | ||
| 116 | - | func (m *Model) resizePanes() { |
|
| 117 | - | if !m.ready { |
|
| 118 | - | return |
|
| 119 | - | } |
|
| 120 | - | listW := m.width * 30 / 100 |
|
| 121 | - | if listW < 24 { |
|
| 122 | - | listW = 24 |
|
| 123 | - | } |
|
| 124 | - | contentW := m.width - listW - 2 |
|
| 125 | - | if contentW < 20 { |
|
| 126 | - | contentW = 20 |
|
| 127 | - | } |
|
| 128 | - | bodyH := m.height - 2 |
|
| 129 | - | if bodyH < 5 { |
|
| 130 | - | bodyH = 5 |
|
| 131 | - | } |
|
| 132 | - | ||
| 133 | - | m.contentVP.SetWidth(contentW - 2) |
|
| 134 | - | m.contentVP.SetHeight(bodyH - 2) |
|
| 135 | - | ||
| 136 | - | m.nameInput.SetWidth(contentW - 4) |
|
| 137 | - | if m.wrapContent { |
|
| 138 | - | m.contentArea.SetWidth(contentW - 2) |
|
| 139 | - | } else { |
|
| 140 | - | m.contentArea.SetWidth(10000) |
|
| 141 | - | } |
|
| 142 | - | m.contentArea.SetHeight(bodyH - 5) |
|
| 143 | - | ||
| 144 | - | m.searchInput.SetWidth(listW - 4) |
|
| 145 | - | ||
| 146 | - | m.refreshPreview() |
|
| 147 | - | } |
|
| 148 | - | ||
| 149 | - | func (m *Model) refreshPreview() { |
|
| 150 | - | s := m.current() |
|
| 151 | - | if s == nil { |
|
| 152 | - | m.contentVP.SetContent("") |
|
| 153 | - | return |
|
| 154 | - | } |
|
| 155 | - | body := s.Content |
|
| 156 | - | if m.highlighter != nil { |
|
| 157 | - | body = m.highlighter.render(s.ShortID, s.Name, s.Content) |
|
| 158 | - | } |
|
| 159 | - | if m.wrapContent && m.contentVP.Width() > 0 { |
|
| 160 | - | body = lipgloss.NewStyle().Width(m.contentVP.Width()).Render(body) |
|
| 161 | - | } |
|
| 162 | - | m.contentVP.SetContent(body) |
|
| 163 | - | } |
|
| 164 | - | ||
| 165 | 94 | func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { |
|
| 166 | 95 | if msg.String() == "ctrl+c" { |
|
| 167 | 96 | return m, tea.Quit |
|
| 170 | 99 | if m.confirmDelete { |
|
| 171 | 100 | switch msg.String() { |
|
| 172 | 101 | case "y", "Y": |
|
| 173 | - | s := m.current() |
|
| 174 | 102 | m.confirmDelete = false |
|
| 175 | - | if s == nil { |
|
| 103 | + | s, ok := m.list.Selected() |
|
| 104 | + | if !ok { |
|
| 176 | 105 | return m, nil |
|
| 177 | 106 | } |
|
| 178 | 107 | return m, deleteSnippetCmd(m.backend, s.ShortID) |
|
| 179 | 108 | case "n", "N", "esc", "q": |
|
| 180 | 109 | m.confirmDelete = false |
|
| 181 | - | return m, nil |
|
| 182 | 110 | } |
|
| 183 | 111 | return m, nil |
|
| 184 | 112 | } |
|
| 190 | 118 | return m, nil |
|
| 191 | 119 | } |
|
| 192 | 120 | ||
| 193 | - | switch m.focus { |
|
| 194 | - | case FocusList: |
|
| 195 | - | return m.keyList(msg) |
|
| 196 | - | case FocusContent: |
|
| 197 | - | return m.keyContent(msg) |
|
| 198 | - | case FocusCreateName, FocusCreateContent, FocusEditName, FocusEditContent: |
|
| 199 | - | return m.keyForm(msg) |
|
| 200 | - | case FocusSearch: |
|
| 201 | - | return m.keySearch(msg) |
|
| 121 | + | switch m.state { |
|
| 122 | + | case stateList: |
|
| 123 | + | return m.handleListKey(msg) |
|
| 124 | + | case stateContent: |
|
| 125 | + | return m.handleContentKey(msg) |
|
| 126 | + | case stateForm: |
|
| 127 | + | var cmd tea.Cmd |
|
| 128 | + | m.form, cmd = m.form.Update(msg) |
|
| 129 | + | return m, cmd |
|
| 202 | 130 | } |
|
| 203 | 131 | return m, nil |
|
| 204 | 132 | } |
|
| 205 | 133 | ||
| 206 | - | func (m Model) keyList(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { |
|
| 207 | - | list := m.visible() |
|
| 134 | + | func (m Model) handleListKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { |
|
| 135 | + | if m.list.IsFiltering() { |
|
| 136 | + | var cmd tea.Cmd |
|
| 137 | + | m.list, cmd = m.list.Update(msg) |
|
| 138 | + | m.refreshContentFromSelection() |
|
| 139 | + | return m, cmd |
|
| 140 | + | } |
|
| 141 | + | ||
| 208 | 142 | switch { |
|
| 209 | 143 | case key.Matches(msg, m.keys.Quit): |
|
| 210 | 144 | return m, tea.Quit |
|
| 211 | - | case key.Matches(msg, m.keys.Down): |
|
| 212 | - | if m.cursor < len(list)-1 { |
|
| 213 | - | m.cursor++ |
|
| 214 | - | m.refreshPreview() |
|
| 215 | - | } |
|
| 216 | - | case key.Matches(msg, m.keys.Up): |
|
| 217 | - | if m.cursor > 0 { |
|
| 218 | - | m.cursor-- |
|
| 219 | - | m.refreshPreview() |
|
| 220 | - | } |
|
| 221 | 145 | case key.Matches(msg, m.keys.Open): |
|
| 222 | - | if len(list) > 0 { |
|
| 223 | - | m.focus = FocusContent |
|
| 224 | - | m.contentVP.GotoTop() |
|
| 146 | + | if s, ok := m.list.Selected(); ok { |
|
| 147 | + | m.cont.SetSnippet(&s) |
|
| 148 | + | m.state = stateContent |
|
| 225 | 149 | } |
|
| 150 | + | return m, nil |
|
| 226 | 151 | case key.Matches(msg, m.keys.Create): |
|
| 227 | - | m.focus = FocusCreateName |
|
| 228 | - | m.editShortID = "" |
|
| 229 | - | m.nameInput.SetValue("") |
|
| 230 | - | m.contentArea.SetValue("") |
|
| 231 | - | m.nameInput.Focus() |
|
| 232 | - | m.contentArea.Blur() |
|
| 152 | + | m.form.StartCreate() |
|
| 153 | + | m.state = stateForm |
|
| 154 | + | return m, nil |
|
| 233 | 155 | case key.Matches(msg, m.keys.Edit): |
|
| 234 | - | s := m.current() |
|
| 235 | - | if s != nil { |
|
| 236 | - | m.focus = FocusEditName |
|
| 237 | - | m.editShortID = s.ShortID |
|
| 238 | - | m.nameInput.SetValue(s.Name) |
|
| 239 | - | m.contentArea.SetValue(s.Content) |
|
| 240 | - | m.nameInput.Focus() |
|
| 241 | - | m.contentArea.Blur() |
|
| 156 | + | if s, ok := m.list.Selected(); ok { |
|
| 157 | + | m.form.StartEdit(s) |
|
| 158 | + | m.state = stateForm |
|
| 242 | 159 | } |
|
| 160 | + | return m, nil |
|
| 243 | 161 | case key.Matches(msg, m.keys.ExtEdit): |
|
| 244 | - | s := m.current() |
|
| 245 | - | if s != nil { |
|
| 162 | + | if s, ok := m.list.Selected(); ok { |
|
| 246 | 163 | return m, openExternalEditor(s.ShortID, s.Name, s.Content) |
|
| 247 | 164 | } |
|
| 165 | + | return m, nil |
|
| 248 | 166 | case key.Matches(msg, m.keys.Delete): |
|
| 249 | - | if m.current() != nil { |
|
| 167 | + | if _, ok := m.list.Selected(); ok { |
|
| 250 | 168 | m.confirmDelete = true |
|
| 251 | 169 | } |
|
| 170 | + | return m, nil |
|
| 252 | 171 | case key.Matches(msg, m.keys.Copy): |
|
| 253 | - | s := m.current() |
|
| 254 | - | if s != nil { |
|
| 255 | - | if err := clipboard.WriteAll(s.Content); err != nil { |
|
| 256 | - | return m, m.setStatus("clipboard: "+err.Error(), false) |
|
| 257 | - | } |
|
| 258 | - | return m, m.setStatus("copied text", true) |
|
| 172 | + | if s, ok := m.list.Selected(); ok { |
|
| 173 | + | return m, copyToClipboardCmd(s.Content, "copied text") |
|
| 259 | 174 | } |
|
| 175 | + | return m, nil |
|
| 260 | 176 | case key.Matches(msg, m.keys.CopyLink): |
|
| 261 | - | s := m.current() |
|
| 262 | - | if s != nil && m.isRemote { |
|
| 263 | - | link := m.shareURL(s.ShortID) |
|
| 264 | - | if err := clipboard.WriteAll(link); err != nil { |
|
| 265 | - | return m, m.setStatus("clipboard: "+err.Error(), false) |
|
| 266 | - | } |
|
| 267 | - | return m, m.setStatus("copied link", true) |
|
| 177 | + | if !m.isRemote { |
|
| 178 | + | return m, m.setStatus("local mode: no link", false) |
|
| 268 | 179 | } |
|
| 269 | - | return m, m.setStatus("local mode: no link", false) |
|
| 180 | + | if s, ok := m.list.Selected(); ok { |
|
| 181 | + | return m, copyToClipboardCmd(shareLinkURL(m.backend.RemoteURL(), s.ShortID), "copied link") |
|
| 182 | + | } |
|
| 183 | + | return m, nil |
|
| 270 | 184 | case key.Matches(msg, m.keys.OpenBrowser): |
|
| 271 | - | s := m.current() |
|
| 272 | - | if s != nil && m.isRemote { |
|
| 273 | - | link := m.shareURL(s.ShortID) |
|
| 274 | - | if err := openURL(link); err != nil { |
|
| 275 | - | return m, m.setStatus("open: "+err.Error(), false) |
|
| 276 | - | } |
|
| 277 | - | return m, m.setStatus("opened "+link, true) |
|
| 185 | + | if !m.isRemote { |
|
| 186 | + | return m, nil |
|
| 278 | 187 | } |
|
| 279 | - | case key.Matches(msg, m.keys.Search): |
|
| 280 | - | m.focus = FocusSearch |
|
| 281 | - | m.searchInput.Focus() |
|
| 188 | + | if s, ok := m.list.Selected(); ok { |
|
| 189 | + | return m, openURLCmd(shareLinkURL(m.backend.RemoteURL(), s.ShortID)) |
|
| 190 | + | } |
|
| 191 | + | return m, nil |
|
| 282 | 192 | case key.Matches(msg, m.keys.Refresh): |
|
| 283 | 193 | m.loading = true |
|
| 284 | 194 | return m, loadSnippetsCmd(m.backend) |
|
| 285 | 195 | case key.Matches(msg, m.keys.Help): |
|
| 286 | 196 | m.showHelp = true |
|
| 197 | + | return m, nil |
|
| 287 | 198 | } |
|
| 288 | - | return m, nil |
|
| 199 | + | ||
| 200 | + | var cmd tea.Cmd |
|
| 201 | + | m.list, cmd = m.list.Update(msg) |
|
| 202 | + | m.refreshContentFromSelection() |
|
| 203 | + | return m, cmd |
|
| 289 | 204 | } |
|
| 290 | 205 | ||
| 291 | - | func (m Model) keyContent(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { |
|
| 206 | + | func (m Model) handleContentKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { |
|
| 292 | 207 | switch { |
|
| 293 | 208 | case key.Matches(msg, m.keys.WrapToggle): |
|
| 294 | - | m.wrapContent = !m.wrapContent |
|
| 295 | - | m.contentVP.GotoTop() |
|
| 296 | - | m.refreshPreview() |
|
| 297 | - | if m.wrapContent { |
|
| 209 | + | m.cont.ToggleWrap() |
|
| 210 | + | if m.cont.Wrap() { |
|
| 298 | 211 | return m, m.setStatus("wrap on", true) |
|
| 299 | 212 | } |
|
| 300 | 213 | return m, m.setStatus("wrap off", true) |
|
| 301 | 214 | case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.Back): |
|
| 302 | - | m.focus = FocusList |
|
| 215 | + | m.state = stateList |
|
| 303 | 216 | return m, nil |
|
| 304 | - | case key.Matches(msg, m.keys.Down): |
|
| 305 | - | m.contentVP.ScrollDown(1) |
|
| 306 | - | case key.Matches(msg, m.keys.Up): |
|
| 307 | - | m.contentVP.ScrollUp(1) |
|
| 217 | + | case key.Matches(msg, m.keys.ScrollDown): |
|
| 218 | + | m.cont = m.cont.ScrollDown(1) |
|
| 219 | + | return m, nil |
|
| 220 | + | case key.Matches(msg, m.keys.ScrollUp): |
|
| 221 | + | m.cont = m.cont.ScrollUp(1) |
|
| 222 | + | return m, nil |
|
| 308 | 223 | case key.Matches(msg, m.keys.Edit): |
|
| 309 | - | s := m.current() |
|
| 310 | - | if s != nil { |
|
| 311 | - | m.focus = FocusEditName |
|
| 312 | - | m.editShortID = s.ShortID |
|
| 313 | - | m.nameInput.SetValue(s.Name) |
|
| 314 | - | m.contentArea.SetValue(s.Content) |
|
| 315 | - | m.nameInput.Focus() |
|
| 224 | + | if s, ok := m.list.Selected(); ok { |
|
| 225 | + | m.form.StartEdit(s) |
|
| 226 | + | m.state = stateForm |
|
| 316 | 227 | } |
|
| 228 | + | return m, nil |
|
| 317 | 229 | case key.Matches(msg, m.keys.ExtEdit): |
|
| 318 | - | s := m.current() |
|
| 319 | - | if s != nil { |
|
| 230 | + | if s, ok := m.list.Selected(); ok { |
|
| 320 | 231 | return m, openExternalEditor(s.ShortID, s.Name, s.Content) |
|
| 321 | 232 | } |
|
| 233 | + | return m, nil |
|
| 322 | 234 | case key.Matches(msg, m.keys.Copy): |
|
| 323 | - | s := m.current() |
|
| 324 | - | if s != nil { |
|
| 325 | - | clipboard.WriteAll(s.Content) |
|
| 326 | - | return m, m.setStatus("copied text", true) |
|
| 235 | + | if s, ok := m.list.Selected(); ok { |
|
| 236 | + | return m, copyToClipboardCmd(s.Content, "copied text") |
|
| 327 | 237 | } |
|
| 238 | + | return m, nil |
|
| 328 | 239 | case key.Matches(msg, m.keys.CopyLink): |
|
| 329 | - | s := m.current() |
|
| 330 | - | if s != nil && m.isRemote { |
|
| 331 | - | clipboard.WriteAll(m.shareURL(s.ShortID)) |
|
| 332 | - | return m, m.setStatus("copied link", true) |
|
| 333 | - | } |
|
| 334 | - | case key.Matches(msg, m.keys.OpenBrowser): |
|
| 335 | - | s := m.current() |
|
| 336 | - | if s != nil && m.isRemote { |
|
| 337 | - | openURL(m.shareURL(s.ShortID)) |
|
| 240 | + | if !m.isRemote { |
|
| 241 | + | return m, m.setStatus("local mode: no link", false) |
|
| 338 | 242 | } |
|
| 339 | - | case key.Matches(msg, m.keys.Help): |
|
| 340 | - | m.showHelp = true |
|
| 341 | - | } |
|
| 342 | - | var cmd tea.Cmd |
|
| 343 | - | m.contentVP, cmd = m.contentVP.Update(msg) |
|
| 344 | - | return m, cmd |
|
| 345 | - | } |
|
| 346 | - | ||
| 347 | - | func (m Model) keyForm(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { |
|
| 348 | - | switch { |
|
| 349 | - | case key.Matches(msg, m.keys.WrapToggle): |
|
| 350 | - | m.wrapContent = !m.wrapContent |
|
| 351 | - | if m.wrapContent { |
|
| 352 | - | m.contentArea.SetWidth(m.contentVP.Width()) |
|
| 353 | - | return m, m.setStatus("wrap on", true) |
|
| 243 | + | if s, ok := m.list.Selected(); ok { |
|
| 244 | + | return m, copyToClipboardCmd(shareLinkURL(m.backend.RemoteURL(), s.ShortID), "copied link") |
|
| 354 | 245 | } |
|
| 355 | - | m.contentArea.SetWidth(10000) |
|
| 356 | - | return m, m.setStatus("wrap off", true) |
|
| 357 | - | case key.Matches(msg, m.keys.Cancel): |
|
| 358 | - | m.focus = FocusList |
|
| 359 | - | m.nameInput.Blur() |
|
| 360 | - | m.contentArea.Blur() |
|
| 361 | 246 | return m, nil |
|
| 362 | - | case key.Matches(msg, m.keys.Save): |
|
| 363 | - | name := strings.TrimSpace(m.nameInput.Value()) |
|
| 364 | - | if name == "" { |
|
| 365 | - | return m, m.setStatus("name required", false) |
|
| 247 | + | case key.Matches(msg, m.keys.OpenBrowser): |
|
| 248 | + | if !m.isRemote { |
|
| 249 | + | return m, nil |
|
| 366 | 250 | } |
|
| 367 | - | content := m.contentArea.Value() |
|
| 368 | - | if strings.TrimSpace(content) == "" { |
|
| 369 | - | return m, m.setStatus("content required", false) |
|
| 370 | - | } |
|
| 371 | - | return m, saveSnippetCmd(m.backend, m.editShortID, name, content) |
|
| 372 | - | case key.Matches(msg, m.keys.SwitchField): |
|
| 373 | - | switch m.focus { |
|
| 374 | - | case FocusCreateName: |
|
| 375 | - | m.focus = FocusCreateContent |
|
| 376 | - | case FocusCreateContent: |
|
| 377 | - | m.focus = FocusCreateName |
|
| 378 | - | case FocusEditName: |
|
| 379 | - | m.focus = FocusEditContent |
|
| 380 | - | case FocusEditContent: |
|
| 381 | - | m.focus = FocusEditName |
|
| 251 | + | if s, ok := m.list.Selected(); ok { |
|
| 252 | + | return m, openURLCmd(shareLinkURL(m.backend.RemoteURL(), s.ShortID)) |
|
| 382 | 253 | } |
|
| 383 | - | m.applyFormFocus() |
|
| 254 | + | return m, nil |
|
| 255 | + | case key.Matches(msg, m.keys.Help): |
|
| 256 | + | m.showHelp = true |
|
| 384 | 257 | return m, nil |
|
| 385 | 258 | } |
|
| 386 | 259 | ||
| 387 | 260 | var cmd tea.Cmd |
|
| 388 | - | switch m.focus { |
|
| 389 | - | case FocusCreateName, FocusEditName: |
|
| 390 | - | m.nameInput, cmd = m.nameInput.Update(msg) |
|
| 391 | - | case FocusCreateContent, FocusEditContent: |
|
| 392 | - | m.contentArea, cmd = m.contentArea.Update(msg) |
|
| 393 | - | } |
|
| 261 | + | m.cont, cmd = m.cont.Update(msg) |
|
| 394 | 262 | return m, cmd |
|
| 395 | 263 | } |
|
| 396 | 264 | ||
| 397 | - | func (m *Model) applyFormFocus() { |
|
| 398 | - | switch m.focus { |
|
| 399 | - | case FocusCreateName, FocusEditName: |
|
| 400 | - | m.nameInput.Focus() |
|
| 401 | - | m.contentArea.Blur() |
|
| 402 | - | case FocusCreateContent, FocusEditContent: |
|
| 403 | - | m.contentArea.Focus() |
|
| 404 | - | m.nameInput.Blur() |
|
| 265 | + | func (m *Model) refreshContentFromSelection() { |
|
| 266 | + | if s, ok := m.list.Selected(); ok { |
|
| 267 | + | m.cont.SetSnippet(&s) |
|
| 268 | + | } else { |
|
| 269 | + | m.cont.SetSnippet(nil) |
|
| 405 | 270 | } |
|
| 406 | 271 | } |
|
| 407 | 272 | ||
| 408 | - | func (m Model) keySearch(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { |
|
| 409 | - | switch msg.String() { |
|
| 410 | - | case "esc": |
|
| 411 | - | m.searchInput.SetValue("") |
|
| 412 | - | m.searchInput.Blur() |
|
| 413 | - | m.focus = FocusList |
|
| 414 | - | m.applyFilter("") |
|
| 415 | - | m.refreshPreview() |
|
| 416 | - | return m, nil |
|
| 417 | - | case "enter": |
|
| 418 | - | m.searchInput.Blur() |
|
| 419 | - | m.focus = FocusList |
|
| 420 | - | return m, nil |
|
| 273 | + | func (m *Model) applyLayout() { |
|
| 274 | + | if !m.ready { |
|
| 275 | + | return |
|
| 421 | 276 | } |
|
| 422 | - | var cmd tea.Cmd |
|
| 423 | - | m.searchInput, cmd = m.searchInput.Update(msg) |
|
| 424 | - | m.applyFilter(m.searchInput.Value()) |
|
| 425 | - | m.refreshPreview() |
|
| 426 | - | return m, cmd |
|
| 277 | + | listW := m.width * 30 / 100 |
|
| 278 | + | if listW < 24 { |
|
| 279 | + | listW = 24 |
|
| 280 | + | } |
|
| 281 | + | contentW := m.width - listW - 2 |
|
| 282 | + | if contentW < 20 { |
|
| 283 | + | contentW = 20 |
|
| 284 | + | } |
|
| 285 | + | bodyH := m.height - 2 |
|
| 286 | + | if bodyH < 5 { |
|
| 287 | + | bodyH = 5 |
|
| 288 | + | } |
|
| 289 | + | ||
| 290 | + | m.list.SetSize(max(listW-paneFrameWidth(), 1), max(bodyH-paneFrameHeight(), 1)) |
|
| 291 | + | m.cont.SetSize(max(contentW-paneFrameWidth(), 1), max(bodyH-paneFrameHeight()-1, 1)) |
|
| 292 | + | m.form.SetSize(max(contentW-paneFrameWidth(), 1), max(bodyH-paneFrameHeight(), 1)) |
|
| 293 | + | } |
|
| 294 | + | ||
| 295 | + | func (m *Model) setStatus(text string, ok bool) tea.Cmd { |
|
| 296 | + | m.status = text |
|
| 297 | + | m.statusOK = ok |
|
| 298 | + | m.statusUntil = time.Now().Add(2 * time.Second) |
|
| 299 | + | return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearStatusMsg{} }) |
|
| 427 | 300 | } |
|
| 2 | 2 | ||
| 3 | 3 | import ( |
|
| 4 | 4 | "fmt" |
|
| 5 | - | "strings" |
|
| 6 | 5 | ||
| 7 | 6 | tea "charm.land/bubbletea/v2" |
|
| 8 | 7 | "charm.land/lipgloss/v2" |
|
| 11 | 10 | var ( |
|
| 12 | 11 | borderStyle = lipgloss.NewStyle(). |
|
| 13 | 12 | Border(lipgloss.RoundedBorder()). |
|
| 14 | - | BorderForeground(lipgloss.Color("240")) |
|
| 13 | + | BorderForeground(lipgloss.Color("8")) |
|
| 15 | 14 | borderActive = lipgloss.NewStyle(). |
|
| 16 | 15 | Border(lipgloss.RoundedBorder()). |
|
| 17 | - | BorderForeground(lipgloss.Color("214")) |
|
| 16 | + | BorderForeground(lipgloss.Color("3")) |
|
| 18 | 17 | titleStyle = lipgloss.NewStyle(). |
|
| 19 | 18 | Bold(true). |
|
| 20 | - | Foreground(lipgloss.Color("214")). |
|
| 19 | + | Foreground(lipgloss.Color("3")). |
|
| 21 | 20 | Padding(0, 1) |
|
| 22 | - | itemStyle = lipgloss.NewStyle().Padding(0, 1) |
|
| 23 | - | itemSelected = lipgloss.NewStyle(). |
|
| 24 | - | Padding(0, 1). |
|
| 25 | - | Bold(true). |
|
| 26 | - | Foreground(lipgloss.Color("214")) |
|
| 27 | - | dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) |
|
| 28 | - | statusOK = lipgloss.NewStyle(). |
|
| 29 | - | Foreground(lipgloss.Color("82")). |
|
| 21 | + | statusOKStyle = lipgloss.NewStyle(). |
|
| 22 | + | Foreground(lipgloss.Color("2")). |
|
| 30 | 23 | Bold(true) |
|
| 31 | - | statusErr = lipgloss.NewStyle(). |
|
| 32 | - | Foreground(lipgloss.Color("196")). |
|
| 24 | + | statusErrStyle = lipgloss.NewStyle(). |
|
| 25 | + | Foreground(lipgloss.Color("1")). |
|
| 33 | 26 | Bold(true) |
|
| 34 | 27 | hintStyle = lipgloss.NewStyle(). |
|
| 35 | - | Foreground(lipgloss.Color("244")) |
|
| 28 | + | Foreground(lipgloss.Color("8")) |
|
| 36 | 29 | modalStyle = lipgloss.NewStyle(). |
|
| 37 | 30 | Border(lipgloss.RoundedBorder()). |
|
| 38 | - | BorderForeground(lipgloss.Color("214")). |
|
| 39 | - | Padding(1, 2). |
|
| 40 | - | Background(lipgloss.Color("236")) |
|
| 31 | + | BorderForeground(lipgloss.Color("3")). |
|
| 32 | + | Padding(1, 2) |
|
| 41 | 33 | ) |
|
| 42 | 34 | ||
| 43 | 35 | func (m Model) View() tea.View { |
|
| 52 | 44 | contentW := m.width - listW - 2 |
|
| 53 | 45 | bodyH := m.height - 2 |
|
| 54 | 46 | ||
| 55 | - | left := m.renderList(listW, bodyH) |
|
| 56 | - | right := m.renderRight(contentW, bodyH) |
|
| 47 | + | left := m.renderListPane(listW, bodyH) |
|
| 48 | + | right := m.renderRightPane(contentW, bodyH) |
|
| 57 | 49 | ||
| 58 | 50 | body := lipgloss.JoinHorizontal(lipgloss.Top, left, right) |
|
| 59 | 51 | footer := m.renderFooter() |
|
| 60 | - | ||
| 61 | 52 | base := lipgloss.JoinVertical(lipgloss.Left, body, footer) |
|
| 62 | 53 | ||
| 63 | 54 | var overlays []*lipgloss.Layer |
|
| 66 | 57 | modalStyle.Render(m.help.FullHelpView(m.keys.FullHelp())), 1)) |
|
| 67 | 58 | } |
|
| 68 | 59 | if m.confirmDelete { |
|
| 69 | - | s := m.current() |
|
| 70 | 60 | name := "" |
|
| 71 | - | if s != nil { |
|
| 61 | + | if s, ok := m.list.Selected(); ok { |
|
| 72 | 62 | name = s.Name |
|
| 63 | + | if name == "" { |
|
| 64 | + | name = s.ShortID |
|
| 65 | + | } |
|
| 73 | 66 | } |
|
| 74 | 67 | overlays = append(overlays, centerLayer(m.width, m.height, |
|
| 75 | 68 | modalStyle.Render(fmt.Sprintf("Delete %q?\n\ny / n", name)), 2)) |
|
| 76 | 69 | } |
|
| 77 | 70 | if m.status != "" { |
|
| 78 | - | st := statusOK |
|
| 71 | + | st := statusOKStyle |
|
| 79 | 72 | if !m.statusOK { |
|
| 80 | - | st = statusErr |
|
| 73 | + | st = statusErrStyle |
|
| 81 | 74 | } |
|
| 82 | 75 | overlays = append(overlays, bottomCenterLayer(m.width, m.height, |
|
| 83 | 76 | modalStyle.Render(st.Render(m.status)), 3)) |
|
| 94 | 87 | return tea.View{Content: content, AltScreen: true} |
|
| 95 | 88 | } |
|
| 96 | 89 | ||
| 97 | - | func centerLayer(w, h int, content string, z int) *lipgloss.Layer { |
|
| 98 | - | cw, ch := lipgloss.Width(content), lipgloss.Height(content) |
|
| 99 | - | x := (w - cw) / 2 |
|
| 100 | - | y := (h - ch) / 2 |
|
| 101 | - | if x < 0 { |
|
| 102 | - | x = 0 |
|
| 103 | - | } |
|
| 104 | - | if y < 0 { |
|
| 105 | - | y = 0 |
|
| 106 | - | } |
|
| 107 | - | return lipgloss.NewLayer(content).X(x).Y(y).Z(z) |
|
| 108 | - | } |
|
| 109 | - | ||
| 110 | - | func bottomCenterLayer(w, h int, content string, z int) *lipgloss.Layer { |
|
| 111 | - | cw, ch := lipgloss.Width(content), lipgloss.Height(content) |
|
| 112 | - | x := (w - cw) / 2 |
|
| 113 | - | y := h - ch - 1 |
|
| 114 | - | if x < 0 { |
|
| 115 | - | x = 0 |
|
| 116 | - | } |
|
| 117 | - | if y < 0 { |
|
| 118 | - | y = 0 |
|
| 119 | - | } |
|
| 120 | - | return lipgloss.NewLayer(content).X(x).Y(y).Z(z) |
|
| 121 | - | } |
|
| 122 | - | ||
| 123 | - | func (m Model) renderList(w, h int) string { |
|
| 90 | + | func (m Model) renderListPane(w, h int) string { |
|
| 124 | 91 | style := borderStyle |
|
| 125 | - | if m.focus == FocusList || m.focus == FocusSearch { |
|
| 92 | + | if m.state == stateList { |
|
| 126 | 93 | style = borderActive |
|
| 127 | 94 | } |
|
| 128 | - | ||
| 129 | - | list := m.visible() |
|
| 130 | - | rows := make([]string, 0, len(list)+2) |
|
| 131 | - | rows = append(rows, titleStyle.Render("snippets")) |
|
| 132 | - | if len(list) == 0 { |
|
| 133 | - | rows = append(rows, hintStyle.Render(" (empty — press c)")) |
|
| 134 | - | } |
|
| 135 | - | for i, s := range list { |
|
| 136 | - | label := s.Name |
|
| 137 | - | if label == "" { |
|
| 138 | - | label = s.ShortID |
|
| 139 | - | } |
|
| 140 | - | line := truncate(label, w-6) |
|
| 141 | - | id := dimStyle.Render(" " + s.ShortID) |
|
| 142 | - | if i == m.cursor { |
|
| 143 | - | rows = append(rows, itemSelected.Render("▶ "+line)+id) |
|
| 144 | - | } else { |
|
| 145 | - | rows = append(rows, itemStyle.Render(" "+line)+id) |
|
| 146 | - | } |
|
| 147 | - | } |
|
| 148 | - | ||
| 149 | - | if m.focus == FocusSearch || m.searchInput.Value() != "" { |
|
| 150 | - | rows = append(rows, "", hintStyle.Render(m.searchInput.View())) |
|
| 151 | - | } |
|
| 152 | - | ||
| 153 | - | content := strings.Join(rows, "\n") |
|
| 154 | - | return style.Width(w).Height(h).Render(content) |
|
| 95 | + | return style.Width(w).Height(h).Render(m.list.View()) |
|
| 155 | 96 | } |
|
| 156 | 97 | ||
| 157 | - | func (m Model) renderRight(w, h int) string { |
|
| 158 | - | switch m.focus { |
|
| 159 | - | case FocusCreateName, FocusCreateContent, FocusEditName, FocusEditContent: |
|
| 98 | + | func (m Model) renderRightPane(w, h int) string { |
|
| 99 | + | if m.state == stateForm { |
|
| 160 | 100 | return m.renderForm(w, h) |
|
| 161 | 101 | } |
|
| 162 | 102 | return m.renderContent(w, h) |
|
| 164 | 104 | ||
| 165 | 105 | func (m Model) renderContent(w, h int) string { |
|
| 166 | 106 | style := borderStyle |
|
| 167 | - | if m.focus == FocusContent { |
|
| 107 | + | if m.state == stateContent { |
|
| 168 | 108 | style = borderActive |
|
| 169 | 109 | } |
|
| 170 | 110 | header := "preview" |
|
| 171 | - | if m.wrapContent { |
|
| 111 | + | if m.cont.Wrap() { |
|
| 172 | 112 | header += " (wrap)" |
|
| 173 | 113 | } |
|
| 174 | - | s := m.current() |
|
| 175 | - | if s != nil { |
|
| 176 | - | header = s.Name |
|
| 177 | - | if header == "" { |
|
| 178 | - | header = s.ShortID |
|
| 114 | + | if t := m.cont.Header(); t != "" { |
|
| 115 | + | header = t |
|
| 116 | + | if m.cont.Wrap() { |
|
| 117 | + | header += " (wrap)" |
|
| 179 | 118 | } |
|
| 180 | 119 | } |
|
| 181 | - | body := m.contentVP.View() |
|
| 182 | - | inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), body) |
|
| 120 | + | inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), m.cont.View()) |
|
| 183 | 121 | return style.Width(w).Height(h).Render(inner) |
|
| 184 | 122 | } |
|
| 185 | 123 | ||
| 186 | 124 | func (m Model) renderForm(w, h int) string { |
|
| 187 | 125 | header := "new snippet" |
|
| 188 | - | if m.editShortID != "" { |
|
| 126 | + | if !m.form.IsCreate() { |
|
| 189 | 127 | header = "edit" |
|
| 190 | 128 | } |
|
| 191 | - | if m.wrapContent { |
|
| 192 | - | header += " (wrap)" |
|
| 193 | - | } |
|
| 194 | - | name := m.nameInput.View() |
|
| 195 | - | if m.focus == FocusCreateName || m.focus == FocusEditName { |
|
| 196 | - | name = borderActive.Render(name) |
|
| 129 | + | ||
| 130 | + | nameField := m.form.name.View() |
|
| 131 | + | if m.form.ActiveField() == formFieldName { |
|
| 132 | + | nameField = borderActive.Render(nameField) |
|
| 197 | 133 | } else { |
|
| 198 | - | name = borderStyle.Render(name) |
|
| 134 | + | nameField = borderStyle.Render(nameField) |
|
| 199 | 135 | } |
|
| 200 | 136 | ||
| 201 | - | body := m.contentArea.View() |
|
| 202 | - | if m.focus == FocusCreateContent || m.focus == FocusEditContent { |
|
| 137 | + | body := m.form.content.View() |
|
| 138 | + | if m.form.ActiveField() == formFieldContent { |
|
| 203 | 139 | body = borderActive.Render(body) |
|
| 204 | 140 | } else { |
|
| 205 | 141 | body = borderStyle.Render(body) |
|
| 206 | 142 | } |
|
| 207 | 143 | ||
| 208 | - | inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), name, body) |
|
| 209 | - | return borderStyle.Width(w).Height(h).Render(inner) |
|
| 144 | + | inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), nameField, body) |
|
| 145 | + | return borderActive.Width(w).Height(h).Render(inner) |
|
| 210 | 146 | } |
|
| 211 | 147 | ||
| 212 | 148 | func (m Model) renderFooter() string { |
|
| 218 | 154 | return hintStyle.Render(fmt.Sprintf("[%s] %s", mode, help)) |
|
| 219 | 155 | } |
|
| 220 | 156 | ||
| 221 | - | func truncate(s string, n int) string { |
|
| 222 | - | if n < 1 { |
|
| 223 | - | return "" |
|
| 157 | + | func centerLayer(w, h int, content string, z int) *lipgloss.Layer { |
|
| 158 | + | cw, ch := lipgloss.Width(content), lipgloss.Height(content) |
|
| 159 | + | x := (w - cw) / 2 |
|
| 160 | + | y := (h - ch) / 2 |
|
| 161 | + | if x < 0 { |
|
| 162 | + | x = 0 |
|
| 224 | 163 | } |
|
| 225 | - | if len(s) <= n { |
|
| 226 | - | return s |
|
| 164 | + | if y < 0 { |
|
| 165 | + | y = 0 |
|
| 227 | 166 | } |
|
| 228 | - | if n <= 1 { |
|
| 229 | - | return "…" |
|
| 167 | + | return lipgloss.NewLayer(content).X(x).Y(y).Z(z) |
|
| 168 | + | } |
|
| 169 | + | ||
| 170 | + | func bottomCenterLayer(w, h int, content string, z int) *lipgloss.Layer { |
|
| 171 | + | cw, ch := lipgloss.Width(content), lipgloss.Height(content) |
|
| 172 | + | x := (w - cw) / 2 |
|
| 173 | + | y := h - ch - 1 |
|
| 174 | + | if x < 0 { |
|
| 175 | + | x = 0 |
|
| 176 | + | } |
|
| 177 | + | if y < 0 { |
|
| 178 | + | y = 0 |
|
| 230 | 179 | } |
|
| 231 | - | return s[:n-1] + "…" |
|
| 180 | + | return lipgloss.NewLayer(content).X(x).Y(y).Z(z) |
|
| 232 | 181 | } |
|
| 182 | + | ||
| 183 | + | func paneFrameWidth() int { return borderStyle.GetHorizontalFrameSize() } |
|
| 184 | + | func paneFrameHeight() int { return borderStyle.GetVerticalFrameSize() } |
|