mirror of
https://github.com/lukaszraczylo/lolcathost.git
synced 2026-10-11 04:47:18 +00:00
Switch imports to the charm.land v2 modules. Return tea.View from Model.View, set alt screen and window title there, use KeyPressMsg and the "space" key name, and call SetWidth on text inputs.
603 lines
16 KiB
Go
603 lines
16 KiB
Go
// Package tui provides the preset picker component.
|
|
package tui
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"charm.land/bubbles/v2/textinput"
|
|
tea "charm.land/bubbletea/v2"
|
|
"github.com/lukaszraczylo/lolcathost/internal/protocol"
|
|
)
|
|
|
|
// PresetMode represents the preset view mode.
|
|
type PresetMode int
|
|
|
|
const (
|
|
PresetModeSelect PresetMode = iota
|
|
PresetModeAdd
|
|
PresetModeEdit
|
|
PresetModeConfirmDelete
|
|
PresetModePickEnable // Multi-select picker for enable aliases
|
|
PresetModePickDisable // Multi-select picker for disable aliases
|
|
)
|
|
|
|
// PresetFormField represents a form field index.
|
|
type PresetFormField int
|
|
|
|
const (
|
|
PresetFieldName PresetFormField = iota
|
|
PresetFieldEnable
|
|
PresetFieldDisable
|
|
PresetFieldSave
|
|
PresetFieldCount
|
|
)
|
|
|
|
// PresetPicker handles the preset selection and management UI.
|
|
type PresetPicker struct {
|
|
presets []protocol.PresetInfo
|
|
cursor int
|
|
width int
|
|
height int
|
|
mode PresetMode
|
|
fields []textinput.Model
|
|
focus PresetFormField
|
|
editName string // Original name when editing
|
|
availableAliases []string // Available host aliases for reference
|
|
|
|
// Multi-select picker state
|
|
pickerCursor int
|
|
selectedEnable map[string]bool
|
|
selectedDisable map[string]bool
|
|
}
|
|
|
|
// NewPresetPicker creates a new preset picker.
|
|
func NewPresetPicker() *PresetPicker {
|
|
fields := make([]textinput.Model, PresetFieldCount)
|
|
|
|
// Name field
|
|
fields[PresetFieldName] = textinput.New()
|
|
fields[PresetFieldName].Placeholder = "preset-name"
|
|
fields[PresetFieldName].CharLimit = 63
|
|
|
|
// Enable field
|
|
fields[PresetFieldEnable] = textinput.New()
|
|
fields[PresetFieldEnable].Placeholder = "alias1,alias2,alias3"
|
|
fields[PresetFieldEnable].CharLimit = 500
|
|
|
|
// Disable field
|
|
fields[PresetFieldDisable] = textinput.New()
|
|
fields[PresetFieldDisable].Placeholder = "alias1,alias2,alias3"
|
|
fields[PresetFieldDisable].CharLimit = 500
|
|
|
|
return &PresetPicker{
|
|
fields: fields,
|
|
mode: PresetModeSelect,
|
|
selectedEnable: make(map[string]bool),
|
|
selectedDisable: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
// SetPresets updates the available presets (legacy method for compatibility).
|
|
func (p *PresetPicker) SetPresets(presets []string) {
|
|
p.presets = make([]protocol.PresetInfo, len(presets))
|
|
for i, name := range presets {
|
|
p.presets[i] = protocol.PresetInfo{Name: name}
|
|
}
|
|
if p.cursor >= len(presets) {
|
|
p.cursor = max(0, len(presets)-1)
|
|
}
|
|
}
|
|
|
|
// SetPresetsWithInfo updates the available presets with full info.
|
|
func (p *PresetPicker) SetPresetsWithInfo(presets []protocol.PresetInfo) {
|
|
p.presets = presets
|
|
if p.cursor >= len(presets) {
|
|
p.cursor = max(0, len(presets)-1)
|
|
}
|
|
}
|
|
|
|
// SetAvailableAliases sets the list of available host aliases for reference.
|
|
func (p *PresetPicker) SetAvailableAliases(aliases []string) {
|
|
p.availableAliases = aliases
|
|
}
|
|
|
|
// SetSize sets the picker dimensions.
|
|
func (p *PresetPicker) SetSize(width, height int) {
|
|
p.width = width
|
|
p.height = height
|
|
|
|
inputWidth := min(60, width-10)
|
|
for i := range p.fields {
|
|
p.fields[i].SetWidth(inputWidth)
|
|
}
|
|
}
|
|
|
|
// MoveUp moves the cursor up.
|
|
func (p *PresetPicker) MoveUp() {
|
|
if p.cursor > 0 {
|
|
p.cursor--
|
|
}
|
|
}
|
|
|
|
// MoveDown moves the cursor down.
|
|
func (p *PresetPicker) MoveDown() {
|
|
if p.cursor < len(p.presets)-1 {
|
|
p.cursor++
|
|
}
|
|
}
|
|
|
|
// Selected returns the currently selected preset name.
|
|
func (p *PresetPicker) Selected() string {
|
|
if p.cursor >= 0 && p.cursor < len(p.presets) {
|
|
return p.presets[p.cursor].Name
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// SelectedInfo returns the currently selected preset info.
|
|
func (p *PresetPicker) SelectedInfo() *protocol.PresetInfo {
|
|
if p.cursor >= 0 && p.cursor < len(p.presets) {
|
|
return &p.presets[p.cursor]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Len returns the number of presets.
|
|
func (p *PresetPicker) Len() int {
|
|
return len(p.presets)
|
|
}
|
|
|
|
// Mode returns the current mode.
|
|
func (p *PresetPicker) Mode() PresetMode {
|
|
return p.mode
|
|
}
|
|
|
|
// SetMode sets the mode.
|
|
func (p *PresetPicker) SetMode(mode PresetMode) {
|
|
p.mode = mode
|
|
}
|
|
|
|
// Focus returns the currently focused form field.
|
|
func (p *PresetPicker) Focus() PresetFormField {
|
|
return p.focus
|
|
}
|
|
|
|
// InitAdd initializes the form for adding a new preset.
|
|
func (p *PresetPicker) InitAdd() {
|
|
p.mode = PresetModeAdd
|
|
p.editName = ""
|
|
for i := range p.fields {
|
|
p.fields[i].Reset()
|
|
}
|
|
p.focus = PresetFieldName
|
|
p.fields[PresetFieldName].Focus()
|
|
// Clear selections
|
|
p.selectedEnable = make(map[string]bool)
|
|
p.selectedDisable = make(map[string]bool)
|
|
p.pickerCursor = 0
|
|
}
|
|
|
|
// InitEdit initializes the form for editing an existing preset.
|
|
func (p *PresetPicker) InitEdit() {
|
|
preset := p.SelectedInfo()
|
|
if preset == nil {
|
|
return
|
|
}
|
|
|
|
p.mode = PresetModeEdit
|
|
p.editName = preset.Name
|
|
|
|
p.fields[PresetFieldName].SetValue(preset.Name)
|
|
|
|
// Initialize selections from preset (only include aliases that exist)
|
|
p.selectedEnable = make(map[string]bool)
|
|
p.selectedDisable = make(map[string]bool)
|
|
for _, alias := range p.filterExistingAliases(preset.Enable) {
|
|
p.selectedEnable[alias] = true
|
|
}
|
|
for _, alias := range p.filterExistingAliases(preset.Disable) {
|
|
p.selectedDisable[alias] = true
|
|
}
|
|
p.pickerCursor = 0
|
|
|
|
p.focus = PresetFieldName
|
|
p.fields[PresetFieldName].Focus()
|
|
}
|
|
|
|
// InitDelete starts delete confirmation.
|
|
func (p *PresetPicker) InitDelete() {
|
|
if p.SelectedInfo() == nil {
|
|
return
|
|
}
|
|
p.mode = PresetModeConfirmDelete
|
|
}
|
|
|
|
// CancelForm cancels the current form operation.
|
|
func (p *PresetPicker) CancelForm() {
|
|
p.mode = PresetModeSelect
|
|
p.editName = ""
|
|
for i := range p.fields {
|
|
p.fields[i].Reset()
|
|
p.fields[i].Blur()
|
|
}
|
|
}
|
|
|
|
// Update handles input events for form mode.
|
|
func (p *PresetPicker) Update(msg tea.KeyPressMsg) tea.Cmd {
|
|
switch msg.String() {
|
|
case "tab", "down":
|
|
p.nextField()
|
|
return nil
|
|
case "shift+tab", "up":
|
|
p.prevField()
|
|
return nil
|
|
}
|
|
|
|
// Only update text field if focused on name
|
|
if p.focus == PresetFieldName {
|
|
var cmd tea.Cmd
|
|
p.fields[PresetFieldName], cmd = p.fields[PresetFieldName].Update(msg)
|
|
return cmd
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *PresetPicker) nextField() {
|
|
// Only blur/focus the name field (it's the only text input)
|
|
if p.focus == PresetFieldName {
|
|
p.fields[PresetFieldName].Blur()
|
|
}
|
|
p.focus = (p.focus + 1) % PresetFieldCount
|
|
if p.focus == PresetFieldName {
|
|
p.fields[PresetFieldName].Focus()
|
|
}
|
|
}
|
|
|
|
func (p *PresetPicker) prevField() {
|
|
// Only blur/focus the name field (it's the only text input)
|
|
if p.focus == PresetFieldName {
|
|
p.fields[PresetFieldName].Blur()
|
|
}
|
|
p.focus = (p.focus - 1 + PresetFieldCount) % PresetFieldCount
|
|
if p.focus == PresetFieldName {
|
|
p.fields[PresetFieldName].Focus()
|
|
}
|
|
}
|
|
|
|
// EditName returns the original name when editing.
|
|
func (p *PresetPicker) EditName() string {
|
|
return p.editName
|
|
}
|
|
|
|
// IsEdit returns true if in edit mode.
|
|
func (p *PresetPicker) IsEdit() bool {
|
|
return p.mode == PresetModeEdit
|
|
}
|
|
|
|
// ValidateForm validates the form values.
|
|
func (p *PresetPicker) ValidateForm() string {
|
|
name := strings.TrimSpace(p.fields[PresetFieldName].Value())
|
|
|
|
if name == "" {
|
|
return "Preset name is required"
|
|
}
|
|
if len(p.selectedEnable) == 0 && len(p.selectedDisable) == 0 {
|
|
return "Select at least one alias to enable or disable"
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// FormValues returns the current form values using the selection maps.
|
|
func (p *PresetPicker) FormValues() (name string, enable, disable []string) {
|
|
name = strings.TrimSpace(p.fields[PresetFieldName].Value())
|
|
|
|
for alias := range p.selectedEnable {
|
|
enable = append(enable, alias)
|
|
}
|
|
for alias := range p.selectedDisable {
|
|
disable = append(disable, alias)
|
|
}
|
|
|
|
return name, enable, disable
|
|
}
|
|
|
|
// OpenEnablePicker opens the alias picker for enable selection.
|
|
func (p *PresetPicker) OpenEnablePicker() {
|
|
p.mode = PresetModePickEnable
|
|
p.pickerCursor = 0
|
|
}
|
|
|
|
// OpenDisablePicker opens the alias picker for disable selection.
|
|
func (p *PresetPicker) OpenDisablePicker() {
|
|
p.mode = PresetModePickDisable
|
|
p.pickerCursor = 0
|
|
}
|
|
|
|
// ClosePicker closes the alias picker and returns to form.
|
|
func (p *PresetPicker) ClosePicker() {
|
|
if p.editName != "" {
|
|
p.mode = PresetModeEdit
|
|
} else {
|
|
p.mode = PresetModeAdd
|
|
}
|
|
}
|
|
|
|
// TogglePickerSelection toggles the currently highlighted alias.
|
|
func (p *PresetPicker) TogglePickerSelection() {
|
|
filtered := p.getFilteredAliases()
|
|
if p.pickerCursor >= len(filtered) {
|
|
return
|
|
}
|
|
alias := filtered[p.pickerCursor]
|
|
|
|
if p.mode == PresetModePickEnable {
|
|
if p.selectedEnable[alias] {
|
|
delete(p.selectedEnable, alias)
|
|
} else {
|
|
p.selectedEnable[alias] = true
|
|
}
|
|
} else if p.mode == PresetModePickDisable {
|
|
if p.selectedDisable[alias] {
|
|
delete(p.selectedDisable, alias)
|
|
} else {
|
|
p.selectedDisable[alias] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// PickerMoveUp moves picker cursor up.
|
|
func (p *PresetPicker) PickerMoveUp() {
|
|
if p.pickerCursor > 0 {
|
|
p.pickerCursor--
|
|
}
|
|
}
|
|
|
|
// PickerMoveDown moves picker cursor down.
|
|
func (p *PresetPicker) PickerMoveDown() {
|
|
filtered := p.getFilteredAliases()
|
|
if p.pickerCursor < len(filtered)-1 {
|
|
p.pickerCursor++
|
|
}
|
|
}
|
|
|
|
// View renders the preset picker.
|
|
func (p *PresetPicker) View() string {
|
|
switch p.mode {
|
|
case PresetModeAdd, PresetModeEdit:
|
|
return p.formView()
|
|
case PresetModePickEnable, PresetModePickDisable:
|
|
return p.pickerView()
|
|
case PresetModeConfirmDelete:
|
|
return p.deleteView()
|
|
default:
|
|
return p.selectView()
|
|
}
|
|
}
|
|
|
|
func (p *PresetPicker) selectView() string {
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(titleStyle.Render("Presets"))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(helpDescStyle.Render("Quickly enable/disable multiple hosts at once"))
|
|
sb.WriteString("\n\n")
|
|
|
|
if len(p.presets) == 0 {
|
|
sb.WriteString(helpDescStyle.Render("No presets configured."))
|
|
sb.WriteString("\n\n")
|
|
sb.WriteString(helpDescStyle.Render("Press 'n' to create one"))
|
|
} else {
|
|
for i, preset := range p.presets {
|
|
if i == p.cursor {
|
|
sb.WriteString(presetSelectedStyle.Render("▸ " + preset.Name))
|
|
sb.WriteString("\n")
|
|
// Show details for selected preset (only aliases that exist)
|
|
enableList := p.filterExistingAliases(preset.Enable)
|
|
disableList := p.filterExistingAliases(preset.Disable)
|
|
if len(enableList) > 0 {
|
|
sb.WriteString(enabledStyle.Render(" ● Enable: " + strings.Join(enableList, ", ")))
|
|
sb.WriteString("\n")
|
|
}
|
|
if len(disableList) > 0 {
|
|
sb.WriteString(disabledStyle.Render(" ○ Disable: " + strings.Join(disableList, ", ")))
|
|
sb.WriteString("\n")
|
|
}
|
|
} else {
|
|
sb.WriteString(presetItemStyle.Render(" " + preset.Name))
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
sb.WriteString("\n")
|
|
sb.WriteString(WrapHelpText("↑↓ navigate • Enter apply • n new • e edit • d delete • Esc cancel", p.width-6))
|
|
|
|
return dialogStyle.Render(sb.String())
|
|
}
|
|
|
|
func (p *PresetPicker) formView() string {
|
|
var sb strings.Builder
|
|
|
|
title := "Add New Preset"
|
|
if p.mode == PresetModeEdit {
|
|
title = "Edit Preset"
|
|
}
|
|
|
|
sb.WriteString(titleStyle.Render(title))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(helpDescStyle.Render("A preset lets you toggle multiple hosts with one action"))
|
|
sb.WriteString("\n\n")
|
|
|
|
// Name field
|
|
sb.WriteString(inputLabelStyle.Render("Name:"))
|
|
sb.WriteString("\n")
|
|
style := inputStyle
|
|
if p.focus == PresetFieldName {
|
|
style = inputFocusStyle
|
|
}
|
|
sb.WriteString(style.Render(p.fields[PresetFieldName].View()))
|
|
sb.WriteString("\n\n")
|
|
|
|
// Enable selection (button-style)
|
|
enableLabel := "Enable hosts:"
|
|
if p.focus == PresetFieldEnable {
|
|
enableLabel = "▸ Enable hosts: (press Enter to select)"
|
|
}
|
|
sb.WriteString(inputLabelStyle.Render(enableLabel))
|
|
sb.WriteString("\n")
|
|
if len(p.selectedEnable) > 0 {
|
|
var enableList []string
|
|
for alias := range p.selectedEnable {
|
|
enableList = append(enableList, alias)
|
|
}
|
|
sb.WriteString(enabledStyle.Render(" ● " + strings.Join(enableList, ", ")))
|
|
} else {
|
|
sb.WriteString(helpDescStyle.Render(" (none selected)"))
|
|
}
|
|
sb.WriteString("\n\n")
|
|
|
|
// Disable selection (button-style)
|
|
disableLabel := "Disable hosts:"
|
|
if p.focus == PresetFieldDisable {
|
|
disableLabel = "▸ Disable hosts: (press Enter to select)"
|
|
}
|
|
sb.WriteString(inputLabelStyle.Render(disableLabel))
|
|
sb.WriteString("\n")
|
|
if len(p.selectedDisable) > 0 {
|
|
var disableList []string
|
|
for alias := range p.selectedDisable {
|
|
disableList = append(disableList, alias)
|
|
}
|
|
sb.WriteString(disabledStyle.Render(" ○ " + strings.Join(disableList, ", ")))
|
|
} else {
|
|
sb.WriteString(helpDescStyle.Render(" (none selected)"))
|
|
}
|
|
sb.WriteString("\n\n")
|
|
|
|
// Save button
|
|
if p.focus == PresetFieldSave {
|
|
sb.WriteString(presetSelectedStyle.Render("▸ [ Save Preset ]"))
|
|
} else {
|
|
sb.WriteString(presetItemStyle.Render(" [ Save Preset ]"))
|
|
}
|
|
sb.WriteString("\n\n")
|
|
|
|
sb.WriteString(WrapHelpText("Tab/↓ next • Enter select/save • Esc cancel", p.width-6))
|
|
|
|
return dialogStyle.Render(sb.String())
|
|
}
|
|
|
|
// getFilteredAliases returns aliases filtered for the current picker mode.
|
|
// Enable picker hides items already in disable list, and vice versa.
|
|
func (p *PresetPicker) getFilteredAliases() []string {
|
|
var filtered []string
|
|
for _, alias := range p.availableAliases {
|
|
if p.mode == PresetModePickEnable {
|
|
// Don't show items already in disable list (unless also in enable)
|
|
if !p.selectedDisable[alias] || p.selectedEnable[alias] {
|
|
filtered = append(filtered, alias)
|
|
}
|
|
} else {
|
|
// Don't show items already in enable list (unless also in disable)
|
|
if !p.selectedEnable[alias] || p.selectedDisable[alias] {
|
|
filtered = append(filtered, alias)
|
|
}
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// filterExistingAliases filters a list of aliases to only include those that exist.
|
|
func (p *PresetPicker) filterExistingAliases(aliases []string) []string {
|
|
if len(p.availableAliases) == 0 {
|
|
return aliases
|
|
}
|
|
existsMap := make(map[string]bool)
|
|
for _, alias := range p.availableAliases {
|
|
existsMap[alias] = true
|
|
}
|
|
var filtered []string
|
|
for _, alias := range aliases {
|
|
if existsMap[alias] {
|
|
filtered = append(filtered, alias)
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func (p *PresetPicker) pickerView() string {
|
|
var sb strings.Builder
|
|
|
|
title := "Select hosts to ENABLE"
|
|
if p.mode == PresetModePickDisable {
|
|
title = "Select hosts to DISABLE"
|
|
}
|
|
|
|
sb.WriteString(titleStyle.Render(title))
|
|
sb.WriteString("\n")
|
|
sb.WriteString(WrapHelpText("Space to toggle • Enter to confirm • Esc to cancel", p.width-6))
|
|
sb.WriteString("\n\n")
|
|
|
|
filtered := p.getFilteredAliases()
|
|
|
|
if len(filtered) == 0 {
|
|
if len(p.availableAliases) == 0 {
|
|
sb.WriteString(helpDescStyle.Render("No hosts available. Add some hosts first."))
|
|
} else {
|
|
sb.WriteString(helpDescStyle.Render("All hosts are already in the other list."))
|
|
}
|
|
} else {
|
|
// Clamp cursor to filtered list
|
|
if p.pickerCursor >= len(filtered) {
|
|
p.pickerCursor = len(filtered) - 1
|
|
}
|
|
|
|
for i, alias := range filtered {
|
|
var indicator string
|
|
if p.mode == PresetModePickEnable {
|
|
if p.selectedEnable[alias] {
|
|
indicator = enabledStyle.Render("[●]")
|
|
} else {
|
|
indicator = helpDescStyle.Render("[ ]")
|
|
}
|
|
} else {
|
|
if p.selectedDisable[alias] {
|
|
indicator = disabledStyle.Render("[○]")
|
|
} else {
|
|
indicator = helpDescStyle.Render("[ ]")
|
|
}
|
|
}
|
|
|
|
line := indicator + " " + alias
|
|
|
|
if i == p.pickerCursor {
|
|
sb.WriteString(presetSelectedStyle.Render("▸ " + line))
|
|
} else {
|
|
sb.WriteString(presetItemStyle.Render(" " + line))
|
|
}
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
return dialogStyle.Render(sb.String())
|
|
}
|
|
|
|
func (p *PresetPicker) deleteView() string {
|
|
var sb strings.Builder
|
|
|
|
preset := p.SelectedInfo()
|
|
presetName := ""
|
|
if preset != nil {
|
|
presetName = preset.Name
|
|
}
|
|
|
|
sb.WriteString(titleStyle.Render("Delete Preset"))
|
|
sb.WriteString("\n\n")
|
|
sb.WriteString(errorMsgStyle.Render("Are you sure you want to delete preset '" + presetName + "'?"))
|
|
sb.WriteString("\n\n")
|
|
sb.WriteString(helpDescStyle.Render("y confirm • n/Esc cancel"))
|
|
|
|
return dialogStyle.Render(sb.String())
|
|
}
|