diff --git a/README.md b/README.md index e05976f..a6364e1 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ In action - [Features](#features) - [Installing](#install) - [Updating](#updating) -- [Usage](#usage) +- [Getting started](#getting-started) - [Shortcuts](#shortcuts) - [Config](#config) - [FAQ](#faq) @@ -47,11 +47,12 @@ In action - Fuzzy searching for finding coins - Currency conversion - Save and view favorite coins +- Portfolio tracking of holdings - Color support - Help menu - Offline cache - Works on macOS, Linux, and Windows -- It's very lightweight; can be left running for weeks +- It's very lightweight; can be left running indefinitely ## Installing @@ -198,7 +199,7 @@ Use the `refresh` command to update snap. sudo snap refresh cointop --stable ``` -## Usage +## Getting started Just run the `cointop` command to get started: @@ -206,6 +207,26 @@ Just run the `cointop` command to get started: $ cointop ``` +### Navigation + +- Easiest way to navigate up and down is using the arrow keys and +- To go the next and previous pages, use and +- To go to the top and bottom of the page, use g and G +- Check out the rest of [shortcut](#shortcuts) keys for vim-inspired navigation + +### Favorites + +- To toggle a coin as a favorite, press Space on the highlighted coin +- To view all your favorite coins, press F +- To exit out of the favorites view, press F again or q + +### Portfolio + +- To add a coin to your portfolio, press e on the highlighted coin +- To edit the holdings of coin in your portfolio, press e on the highlighted coin +- To view your portfolio, press P +- To exit out of the portfolio view press, P again or q + ## Shortcuts List of default shortcut keys: @@ -244,6 +265,9 @@ Key|Action a|Sort table by *[a]vailable supply* b|Sort table by *[b]alance* c|Show currency convert menu +C|Show currency convert menu +e|Show portfolio edit holdings menu +E|Show portfolio edit holdings menu f|Toggle coin as favorite F|Toggle show favorites g|Go to first line of page (vim inspired) @@ -304,6 +328,7 @@ defaultView = "default" "{" = "first_chart_range" "}" = "last_chart_range" C = "show_currency_convert_menu" + E = "show_portfolio_edit_menu" G = "move_to_page_last_row" H = "move_to_page_visible_first_row" L = "move_to_page_visible_last_row" @@ -329,6 +354,7 @@ defaultView = "default" "ctrl+r" = "refresh" "ctrl+s" = "save" "ctrl+u" = "page_up" + e = "show_portfolio_edit_menu" end = "move_to_page_last_row" enter = "toggle_row_chart" esc = "quit" @@ -414,6 +440,7 @@ Action|Description `toggle_show_favorites`|Toggle show favorites `toggle_portfolio`|Toggle portfolio view `toggle_show_portfolio`|Toggle show portfolio view +`show_portfolio_edit_menu`|Show portfolio edit holdings menu ## FAQ @@ -476,7 +503,19 @@ Frequently asked questions: - A: The yellow asterisk or star means that you've selected that coin to be a favorite. -- Q: How do I view all my portfolio? +- Q: How do I add a coin to my portfolio? + + - Press e on the highlighted coin to enter holdings and add to your portfolio. + +- Q: How do I edit the holdings of a coin in my portfolio? + + - Press e on the highlighted coin to edit the holdings. + +- Q: How do I remove a coin in my portfolio? + + - Press e on the highlighted coin to edit the holdings and set the value to any empty string (blank value). Set it to `0` if you want to keep the coin without a value. + +- Q: How do I view my portfolio? - A: Press P (shift+p) to toggle view your portfolio. diff --git a/cointop/cointop.go b/cointop/cointop.go index a2d4546..3a72b6d 100644 --- a/cointop/cointop.go +++ b/cointop/cointop.go @@ -58,20 +58,26 @@ type Cointop struct { searchfieldviewname string searchfieldvisible bool // DEPRECATED: favorites by 'symbol' is deprecated because of collisions. - favoritesbysymbol map[string]bool - favorites map[string]bool - filterByFavorites bool - savemux sync.Mutex - cache *cache.Cache - debug bool - helpview *gocui.View - helpviewname string - helpvisible bool - currencyconversion string - convertmenuview *gocui.View - convertmenuviewname string - convertmenuvisible bool - portfolio *portfolio + favoritesbysymbol map[string]bool + favorites map[string]bool + filterByFavorites bool + savemux sync.Mutex + cache *cache.Cache + debug bool + helpview *gocui.View + helpviewname string + helpvisible bool + currencyconversion string + convertmenuview *gocui.View + convertmenuviewname string + convertmenuvisible bool + portfolio *portfolio + portfolioupdatemenuview *gocui.View + portfolioupdatemenuviewname string + portfolioupdatemenuvisible bool + inputview *gocui.View + inputviewname string + defaultView string } // PortfolioEntry is portfolio entry @@ -142,6 +148,8 @@ func New() *Cointop { "name", "symbol", "price", + "holdings", + "balance", "marketcap", "24hvolume", "1hchange", @@ -158,6 +166,8 @@ func New() *Cointop { portfolio: &portfolio{ Entries: make(map[string]*portfolioEntry, 0), }, + portfolioupdatemenuviewname: "portfolioupdatemenu", + inputviewname: "input", } err := ct.setupConfig() if err != nil { diff --git a/cointop/config.go b/cointop/config.go index a9e7cf9..e37db7e 100644 --- a/cointop/config.go +++ b/cointop/config.go @@ -143,18 +143,23 @@ func (ct *Cointop) configToToml() ([]byte, error) { portfolioIfc := map[string]interface{}{} for name := range ct.portfolio.Entries { - entry := ct.portfolio.Entries[name] + entry, ok := ct.portfolio.Entries[name] + if !ok || entry.Coin == "" { + continue + } var i interface{} = entry.Holdings portfolioIfc[entry.Coin] = i } var currencyIfc interface{} = ct.currencyconversion + var defaultViewIfc interface{} = ct.defaultView var inputs = &config{ - Shortcuts: shortcutsIfcs, - Favorites: favoritesIfcs, - Portfolio: portfolioIfc, - Currency: currencyIfc, + Shortcuts: shortcutsIfcs, + Favorites: favoritesIfcs, + Portfolio: portfolioIfc, + Currency: currencyIfc, + DefaultView: defaultViewIfc, } var b bytes.Buffer @@ -191,6 +196,7 @@ func (ct *Cointop) loadCurrencyFromConfig() error { func (ct *Cointop) loadDefaultViewFromConfig() error { if defaultView, ok := ct.config.DefaultView.(string); ok { + defaultView = strings.ToLower(defaultView) switch defaultView { case "portfolio": ct.portfoliovisible = true @@ -201,7 +207,9 @@ func (ct *Cointop) loadDefaultViewFromConfig() error { default: ct.portfoliovisible = false ct.filterByFavorites = false + defaultView = "default" } + ct.defaultView = defaultView } return nil } @@ -235,10 +243,8 @@ func (ct *Cointop) loadPortfolioFromConfig() error { holdings = float64(holdingsInt) } } - ct.portfolio.Entries[strings.ToLower(name)] = &portfolioEntry{ - Coin: name, - Holdings: holdings, - } + + ct.setPortfolioEntry(name, holdings) } return nil } diff --git a/cointop/keybindings.go b/cointop/keybindings.go index ce4797e..846c4ad 100644 --- a/cointop/keybindings.go +++ b/cointop/keybindings.go @@ -214,7 +214,7 @@ func (ct *Cointop) keybindings(g *gocui.Gui) error { case "move_down": fn = ct.keyfn(ct.cursorDown) case "previous_page": - fn = ct.handleHkey() + fn = ct.handleHkey(key) case "next_page": fn = ct.keyfn(ct.nextPage) case "page_down": @@ -321,6 +321,8 @@ func (ct *Cointop) keybindings(g *gocui.Gui) error { fn = ct.keyfn(ct.togglePortfolio) case "toggle_show_portfolio": fn = ct.keyfn(ct.toggleShowPortfolio) + case "show_portfolio_edit_menu": + fn = ct.keyfn(ct.togglePortfolioUpdateMenu) default: fn = ct.keyfn(ct.noop) } @@ -340,6 +342,13 @@ func (ct *Cointop) keybindings(g *gocui.Gui) error { ct.setKeybindingMod(gocui.KeyEsc, gocui.ModNone, ct.keyfn(ct.hideHelp), ct.helpviewname) ct.setKeybindingMod('q', gocui.ModNone, ct.keyfn(ct.hideHelp), ct.helpviewname) + // keys to quit portfolio update menu when open + ct.setKeybindingMod(gocui.KeyEsc, gocui.ModNone, ct.keyfn(ct.hidePortfolioUpdateMenu), ct.inputviewname) + ct.setKeybindingMod('q', gocui.ModNone, ct.keyfn(ct.hidePortfolioUpdateMenu), ct.inputviewname) + + // keys to update portfolio holdings + ct.setKeybindingMod(gocui.KeyEnter, gocui.ModNone, ct.keyfn(ct.setPortfolioHoldings), ct.inputviewname) + // keys to quit convert menu when open ct.setKeybindingMod(gocui.KeyEsc, gocui.ModNone, ct.keyfn(ct.hideConvertMenu), ct.convertmenuviewname) ct.setKeybindingMod('q', gocui.ModNone, ct.keyfn(ct.hideConvertMenu), ct.convertmenuviewname) @@ -371,9 +380,9 @@ func (ct *Cointop) keyfn(fn func() error) func(g *gocui.Gui, v *gocui.View) erro } } -func (ct *Cointop) handleHkey() func(g *gocui.Gui, v *gocui.View) error { +func (ct *Cointop) handleHkey(key interface{}) func(g *gocui.Gui, v *gocui.View) error { return func(g *gocui.Gui, v *gocui.View) error { - if ct.portfoliovisible { + if k, ok := key.(rune); ok && k == 'h' && ct.portfoliovisible { ct.sortToggle("holdings", true) } else { ct.prevPage() diff --git a/cointop/layout.go b/cointop/layout.go index e1671a2..88e87d6 100644 --- a/cointop/layout.go +++ b/cointop/layout.go @@ -115,6 +115,28 @@ func (ct *Cointop) layout(g *gocui.Gui) error { ct.helpview.FgColor = gocui.ColorWhite } + if v, err := g.SetView(ct.portfolioupdatemenuviewname, 1, 1, ct.maxtablewidth-2, maxY-1); err != nil { + if err != gocui.ErrUnknownView { + return err + } + ct.portfolioupdatemenuview = v + ct.portfolioupdatemenuview.Frame = false + ct.portfolioupdatemenuview.BgColor = gocui.ColorBlack + ct.portfolioupdatemenuview.FgColor = gocui.ColorWhite + } + + if v, err := g.SetView(ct.inputviewname, 3, 6, 30, 8); err != nil { + if err != gocui.ErrUnknownView { + return err + } + ct.inputview = v + ct.inputview.Frame = true + ct.inputview.Editable = true + ct.inputview.Wrap = true + ct.inputview.BgColor = gocui.ColorBlack + ct.inputview.FgColor = gocui.ColorWhite + } + if v, err := g.SetView(ct.convertmenuviewname, 1, 1, ct.maxtablewidth-2, maxY-1); err != nil { if err != gocui.ErrUnknownView { return err @@ -127,9 +149,11 @@ func (ct *Cointop) layout(g *gocui.Gui) error { // run only once on init. // this bit of code should be at the bottom ct.g = g - g.SetViewOnBottom(ct.searchfieldviewname) // hide - g.SetViewOnBottom(ct.helpviewname) // hide - g.SetViewOnBottom(ct.convertmenuviewname) // hide + g.SetViewOnBottom(ct.searchfieldviewname) // hide + g.SetViewOnBottom(ct.helpviewname) // hide + g.SetViewOnBottom(ct.convertmenuviewname) // hide + g.SetViewOnBottom(ct.portfolioupdatemenuviewname) // hide + g.SetViewOnBottom(ct.inputviewname) // hide ct.setActiveView(ct.tableviewname) ct.intervalFetchData() } @@ -147,6 +171,10 @@ func (ct *Cointop) setActiveView(v string) error { } else if v == ct.tableviewname { ct.g.SetViewOnTop(ct.statusbarviewname) } + if v == ct.portfolioupdatemenuviewname { + ct.g.SetViewOnTop(ct.inputviewname) + ct.g.SetCurrentView(ct.inputviewname) + } return nil } diff --git a/cointop/navigation.go b/cointop/navigation.go index 53920ed..97f4ef7 100644 --- a/cointop/navigation.go +++ b/cointop/navigation.go @@ -212,6 +212,9 @@ func (ct *Cointop) navigatePageLastLine() error { } func (ct *Cointop) prevPage() error { + if ct.isFirstPage() { + return nil + } ct.setPage(ct.page - 1) ct.updateTable() ct.rowChanged() @@ -232,6 +235,10 @@ func (ct *Cointop) firstPage() error { return nil } +func (ct *Cointop) isFirstPage() bool { + return ct.page == 0 +} + func (ct *Cointop) lastPage() error { ct.page = ct.getListCount() / ct.perpage ct.updateTable() diff --git a/cointop/portfolio.go b/cointop/portfolio.go index 9315bd1..6c0213e 100644 --- a/cointop/portfolio.go +++ b/cointop/portfolio.go @@ -1,5 +1,15 @@ package cointop +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/miguelmota/cointop/pkg/color" + "github.com/miguelmota/cointop/pkg/pad" +) + func (ct *Cointop) togglePortfolio() error { ct.filterByFavorites = false ct.portfoliovisible = !ct.portfoliovisible @@ -13,3 +23,159 @@ func (ct *Cointop) toggleShowPortfolio() error { ct.updateTable() return nil } + +func (ct *Cointop) togglePortfolioUpdateMenu() error { + ct.portfolioupdatemenuvisible = !ct.portfolioupdatemenuvisible + if ct.portfolioupdatemenuvisible { + return ct.showPortfolioUpdateMenu() + } + return ct.hidePortfolioUpdateMenu() +} + +func (ct *Cointop) updatePortfolioUpdateMenu() { + coin := ct.highlightedRowCoin() + exists := ct.portfolioEntryExists(coin) + value := strconv.FormatFloat(coin.Holdings, 'f', -1, 64) + var mode string + var current string + var submitText string + if exists { + mode = "Edit" + current = fmt.Sprintf("(current %s %s)", value, coin.Symbol) + submitText = "Set" + } else { + mode = "Add" + submitText = "Add" + } + header := color.GreenBg(fmt.Sprintf(" %s Portfolio Entry %s\n\n", mode, pad.Left("[q] close ", ct.maxtablewidth-15, " "))) + label := fmt.Sprintf(" Enter holdings for %s %s", color.Yellow(coin.Name), current) + content := fmt.Sprintf("%s\n%s\n\n%s%s\n\n\n [Enter] %s [ESC] Cancel", header, label, strings.Repeat(" ", 29), coin.Symbol, submitText) + + ct.update(func() { + ct.portfolioupdatemenuview.Clear() + ct.portfolioupdatemenuview.Frame = true + fmt.Fprintln(ct.portfolioupdatemenuview, content) + fmt.Fprintln(ct.inputview, value) + ct.inputview.SetCursor(len(value), 0) + }) +} + +func (ct *Cointop) showPortfolioUpdateMenu() error { + ct.portfolioupdatemenuvisible = true + ct.updatePortfolioUpdateMenu() + ct.setActiveView(ct.portfolioupdatemenuviewname) + return nil +} + +func (ct *Cointop) hidePortfolioUpdateMenu() error { + ct.portfolioupdatemenuvisible = false + ct.setViewOnBottom(ct.portfolioupdatemenuviewname) + ct.setViewOnBottom(ct.inputviewname) + ct.setActiveView(ct.tableviewname) + ct.update(func() { + ct.portfolioupdatemenuview.Clear() + ct.portfolioupdatemenuview.Frame = false + fmt.Fprintln(ct.portfolioupdatemenuview, "") + + ct.inputview.Clear() + fmt.Fprintln(ct.inputview, "") + }) + return nil +} + +// sets portfolio entry holdings from inputed value +func (ct *Cointop) setPortfolioHoldings() error { + defer ct.hidePortfolioUpdateMenu() + coin := ct.highlightedRowCoin() + + b := make([]byte, 100) + n, err := ct.inputview.Read(b) + if n == 0 { + return nil + } + + value := normalizeFloatstring(string(b)) + shouldDelete := value == "" + var holdings float64 + + if !shouldDelete { + holdings, err = strconv.ParseFloat(value, 64) + if err != nil { + return err + } + } + + ct.setPortfolioEntry(coin.Name, holdings) + + if shouldDelete { + ct.removePortfolioEntry(coin.Name) + ct.updateTable() + ct.goToGlobalIndex(0) + } else { + ct.updateTable() + ct.goToGlobalIndex(coin.Rank - 1) + } + + return nil +} + +func (ct *Cointop) portfolioEntry(c *coin) (*portfolioEntry, bool) { + if c == nil { + return &portfolioEntry{}, true + } + + var p *portfolioEntry + var isNew bool + var ok bool + key := strings.ToLower(c.Name) + if p, ok = ct.portfolio.Entries[key]; !ok { + // NOTE: if not found then try the symbol + key := strings.ToLower(c.Symbol) + if p, ok = ct.portfolio.Entries[key]; !ok { + p = &portfolioEntry{ + Coin: c.Name, + Holdings: 0, + } + isNew = true + } + } + + return p, isNew +} + +func (ct *Cointop) setPortfolioEntry(coin string, holdings float64) { + c, _ := ct.allcoinsslugmap[strings.ToLower(coin)] + p, isNew := ct.portfolioEntry(c) + if isNew { + key := strings.ToLower(coin) + ct.portfolio.Entries[key] = &portfolioEntry{ + Coin: coin, + Holdings: holdings, + } + } else { + p.Holdings = holdings + } +} + +func (ct *Cointop) removePortfolioEntry(coin string) { + delete(ct.portfolio.Entries, strings.ToLower(coin)) +} + +func (ct *Cointop) portfolioEntryExists(c *coin) bool { + _, isNew := ct.portfolioEntry(c) + return !isNew +} + +func (ct *Cointop) portfolioEntriesCount() int { + return len(ct.portfolio.Entries) +} + +func normalizeFloatstring(input string) string { + re := regexp.MustCompile(`(\d+\.\d+|\.\d+|\d+)`) + result := re.FindStringSubmatch(input) + if len(result) > 0 { + return result[0] + } + + return "" +} diff --git a/cointop/shortcuts.go b/cointop/shortcuts.go index 7e6645e..52d77d0 100644 --- a/cointop/shortcuts.go +++ b/cointop/shortcuts.go @@ -38,6 +38,8 @@ func defaultShortcuts() map[string]string { "b": "sort_column_balance", "c": "show_currency_convert_menu", "C": "show_currency_convert_menu", + "e": "show_portfolio_edit_menu", + "E": "show_portfolio_edit_menu", "f": "toggle_favorite", "F": "toggle_show_favorites", "g": "move_to_page_first_row", diff --git a/cointop/table.go b/cointop/table.go index bc8100f..e6830ee 100644 --- a/cointop/table.go +++ b/cointop/table.go @@ -54,7 +54,7 @@ func (ct *Cointop) refreshTable() error { namecolor(pad.Right(fmt.Sprintf("%.22s", name), 21, " ")), color.White(pad.Right(fmt.Sprintf("%.6s", coin.Symbol), 5, " ")), colorprice(fmt.Sprintf("%13s", humanize.Commaf(coin.Price))), - color.White(fmt.Sprintf("%15s", humanize.Commaf(coin.Holdings))), + color.White(fmt.Sprintf("%15s", strconv.FormatFloat(coin.Holdings, 'f', -1, 64))), colorbalance(fmt.Sprintf("%15s", humanize.Commaf(coin.Balance))), color24h(fmt.Sprintf("%8.2f%%", coin.PercentChange24H)), color.White(pad.Right(fmt.Sprintf("%17s", lastUpdated), 80, " ")), @@ -170,24 +170,15 @@ func (ct *Cointop) updateTable() error { if ct.portfoliovisible { for i := range ct.allcoins { - if len(ct.portfolio.Entries) == 0 { + if ct.portfolioEntriesCount() == 0 { break } coin := ct.allcoins[i] - var p *portfolioEntry - var ok bool - if p, ok = ct.portfolio.Entries[strings.ToLower(coin.Name)]; !ok { - // NOTE: if not found then try the symbol - if p, ok = ct.portfolio.Entries[strings.ToLower(coin.Symbol)]; !ok { - continue - } + p, isNew := ct.portfolioEntry(coin) + if isNew { + continue } - holdingsstr := fmt.Sprintf("%.2f", p.Holdings) - if ct.currencyconversion == "ETH" || ct.currencyconversion == "BTC" { - holdingsstr = fmt.Sprintf("%.5f", p.Holdings) - } - holdings, _ := strconv.ParseFloat(holdingsstr, 64) - coin.Holdings = holdings + coin.Holdings = p.Holdings balance := coin.Price * p.Holdings balancestr := fmt.Sprintf("%.2f", balance) @@ -298,6 +289,17 @@ func (ct *Cointop) allCoins() []*coin { return list } + if ct.portfoliovisible { + var list []*coin + for i := range ct.allcoins { + coin := ct.allcoins[i] + if ct.portfolioEntryExists(coin) { + list = append(list, coin) + } + } + return list + } + return ct.allcoins } diff --git a/pkg/api/api.go b/pkg/api/api.go index 934ff8f..7fe56a0 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -11,4 +11,5 @@ func NewCMC() Interface { // NewCC new CryptoCompare API func NewCC() { + // TODO }