diff --git a/README.md b/README.md index 88ec3e3..2418c16 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,31 @@ It's possible to set a custom sort order with the `--sort` flag or `sort:` confi * If a `currency` is not set (default behavior) and the `show-summary` option is enabled, the summary will be calculated in USD regardless of the exchange currency to avoid mixing currencies * Currencies are retrieved only once at start time - currency exchange rates do fluctuate over time and thus converted values may vary depending on when ticker is started +### Custom Color Schemes + +`ticker` supports setting custom color schemes from the config file. Colors are represented by a [hex triplet](https://en.wikipedia.org/wiki/Web_colors#Hex_triplet). Below is an annotated example config block from `.ticker.yaml` where custom colors are set: + +```yaml +# ~/.ticker.yaml +watchlist: + - NET + - TEAM + - ESTC + - BTC-USD +colors: + text: "#005fff" + text-light: "#0087ff" + text-label: "#00d7ff" + text-line: "#00ffff" + text-tag: "#005fff" + background-tag: "#0087ff" +``` + +* Terminals supporting TrueColor will be able to represent the full color space and in other cases colors will be down sampled +* Any omitted or invalid colors will revert to default color scheme values + + + ## Notes * **Real-time quotes** - Quotes are pulled from Yahoo finance which may provide delayed stock quotes depending on the exchange. The major US exchanges (NYSE, NASDAQ) have real-time quotes however other exchanges may not. Consult the [help article](https://help.yahoo.com/kb/SLN2310.html) on exchange delays to determine which exchanges you can expect delays for or use the `--show-tags` flag to include timeliness of data alongside quotes in `ticker`. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 8f6a3f7..6127742 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -8,6 +8,7 @@ import ( . "github.com/achannarasappa/ticker/internal/common" "github.com/achannarasappa/ticker/internal/currency" "github.com/achannarasappa/ticker/internal/position" + "github.com/achannarasappa/ticker/internal/ui/util" "github.com/adrg/xdg" "github.com/go-resty/resty/v2" @@ -112,9 +113,11 @@ func getReference(config Config, client resty.Client) (Reference, error) { symbols := position.GetSymbols(config.Watchlist, aggregatedLots) currencyRates, err := currency.GetCurrencyRates(client, symbols, config.Currency) + styles := util.GetColorScheme(config.ColorScheme) return Reference{ CurrencyRates: currencyRates, + Styles: styles, }, err } diff --git a/internal/common/common.go b/internal/common/common.go index cc6591c..2c70b68 100644 --- a/internal/common/common.go +++ b/internal/common/common.go @@ -11,21 +11,32 @@ type Context struct { } type Config struct { - RefreshInterval int `yaml:"interval"` - Watchlist []string `yaml:"watchlist"` - Lots []Lot `yaml:"lots"` - Separate bool `yaml:"show-separator"` - ExtraInfoExchange bool `yaml:"show-tags"` - ExtraInfoFundamentals bool `yaml:"show-fundamentals"` - ShowSummary bool `yaml:"show-summary"` - ShowHoldings bool `yaml:"show-holdings"` - Proxy string `yaml:"proxy"` - Sort string `yaml:"sort"` - Currency string `yaml:"currency"` + RefreshInterval int `yaml:"interval"` + Watchlist []string `yaml:"watchlist"` + Lots []Lot `yaml:"lots"` + Separate bool `yaml:"show-separator"` + ExtraInfoExchange bool `yaml:"show-tags"` + ExtraInfoFundamentals bool `yaml:"show-fundamentals"` + ShowSummary bool `yaml:"show-summary"` + ShowHoldings bool `yaml:"show-holdings"` + Proxy string `yaml:"proxy"` + Sort string `yaml:"sort"` + Currency string `yaml:"currency"` + ColorScheme ConfigColorScheme `yaml:"colors"` +} + +type ConfigColorScheme struct { + Text string `yaml:"text"` + TextLight string `yaml:"text-light"` + TextLabel string `yaml:"text-label"` + TextLine string `yaml:"text-line"` + TextTag string `yaml:"text-tag"` + BackgroundTag string `yaml:"background-tag"` } type Reference struct { CurrencyRates CurrencyRates + Styles Styles } type Dependencies struct { @@ -46,3 +57,15 @@ type CurrencyRate struct { ToCurrency string Rate float64 } + +type Styles struct { + Text StyleFn + TextLight StyleFn + TextLabel StyleFn + TextBold StyleFn + TextLine StyleFn + TextPrice func(float64, string) string + Tag StyleFn +} + +type StyleFn func(string) string diff --git a/internal/ui/component/summary/summary.go b/internal/ui/component/summary/summary.go index c22337d..f23d69e 100644 --- a/internal/ui/component/summary/summary.go +++ b/internal/ui/component/summary/summary.go @@ -4,7 +4,9 @@ import ( "strings" grid "github.com/achannarasappa/term-grid" + c "github.com/achannarasappa/ticker/internal/common" "github.com/achannarasappa/ticker/internal/position" + . "github.com/achannarasappa/ticker/internal/ui/util" "github.com/muesli/reflow/ansi" ) @@ -12,12 +14,15 @@ import ( type Model struct { Width int Summary position.PositionSummary + Context c.Context + styles c.Styles } // NewModel returns a model with default values. -func NewModel() Model { +func NewModel(ctx c.Context) Model { return Model{ - Width: 80, + Width: 80, + styles: ctx.Reference.Styles, } } @@ -27,15 +32,15 @@ func (m Model) View() string { return "" } - textChange := StyleNeutralFaded("Day Change: ") + quoteChangeText(m.Summary.DayChange, m.Summary.DayChangePercent) + - StyleNeutralFaded(" • ") + - StyleNeutralFaded("Change: ") + quoteChangeText(m.Summary.Change, m.Summary.ChangePercent) + textChange := m.styles.TextLabel("Day Change: ") + quoteChangeText(m.Summary.DayChange, m.Summary.DayChangePercent, m.styles) + + m.styles.TextLabel(" • ") + + m.styles.TextLabel("Change: ") + quoteChangeText(m.Summary.Change, m.Summary.ChangePercent, m.styles) widthChange := ansi.PrintableRuneWidth(textChange) - textValue := StyleNeutralFaded(" • ") + - StyleNeutralFaded("Value: ") + ValueText(m.Summary.Value) + textValue := m.styles.TextLabel(" • ") + + m.styles.TextLabel("Value: ") + ValueText(m.Summary.Value, m.styles) widthValue := ansi.PrintableRuneWidth(textValue) - textCost := StyleNeutralFaded(" • ") + - StyleNeutralFaded("Cost: ") + ValueText(m.Summary.Cost) + textCost := m.styles.TextLabel(" • ") + + m.styles.TextLabel("Cost: ") + ValueText(m.Summary.Cost, m.styles) widthCost := ansi.PrintableRuneWidth(textValue) return grid.Render(grid.Grid{ @@ -62,7 +67,7 @@ func (m Model) View() string { { Width: m.Width, Cells: []grid.Cell{ - {Text: StyleLine(strings.Repeat("━", m.Width))}, + {Text: m.styles.TextLine(strings.Repeat("━", m.Width))}, }, }, }, @@ -71,14 +76,14 @@ func (m Model) View() string { } -func quoteChangeText(change float64, changePercent float64) string { +func quoteChangeText(change float64, changePercent float64, styles c.Styles) string { if change == 0.0 { - return StyleNeutralFaded(ConvertFloatToString(change, false) + " (" + ConvertFloatToString(changePercent, false) + "%)") + return styles.TextLabel(ConvertFloatToString(change, false) + " (" + ConvertFloatToString(changePercent, false) + "%)") } if change > 0.0 { - return StylePrice(changePercent, "↑ "+ConvertFloatToString(change, false)+" ("+ConvertFloatToString(changePercent, false)+"%)") + return styles.TextPrice(changePercent, "↑ "+ConvertFloatToString(change, false)+" ("+ConvertFloatToString(changePercent, false)+"%)") } - return StylePrice(changePercent, "↓ "+ConvertFloatToString(change, false)+" ("+ConvertFloatToString(changePercent, false)+"%)") + return styles.TextPrice(changePercent, "↓ "+ConvertFloatToString(change, false)+" ("+ConvertFloatToString(changePercent, false)+"%)") } diff --git a/internal/ui/component/summary/summary_test.go b/internal/ui/component/summary/summary_test.go index 4dcf1d2..14b9dfc 100644 --- a/internal/ui/component/summary/summary_test.go +++ b/internal/ui/component/summary/summary_test.go @@ -3,6 +3,7 @@ package summary_test import ( "strings" + c "github.com/achannarasappa/ticker/internal/common" "github.com/achannarasappa/ticker/internal/position" . "github.com/achannarasappa/ticker/internal/ui/component/summary" @@ -17,9 +18,19 @@ func removeFormatting(text string) string { var _ = Describe("Summary", func() { + ctxFixture := c.Context{Reference: c.Reference{Styles: c.Styles{ + Text: func(v string) string { return v }, + TextLight: func(v string) string { return v }, + TextLabel: func(v string) string { return v }, + TextBold: func(v string) string { return v }, + TextLine: func(v string) string { return v }, + TextPrice: func(percent float64, text string) string { return text }, + Tag: func(v string) string { return v }, + }}} + When("the change is positive", func() { It("should render a summary with up arrow", func() { - m := NewModel() + m := NewModel(ctxFixture) m.Width = 120 m.Summary = position.PositionSummary{ Value: 10000, @@ -38,7 +49,7 @@ var _ = Describe("Summary", func() { When("the change is negative", func() { It("should render a summary with down arrow", func() { - m := NewModel() + m := NewModel(ctxFixture) m.Width = 120 m.Summary = position.PositionSummary{ Value: 1000, @@ -57,7 +68,7 @@ var _ = Describe("Summary", func() { When("no quotes are set", func() { It("should render an empty summary", func() { - m := NewModel() + m := NewModel(ctxFixture) Expect(removeFormatting(m.View())).To(Equal(strings.Join([]string{ "Day Change: 0.00 (0.00%) • Change: 0.00 (0.00%) • Value: • Cost: ", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", @@ -67,7 +78,7 @@ var _ = Describe("Summary", func() { When("the window width is less than the minimum", func() { It("should render an empty summary", func() { - m := NewModel() + m := NewModel(ctxFixture) m.Width = 10 Expect(m.View()).To(Equal("")) }) diff --git a/internal/ui/component/watchlist/watchlist.go b/internal/ui/component/watchlist/watchlist.go index 1a524d5..601761c 100644 --- a/internal/ui/component/watchlist/watchlist.go +++ b/internal/ui/component/watchlist/watchlist.go @@ -23,6 +23,7 @@ type Model struct { ExtraInfoFundamentals bool Sorter Sorter Context c.Context + styles c.Styles } // NewModel returns a model with default values. @@ -34,6 +35,7 @@ func NewModel(ctx c.Context) Model { ExtraInfoExchange: ctx.Config.ExtraInfoExchange, ExtraInfoFundamentals: ctx.Config.ExtraInfoFundamentals, Sorter: NewSorter(ctx.Config.Sort), + styles: ctx.Reference.Styles, } } @@ -53,7 +55,7 @@ func (m Model) View() string { rows, grid.Row{ Width: m.Width, - Cells: buildCells(quote, position, m.Context.Config), + Cells: buildCells(quote, position, m.Context.Config, m.styles), }) if m.Context.Config.ExtraInfoExchange { @@ -62,7 +64,7 @@ func (m Model) View() string { grid.Row{ Width: m.Width, Cells: []grid.Cell{ - {Text: textTags(quote)}, + {Text: textTags(quote, m.styles)}, }, }) } @@ -73,7 +75,7 @@ func (m Model) View() string { grid.Row{ Width: m.Width, Cells: []grid.Cell{ - {Text: textSeparator(m.Width)}, + {Text: textSeparator(m.Width, m.styles)}, }, }) } @@ -83,36 +85,36 @@ func (m Model) View() string { return grid.Render(grid.Grid{Rows: rows, GutterHorizontal: 1}) } -func buildCells(quote Quote, position Position, config c.Config) []grid.Cell { +func buildCells(quote Quote, position Position, config c.Config, styles c.Styles) []grid.Cell { if !config.ExtraInfoFundamentals && !config.ShowHoldings { return []grid.Cell{ - {Text: textName(quote)}, - {Text: textMarketState(quote), Width: 5, Align: grid.Right}, - {Text: textPosition(quote, position), Width: 25, Align: grid.Right}, - {Text: textQuote(quote), Width: 25, Align: grid.Right}, + {Text: textName(quote, styles)}, + {Text: textMarketState(quote, styles), Width: 5, Align: grid.Right}, + {Text: textPosition(quote, position, styles), Width: 25, Align: grid.Right}, + {Text: textQuote(quote, styles), Width: 25, Align: grid.Right}, } } cellName := []grid.Cell{ - {Text: textName(quote), Width: 20}, + {Text: textName(quote, styles), Width: 20}, {Text: ""}, - {Text: textMarketState(quote), Width: 5, Align: grid.Right}, + {Text: textMarketState(quote, styles), Width: 5, Align: grid.Right}, } widthMinTerm := 90 cells := []grid.Cell{ - {Text: textPosition(quote, position), Width: 25, Align: grid.Right}, - {Text: textQuote(quote), Width: 25, Align: grid.Right}, + {Text: textPosition(quote, position, styles), Width: 25, Align: grid.Right}, + {Text: textQuote(quote, styles), Width: 25, Align: grid.Right}, } if config.ShowHoldings { cells = append( []grid.Cell{ - {Text: textPositionExtendedLabels(position), Width: 15, Align: grid.Right, VisibleMinWidth: widthMinTerm + 15}, - {Text: textPositionExtended(quote, position), Width: 7, Align: grid.Right, VisibleMinWidth: widthMinTerm}, + {Text: textPositionExtendedLabels(position, styles), Width: 15, Align: grid.Right, VisibleMinWidth: widthMinTerm + 15}, + {Text: textPositionExtended(quote, position, styles), Width: 7, Align: grid.Right, VisibleMinWidth: widthMinTerm}, }, cells..., ) @@ -122,10 +124,10 @@ func buildCells(quote Quote, position Position, config c.Config) []grid.Cell { if config.ExtraInfoFundamentals { cells = append( []grid.Cell{ - {Text: textQuoteRangeLabels(quote), Width: 15, Align: grid.Right, VisibleMinWidth: widthMinTerm + 50}, - {Text: textQuoteRange(quote), Width: 20, Align: grid.Right, VisibleMinWidth: widthMinTerm + 30}, - {Text: textQuoteExtendedLabels(quote), Width: 15, Align: grid.Right, VisibleMinWidth: widthMinTerm + 15}, - {Text: textQuoteExtended(quote), Width: 7, Align: grid.Right, VisibleMinWidth: widthMinTerm}, + {Text: textQuoteRangeLabels(quote, styles), Width: 15, Align: grid.Right, VisibleMinWidth: widthMinTerm + 50}, + {Text: textQuoteRange(quote, styles), Width: 20, Align: grid.Right, VisibleMinWidth: widthMinTerm + 30}, + {Text: textQuoteExtendedLabels(quote, styles), Width: 15, Align: grid.Right, VisibleMinWidth: widthMinTerm + 15}, + {Text: textQuoteExtended(quote, styles), Width: 7, Align: grid.Right, VisibleMinWidth: widthMinTerm}, }, cells..., ) @@ -140,37 +142,37 @@ func buildCells(quote Quote, position Position, config c.Config) []grid.Cell { } -func textName(quote Quote) string { +func textName(quote Quote, styles c.Styles) string { if len(quote.ShortName) > 20 { quote.ShortName = quote.ShortName[:20] } - return StyleNeutralBold(quote.Symbol) + + return styles.TextBold(quote.Symbol) + "\n" + - StyleNeutralFaded(quote.ShortName) + styles.TextLabel(quote.ShortName) } -func textQuote(quote Quote) string { - return StyleNeutral(ConvertFloatToString(quote.Price, quote.IsVariablePrecision)) + +func textQuote(quote Quote, styles c.Styles) string { + return styles.Text(ConvertFloatToString(quote.Price, quote.IsVariablePrecision)) + "\n" + - quoteChangeText(quote.Change, quote.ChangePercent, quote.IsVariablePrecision) + quoteChangeText(quote.Change, quote.ChangePercent, quote.IsVariablePrecision, styles) } -func textPosition(quote Quote, position Position) string { +func textPosition(quote Quote, position Position, styles c.Styles) string { positionValue := "" positionChange := "" if position.Value != 0.0 { - positionValue = ValueText(position.Value) + - StyleNeutralLight( + positionValue = ValueText(position.Value, styles) + + styles.TextLight( " ("+ ConvertFloatToString(position.Weight, quote.IsVariablePrecision)+"%"+ ")") } if position.TotalChange != 0.0 { - positionChange = quoteChangeText(position.TotalChange, position.TotalChangePercent, quote.IsVariablePrecision) + positionChange = quoteChangeText(position.TotalChange, position.TotalChangePercent, quote.IsVariablePrecision, styles) } return positionValue + @@ -178,55 +180,55 @@ func textPosition(quote Quote, position Position) string { positionChange } -func textQuoteExtended(quote Quote) string { +func textQuoteExtended(quote Quote, styles c.Styles) string { - return StyleNeutral(ConvertFloatToString(quote.PricePrevClose, quote.IsVariablePrecision)) + + return styles.Text(ConvertFloatToString(quote.PricePrevClose, quote.IsVariablePrecision)) + "\n" + - StyleNeutral(ConvertFloatToString(quote.PriceOpen, quote.IsVariablePrecision)) + styles.Text(ConvertFloatToString(quote.PriceOpen, quote.IsVariablePrecision)) } -func textQuoteExtendedLabels(quote Quote) string { +func textQuoteExtendedLabels(quote Quote, styles c.Styles) string { - return StyleNeutralFaded("Prev. Close: ") + + return styles.TextLabel("Prev. Close: ") + "\n" + - StyleNeutralFaded("Open: ") + styles.TextLabel("Open: ") } -func textPositionExtended(quote Quote, position Position) string { +func textPositionExtended(quote Quote, position Position, styles c.Styles) string { if position.Quantity == 0.0 { return "" } - return StyleNeutral(ConvertFloatToString(position.Quantity, quote.IsVariablePrecision)) + + return styles.Text(ConvertFloatToString(position.Quantity, quote.IsVariablePrecision)) + "\n" + - StyleNeutral(ConvertFloatToString(position.AverageCost, quote.IsVariablePrecision)) + styles.Text(ConvertFloatToString(position.AverageCost, quote.IsVariablePrecision)) } -func textPositionExtendedLabels(position Position) string { +func textPositionExtendedLabels(position Position, styles c.Styles) string { if position.Quantity == 0.0 { return "" } - return StyleNeutralFaded("Quantity:") + + return styles.TextLabel("Quantity:") + "\n" + - StyleNeutralFaded("Avg. Cost:") + styles.TextLabel("Avg. Cost:") } -func textQuoteRange(quote Quote) string { +func textQuoteRange(quote Quote, styles c.Styles) string { textDayRange := "" if quote.PriceDayHigh != 0.0 && quote.PriceDayLow != 0.0 { textDayRange = ConvertFloatToString(quote.PriceDayLow, quote.IsVariablePrecision) + - StyleNeutral(" - ") + + styles.Text(" - ") + ConvertFloatToString(quote.PriceDayHigh, quote.IsVariablePrecision) + "\n" + ConvertFloatToString(quote.FiftyTwoWeekLow, quote.IsVariablePrecision) + - StyleNeutral(" - ") + + styles.Text(" - ") + ConvertFloatToString(quote.FiftyTwoWeekHigh, quote.IsVariablePrecision) } @@ -234,24 +236,24 @@ func textQuoteRange(quote Quote) string { } -func textQuoteRangeLabels(quote Quote) string { +func textQuoteRangeLabels(quote Quote, styles c.Styles) string { textDayRange := "" if quote.PriceDayHigh != 0.0 && quote.PriceDayLow != 0.0 { - textDayRange = StyleNeutralFaded("Day Range: ") + + textDayRange = styles.TextLabel("Day Range: ") + "\n" + - StyleNeutralFaded("52wk Range: ") + styles.TextLabel("52wk Range: ") } return textDayRange } -func textSeparator(width int) string { - return StyleLine(strings.Repeat("─", width)) +func textSeparator(width int, styles c.Styles) string { + return styles.TextLine(strings.Repeat("─", width)) } -func textTags(q Quote) string { +func textTags(q Quote, styles c.Styles) string { currencyText := q.Currency @@ -259,7 +261,7 @@ func textTags(q Quote) string { currencyText = q.Currency + " → " + q.CurrencyConverted } - return formatTag(currencyText) + " " + formatTag(exchangeDelayText(q.ExchangeDelay)) + " " + formatTag(q.ExchangeName) + return formatTag(currencyText, styles) + " " + formatTag(exchangeDelayText(q.ExchangeDelay), styles) + " " + formatTag(q.ExchangeName, styles) } func exchangeDelayText(delay float64) string { @@ -270,30 +272,30 @@ func exchangeDelayText(delay float64) string { return "Delayed " + strconv.FormatFloat(delay, 'f', 0, 64) + "min" } -func formatTag(text string) string { - return StyleTagEnd(" ") + StyleTag(text) + StyleTagEnd(" ") +func formatTag(text string, style c.Styles) string { + return style.Tag(" " + text + " ") } -func textMarketState(q Quote) string { +func textMarketState(q Quote, styles c.Styles) string { if q.IsRegularTradingSession { - return StyleNeutralFaded(" ● ") + return styles.TextLabel(" ● ") } if !q.IsRegularTradingSession && q.IsActive { - return StyleNeutralFaded(" ○ ") + return styles.TextLabel(" ○ ") } return "" } -func quoteChangeText(change float64, changePercent float64, isVariablePrecision bool) string { +func quoteChangeText(change float64, changePercent float64, isVariablePrecision bool, styles c.Styles) string { if change == 0.0 { - return StylePrice(changePercent, " "+ConvertFloatToString(change, isVariablePrecision)+" ("+ConvertFloatToString(changePercent, false)+"%)") + return styles.TextPrice(changePercent, " "+ConvertFloatToString(change, isVariablePrecision)+" ("+ConvertFloatToString(changePercent, false)+"%)") } if change > 0.0 { - return StylePrice(changePercent, "↑ "+ConvertFloatToString(change, isVariablePrecision)+" ("+ConvertFloatToString(changePercent, false)+"%)") + return styles.TextPrice(changePercent, "↑ "+ConvertFloatToString(change, isVariablePrecision)+" ("+ConvertFloatToString(changePercent, false)+"%)") } - return StylePrice(changePercent, "↓ "+ConvertFloatToString(change, isVariablePrecision)+" ("+ConvertFloatToString(changePercent, false)+"%)") + return styles.TextPrice(changePercent, "↓ "+ConvertFloatToString(change, isVariablePrecision)+" ("+ConvertFloatToString(changePercent, false)+"%)") } diff --git a/internal/ui/component/watchlist/watchlist_test.go b/internal/ui/component/watchlist/watchlist_test.go index 6bea2a8..b5380ce 100644 --- a/internal/ui/component/watchlist/watchlist_test.go +++ b/internal/ui/component/watchlist/watchlist_test.go @@ -20,6 +20,17 @@ func removeFormatting(text string) string { } var _ = Describe("Watchlist", func() { + + stylesFixture := c.Styles{ + Text: func(v string) string { return v }, + TextLight: func(v string) string { return v }, + TextLabel: func(v string) string { return v }, + TextBold: func(v string) string { return v }, + TextLine: func(v string) string { return v }, + TextPrice: func(percent float64, text string) string { return text }, + Tag: func(v string) string { return v }, + } + describe := func(desc string) func(bool, bool, float64, Position, string) string { return func(isActive bool, isRegularTradingSession bool, change float64, position Position, expected string) string { return fmt.Sprintf("%s expected:%s", desc, expected) @@ -39,6 +50,7 @@ var _ = Describe("Watchlist", func() { } m := NewModel(c.Context{ + Reference: c.Reference{Styles: stylesFixture}, Config: c.Config{ Separate: false, ExtraInfoExchange: false, @@ -210,6 +222,7 @@ var _ = Describe("Watchlist", func() { It("should render a watchlist with each symbol", func() { m := NewModel(c.Context{ + Reference: c.Reference{Styles: stylesFixture}, Config: c.Config{ Separate: false, ExtraInfoExchange: false, @@ -280,6 +293,7 @@ Microsoft Corporatio 0.00 (0.00%) It("should render a watchlist with separators", func() { m := NewModel(c.Context{ + Reference: c.Reference{Styles: stylesFixture}, Config: c.Config{ Separate: true, ExtraInfoExchange: false, @@ -340,6 +354,7 @@ Google Inc. ↓ -32.02 (-1.35 When("the option for extra exchange information is set", func() { It("should render extra exchange information", func() { m := NewModel(c.Context{ + Reference: c.Reference{Styles: stylesFixture}, Config: c.Config{ ExtraInfoExchange: true, }, @@ -371,6 +386,7 @@ Google Inc. ↓ -32.02 (-1.35 When("the exchange has a delay", func() { It("should render extra exchange information with the delay amount", func() { m := NewModel(c.Context{ + Reference: c.Reference{Styles: stylesFixture}, Config: c.Config{ ExtraInfoExchange: true, }, @@ -403,6 +419,7 @@ Google Inc. ↓ -32.02 (-1.35 When("the currency is being converted", func() { It("should show an indicator with the to and from currency codes", func() { m := NewModel(c.Context{ + Reference: c.Reference{Styles: stylesFixture}, Config: c.Config{ ExtraInfoExchange: true, Currency: "EUR", @@ -440,6 +457,7 @@ Google Inc. ↓ -32.02 (-1.35 When("the option for extra fundamental information is set", func() { It("should render extra fundamental information", func() { m := NewModel(c.Context{ + Reference: c.Reference{Styles: stylesFixture}, Config: c.Config{ ExtraInfoFundamentals: true, }, @@ -479,6 +497,7 @@ Google Inc. ↓ -32.02 (-1.35 When("there is no day range", func() { It("should not render the day range field", func() { m := NewModel(c.Context{ + Reference: c.Reference{Styles: stylesFixture}, Config: c.Config{ ExtraInfoFundamentals: true, }, @@ -513,6 +532,7 @@ Google Inc. ↓ -32.02 (-1.35 When("the option for extra holding information is set", func() { It("should render extra holding information", func() { m := NewModel(c.Context{ + Reference: c.Reference{Styles: stylesFixture}, Config: c.Config{ ShowHoldings: true, }, @@ -556,6 +576,7 @@ Google Inc. ↓ -32.02 (-1.35 When("there is no position", func() { It("should not render quantity or average cost", func() { m := NewModel(c.Context{ + Reference: c.Reference{Styles: stylesFixture}, Config: c.Config{ ShowHoldings: true, }, @@ -587,6 +608,7 @@ Google Inc. ↓ -32.02 (-1.35 When("no quotes are set", func() { It("should render an empty watchlist", func() { m := NewModel(c.Context{ + Reference: c.Reference{Styles: stylesFixture}, Config: c.Config{ Separate: false, ExtraInfoExchange: false, @@ -601,6 +623,7 @@ Google Inc. ↓ -32.02 (-1.35 When("the window width is less than the minimum", func() { It("should render an empty watchlist", func() { m := NewModel(c.Context{ + Reference: c.Reference{Styles: stylesFixture}, Config: c.Config{ Separate: false, ExtraInfoExchange: false, diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 311033e..7f59b1c 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -66,7 +66,7 @@ func NewModel(dep c.Dependencies, ctx c.Context) Model { getQuotes: quote.GetQuotes(ctx, *dep.HttpClient, symbols), getPositions: position.GetPositions(ctx, aggregatedLots), watchlist: watchlist.NewModel(ctx), - summary: summary.NewModel(), + summary: summary.NewModel(ctx), } } diff --git a/internal/ui/util/format.go b/internal/ui/util/format.go index 418fc97..6c8bce8 100644 --- a/internal/ui/util/format.go +++ b/internal/ui/util/format.go @@ -3,6 +3,8 @@ package util import ( "math" "strconv" + + c "github.com/achannarasappa/ticker/internal/common" ) func getPrecision(f float64) int { @@ -39,10 +41,10 @@ func ConvertFloatToString(f float64, isVariablePrecision bool) string { return strconv.FormatFloat(f, 'f', prec, 64) } -func ValueText(value float64) string { +func ValueText(value float64, styles c.Styles) string { if value <= 0.0 { return "" } - return StyleNeutral(ConvertFloatToString(value, false)) + return styles.Text(ConvertFloatToString(value, false)) } diff --git a/internal/ui/util/style.go b/internal/ui/util/style.go index d9c3596..f6f5846 100644 --- a/internal/ui/util/style.go +++ b/internal/ui/util/style.go @@ -2,7 +2,9 @@ package util import ( "math" + "regexp" + c "github.com/achannarasappa/ticker/internal/common" "github.com/lucasb-eyer/go-colorful" te "github.com/muesli/termenv" ) @@ -13,13 +15,6 @@ const ( var ( p = te.ColorProfile() - StyleNeutral = NewStyle("#d0d0d0", "", false) - StyleNeutralBold = NewStyle("#d0d0d0", "", true) - StyleNeutralLight = NewStyle("#8a8a8a", "", false) - StyleNeutralFaded = NewStyle("#626262", "", false) - StyleLine = NewStyle("#3a3a3a", "", false) - StyleTag = NewStyle("#8a8a8a", "#303030", false) - StyleTagEnd = NewStyle("#303030", "#303030", false) stylePricePositive = newStyleFromGradient("#C6FF40", "#779929") stylePriceNegative = newStyleFromGradient("#FF7940", "#994926") ) @@ -32,7 +27,7 @@ func NewStyle(fg string, bg string, bold bool) func(string) string { return s.Styled } -func StylePrice(percent float64, text string) string { +func stylePrice(percent float64, text string) string { out := te.String(text) @@ -68,11 +63,7 @@ func StylePrice(percent float64, text string) string { return out.Foreground(p.Color("160")).String() } - if percent < 0.0 { - return out.Foreground(p.Color("196")).String() - } - - return text + return out.Foreground(p.Color("196")).String() } @@ -99,3 +90,51 @@ func getNormalizedPercentWithMax(percent float64, maxPercent float64) float64 { return math.Abs(percent / maxPercent) } + +func GetColorScheme(colorScheme c.ConfigColorScheme) c.Styles { + + return c.Styles{ + Text: NewStyle( + getColorOrDefault(colorScheme.Text, "#d0d0d0"), + "", + false, + ), + TextLight: NewStyle( + getColorOrDefault(colorScheme.TextLight, "#8a8a8a"), + "", + false, + ), + TextBold: NewStyle( + getColorOrDefault(colorScheme.Text, "#d0d0d0"), + "", + true, + ), + TextLabel: NewStyle( + getColorOrDefault(colorScheme.TextLabel, "#626262"), + "", + false, + ), + TextLine: NewStyle( + getColorOrDefault(colorScheme.TextLine, "#3a3a3a"), + "", + false, + ), + TextPrice: stylePrice, + Tag: NewStyle( + getColorOrDefault(colorScheme.TextTag, "#8a8a8a"), + getColorOrDefault(colorScheme.BackgroundTag, "#303030"), + false, + ), + } + +} + +func getColorOrDefault(colorConfig string, colorDefault string) string { + re := regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}){1,2}$`) + + if len(re.FindStringIndex(colorConfig)) > 0 { + return colorConfig + } + + return colorDefault +} diff --git a/internal/ui/util/util_test.go b/internal/ui/util/util_test.go index b7f3194..9a82e03 100644 --- a/internal/ui/util/util_test.go +++ b/internal/ui/util/util_test.go @@ -4,10 +4,22 @@ import ( . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" + c "github.com/achannarasappa/ticker/internal/common" . "github.com/achannarasappa/ticker/internal/ui/util" ) var _ = Describe("Util", func() { + + stylesFixture := c.Styles{ + Text: func(v string) string { return v }, + TextLight: func(v string) string { return v }, + TextLabel: func(v string) string { return v }, + TextBold: func(v string) string { return v }, + TextLine: func(v string) string { return v }, + TextPrice: func(percent float64, text string) string { return text }, + Tag: func(v string) string { return v }, + } + Describe("ConvertFloatToString", func() { It("should convert a float to a precision of two", func() { output := ConvertFloatToString(0.563412, false) @@ -41,13 +53,13 @@ var _ = Describe("Util", func() { Describe("ValueText", func() { When("value is <= 0.0", func() { It("should return an empty string", func() { - output := ValueText(0.0) + output := ValueText(0.0, stylesFixture) Expect(output).To(ContainSubstring("")) }) }) It("should generate text for values", func() { - output := ValueText(435.32) - expectedOutput := NewStyle("#d0d0d0", "", false)("435.32") + output := ValueText(435.32, stylesFixture) + expectedOutput := "435.32" Expect(output).To(Equal(expectedOutput)) }) }) @@ -72,82 +84,111 @@ var _ = Describe("Util", func() { }) }) - Describe("StylePrice", func() { - When("there is no percent change", func() { - It("should color text grey", func() { - output := StylePrice(0.0, "$100.00") - expectedASCII := "$100.00" - expectedANSI16Color := "\x1b[90m$100.00\x1b[0m" - expectedANSI256Color := "\x1b[38;5;241m$100.00\x1b[0m" - expectedTrueColor := "\x1b[38;5;241m$100.00\x1b[0m" - Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor))) - }) + Describe("GetColorScheme", func() { + + It("should use the default color scheme", func() { + input := c.ConfigColorScheme{} + output := GetColorScheme(input).Text("test") + expectedASCII := "test" + expectedANSI16Color := "\x1b[38;5;188mtest\x1b[0m" + expectedANSI256Color := "\x1b[38;2;208;208;208mtest\x1b[0m" + expectedTrueColor := "\x1b[38;2;208;208;208mtest\x1b[0m" + Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor))) }) - When("there is a percent change over 10%", func() { - It("should color text dark green", func() { - output := StylePrice(11.0, "$100.00") - expectedASCII := "$100.00" - expectedANSI16Color := "\x1b[32m$100.00\x1b[0m" - expectedANSI256Color := "\x1b[38;5;70m$100.00\x1b[0m" - expectedTrueColor := "\x1b[38;2;119;153;40m$100.00\x1b[0m" + When("a custom color is set", func() { + It("should use the custom color", func() { + input := c.ConfigColorScheme{Text: "#ffffff"} + output := GetColorScheme(input).Text("test") + expectedASCII := "test" + expectedANSI16Color := "\x1b[38;5;231mtest\x1b[0m" + expectedANSI256Color := "\x1b[38;2;255;255;255mtest\x1b[0m" + expectedTrueColor := "\x1b[38;2;255;255;255mtest\x1b[0m" Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor))) }) }) - When("there is a percent change between 5% and 10%", func() { - It("should color text medium green", func() { - output := StylePrice(7.0, "$100.00") - expectedASCII := "$100.00" - expectedANSI16Color := "\x1b[92m$100.00\x1b[0m" - expectedANSI256Color := "\x1b[38;5;76m$100.00\x1b[0m" - expectedTrueColor := "\x1b[38;2;143;184;48m$100.00\x1b[0m" - Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor))) + Context("stylePrice", func() { + + styles := GetColorScheme(c.ConfigColorScheme{}) + + When("there is no percent change", func() { + It("should color text grey", func() { + output := styles.TextPrice(0.0, "$100.00") + expectedASCII := "$100.00" + expectedANSI16Color := "\x1b[90m$100.00\x1b[0m" + expectedANSI256Color := "\x1b[38;5;241m$100.00\x1b[0m" + expectedTrueColor := "\x1b[38;5;241m$100.00\x1b[0m" + Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor))) + }) }) - }) - When("there is a percent change between 0% and 5%", func() { - It("should color text light green", func() { - output := StylePrice(3.0, "$100.00") - expectedASCII := "$100.00" - expectedANSI16Color := "\x1b[92m$100.00\x1b[0m" - expectedANSI256Color := "\x1b[38;5;82m$100.00\x1b[0m" - expectedTrueColor := "\x1b[38;2;174;224;56m$100.00\x1b[0m" - Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor))) + When("there is a percent change over 10%", func() { + It("should color text dark green", func() { + output := styles.TextPrice(11.0, "$100.00") + expectedASCII := "$100.00" + expectedANSI16Color := "\x1b[32m$100.00\x1b[0m" + expectedANSI256Color := "\x1b[38;5;70m$100.00\x1b[0m" + expectedTrueColor := "\x1b[38;2;119;153;40m$100.00\x1b[0m" + Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor))) + }) }) - }) - When("there is a percent change over -10%", func() { - It("should color text dark red", func() { - output := StylePrice(-11.0, "$100.00") - expectedASCII := "$100.00" - expectedANSI16Color := "\x1b[31m$100.00\x1b[0m" - expectedANSI256Color := "\x1b[38;5;124m$100.00\x1b[0m" - expectedTrueColor := "\x1b[38;2;153;73;38m$100.00\x1b[0m" - Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor))) + When("there is a percent change between 5% and 10%", func() { + It("should color text medium green", func() { + output := styles.TextPrice(7.0, "$100.00") + expectedASCII := "$100.00" + expectedANSI16Color := "\x1b[92m$100.00\x1b[0m" + expectedANSI256Color := "\x1b[38;5;76m$100.00\x1b[0m" + expectedTrueColor := "\x1b[38;2;143;184;48m$100.00\x1b[0m" + Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor))) + }) }) - }) - When("there is a percent change between -5% and -10%", func() { - It("should color text medium red", func() { - output := StylePrice(-7.0, "$100.00") - expectedASCII := "$100.00" - expectedANSI16Color := "\x1b[91m$100.00\x1b[0m" - expectedANSI256Color := "\x1b[38;5;160m$100.00\x1b[0m" - expectedTrueColor := "\x1b[38;2;184;87;46m$100.00\x1b[0m" - Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor))) + When("there is a percent change between 0% and 5%", func() { + It("should color text light green", func() { + output := styles.TextPrice(3.0, "$100.00") + expectedASCII := "$100.00" + expectedANSI16Color := "\x1b[92m$100.00\x1b[0m" + expectedANSI256Color := "\x1b[38;5;82m$100.00\x1b[0m" + expectedTrueColor := "\x1b[38;2;174;224;56m$100.00\x1b[0m" + Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor))) + }) }) - }) - When("there is a percent change between 0% and -5%", func() { - It("should color text light red", func() { - output := StylePrice(-3.0, "$100.00") - expectedASCII := "$100.00" - expectedANSI16Color := "\x1b[91m$100.00\x1b[0m" - expectedANSI256Color := "\x1b[38;5;196m$100.00\x1b[0m" - expectedTrueColor := "\x1b[38;2;224;107;56m$100.00\x1b[0m" - Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor))) + When("there is a percent change over -10%", func() { + It("should color text dark red", func() { + output := styles.TextPrice(-11.0, "$100.00") + expectedASCII := "$100.00" + expectedANSI16Color := "\x1b[31m$100.00\x1b[0m" + expectedANSI256Color := "\x1b[38;5;124m$100.00\x1b[0m" + expectedTrueColor := "\x1b[38;2;153;73;38m$100.00\x1b[0m" + Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor))) + }) }) + + When("there is a percent change between -5% and -10%", func() { + It("should color text medium red", func() { + output := styles.TextPrice(-7.0, "$100.00") + expectedASCII := "$100.00" + expectedANSI16Color := "\x1b[91m$100.00\x1b[0m" + expectedANSI256Color := "\x1b[38;5;160m$100.00\x1b[0m" + expectedTrueColor := "\x1b[38;2;184;87;46m$100.00\x1b[0m" + Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor))) + }) + }) + + When("there is a percent change between 0% and -5%", func() { + It("should color text light red", func() { + output := styles.TextPrice(-3.0, "$100.00") + expectedASCII := "$100.00" + expectedANSI16Color := "\x1b[91m$100.00\x1b[0m" + expectedANSI256Color := "\x1b[38;5;196m$100.00\x1b[0m" + expectedTrueColor := "\x1b[38;2;224;107;56m$100.00\x1b[0m" + Expect(output).To(SatisfyAny(Equal(expectedASCII), Equal(expectedANSI16Color), Equal(expectedANSI256Color), Equal(expectedTrueColor))) + }) + }) + }) })