From 8070a983835c4af6d5b3348da1d52ec9a8f2f4b0 Mon Sep 17 00:00:00 2001 From: GitBluub Date: Sat, 4 May 2024 12:33:11 +0200 Subject: [PATCH 1/4] minimum input select --- tui/entry.go | 44 ++++++++++++++++++++++++++++++++++++++++---- tui/go.mod | 8 +++++--- tui/go.sum | 12 ++++++++---- 3 files changed, 53 insertions(+), 11 deletions(-) diff --git a/tui/entry.go b/tui/entry.go index 838676b..cc73849 100644 --- a/tui/entry.go +++ b/tui/entry.go @@ -3,10 +3,13 @@ package main import ( "fmt" "os" + "strings" "time" "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + huh "github.com/charmbracelet/huh" ) type Feed struct { @@ -45,12 +48,18 @@ func (e Entry) Description() string { } type Model struct { - list list.Model - err error + list list.Model + textInput textinput.Model + err error } func New() *Model { - return &Model{} + ti := textinput.New() + ti.Placeholder = "Pikachu" + ti.Focus() + ti.CharLimit = 156 + ti.Width = 20 + return &Model{textInput: ti} } func (m *Model) initList(width int, height int) { @@ -73,14 +82,41 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.initList(msg.Width, msg.Height) + case tea.KeyMsg: + switch msg.Type { + case tea.KeyEnter, tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + } + } var cmd tea.Cmd m.list, cmd = m.list.Update(msg) + m.textInput, cmd = m.textInput.Update(msg) + if strings.HasSuffix(m.textInput.Value(), "feed:") { + var feed string + huh.NewSelect[string](). + Title("Pick a country."). + Options( + huh.NewOption("United States", "US"), + huh.NewOption("Germany", "DE"), + huh.NewOption("Brazil", "BR"), + huh.NewOption("Canada", "CA"), + ). + Value(&feed).Run() + m.textInput.SetValue(m.textInput.Value() + feed) + // var suggestions = []string{"cncf.io/rss", "zwindler.blog/index.xml"} + // s := make([]string, len(suggestions)) + // for i := range suggestions { + // s[i] = fmt.Sprintf("%s%s", m.textInput.Value(), suggestions[i]) + // } + // m.textInput.SetSuggestions(s) + // m.textInput.ShowSuggestions = true + } return m, cmd } func (m Model) View() string { - return m.list.View() + return m.textInput.View() // + m.list.View() } func main() { diff --git a/tui/go.mod b/tui/go.mod index 002a8fd..04012b9 100644 --- a/tui/go.mod +++ b/tui/go.mod @@ -5,15 +5,17 @@ go 1.22.2 require ( github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.26.1 - github.com/charmbracelet/lipgloss v0.10.0 + github.com/charmbracelet/huh v0.3.0 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.2.0 // indirect + github.com/charmbracelet/lipgloss v0.10.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect @@ -25,5 +27,5 @@ require ( golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.19.0 // indirect golang.org/x/term v0.19.0 // indirect - golang.org/x/text v0.3.8 // indirect + golang.org/x/text v0.13.0 // indirect ) diff --git a/tui/go.sum b/tui/go.sum index 6ee082c..319a10c 100644 --- a/tui/go.sum +++ b/tui/go.sum @@ -2,10 +2,14 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= github.com/charmbracelet/bubbletea v0.26.1 h1:xujcQeF73rh4jwu3+zhfQsvV18x+7zIjlw7/CYbzGJ0= github.com/charmbracelet/bubbletea v0.26.1/go.mod h1:FzKr7sKoO8iFVcdIBM9J0sJOcQv5nDQaYwsee3kpbgo= +github.com/charmbracelet/huh v0.3.0 h1:CxPplWkgW2yUTDDG0Z4S5HH8SJOosWHd4LxCvi0XsKE= +github.com/charmbracelet/huh v0.3.0/go.mod h1:fujUdKX8tC45CCSaRQdw789O6uaCRwx8l2NDyKfC4jA= github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= @@ -14,8 +18,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= @@ -43,5 +47,5 @@ golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= From b2fd1278e723f0348a5dd3868aace983313a161c Mon Sep 17 00:00:00 2001 From: GitBluub Date: Sat, 4 May 2024 13:24:45 +0200 Subject: [PATCH 2/4] separate func to handle automplete --- tui/entry.go | 61 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/tui/entry.go b/tui/entry.go index cc73849..b5e3c7d 100644 --- a/tui/entry.go +++ b/tui/entry.go @@ -58,7 +58,7 @@ func New() *Model { ti.Placeholder = "Pikachu" ti.Focus() ti.CharLimit = 156 - ti.Width = 20 + ti.Width = 56 return &Model{textInput: ti} } @@ -78,6 +78,43 @@ func (m Model) Init() tea.Cmd { return nil } +func (m Model) handleSearchCompletion() (Model, tea.Cmd) { + var cmd tea.Cmd + queryWords := strings.Split(m.textInput.Value(), " ") + if len(queryWords) == 0 { + return m, cmd + } + lastWord := queryWords[len(queryWords)-1] + if lastWord == "tag:" { + var feed string + huh.NewSelect[string](). + Title("Pick a feed."). + Options( + huh.NewOption("United States", "US"), + huh.NewOption("Germany", "DE"), + huh.NewOption("Brazil", "BR"), + huh.NewOption("Canada", "CA"), + ). + Value(&feed).Run() + m.textInput.SetValue(m.textInput.Value() + feed) + m.textInput.CursorEnd() + } + + if lastWord == "feed:" { + var feed string + var feeds = []string{"Devops", "System", "Angular"} + huh.NewSelect[string](). + Title("Pick a feed."). + Options( + huh.NewOptions(feeds...)..., + ). + Value(&feed).Run() + m.textInput.SetValue(m.textInput.Value() + feed) + m.textInput.CursorEnd() + } + return m, cmd +} + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -92,26 +129,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.list, cmd = m.list.Update(msg) m.textInput, cmd = m.textInput.Update(msg) - if strings.HasSuffix(m.textInput.Value(), "feed:") { - var feed string - huh.NewSelect[string](). - Title("Pick a country."). - Options( - huh.NewOption("United States", "US"), - huh.NewOption("Germany", "DE"), - huh.NewOption("Brazil", "BR"), - huh.NewOption("Canada", "CA"), - ). - Value(&feed).Run() - m.textInput.SetValue(m.textInput.Value() + feed) - // var suggestions = []string{"cncf.io/rss", "zwindler.blog/index.xml"} - // s := make([]string, len(suggestions)) - // for i := range suggestions { - // s[i] = fmt.Sprintf("%s%s", m.textInput.Value(), suggestions[i]) - // } - // m.textInput.SetSuggestions(s) - // m.textInput.ShowSuggestions = true - } + m, cmd = m.handleSearchCompletion() + return m, cmd } From dddcad3b7af0370cbd75b2d39074dd9c2a85ea40 Mon Sep 17 00:00:00 2001 From: GitBluub Date: Sat, 4 May 2024 16:16:00 +0200 Subject: [PATCH 3/4] feat: basic http req, need auth --- tui/entry.go | 91 +++++++++++++++++++++++++++++++++++++++++++++++++--- tui/http.go | 47 +++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 tui/http.go diff --git a/tui/entry.go b/tui/entry.go index b5e3c7d..0f4f828 100644 --- a/tui/entry.go +++ b/tui/entry.go @@ -2,10 +2,13 @@ package main import ( "fmt" + "log" + "net/http" "os" "strings" "time" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -35,6 +38,12 @@ type Entry struct { feed Feed } +type statusMsg int + +type errMsg struct{ error } + +func (e errMsg) Error() string { return e.error.Error() } + func (e Entry) FilterValue() string { return e.title } @@ -75,7 +84,7 @@ func (m *Model) initList(width int, height int) { } func (m Model) Init() tea.Cmd { - return nil + return checkServer } func (m Model) handleSearchCompletion() (Model, tea.Cmd) { @@ -103,6 +112,8 @@ func (m Model) handleSearchCompletion() (Model, tea.Cmd) { if lastWord == "feed:" { var feed string var feeds = []string{"Devops", "System", "Angular"} + tea.LogToFile("yay.log", "") + log.Print("input") huh.NewSelect[string](). Title("Pick a feed."). Options( @@ -115,6 +126,67 @@ func (m Model) handleSearchCompletion() (Model, tea.Cmd) { return m, cmd } +func (m *Model) deleteWordBackward() { + if m.textInput.Position() == 0 || len(m.textInput.Value()) == 0 { + return + } + + // TODO: wtf are other echo modes, dont care + //if m.textInput.EchoMode != textinput.EchoNormal { + // m.deleteBeforeCursor() + // return + //} + + // Linter note: it's critical that we acquire the initial cursor position + // here prior to altering it via SetCursor() below. As such, moving this + // call into the corresponding if clause does not apply here. + oldPos := m.textInput.Position() //nolint:ifshort + + m.textInput.SetCursor(oldPos - 1) + // ECHO character? + for m.textInput.Value()[m.textInput.Position()] == ' ' { + if m.textInput.Position() <= 0 { + break + } + // ignore series of whitespace before cursor + m.textInput.SetCursor(m.textInput.Position() - 1) + } + + for m.textInput.Position() > 0 { + if m.textInput.Value()[m.textInput.Position()] != ' ' { + m.textInput.SetCursor(m.textInput.Position() - 1) + } else { + if m.textInput.Position() > 0 { + // keep the previous space + m.textInput.SetCursor(m.textInput.Position() + 1) + } + break + } + } + + if oldPos > len(m.textInput.Value()) { + m.textInput.SetValue(m.textInput.Value()[:m.textInput.Position()]) + } else { + m.textInput.SetValue(m.textInput.Value()[:m.textInput.Position()] + m.textInput.Value()[oldPos:]) + } +} + +const url = "localhost:3000" + +func checkServer() tea.Msg { + c := &http.Client{ + Timeout: 10 * time.Second, + } + res, err := c.Get(url) + if err != nil { + return errMsg{err} + } + defer res.Body.Close() // nolint:errcheck + + return statusMsg(res.StatusCode) + +} + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -124,18 +196,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyEnter, tea.KeyCtrlC, tea.KeyEsc: return m, tea.Quit } - + switch { + case key.Matches(msg, m.textInput.KeyMap.DeleteCharacterBackward): + words := strings.Split(m.textInput.Value(), " ") + if len(words) > 0 && (strings.HasPrefix(words[len(words)-1], "tag:") || strings.HasPrefix(words[len(words)-1], "feed:")) { + m.deleteWordBackward() + } + } + // if writing } var cmd tea.Cmd m.list, cmd = m.list.Update(msg) m.textInput, cmd = m.textInput.Update(msg) - m, cmd = m.handleSearchCompletion() + switch msg := msg.(type) { + case tea.KeyMsg: + _ = msg + m, cmd = m.handleSearchCompletion() + } return m, cmd } func (m Model) View() string { - return m.textInput.View() // + m.list.View() + return m.textInput.View() + m.list.View() } func main() { diff --git a/tui/http.go b/tui/http.go new file mode 100644 index 0000000..e45d377 --- /dev/null +++ b/tui/http.go @@ -0,0 +1,47 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + tea "github.com/charmbracelet/bubbletea" +) + +type getEntriesSuccessMsg []Entry +type getEntriesErrMsg error + +const serverUrl = "localhost:3000" + +func getEntries() tea.Msg { + url := fmt.Sprintf("%s/entries", serverUrl) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return getEntriesErrMsg(err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return getEntriesErrMsg(err) + } + defer resp.Body.Close() // nolint: errcheck + + data, err := io.ReadAll(resp.Body) + if err != nil { + return getEntriesErrMsg(err) + } + + var entries []Entry + + err = json.Unmarshal(data, &entries) + if err != nil { + return getEntriesErrMsg(err) + } + + return getEntriesSuccessMsg(entries) +} + +type getFeedsSuccessMsg []Feed +type getFeedsErrMsg error From d25a92fdd74fea0a9426d3399065617222b0e472 Mon Sep 17 00:00:00 2001 From: GitBluub Date: Sat, 4 May 2024 17:57:48 +0200 Subject: [PATCH 4/4] pages structure and http handling with login page --- tui/auth.go | 8 ++ tui/entry.go | 202 ----------------------------------------- tui/http.go | 73 ++++++++++----- tui/main.go | 249 +++++++++++++++++++++++++++++++++++++++++++++++++++ tui/pages.go | 36 ++++++++ 5 files changed, 342 insertions(+), 226 deletions(-) create mode 100644 tui/auth.go create mode 100644 tui/main.go create mode 100644 tui/pages.go diff --git a/tui/auth.go b/tui/auth.go new file mode 100644 index 0000000..15eb534 --- /dev/null +++ b/tui/auth.go @@ -0,0 +1,8 @@ +package main + +import huh "github.com/charmbracelet/huh" + +type Auth struct { + form *huh.Form + jwt *string +} diff --git a/tui/entry.go b/tui/entry.go index 0f4f828..b5adbb6 100644 --- a/tui/entry.go +++ b/tui/entry.go @@ -1,18 +1,7 @@ package main import ( - "fmt" - "log" - "net/http" - "os" - "strings" "time" - - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - huh "github.com/charmbracelet/huh" ) type Feed struct { @@ -37,194 +26,3 @@ type Entry struct { isReadLater bool feed Feed } - -type statusMsg int - -type errMsg struct{ error } - -func (e errMsg) Error() string { return e.error.Error() } - -func (e Entry) FilterValue() string { - return e.title -} - -func (e Entry) Title() string { - return e.title -} - -func (e Entry) Description() string { - return fmt.Sprintf("%s", "my desc") // TODO: real description -} - -type Model struct { - list list.Model - textInput textinput.Model - err error -} - -func New() *Model { - ti := textinput.New() - ti.Placeholder = "Pikachu" - ti.Focus() - ti.CharLimit = 156 - ti.Width = 56 - return &Model{textInput: ti} -} - -func (m *Model) initList(width int, height int) { - m.list = list.New([]list.Item{}, list.NewDefaultDelegate(), width, height) - m.list.Title = "Posts" - m.list.SetFilteringEnabled(false) - var f = Feed{id: "1", tags: []string{"Devops", "Kubernetes"}, name: "zwindler", url: "zwindler.blog", faviconUrl: "zwindler.blog.favicon"} - m.list.SetItems([]list.Item{ - Entry{id: "1", title: "yay", content: "ouin ouin ouin", link: "awd", date: time.Now(), isRead: false, isIgnored: false, isReadLater: false, isBookmarked: false, feed: f}, - Entry{id: "2", title: "grrrrr", content: "ouin ouin ouin", link: "awd", date: time.Now(), isRead: false, isIgnored: false, isReadLater: false, isBookmarked: false, feed: f}, - Entry{id: "3", title: "my life is pain", content: "ouin ouin ouin", link: "awd", date: time.Now(), isRead: false, isIgnored: false, isReadLater: false, isBookmarked: false, feed: f}, - }) -} - -func (m Model) Init() tea.Cmd { - return checkServer -} - -func (m Model) handleSearchCompletion() (Model, tea.Cmd) { - var cmd tea.Cmd - queryWords := strings.Split(m.textInput.Value(), " ") - if len(queryWords) == 0 { - return m, cmd - } - lastWord := queryWords[len(queryWords)-1] - if lastWord == "tag:" { - var feed string - huh.NewSelect[string](). - Title("Pick a feed."). - Options( - huh.NewOption("United States", "US"), - huh.NewOption("Germany", "DE"), - huh.NewOption("Brazil", "BR"), - huh.NewOption("Canada", "CA"), - ). - Value(&feed).Run() - m.textInput.SetValue(m.textInput.Value() + feed) - m.textInput.CursorEnd() - } - - if lastWord == "feed:" { - var feed string - var feeds = []string{"Devops", "System", "Angular"} - tea.LogToFile("yay.log", "") - log.Print("input") - huh.NewSelect[string](). - Title("Pick a feed."). - Options( - huh.NewOptions(feeds...)..., - ). - Value(&feed).Run() - m.textInput.SetValue(m.textInput.Value() + feed) - m.textInput.CursorEnd() - } - return m, cmd -} - -func (m *Model) deleteWordBackward() { - if m.textInput.Position() == 0 || len(m.textInput.Value()) == 0 { - return - } - - // TODO: wtf are other echo modes, dont care - //if m.textInput.EchoMode != textinput.EchoNormal { - // m.deleteBeforeCursor() - // return - //} - - // Linter note: it's critical that we acquire the initial cursor position - // here prior to altering it via SetCursor() below. As such, moving this - // call into the corresponding if clause does not apply here. - oldPos := m.textInput.Position() //nolint:ifshort - - m.textInput.SetCursor(oldPos - 1) - // ECHO character? - for m.textInput.Value()[m.textInput.Position()] == ' ' { - if m.textInput.Position() <= 0 { - break - } - // ignore series of whitespace before cursor - m.textInput.SetCursor(m.textInput.Position() - 1) - } - - for m.textInput.Position() > 0 { - if m.textInput.Value()[m.textInput.Position()] != ' ' { - m.textInput.SetCursor(m.textInput.Position() - 1) - } else { - if m.textInput.Position() > 0 { - // keep the previous space - m.textInput.SetCursor(m.textInput.Position() + 1) - } - break - } - } - - if oldPos > len(m.textInput.Value()) { - m.textInput.SetValue(m.textInput.Value()[:m.textInput.Position()]) - } else { - m.textInput.SetValue(m.textInput.Value()[:m.textInput.Position()] + m.textInput.Value()[oldPos:]) - } -} - -const url = "localhost:3000" - -func checkServer() tea.Msg { - c := &http.Client{ - Timeout: 10 * time.Second, - } - res, err := c.Get(url) - if err != nil { - return errMsg{err} - } - defer res.Body.Close() // nolint:errcheck - - return statusMsg(res.StatusCode) - -} - -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.initList(msg.Width, msg.Height) - case tea.KeyMsg: - switch msg.Type { - case tea.KeyEnter, tea.KeyCtrlC, tea.KeyEsc: - return m, tea.Quit - } - switch { - case key.Matches(msg, m.textInput.KeyMap.DeleteCharacterBackward): - words := strings.Split(m.textInput.Value(), " ") - if len(words) > 0 && (strings.HasPrefix(words[len(words)-1], "tag:") || strings.HasPrefix(words[len(words)-1], "feed:")) { - m.deleteWordBackward() - } - } - // if writing - } - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - m.textInput, cmd = m.textInput.Update(msg) - switch msg := msg.(type) { - case tea.KeyMsg: - _ = msg - m, cmd = m.handleSearchCompletion() - } - - return m, cmd -} - -func (m Model) View() string { - return m.textInput.View() + m.list.View() -} - -func main() { - m := New() - p := tea.NewProgram(m) - if _, err := p.Run(); err != nil { - os.Exit(1) - } -} diff --git a/tui/http.go b/tui/http.go index e45d377..6e75000 100644 --- a/tui/http.go +++ b/tui/http.go @@ -9,38 +9,63 @@ import ( tea "github.com/charmbracelet/bubbletea" ) +type statusMsg int + +type errMsg struct{ error } +type missingJwtMsg struct{} + +func (e errMsg) Error() string { return e.error.Error() } + type getEntriesSuccessMsg []Entry type getEntriesErrMsg error const serverUrl = "localhost:3000" -func getEntries() tea.Msg { - url := fmt.Sprintf("%s/entries", serverUrl) +type loginSuccessMsg struct{ string } - req, err := http.NewRequest(http.MethodGet, url, nil) - if err != nil { - return getEntriesErrMsg(err) +func login(username string, password string) tea.Cmd { + return func() tea.Msg { + _ = username + _ = password + return loginSuccessMsg{"dawdaw"} + } +} + +func getEntries(jwt *string) tea.Cmd { + return func() tea.Msg { + url := fmt.Sprintf("%s/entries", serverUrl) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if jwt == nil { + return missingJwtMsg{} + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", *jwt)) + + if err != nil { + return getEntriesErrMsg(err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return getEntriesErrMsg(err) + } + defer resp.Body.Close() // nolint: errcheck + + data, err := io.ReadAll(resp.Body) + if err != nil { + return getEntriesErrMsg(err) + } + + var entries []Entry + + err = json.Unmarshal(data, &entries) + if err != nil { + return getEntriesErrMsg(err) + } + + return getEntriesSuccessMsg(entries) } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return getEntriesErrMsg(err) - } - defer resp.Body.Close() // nolint: errcheck - - data, err := io.ReadAll(resp.Body) - if err != nil { - return getEntriesErrMsg(err) - } - - var entries []Entry - - err = json.Unmarshal(data, &entries) - if err != nil { - return getEntriesErrMsg(err) - } - - return getEntriesSuccessMsg(entries) } type getFeedsSuccessMsg []Feed diff --git a/tui/main.go b/tui/main.go new file mode 100644 index 0000000..b690ced --- /dev/null +++ b/tui/main.go @@ -0,0 +1,249 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + huh "github.com/charmbracelet/huh" +) + +func (e Entry) FilterValue() string { + return e.title +} + +func (e Entry) Title() string { + return e.title +} + +func (e Entry) Description() string { + return fmt.Sprintf("%s", "my desc") // TODO: real description +} + +type Model struct { + list list.Model + textInput textinput.Model + err error + auth Auth + page VexPage + query string + feeds []Feed + entries []Entry + tags []string +} + +func New() *Model { + loginForm := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Username"). + Key("username"), + // Validating fields is easy. The form will mark erroneous fields + // and display error messages accordingly. + // TODO: + // Validate(func(str string) error { + // if str == "octopus773" { + // return errors.New("Not you") + // } + // return nil + // }))) + huh.NewInput(). + Title("Password"). + Key("password"). + Password(true), + )) + ti := textinput.New() + ti.Placeholder = "Pikachu" + ti.Focus() + ti.CharLimit = 156 + ti.Width = 56 + return &Model{textInput: ti, auth: Auth{form: loginForm, jwt: new(string)}, page: "LOGIN"} +} + +func (m Model) getEverything() tea.Cmd { + return func() tea.Msg { + return tea.Batch(getEntries(m.auth.jwt)) // getTags, getFeeds) + } +} + +func (m *Model) initList(width int, height int) { + m.list = list.New([]list.Item{}, list.NewDefaultDelegate(), width, height) + m.list.Title = "Posts" + m.list.SetFilteringEnabled(false) + var f = Feed{id: "1", tags: []string{"Devops", "Kubernetes"}, name: "zwindler", url: "zwindler.blog", faviconUrl: "zwindler.blog.favicon"} + m.list.SetItems([]list.Item{ + Entry{id: "1", title: "yay", content: "ouin ouin ouin", link: "awd", date: time.Now(), isRead: false, isIgnored: false, isReadLater: false, isBookmarked: false, feed: f}, + Entry{id: "2", title: "grrrrr", content: "ouin ouin ouin", link: "awd", date: time.Now(), isRead: false, isIgnored: false, isReadLater: false, isBookmarked: false, feed: f}, + Entry{id: "3", title: "my life is pain", content: "ouin ouin ouin", link: "awd", date: time.Now(), isRead: false, isIgnored: false, isReadLater: false, isBookmarked: false, feed: f}, + }) +} + +func (m Model) Init() tea.Cmd { + return tea.Batch(checkServer, m.auth.form.Init()) + +} + +func (m Model) handleSearchCompletion() (Model, tea.Cmd) { + var cmd tea.Cmd + queryWords := strings.Split(m.textInput.Value(), " ") + if len(queryWords) == 0 { + return m, cmd + } + lastWord := queryWords[len(queryWords)-1] + if lastWord == "tag:" { + var feed string + huh.NewSelect[string](). + Title("Pick a feed."). + Options( + huh.NewOption("United States", "US"), + huh.NewOption("Germany", "DE"), + huh.NewOption("Brazil", "BR"), + huh.NewOption("Canada", "CA"), + ). + Value(&feed).Run() + m.textInput.SetValue(m.textInput.Value() + feed) + m.textInput.CursorEnd() + } + + if lastWord == "feed:" { + var feed string + var feeds = []string{"Devops", "System", "Angular"} + huh.NewSelect[string](). + Title("Pick a feed."). + Options( + huh.NewOptions(feeds...)..., + ). + Value(&feed).Run() + m.textInput.SetValue(m.textInput.Value() + feed) + m.textInput.CursorEnd() + } + return m, cmd +} + +func (m *Model) deleteWordBackward() { + if m.textInput.Position() == 0 || len(m.textInput.Value()) == 0 { + return + } + + // TODO: wtf are other echo modes, dont care + //if m.textInput.EchoMode != textinput.EchoNormal { + // m.deleteBeforeCursor() + // return + //} + + // Linter note: it's critical that we acquire the initial cursor position + // here prior to altering it via SetCursor() below. As such, moving this + // call into the corresponding if clause does not apply here. + oldPos := m.textInput.Position() //nolint:ifshort + + m.textInput.SetCursor(oldPos - 1) + // ECHO character? + for m.textInput.Value()[m.textInput.Position()] == ' ' { + if m.textInput.Position() <= 0 { + break + } + // ignore series of whitespace before cursor + m.textInput.SetCursor(m.textInput.Position() - 1) + } + + for m.textInput.Position() > 0 { + if m.textInput.Value()[m.textInput.Position()] != ' ' { + m.textInput.SetCursor(m.textInput.Position() - 1) + } else { + if m.textInput.Position() > 0 { + // keep the previous space + m.textInput.SetCursor(m.textInput.Position() + 1) + } + break + } + } + + if oldPos > len(m.textInput.Value()) { + m.textInput.SetValue(m.textInput.Value()[:m.textInput.Position()]) + } else { + m.textInput.SetValue(m.textInput.Value()[:m.textInput.Position()] + m.textInput.Value()[oldPos:]) + } +} + +const url = "localhost:3000" + +func checkServer() tea.Msg { + c := &http.Client{ + Timeout: 10 * time.Second, + } + res, err := c.Get(url) + if err != nil { + return errMsg{err} + } + defer res.Body.Close() // nolint:errcheck + + return statusMsg(res.StatusCode) + +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.initList(msg.Width, msg.Height) + case loginSuccessMsg: + *m.auth.jwt = msg.string + m.page = FEEDS + + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + } + switch { + case key.Matches(msg, m.textInput.KeyMap.DeleteCharacterBackward): + words := strings.Split(m.textInput.Value(), " ") + if len(words) > 0 && (strings.HasPrefix(words[len(words)-1], "tag:") || strings.HasPrefix(words[len(words)-1], "feed:")) { + m.deleteWordBackward() + } + } + // if writing + } + var cmds []tea.Cmd + + // Process the form + form, cmd := m.auth.form.Update(msg) + if f, ok := form.(*huh.Form); ok { + m.auth.form = f + cmds = append(cmds, cmd) + } + + if m.auth.form.State == huh.StateCompleted { + // Quit when the form is done. + username := m.auth.form.GetString("username") + password := m.auth.form.GetString("password") + cmds = append(cmds, login(username, password)) + } + + m.list, cmd = m.list.Update(msg) + cmds = append(cmds, cmd) + m.textInput, cmd = m.textInput.Update(msg) + cmds = append(cmds, cmd) + switch msg := msg.(type) { + case tea.KeyMsg: + _ = msg + m, cmd = m.handleSearchCompletion() + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func main() { + tea.LogToFile("yay.log", "") + m := New() + p := tea.NewProgram(m) + if _, err := p.Run(); err != nil { + os.Exit(1) + } +} diff --git a/tui/pages.go b/tui/pages.go new file mode 100644 index 0000000..ee62991 --- /dev/null +++ b/tui/pages.go @@ -0,0 +1,36 @@ +package main + +const ( + LOGIN = "LOGIN" + ENTRIES = "ENTRIES" + FEEDS = "FEEDS" + TAGS = "TAGS" +) + +type VexPage string + +func (m Model) LoginView() string { + return m.auth.form.View() +} +func (m Model) EntriesView() string { + return "" +} +func (m Model) FeedsView() string { + return "feeds" +} +func (m Model) TagsView() string { + return "" +} +func (m Model) View() string { + switch m.page { + case LOGIN: + return m.LoginView() + case ENTRIES: + return m.EntriesView() + case FEEDS: + return m.FeedsView() + case TAGS: + return m.TagsView() + } + return m.textInput.View() + m.list.View() +}