Skip to content

Commit

Permalink
(feat): add theme customization (#47)
Browse files Browse the repository at this point in the history
Signed-off-by: everettraven <everettraven@gmail.com>
  • Loading branch information
everettraven committed Jan 5, 2024
1 parent fa4c07e commit 83d62e6
Show file tree
Hide file tree
Showing 16 changed files with 199 additions and 55 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
[![asciicast](https://asciinema.org/a/625808.svg)](https://asciinema.org/a/625808)

## Motivation

I created `buoy` because I do a lot of work on Kubernetes controllers. When I am making changes, I often find myself typing out a bunch of the same `kubectl ...` commands and switching between them.
Some of those commands are blocking (i.e `kubectl get logs -f ...`) and to keep them running while running other commands required opening a new terminal window and more typing.
Since I was running pretty repetitive commands I thought there had to be a better solution. I looked through existing CLI tooling around this space, but none had a simple interface that followed the pattern of
"define what you want to see and I'll show it to you". Thus `buoy` was created to fill this gap (and save me some time while delaying the inevitable arthritis).

## Quickstart

Install `buoy` by downloading one of the binaries from the [releases](https://github.com/everettraven/buoy/releases) or by running:
Expand Down
1 change: 1 addition & 0 deletions docs/_sidebar.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@
- Features
- [Dot Notation Field Paths](features/dot-notation-paths.md)
- [Remote Dashboard Configurations](features/remote-configs.md)
- [Theme Customization](features/themes.md)

33 changes: 33 additions & 0 deletions docs/features/themes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Customizing the theme of your dashboards

`buoy` supports customizing the theme of your dashboard via a theme configuration file. There are two ways this can be done:
1. Specifying the path to the file via the `--theme` flag. Ex: `buoy dash.json --theme path/to/theme.json`
2. Creating a theme file in the default theme configuration file path (`~/.config/buoy/themes/default.json`)

Currently, the theme configuration files must be JSON and looks like:
```json
{
"tabColor": {
"light": "63",
"dark": "117"
},
"selectedRowHighlightColor": {
"light": "63",
"dark": "117"
},
"logSearchHighlightColor": {
"light": "63",
"dark": "117"
},
"syntaxHighlightDarkColor": "nord",
"syntaxHighlightLightColor": "monokailight"
}
```

?> The example above is a representation of the default theme that is used when no theme configurations are found/specified

- `tabColor` is the adaptive color scheme used when rendering each tab and the borders of the dashboard. `buoy` renders adaptively based on the color of the terminal background so the color code specified in `light` will be the color that is used when rendering on a light terminal background and `dark` will be used when rendering on a dark terminal background. `buoy` uses https://github.com/charmbracelet/lipgloss for styling and thus respects the same color values. The supported values for colors can be found at https://github.com/charmbracelet/lipgloss?tab=readme-ov-file#colors
- `selectedRowHighlightColor` is the adaptive color scheme used to highlight the currently selected row on a `table` panel.
- `logSearchHighlightColor` is the adaptive color scheme used to highlight search results when searching in a `log` panel.
- `syntaxHighlightDarkColor` is the color theme used for syntax highlighting against a dark terminal background. `buoy` uses https://github.com/alecthomas/chroma for syntax highlighting and thus the same color themes. The available color themes can be found at https://github.com/alecthomas/chroma/tree/master/styles
- `syntaxHighlightLightColor` is the color theme used for syntax highlighting against a light terminal background.
File renamed without changes.
File renamed without changes.
16 changes: 16 additions & 0 deletions examples/themes/theme.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"tabColor": {
"light": "236",
"dark": "220"
},
"selectedRowHighlightColor": {
"light": "236",
"dark": "220"
},
"logSearchHighlightColor": {
"light": "236",
"dark": "220"
},
"syntaxHighlightDarkColor": "monokai",
"syntaxHighlightLightColor": "monokailight"
}
19 changes: 15 additions & 4 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

tea "github.com/charmbracelet/bubbletea"
"github.com/everettraven/buoy/pkg/charm/models"
"github.com/everettraven/buoy/pkg/charm/styles"
"github.com/everettraven/buoy/pkg/paneler"
"github.com/everettraven/buoy/pkg/types"
"github.com/spf13/cobra"
Expand All @@ -24,15 +25,20 @@ var rootCommand = &cobra.Command{
Short: "declarative kubernetes dashboard in the terminal",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return run(args[0])
themePath, err := cmd.Flags().GetString("theme")
if err != nil {
return fmt.Errorf("getting theme flag: %w", err)
}
return run(args[0], themePath)
},
}

func init() {
rootCommand.AddCommand(versionCommand)
rootCommand.Flags().String("theme", styles.DefaultThemePath, "path to theme file")
}

func run(path string) error {
func run(path string, themePath string) error {
var raw []byte
var ext string
u, err := url.ParseRequestURI(path)
Expand Down Expand Up @@ -65,8 +71,13 @@ func run(path string) error {
log.Fatalf("unmarshalling dashboard: %s", err)
}

theme, err := styles.LoadTheme(themePath)
if err != nil {
log.Fatalf("loading theme: %s", err)
}

cfg := config.GetConfigOrDie()
p, err := paneler.NewDefaultPaneler(cfg)
p, err := paneler.NewDefaultPaneler(cfg, theme)
if err != nil {
log.Fatalf("configuring paneler: %s", err)
}
Expand All @@ -80,7 +91,7 @@ func run(path string) error {
panelModels = append(panelModels, mod)
}

m := models.NewDashboard(models.DefaultDashboardKeys, panelModels...)
m := models.NewDashboard(models.DefaultDashboardKeys, theme, panelModels...)
if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
Expand Down
8 changes: 5 additions & 3 deletions pkg/charm/models/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,19 +53,21 @@ type Dashboard struct {
width int
help help.Model
keys DashboardKeyMap
theme *styles.Theme
}

func NewDashboard(keys DashboardKeyMap, panels ...tea.Model) *Dashboard {
func NewDashboard(keys DashboardKeyMap, theme *styles.Theme, panels ...tea.Model) *Dashboard {
tabs := []Tab{}
for _, panel := range panels {
if namer, ok := panel.(Namer); ok {
tabs = append(tabs, Tab{Name: namer.Name(), Model: panel})
}
}
return &Dashboard{
tabber: NewTabber(DefaultTabberKeys, tabs...),
tabber: NewTabber(DefaultTabberKeys, theme, tabs...),
help: help.New(),
keys: keys,
theme: theme,
}
}

Expand Down Expand Up @@ -96,7 +98,7 @@ func (d *Dashboard) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}

func (d *Dashboard) View() string {
div := styles.TabGap().Render(strings.Repeat(" ", max(0, d.width-2)))
div := d.theme.TabGap().Render(strings.Repeat(" ", max(0, d.width-2)))
return lipgloss.JoinVertical(0, d.tabber.View(), div, d.help.View(d.Help()))
}

Expand Down
31 changes: 18 additions & 13 deletions pkg/charm/models/panels/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,10 @@ type Logs struct {
mode string
keys LogsKeyMap
strictSearch bool
theme *styles.Theme
}

func NewLogs(keys LogsKeyMap, name string) *Logs {
func NewLogs(keys LogsKeyMap, name string, theme *styles.Theme) *Logs {
searchbar := textinput.New()
searchbar.Prompt = "> "
searchbar.Placeholder = "search term"
Expand All @@ -87,6 +88,7 @@ func NewLogs(keys LogsKeyMap, name string) *Logs {
content: "",
mode: modeLogs,
keys: keys,
theme: theme,
}
}

Expand Down Expand Up @@ -139,7 +141,7 @@ func (m *Logs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}

if m.mode == modeSearched {
m.viewport.SetContent(searchLogs(m.content, m.searchbar.Value(), m.viewport.Width, m.strictSearch))
m.viewport.SetContent(m.searchLogs())
}

m.viewport, cmd = m.viewport.Update(msg)
Expand All @@ -151,7 +153,7 @@ func (m *Logs) View() string {
if m.strictSearch {
searchMode = "strict"
}
searchModeOutput := styles.LogSearchModeStyle().Render(fmt.Sprintf("search mode: %s", searchMode))
searchModeOutput := m.theme.LogSearchModeStyle().Render(fmt.Sprintf("search mode: %s", searchMode))

if m.mode == modeSearching {
return lipgloss.JoinVertical(lipgloss.Top,
Expand Down Expand Up @@ -185,19 +187,22 @@ func (m *Logs) Name() string {
return m.name
}

// searchLogs searches the logs for the given term
// searchLogs searches the logs for the term in the searchbar
// and returns a string with the matching log lines
// and the matched term highlighted. Uses fuzzy search
// if strict is false. Wraps logs to the given width if wrap > 0.
func searchLogs(logs, term string, wrap int, strict bool) string {
splitLogs := strings.Split(logs, "\n")
// if strict search is not enabled. Wraps logs to the width of the viewport.
func (m *Logs) searchLogs() string {
term := m.searchbar.Value()
wrap := m.viewport.Width
strict := m.strictSearch
splitLogs := strings.Split(m.content, "\n")
if strict {
return strictMatchLogs(term, splitLogs, wrap)
return strictMatchLogs(term, splitLogs, m.viewport.Width, m.theme.LogSearchHighlightStyle())
}
return fuzzyMatchLogs(term, splitLogs, wrap)
return fuzzyMatchLogs(term, splitLogs, wrap, m.theme.LogSearchHighlightStyle())
}

func strictMatchLogs(searchTerm string, logLines []string, wrap int) string {
func strictMatchLogs(searchTerm string, logLines []string, wrap int, style lipgloss.Style) string {
var results strings.Builder
for _, log := range logLines {
if wrap > 0 {
Expand All @@ -207,15 +212,15 @@ func strictMatchLogs(searchTerm string, logLines []string, wrap int) string {
highlighted := strings.Replace(
log,
searchTerm,
styles.LogSearchHighlightStyle().Render(searchTerm), -1,
style.Render(searchTerm), -1,
)
results.WriteString(highlighted + "\n")
}
}
return results.String()
}

func fuzzyMatchLogs(searchTerm string, logLines []string, wrap int) string {
func fuzzyMatchLogs(searchTerm string, logLines []string, wrap int, style lipgloss.Style) string {
var matches []fuzzy.Match
if wrap > 0 {
wrappedLogs := []string{}
Expand All @@ -231,7 +236,7 @@ func fuzzyMatchLogs(searchTerm string, logLines []string, wrap int) string {
for _, match := range matches {
for i := 0; i < len(match.Str); i++ {
if matched(i, match.MatchedIndexes) {
results.WriteString(styles.LogSearchHighlightStyle().Render(string(match.Str[i])))
results.WriteString(style.Render(string(match.Str[i])))
} else {
results.WriteString(string(match.Str[i]))
}
Expand Down
10 changes: 6 additions & 4 deletions pkg/charm/models/panels/table.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,10 @@ type Table struct {
err error
tempRows []tbl.Row
keys TableKeyMap
theme *styles.Theme
}

func NewTable(keys TableKeyMap, table *buoytypes.Table, lister cache.GenericLister, scope meta.RESTScopeName) *Table {
func NewTable(keys TableKeyMap, table *buoytypes.Table, lister cache.GenericLister, scope meta.RESTScopeName, theme *styles.Theme) *Table {
tblColumns := []string{}
width := 0
for _, column := range table.Columns {
Expand All @@ -85,7 +86,7 @@ func NewTable(keys TableKeyMap, table *buoytypes.Table, lister cache.GenericList
}

tab := tbl.New(tblColumns, 100, 10)
tab.Styles.SelectedRow = styles.TableSelectedRowStyle()
tab.Styles.SelectedRow = theme.TableSelectedRowStyle()

return &Table{
table: tab,
Expand All @@ -98,6 +99,7 @@ func NewTable(keys TableKeyMap, table *buoytypes.Table, lister cache.GenericList
rows: map[types.UID]*RowInfo{},
columns: table.Columns,
keys: keys,
theme: theme,
}
}

Expand Down Expand Up @@ -242,9 +244,9 @@ func (m *Table) FetchContentForIndex(index int) (string, error) {
return "", fmt.Errorf("converting JSON to YAML for item %q: %w", name, err)
}

theme := "nord"
theme := m.theme.SyntaxHighlightDarkTheme
if !lipgloss.HasDarkBackground() {
theme = "monokailight"
theme = m.theme.SyntaxHighlightLightTheme
}
rw := &bytes.Buffer{}
err = quick.Highlight(rw, string(itemYAML), "yaml", "terminal16m", theme)
Expand Down
18 changes: 11 additions & 7 deletions pkg/charm/models/tabber.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ type Tabber struct {
selected int
keyMap TabberKeyMap
width int
theme *styles.Theme
}

func NewTabber(keyMap TabberKeyMap, tabs ...Tab) *Tabber {
func NewTabber(keyMap TabberKeyMap, theme *styles.Theme, tabs ...Tab) *Tabber {
return &Tabber{
tabs: tabs,
keyMap: keyMap,
theme: theme,
}
}

Expand Down Expand Up @@ -71,22 +73,23 @@ func (t *Tabber) Update(msg tea.Msg) (*Tabber, tea.Cmd) {
}

func (t *Tabber) View() string {
tabRightArrow := styles.TabGap().Render(" ▶ ")
tabLeftArrow := styles.TabGap().Render(" ◀ ")
tabRightArrow := t.theme.TabGap().Render(" ▶ ")
tabLeftArrow := t.theme.TabGap().Render(" ◀ ")

pager := &pager{
tabRightArrow: tabRightArrow,
tabLeftArrow: tabLeftArrow,
pages: []page{},
theme: t.theme,
}
pager.setPages(t.tabs, t.selected, t.width)

tabBlock := pager.renderForSelectedTab(t.selected)
// gap is a repeating of the spaces so that the bottom border continues the entire width
// of the terminal. This allows it to look like a proper set of tabs
gap := styles.TabGap().Render(strings.Repeat(" ", max(0, t.width-lipgloss.Width(tabBlock)-2)))
gap := t.theme.TabGap().Render(strings.Repeat(" ", max(0, t.width-lipgloss.Width(tabBlock)-2)))
tabsWithBorder := lipgloss.JoinHorizontal(lipgloss.Bottom, tabBlock, gap)
content := styles.ContentStyle().Render(t.tabs[t.selected].Model.View())
content := t.theme.ContentStyle().Render(t.tabs[t.selected].Model.View())
return lipgloss.JoinVertical(0, tabsWithBorder, content)
}

Expand Down Expand Up @@ -141,6 +144,7 @@ type pager struct {
pages []page
tabRightArrow string
tabLeftArrow string
theme *styles.Theme
}

func (p *pager) renderForSelectedTab(selected int) string {
Expand All @@ -164,9 +168,9 @@ func (p *pager) setPages(tabs []Tab, selected int, width int) {
tempTab := ""
tempPage := page{start: 0, tabs: []string{}}
for i, tab := range tabs {
renderedTab := styles.TabStyle().Render(tab.Name)
renderedTab := p.theme.TabStyle().Render(tab.Name)
if i == selected {
renderedTab = styles.SelectedTabStyle().Render(tab.Name)
renderedTab = p.theme.SelectedTabStyle().Render(tab.Name)
}
tempTab = lipgloss.JoinHorizontal(lipgloss.Top, tempTab, renderedTab)
joined := lipgloss.JoinHorizontal(lipgloss.Bottom, p.tabLeftArrow, tempTab, p.tabRightArrow)
Expand Down
Loading

0 comments on commit 83d62e6

Please sign in to comment.