theme.go

  1package dialog
  2
  3import (
  4	"github.com/charmbracelet/bubbles/key"
  5	tea "github.com/charmbracelet/bubbletea"
  6	"github.com/charmbracelet/lipgloss"
  7	"github.com/opencode-ai/opencode/internal/tui/layout"
  8	"github.com/opencode-ai/opencode/internal/tui/styles"
  9	"github.com/opencode-ai/opencode/internal/tui/theme"
 10	"github.com/opencode-ai/opencode/internal/tui/util"
 11)
 12
 13// ThemeChangedMsg is sent when the theme is changed
 14type ThemeChangedMsg struct {
 15	ThemeName string
 16}
 17
 18// CloseThemeDialogMsg is sent when the theme dialog is closed
 19type CloseThemeDialogMsg struct{}
 20
 21// ThemeDialog interface for the theme switching dialog
 22type ThemeDialog interface {
 23	tea.Model
 24	layout.Bindings
 25}
 26
 27type themeDialogCmp struct {
 28	themes       []string
 29	selectedIdx  int
 30	width        int
 31	height       int
 32	currentTheme string
 33}
 34
 35type themeKeyMap struct {
 36	Up     key.Binding
 37	Down   key.Binding
 38	Enter  key.Binding
 39	Escape key.Binding
 40	J      key.Binding
 41	K      key.Binding
 42}
 43
 44var themeKeys = themeKeyMap{
 45	Up: key.NewBinding(
 46		key.WithKeys("up"),
 47		key.WithHelp("↑", "previous theme"),
 48	),
 49	Down: key.NewBinding(
 50		key.WithKeys("down"),
 51		key.WithHelp("↓", "next theme"),
 52	),
 53	Enter: key.NewBinding(
 54		key.WithKeys("enter"),
 55		key.WithHelp("enter", "select theme"),
 56	),
 57	Escape: key.NewBinding(
 58		key.WithKeys("esc"),
 59		key.WithHelp("esc", "close"),
 60	),
 61	J: key.NewBinding(
 62		key.WithKeys("j"),
 63		key.WithHelp("j", "next theme"),
 64	),
 65	K: key.NewBinding(
 66		key.WithKeys("k"),
 67		key.WithHelp("k", "previous theme"),
 68	),
 69}
 70
 71func (t *themeDialogCmp) Init() tea.Cmd {
 72	// Load available themes and update selectedIdx based on current theme
 73	t.themes = theme.AvailableThemes()
 74	t.currentTheme = theme.CurrentThemeName()
 75
 76	// Find the current theme in the list
 77	for i, name := range t.themes {
 78		if name == t.currentTheme {
 79			t.selectedIdx = i
 80			break
 81		}
 82	}
 83
 84	return nil
 85}
 86
 87func (t *themeDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 88	switch msg := msg.(type) {
 89	case tea.KeyMsg:
 90		switch {
 91		case key.Matches(msg, themeKeys.Up) || key.Matches(msg, themeKeys.K):
 92			if t.selectedIdx > 0 {
 93				t.selectedIdx--
 94			}
 95			return t, nil
 96		case key.Matches(msg, themeKeys.Down) || key.Matches(msg, themeKeys.J):
 97			if t.selectedIdx < len(t.themes)-1 {
 98				t.selectedIdx++
 99			}
100			return t, nil
101		case key.Matches(msg, themeKeys.Enter):
102			if len(t.themes) > 0 {
103				previousTheme := theme.CurrentThemeName()
104				selectedTheme := t.themes[t.selectedIdx]
105				if previousTheme == selectedTheme {
106					return t, util.CmdHandler(CloseThemeDialogMsg{})
107				}
108				if err := theme.SetTheme(selectedTheme); err != nil {
109					return t, util.ReportError(err)
110				}
111				return t, util.CmdHandler(ThemeChangedMsg{
112					ThemeName: selectedTheme,
113				})
114			}
115		case key.Matches(msg, themeKeys.Escape):
116			return t, util.CmdHandler(CloseThemeDialogMsg{})
117		}
118	case tea.WindowSizeMsg:
119		t.width = msg.Width
120		t.height = msg.Height
121	}
122	return t, nil
123}
124
125func (t *themeDialogCmp) View() string {
126	currentTheme := theme.CurrentTheme()
127	baseStyle := styles.BaseStyle()
128
129	if len(t.themes) == 0 {
130		return baseStyle.Padding(1, 2).
131			Border(lipgloss.RoundedBorder()).
132			BorderBackground(currentTheme.Background()).
133			BorderForeground(currentTheme.TextMuted()).
134			Width(40).
135			Render("No themes available")
136	}
137
138	// Calculate max width needed for theme names
139	maxWidth := 40 // Minimum width
140	for _, themeName := range t.themes {
141		if len(themeName) > maxWidth-4 { // Account for padding
142			maxWidth = len(themeName) + 4
143		}
144	}
145
146	maxWidth = max(30, min(maxWidth, t.width-15)) // Limit width to avoid overflow
147
148	// Build the theme list
149	themeItems := make([]string, 0, len(t.themes))
150	for i, themeName := range t.themes {
151		itemStyle := baseStyle.Width(maxWidth)
152
153		if i == t.selectedIdx {
154			itemStyle = itemStyle.
155				Background(currentTheme.Primary()).
156				Foreground(currentTheme.Background()).
157				Bold(true)
158		}
159
160		themeItems = append(themeItems, itemStyle.Padding(0, 1).Render(themeName))
161	}
162
163	title := baseStyle.
164		Foreground(currentTheme.Primary()).
165		Bold(true).
166		Width(maxWidth).
167		Padding(0, 1).
168		Render("Select Theme")
169
170	content := lipgloss.JoinVertical(
171		lipgloss.Left,
172		title,
173		baseStyle.Width(maxWidth).Render(""),
174		baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, themeItems...)),
175		baseStyle.Width(maxWidth).Render(""),
176	)
177
178	return baseStyle.Padding(1, 2).
179		Border(lipgloss.RoundedBorder()).
180		BorderBackground(currentTheme.Background()).
181		BorderForeground(currentTheme.TextMuted()).
182		Width(lipgloss.Width(content) + 4).
183		Render(content)
184}
185
186func (t *themeDialogCmp) BindingKeys() []key.Binding {
187	return layout.KeyMapToSlice(themeKeys)
188}
189
190// NewThemeDialogCmp creates a new theme switching dialog
191func NewThemeDialogCmp() ThemeDialog {
192	return &themeDialogCmp{
193		themes:       []string{},
194		selectedIdx:  0,
195		currentTheme: "",
196	}
197}
198