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 838676b..b5adbb6 100644 --- a/tui/entry.go +++ b/tui/entry.go @@ -1,12 +1,7 @@ package main import ( - "fmt" - "os" "time" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" ) type Feed struct { @@ -31,62 +26,3 @@ type Entry struct { isReadLater bool feed Feed } - -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 - err error -} - -func New() *Model { - return &Model{} -} - -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 nil -} - -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.initList(msg.Width, msg.Height) - } - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - return m, cmd -} - -func (m Model) View() string { - return m.list.View() -} - -func main() { - m := New() - p := tea.NewProgram(m) - if _, err := p.Run(); err != nil { - os.Exit(1) - } -} 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= diff --git a/tui/http.go b/tui/http.go new file mode 100644 index 0000000..6e75000 --- /dev/null +++ b/tui/http.go @@ -0,0 +1,72 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + 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" + +type loginSuccessMsg struct{ string } + +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) + } + +} + +type getFeedsSuccessMsg []Feed +type getFeedsErrMsg error 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() +}