Files
wg-admin/internal/tui/components/search.go

312 lines
6.6 KiB
Go

package components
import (
"github.com/calmcacil/wg-admin/internal/tui/theme"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// SearchFilterType represents the type of filter
type SearchFilterType string
const (
FilterByName SearchFilterType = "name"
FilterByIPv4 SearchFilterType = "ipv4"
FilterByIPv6 SearchFilterType = "ipv6"
FilterByStatus SearchFilterType = "status"
)
// SearchModel represents the search component
type SearchModel struct {
input textinput.Model
active bool
filterType SearchFilterType
matchCount int
totalCount int
visible bool
}
// Styles
// Styles (using theme package)
var (
searchBarStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Background(theme.StyleBackground).
Padding(0, 1).
Border(lipgloss.RoundedBorder()).
BorderForeground(theme.StyleBorder)
searchPromptStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Bold(true)
searchFilterStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("147"))
searchCountStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241"))
searchHelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("243"))
)
// NewSearch creates a new search component
func NewSearch() *SearchModel {
ti := textinput.New()
ti.Placeholder = "Search clients..."
ti.Focus()
ti.CharLimit = 156
ti.Width = 40
ti.Prompt = ""
return &SearchModel{
input: ti,
active: false,
filterType: FilterByName,
matchCount: 0,
totalCount: 0,
visible: true,
}
}
// Init initializes the search component
func (m *SearchModel) Init() tea.Cmd {
return nil
}
// Update handles messages for the search component
func (m *SearchModel) Update(msg tea.Msg) (*SearchModel, tea.Cmd) {
if !m.active {
return m, nil
}
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
m.active = false
m.input.Reset()
m.matchCount = m.totalCount
return m, nil
case "tab":
m.cycleFilterType()
return m, nil
}
}
m.input, cmd = m.input.Update(msg)
return m, cmd
}
// View renders the search component
func (m *SearchModel) View() string {
if !m.visible {
return ""
}
var filterLabel string
switch m.filterType {
case FilterByName:
filterLabel = "Name"
case FilterByIPv4:
filterLabel = "IPv4"
case FilterByIPv6:
filterLabel = "IPv6"
case FilterByStatus:
filterLabel = "Status"
}
searchIndicator := ""
if m.active {
searchIndicator = searchPromptStyle.Render("🔍 ")
} else {
searchIndicator = searchPromptStyle.Render("⌕ ")
}
filterText := searchFilterStyle.Render("[" + filterLabel + "]")
countText := ""
if m.totalCount > 0 {
countText = searchCountStyle.Render(
lipgloss.JoinHorizontal(
lipgloss.Left,
strings.Repeat(" ", 4),
"Matched: ",
m.renderCount(m.matchCount),
"/",
m.renderCount(m.totalCount),
),
)
}
helpText := ""
if m.active {
helpText = searchHelpStyle.Render(" | Tab: filter | Esc: clear")
} else {
helpText = searchHelpStyle.Render(" | /: search")
}
content := lipgloss.JoinHorizontal(
lipgloss.Left,
searchIndicator,
m.input.View(),
filterText,
countText,
helpText,
)
return searchBarStyle.Render(content)
}
// IsActive returns true if search is active
func (m *SearchModel) IsActive() bool {
return m.active
}
// Activate activates the search
func (m *SearchModel) Activate() {
m.active = true
m.input.Focus()
}
// Deactivate deactivates the search
func (m *SearchModel) Deactivate() {
m.active = false
m.input.Reset()
m.matchCount = m.totalCount
}
// Clear clears the search input and filter
func (m *SearchModel) Clear() {
m.input.Reset()
m.matchCount = m.totalCount
}
// GetQuery returns the current search query
func (m *SearchModel) GetQuery() string {
return m.input.Value()
}
// GetFilterType returns the current filter type
func (m *SearchModel) GetFilterType() SearchFilterType {
return m.filterType
}
// SetTotalCount sets the total number of items
func (m *SearchModel) SetTotalCount(count int) {
m.totalCount = count
if !m.active {
m.matchCount = count
}
}
// SetMatchCount sets the number of matching items
func (m *SearchModel) SetMatchCount(count int) {
m.matchCount = count
}
// Filter filters a list of client data based on the current search query
func (m *SearchModel) Filter(clients []ClientData) []ClientData {
query := strings.TrimSpace(m.input.Value())
if query == "" || !m.active {
m.matchCount = len(clients)
return clients
}
var filtered []ClientData
queryLower := strings.ToLower(query)
for _, client := range clients {
var matches bool
switch m.filterType {
case FilterByName:
matches = strings.Contains(strings.ToLower(client.Name), queryLower)
case FilterByIPv4:
matches = strings.Contains(strings.ToLower(client.IPv4), queryLower)
case FilterByIPv6:
matches = strings.Contains(strings.ToLower(client.IPv6), queryLower)
case FilterByStatus:
matches = strings.Contains(strings.ToLower(client.Status), queryLower)
}
if matches {
filtered = append(filtered, client)
}
}
m.matchCount = len(filtered)
return filtered
}
// HighlightMatches highlights matching text in the given value
func (m *SearchModel) HighlightMatches(value string) string {
if !m.active {
return value
}
query := strings.TrimSpace(m.input.Value())
if query == "" {
return value
}
queryLower := strings.ToLower(query)
valueLower := strings.ToLower(value)
index := strings.Index(valueLower, queryLower)
if index == -1 {
return value
}
matchStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("226")).
Background(lipgloss.Color("57")).
Bold(true)
before := value[:index]
match := value[index : index+len(query)]
after := value[index+len(query):]
return lipgloss.JoinHorizontal(
lipgloss.Left,
before,
matchStyle.Render(string(match)),
after,
)
}
// cycleFilterType cycles to the next filter type
func (m *SearchModel) cycleFilterType() {
switch m.filterType {
case FilterByName:
m.filterType = FilterByIPv4
case FilterByIPv4:
m.filterType = FilterByIPv6
case FilterByIPv6:
m.filterType = FilterByStatus
case FilterByStatus:
m.filterType = FilterByName
}
}
// renderCount renders a count number with proper styling
func (m *SearchModel) renderCount(count int) string {
if m.matchCount == 0 && m.active && m.input.Value() != "" {
return lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Render("No matches")
}
return searchCountStyle.Render(string(rune('0' + count)))
}
// ClientData represents client data for filtering
type ClientData struct {
Name string
IPv4 string
IPv6 string
Status string
}