diff --git a/README.md b/README.md index 5276e08..926d43d 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ In action - Charts for coins and global market graphs - Quick chart date range change - Fuzzy searching for finding coins +- Currency conversion - Save and view favorite coins - Color support - Help menu @@ -163,7 +164,7 @@ Key|Action 2|Sort table by *[2]4 hour change* 7|Sort table by *[7] day change* a|Sort table by *[a]vailable supply* -c|Toggle [c]hart for highlighted coin +c|Show currency convert menu f|Toggle show favorites F|Toggle show favorites g|Go to first line of page (vim inspired) @@ -231,7 +232,7 @@ You can then configure the actions you want for each key: left = "previous_page" right = "next_page" up = "move_up" - c = "toggle_row_chart" + c = "show_currency_convert_menu" "ctrl+c" = "quit" "ctrl+d" = "page_down" "ctrl+f" = "open_search" @@ -276,6 +277,7 @@ Action|Description `first_chart_range`|Select first chart date range (e.g. 1H) `first_page`|Go to first page `help`|Show help +`hide_currency_convert_menu`|Hide currency convert menu `last_chart_range`|Select last chart date range (e.g. All Time) `last_page`|Go to last page `move_to_page_first_row`|Move to first row on page @@ -296,6 +298,8 @@ Action|Description `quit`|Quit application `refresh`|Do a manual refresh on the data `save`|Save config +`show_currency_convert_menu`|Show currency convert menu +`show_favorites`|Show favorites `sort_column_1h_change`|Sort table by column *1 hour change* `sort_column_24h_change`|Sort table by column *24 hour change* `sort_column_24h_volume`|Sort table by column *24 hour volume* @@ -314,6 +318,7 @@ Action|Description `sort_right_column`|Sort the column to the right of the highlighted column `toggle_row_chart`|Toggle the chart for the highlighted row `toggle_favorite`|Toggle coin as favorite +`toggle_show_currency_convert_menu`|Toggle show currency convert menu `toggle_show_favorites`|Toggle show favorites ## FAQ @@ -422,6 +427,14 @@ Action|Description YTD = Year-to-date +- Q: How do I change the fiat currency? + + - A: Press c to show the currency convert menu, and press the corresponding key to select that as the fiat currency. + +- Q: Which currencies can I convert to? + + - A: The supported fiat currencies for conversion are `USD`, `EUR`, `GBP`, `CNY`, `HKD`, `JPY`, `KRW`, `NZD`, `CFH`, `MXN`, `AUD`, `IDR`, `RUB`, and `CAD`. The supported crypto currencies for conversion are `BTC` and `ETH`. + ## Development ### Go diff --git a/cointop/actions.go b/cointop/actions.go index 993bed5..fab59a9 100644 --- a/cointop/actions.go +++ b/cointop/actions.go @@ -2,49 +2,52 @@ package cointop func actionsMap() map[string]bool { return map[string]bool{ - "first_page": true, - "help": true, - "toggle_show_help": true, - "close_help": true, - "last_page": true, - "move_to_page_first_row": true, - "move_to_page_last_row": true, - "move_to_page_visible_first_row": true, - "move_to_page_visible_last_row": true, - "move_to_page_visible_middle_row": true, - "move_up": true, - "move_down": true, - "next_page": true, - "open_link": true, - "page_down": true, - "page_up": true, - "previous_page": true, - "quit": true, - "refresh": true, - "sort_column_1h_change": true, - "sort_column_24h_change": true, - "sort_column_24h_volume": true, - "sort_column_7d_change": true, - "sort_column_asc": true, - "sort_column_available_supply": true, - "sort_column_desc": true, - "sort_column_last_updated": true, - "sort_column_market_cap": true, - "sort_column_name": true, - "sort_column_price": true, - "sort_column_rank": true, - "sort_column_symbol": true, - "sort_column_total_supply": true, - "sort_left_column": true, - "sort_right_column": true, - "toggle_row_chart": true, - "open_search": true, - "toggle_favorite": true, - "toggle_show_favorites": true, - "previous_chart_range": true, - "next_chart_range": true, - "first_chart_range": true, - "last_chart_range": true, + "first_page": true, + "help": true, + "toggle_show_help": true, + "close_help": true, + "last_page": true, + "move_to_page_first_row": true, + "move_to_page_last_row": true, + "move_to_page_visible_first_row": true, + "move_to_page_visible_last_row": true, + "move_to_page_visible_middle_row": true, + "move_up": true, + "move_down": true, + "next_page": true, + "open_link": true, + "page_down": true, + "page_up": true, + "previous_page": true, + "quit": true, + "refresh": true, + "sort_column_1h_change": true, + "sort_column_24h_change": true, + "sort_column_24h_volume": true, + "sort_column_7d_change": true, + "sort_column_asc": true, + "sort_column_available_supply": true, + "sort_column_desc": true, + "sort_column_last_updated": true, + "sort_column_market_cap": true, + "sort_column_name": true, + "sort_column_price": true, + "sort_column_rank": true, + "sort_column_symbol": true, + "sort_column_total_supply": true, + "sort_left_column": true, + "sort_right_column": true, + "toggle_row_chart": true, + "open_search": true, + "toggle_favorite": true, + "toggle_show_favorites": true, + "previous_chart_range": true, + "next_chart_range": true, + "first_chart_range": true, + "last_chart_range": true, + "toggle_show_currency_convert_menu": true, + "show_currency_convert_menu": true, + "hide_currency_convert_menu": true, } } diff --git a/cointop/cointop.go b/cointop/cointop.go index 2c37445..c085a28 100644 --- a/cointop/cointop.go +++ b/cointop/cointop.go @@ -64,6 +64,9 @@ type Cointop struct { helpviewname string helpvisible bool currencyconversion string + convertmenuview *gocui.View + convertmenuviewname string + convertmenuvisible bool } // Instance running cointop instance @@ -136,6 +139,7 @@ func Run() { statusbarviewname: "statusbar", searchfieldviewname: "searchfield", helpviewname: "help", + convertmenuviewname: "convertmenu", currencyconversion: "USD", } Instance = &ct @@ -180,7 +184,7 @@ func Run() { } func (ct *Cointop) quit() error { - if ct.helpvisible || ct.searchfieldvisible { + if ct.helpvisible || ct.convertmenuvisible || ct.searchfieldvisible { return nil } diff --git a/cointop/conversions.go b/cointop/conversions.go new file mode 100644 index 0000000..d7b39ab --- /dev/null +++ b/cointop/conversions.go @@ -0,0 +1,141 @@ +package cointop + +import ( + "fmt" + "sort" + + "github.com/miguelmota/cointop/pkg/color" + "github.com/miguelmota/cointop/pkg/pad" +) + +//var supportedfiatconversions = []string{"AUD", "BRL", "CAD", "CHF", "CLP", "CNY", "CZK", "DKK", "EUR", "GBP", "HKD", "HUF", "IDR", "ILS", "INR", "JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PHP", "PKR", "PLN", "RUB", "SEK", "SGD", "THB", "TRY", "TWD", "ZAR"} + +var supportedfiatconversions = map[string]string{ + "USD": "US Dollar", + "EUR": "Euro", + "GBP": "British Pound", + "CNY": "Chinese Yuan", + "HKD": "Hong Kong Dollar", + "JPY": "Japanese Yen", + "KRW": "South Korean Won", + "NZD": "New Zealand Dollar", + "CFH": "Swiss Franc", + "MXN": "Mexican Peso", + "AUD": "Australian Dollar", + "IDR": "Indonesian Rupiah", + "RUB": "Russian Ruble", + "CAD": "Canadian dollar", +} + +var supportedcryptoconversion = map[string]string{ + "BTC": "Bitcoin", + "ETH": "Ethereum", +} + +var alphanumericcharacters = []rune{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'} + +func (ct *Cointop) supportedCurrencyConversions() map[string]string { + all := map[string]string{} + for k, v := range supportedfiatconversions { + all[k] = v + } + for k, v := range supportedcryptoconversion { + all[k] = v + } + return all +} + +func (ct *Cointop) supportedFiatCurrencyConversions() map[string]string { + return supportedfiatconversions +} + +func (ct *Cointop) supportedCryptoCurrencyConversions() map[string]string { + return supportedfiatconversions +} + +func (ct *Cointop) sortedSupportedCurrencyConversions() []string { + currencies := ct.supportedCurrencyConversions() + keys := make([]string, 0, len(currencies)) + for k := range currencies { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func (ct *Cointop) toggleConvertMenu() error { + ct.convertmenuvisible = !ct.convertmenuvisible + if ct.convertmenuvisible { + return ct.showConvertMenu() + } + return ct.hideConvertMenu() +} + +func (ct *Cointop) updateConvertMenu() { + header := color.GreenBg(fmt.Sprintf(" Currency Conversion %s\n\n", pad.Left("[q] close menu ", ct.maxtablewidth-20, " "))) + helpline := " Press the corresponding key to select currency for conversion\n\n" + cnt := 0 + h := ct.viewHeight(ct.convertmenuviewname) + percol := h - 5 + cols := make([][]string, percol) + for i := range cols { + cols[i] = make([]string, 20) + } + + keys := ct.sortedSupportedCurrencyConversions() + currencies := ct.supportedCurrencyConversions() + for i, key := range keys { + currency := currencies[key] + if cnt%percol == 0 { + cnt = 0 + } + item := fmt.Sprintf(" [ %1s ] %4s %-34s", string(alphanumericcharacters[i]), key, color.Yellow(currency)) + cols[cnt] = append(cols[cnt], item) + cnt = cnt + 1 + } + + var body string + for i := 0; i < percol; i++ { + var row string + for j := 0; j < len(cols[i]); j++ { + item := cols[i][j] + row = fmt.Sprintf("%s%s", row, item) + } + body = fmt.Sprintf("%s%s\n", body, row) + } + + content := fmt.Sprintf("%s%s%s", header, helpline, body) + ct.update(func() { + ct.convertmenuview.Clear() + ct.convertmenuview.Frame = true + fmt.Fprintln(ct.convertmenuview, content) + }) +} + +func (ct *Cointop) showConvertMenu() error { + ct.convertmenuvisible = true + ct.updateConvertMenu() + ct.setActiveView(ct.convertmenuviewname) + return nil +} + +func (ct *Cointop) hideConvertMenu() error { + ct.convertmenuvisible = false + ct.setViewOnBottom(ct.convertmenuviewname) + ct.setActiveView(ct.tableviewname) + ct.update(func() { + ct.convertmenuview.Clear() + ct.convertmenuview.Frame = false + fmt.Fprintln(ct.convertmenuview, "") + }) + return nil +} + +func (ct *Cointop) setCurrencyConverstion(convert string) func() error { + return func() error { + ct.currencyconversion = convert + ct.hideConvertMenu() + go ct.refreshAll() + return nil + } +} diff --git a/cointop/help.go b/cointop/help.go index c69b180..e8bf51a 100644 --- a/cointop/help.go +++ b/cointop/help.go @@ -25,7 +25,7 @@ func (ct *Cointop) updateHelp() { header := color.GreenBg(fmt.Sprintf(" Help %s\n\n", pad.Left("[q] close help ", ct.maxtablewidth-10, " "))) cnt := 0 - h := ct.viewHeight("help") + h := ct.viewHeight(ct.helpviewname) percol := h - 3 cols := make([][]string, percol) for i := range cols { @@ -63,7 +63,7 @@ func (ct *Cointop) updateHelp() { func (ct *Cointop) showHelp() error { ct.helpvisible = true ct.updateHelp() - ct.setActiveView("help") + ct.setActiveView(ct.helpviewname) return nil } diff --git a/cointop/keybindings.go b/cointop/keybindings.go index d723da3..c9acc36 100644 --- a/cointop/keybindings.go +++ b/cointop/keybindings.go @@ -241,6 +241,9 @@ func (ct *Cointop) keybindings(g *gocui.Gui) error { case "toggle_show_help": fn = ct.keyfn(ct.toggleHelp) view = "" + case "show_help": + fn = ct.keyfn(ct.showHelp) + view = "" case "hide_help": fn = ct.keyfn(ct.hideHelp) view = "help" @@ -298,6 +301,15 @@ func (ct *Cointop) keybindings(g *gocui.Gui) error { fn = ct.keyfn(ct.firstChartRange) case "last_chart_range": fn = ct.keyfn(ct.lastChartRange) + case "toggle_show_currency_convert_menu": + fn = ct.keyfn(ct.toggleConvertMenu) + view = "" + case "show_currency_convert_menu": + fn = ct.keyfn(ct.showConvertMenu) + view = "" + case "hide_currency_convert_menu": + fn = ct.keyfn(ct.hideConvertMenu) + view = "convertmenu" default: fn = ct.keyfn(ct.noop) } @@ -310,14 +322,27 @@ func (ct *Cointop) keybindings(g *gocui.Gui) error { ct.setKeybindingMod(gocui.KeyCtrlZ, gocui.ModNone, ct.keyfn(ct.forceQuit), "") // searchfield keys - ct.setKeybindingMod(gocui.KeyEnter, gocui.ModNone, ct.keyfn(ct.doSearch), "searchfield") - ct.setKeybindingMod(gocui.KeyEsc, gocui.ModNone, ct.keyfn(ct.cancelSearch), "searchfield") + ct.setKeybindingMod(gocui.KeyEnter, gocui.ModNone, ct.keyfn(ct.doSearch), ct.searchfieldviewname) + ct.setKeybindingMod(gocui.KeyEsc, gocui.ModNone, ct.keyfn(ct.cancelSearch), ct.searchfieldviewname) // keys to quit help when open - ct.setKeybindingMod(gocui.KeyEsc, gocui.ModNone, ct.keyfn(ct.hideHelp), "help") - ct.setKeybindingMod('q', gocui.ModNone, ct.keyfn(ct.hideHelp), "help") - ct.setKeybindingMod('x', gocui.ModNone, ct.keyfn(ct.hideHelp), "help") - ct.setKeybindingMod('c', gocui.ModNone, ct.keyfn(ct.hideHelp), "help") + ct.setKeybindingMod(gocui.KeyEsc, gocui.ModNone, ct.keyfn(ct.hideHelp), ct.helpviewname) + ct.setKeybindingMod('q', gocui.ModNone, ct.keyfn(ct.hideHelp), ct.helpviewname) + ct.setKeybindingMod('x', gocui.ModNone, ct.keyfn(ct.hideHelp), ct.helpviewname) + ct.setKeybindingMod('c', gocui.ModNone, ct.keyfn(ct.hideHelp), ct.helpviewname) + + // 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) + ct.setKeybindingMod('x', gocui.ModNone, ct.keyfn(ct.hideConvertMenu), ct.convertmenuviewname) + + // character key press to select option + // TODO: use scrolling table + keys := ct.sortedSupportedCurrencyConversions() + for i, k := range keys { + ct.setKeybindingMod(rune(alphanumericcharacters[i]), gocui.ModNone, ct.keyfn(ct.setCurrencyConverstion(k)), ct.convertmenuviewname) + } + return nil } diff --git a/cointop/layout.go b/cointop/layout.go index 5b3e316..d37afbd 100644 --- a/cointop/layout.go +++ b/cointop/layout.go @@ -113,12 +113,23 @@ func (ct *Cointop) layout(g *gocui.Gui) error { ct.helpview.Frame = false ct.helpview.BgColor = gocui.ColorBlack ct.helpview.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 + } + ct.convertmenuview = v + ct.convertmenuview.Frame = false + ct.convertmenuview.BgColor = gocui.ColorBlack + ct.convertmenuview.FgColor = gocui.ColorWhite // 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 ct.setActiveView(ct.tableviewname) ct.intervalFetchData() } diff --git a/cointop/shortcuts.go b/cointop/shortcuts.go index 9ad6506..7993c12 100644 --- a/cointop/shortcuts.go +++ b/cointop/shortcuts.go @@ -32,7 +32,8 @@ func defaultShortcuts() map[string]string { "2": "sort_column_24h_change", "7": "sort_column_7d_change", "a": "sort_column_available_supply", - "c": "toggle_row_chart", + "c": "show_currency_convert_menu", + "C": "show_currency_convert_menu", "f": "toggle_show_favorites", "F": "toggle_show_favorites", "g": "move_to_page_first_row", diff --git a/cointop/statusbar.go b/cointop/statusbar.go index 59593b1..ad7a02f 100644 --- a/cointop/statusbar.go +++ b/cointop/statusbar.go @@ -11,7 +11,7 @@ func (ct *Cointop) updateStatusbar(s string) { ct.statusbarview.Clear() currpage := ct.currentDisplayPage() totalpages := ct.totalPages() - base := fmt.Sprintf("%sQuit %sHelp %sChart %sRange %sSearch", "[Q]", "[?]", "[Enter]", "[[ ]]", "[/]") + base := fmt.Sprintf("%sQuit %sHelp %sChart %sRange %sSearch %sConvert", "[Q]", "[?]", "[Enter]", "[[ ]]", "[/]", "[C]") fmt.Fprintln(ct.statusbarview, pad.Right(fmt.Sprintf("%v %sPage %v/%v %s", base, "[← →]", currpage, totalpages, s), ct.maxtablewidth, " ")) }) } diff --git a/cointop/table.go b/cointop/table.go index 910b961..840299f 100644 --- a/cointop/table.go +++ b/cointop/table.go @@ -142,6 +142,9 @@ func (ct *Cointop) updateTable() error { if end < 0 { end = 0 } + if start >= end { + return nil + } if end > 0 { sliced = allcoins[start:end] } diff --git a/pkg/api/cmc/cmc.go b/pkg/api/cmc/cmc.go index 66b7574..333ebb1 100644 --- a/pkg/api/cmc/cmc.go +++ b/pkg/api/cmc/cmc.go @@ -2,6 +2,7 @@ package api import ( "strconv" + "sync" apitypes "github.com/miguelmota/cointop/pkg/api/types" cmc "github.com/miguelmota/cointop/pkg/cmc" @@ -16,11 +17,15 @@ func New() *Service { return &Service{} } -// GetAllCoinData gets all coin data -func (s *Service) GetAllCoinData(convert string) (map[string]apitypes.Coin, error) { +// https://api.coinmarketcap.com/v1/ticker/?start=0&limit=0 + +func getLimitedCoinData(convert string, offset int) (map[string]apitypes.Coin, error) { ret := make(map[string]apitypes.Coin) + max := 100 coins, err := cmc.Tickers(&cmc.TickersOptions{ Convert: convert, + Start: max * offset, + Limit: max, }) if err != nil { return ret, err @@ -46,6 +51,27 @@ func (s *Service) GetAllCoinData(convert string) (map[string]apitypes.Coin, erro return ret, nil } +// GetAllCoinData gets all coin data +func (s *Service) GetAllCoinData(convert string) (map[string]apitypes.Coin, error) { + var wg sync.WaitGroup + ret := make(map[string]apitypes.Coin) + for i := 0; i < 10; i++ { + wg.Add(1) + go func(j int) { + coins, err := getLimitedCoinData(convert, j) + if err != nil { + return + } + for k, v := range coins { + ret[k] = v + } + defer wg.Done() + }(i) + } + wg.Wait() + return ret, nil +} + // GetCoinGraphData gets coin graph data func (s *Service) GetCoinGraphData(coin string, start int64, end int64) (apitypes.CoinGraph, error) { ret := apitypes.CoinGraph{}