Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/tui/internal/components/dialog/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ type SearchRemoveItemMsg struct {
Index int
}

// SearchSelectionChangedMsg is emitted when the selection changes
type SearchSelectionChangedMsg struct {
Item any
Index int
}

// SearchDialog is a reusable component that combines a text input with a list
type SearchDialog struct {
textInput textinput.Model
Expand Down Expand Up @@ -167,20 +173,34 @@ func (s *SearchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}

case key.Matches(msg, searchKeys.Up):
_, prevIdx := s.list.GetSelectedItem()
var cmd tea.Cmd
listModel, cmd := s.list.Update(msg)
s.list = listModel.(list.List[list.Item])
if cmd != nil {
cmds = append(cmds, cmd)
}
// Check if selection changed
if newItem, newIdx := s.list.GetSelectedItem(); newIdx != prevIdx {
cmds = append(cmds, func() tea.Msg {
return SearchSelectionChangedMsg{Item: newItem, Index: newIdx}
})
}

case key.Matches(msg, searchKeys.Down):
_, prevIdx := s.list.GetSelectedItem()
var cmd tea.Cmd
listModel, cmd := s.list.Update(msg)
s.list = listModel.(list.List[list.Item])
if cmd != nil {
cmds = append(cmds, cmd)
}
// Check if selection changed
if newItem, newIdx := s.list.GetSelectedItem(); newIdx != prevIdx {
cmds = append(cmds, func() tea.Msg {
return SearchSelectionChangedMsg{Item: newItem, Index: newIdx}
})
}

default:
oldValue := s.textInput.Value()
Expand Down Expand Up @@ -245,3 +265,13 @@ func (s *SearchDialog) Blur() {
s.focused = false
s.textInput.Blur()
}

// SetSelectedIndex sets the selected index in the list
func (s *SearchDialog) SetSelectedIndex(index int) {
s.list.SetSelectedIndex(index)
}

// GetSelectedItem returns the currently selected item and its index
func (s *SearchDialog) GetSelectedItem() (list.Item, int) {
return s.list.GetSelectedItem()
}
219 changes: 142 additions & 77 deletions packages/tui/internal/components/dialog/theme.go
Original file line number Diff line number Diff line change
@@ -1,82 +1,107 @@
package dialog

import (
"sort"

tea "github.com/charmbracelet/bubbletea/v2"
list "github.com/sst/opencode/internal/components/list"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/list"
"github.com/sst/opencode/internal/components/modal"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)

// ThemeSelectedMsg is sent when the theme is changed
type ThemeSelectedMsg struct {
ThemeName string
}
const dialogWidth = 50

// Theme messages
type (
ThemeSelectedMsg struct{ ThemeName string }
ThemePreviewMsg struct{ ThemeName string }
)

// ThemeDialog interface for the theme switching dialog
type ThemeDialog interface {
layout.Modal
}

type themeDialog struct {
width int
height int

app *app.App
modal *modal.Modal
list list.List[list.Item]
searchDialog *SearchDialog
originalTheme string
themeApplied bool
}

type themeItem struct {
name string
}

func (t themeItem) Render(selected bool, width int, baseStyle styles.Style) string {
currentTheme := theme.CurrentTheme()
style := baseStyle.Background(currentTheme.BackgroundPanel()).Foreground(currentTheme.Text())
if selected {
style = style.Foreground(currentTheme.Primary())
}
return style.PaddingLeft(1).Render(t.name)
}

func (t themeItem) Selectable() bool { return true }

func (t *themeDialog) Init() tea.Cmd {
return nil
t.setupDialog()
return t.searchDialog.Init()
}

func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
t.width = msg.Width
t.height = msg.Height
case tea.KeyMsg:
switch msg.String() {
case "enter":
if item, idx := t.list.GetSelectedItem(); idx >= 0 {
if stringItem, ok := item.(list.StringItem); ok {
selectedTheme := string(stringItem)
if err := theme.SetTheme(selectedTheme); err != nil {
// status.Error(err.Error())
return t, nil
}
t.themeApplied = true
return t, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}),
)
}
}

case SearchSelectionMsg:
if item, ok := msg.Item.(themeItem); ok {
theme.SetTheme(item.name)
t.themeApplied = true
return t, tea.Sequence(
util.CmdHandler(modal.CloseModalMsg{}),
util.CmdHandler(ThemeSelectedMsg{ThemeName: item.name}),
)
}
}
return t, util.CmdHandler(modal.CloseModalMsg{})

case SearchCancelledMsg:
return t, util.CmdHandler(modal.CloseModalMsg{})

_, prevIdx := t.list.GetSelectedItem()
case SearchRemoveItemMsg:
// No recent themes functionality
return t, nil

var cmd tea.Cmd
listModel, cmd := t.list.Update(msg)
t.list = listModel.(list.List[list.Item])
case SearchQueryChangedMsg:
t.refreshItems()
t.previewFirstTheme()
return t, nil

if item, newIdx := t.list.GetSelectedItem(); newIdx >= 0 && newIdx != prevIdx {
if stringItem, ok := item.(list.StringItem); ok {
theme.SetTheme(string(stringItem))
return t, util.CmdHandler(ThemeSelectedMsg{ThemeName: string(stringItem)})
case SearchSelectionChangedMsg:
if item, ok := msg.Item.(themeItem); ok {
theme.SetTheme(item.name)
return t, util.CmdHandler(ThemePreviewMsg{ThemeName: item.name})
}
return t, nil

case tea.WindowSizeMsg:
t.searchDialog.SetWidth(dialogWidth)
t.searchDialog.SetHeight(msg.Height)
}

updatedDialog, cmd := t.searchDialog.Update(msg)
t.searchDialog = updatedDialog.(*SearchDialog)
return t, cmd
}

func (t *themeDialog) View() string {
return t.searchDialog.View()
}

func (t *themeDialog) Render(background string) string {
return t.modal.Render(t.list.View(), background)
return t.modal.Render(t.View(), background)
}

func (t *themeDialog) Close() tea.Cmd {
Expand All @@ -87,46 +112,86 @@ func (t *themeDialog) Close() tea.Cmd {
return nil
}

// NewThemeDialog creates a new theme switching dialog
func NewThemeDialog() ThemeDialog {
themes := theme.AvailableThemes()
currentTheme := theme.CurrentThemeName()
func (t *themeDialog) setupDialog() {
t.searchDialog = NewSearchDialog("Search themes...", 10)
t.searchDialog.SetWidth(dialogWidth)
t.searchDialog.Focus()
t.refreshItems()
t.setCurrentThemeSelection()
}

func (t *themeDialog) refreshItems() {
query := t.searchDialog.GetQuery()
items := t.buildItems(query)
t.searchDialog.SetItems(items)
}

func (t *themeDialog) buildItems(query string) []list.Item {
allThemes := theme.AvailableThemes()

if query != "" {
return t.buildSearchItems(query, allThemes)
}
return t.buildAllThemes(allThemes)
}

func (t *themeDialog) buildSearchItems(query string, themes []string) []list.Item {
matches := fuzzy.RankFindFold(query, themes)
sort.Sort(matches)

items := make([]list.Item, len(matches))
for i, match := range matches {
items[i] = themeItem{name: match.Target}
}
return items
}

func (t *themeDialog) buildAllThemes(themes []string) []list.Item {
var items []list.Item

var selectedIdx int
for i, name := range themes {
if name == currentTheme {
selectedIdx = i
// Sort themes alphabetically
sorted := make([]string, len(themes))
copy(sorted, themes)
sort.Strings(sorted)

for _, name := range sorted {
items = append(items, themeItem{name: name})
}

return items
}

func (t *themeDialog) setCurrentThemeSelection() {
current := theme.CurrentThemeName()
items := t.buildItems("")

for i, item := range items {
if themeItem, ok := item.(themeItem); ok && themeItem.name == current {
t.searchDialog.SetSelectedIndex(i)
break
}
}
}

// Convert themes to list items
items := make([]list.Item, len(themes))
for i, theme := range themes {
items[i] = list.StringItem(theme)
func (t *themeDialog) previewFirstTheme() {
items := t.buildItems(t.searchDialog.GetQuery())
for _, item := range items {
if themeItem, ok := item.(themeItem); ok {
theme.SetTheme(themeItem.name)
break
}
}
}

listComponent := list.NewListComponent(
list.WithItems(items),
list.WithMaxVisibleHeight[list.Item](10),
list.WithFallbackMessage[list.Item]("No themes available"),
list.WithAlphaNumericKeys[list.Item](true),
list.WithRenderFunc(func(item list.Item, selected bool, width int, baseStyle styles.Style) string {
return item.Render(selected, width, baseStyle)
}),
list.WithSelectableFunc(func(item list.Item) bool {
return item.Selectable()
}),
)

// Set the initial selection to the current theme
listComponent.SetSelectedIndex(selectedIdx)

// Set the max width for the list to match the modal width
listComponent.SetMaxWidth(36) // 40 (modal max width) - 4 (modal padding)
return &themeDialog{
list: listComponent,
modal: modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(40)),
originalTheme: currentTheme,
themeApplied: false,
// NewThemeDialog creates a new theme selection dialog
func NewThemeDialog(app *app.App) ThemeDialog {
dialog := &themeDialog{
app: app,
originalTheme: theme.CurrentThemeName(),
modal: modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(dialogWidth+4)),
}

dialog.setupDialog()

return dialog
}
Loading