ui.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package init
  6
  7import (
  8	"errors"
  9	"fmt"
 10	"os"
 11	"regexp"
 12	"strconv"
 13	"strings"
 14
 15	"github.com/charmbracelet/huh"
 16	"github.com/spf13/cobra"
 17
 18	"git.secluded.site/lune/internal/config"
 19	"git.secluded.site/lune/internal/ui"
 20	"git.secluded.site/lune/internal/validate"
 21)
 22
 23const (
 24	choiceBack   = "back"
 25	choiceAdd    = "add"
 26	choiceDone   = "done"
 27	actionEdit   = "edit"
 28	actionDelete = "delete"
 29	actionGoals  = "goals"
 30)
 31
 32var (
 33	errKeyRequired   = errors.New("key is required")
 34	errKeyFormat     = errors.New("key must be lowercase letters, numbers, and hyphens (e.g. 'work' or 'q1-goals')")
 35	errKeyDuplicate  = errors.New("this key is already in use")
 36	errIDRequired    = errors.New("ID is required")
 37	errIDFormat      = errors.New("invalid UUID format")
 38	errUserAborted   = errors.New("user aborted")
 39	errIndexOutRange = errors.New("index out of range")
 40)
 41
 42func configureUIPrefs(cfg *config.Config) error {
 43	color := cfg.UI.Color
 44	if color == "" {
 45		color = "auto"
 46	}
 47
 48	err := huh.NewSelect[string]().
 49		Title("Color output").
 50		Description("When should lune use colored output?").
 51		Options(
 52			huh.NewOption("Auto (detect terminal capability)", "auto"),
 53			huh.NewOption("Always", "always"),
 54			huh.NewOption("Never", "never"),
 55		).
 56		Value(&color).
 57		Run()
 58	if err != nil {
 59		if errors.Is(err, huh.ErrUserAborted) {
 60			return nil
 61		}
 62
 63		return err
 64	}
 65
 66	cfg.UI.Color = color
 67
 68	return nil
 69}
 70
 71func resetConfig(cmd *cobra.Command, cfg *config.Config) error {
 72	path, err := config.Path()
 73	if err != nil {
 74		return err
 75	}
 76
 77	var confirm bool
 78
 79	err = huh.NewConfirm().
 80		Title("Reset all configuration?").
 81		Description(fmt.Sprintf("This will delete %s\nYou'll need to run 'lune init' again.", path)).
 82		Affirmative("Reset").
 83		Negative("Cancel").
 84		Value(&confirm).
 85		Run()
 86	if err != nil {
 87		if errors.Is(err, huh.ErrUserAborted) {
 88			return nil
 89		}
 90
 91		return err
 92	}
 93
 94	if confirm {
 95		if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
 96			return fmt.Errorf("removing config: %w", err)
 97		}
 98
 99		fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Configuration reset."))
100
101		*cfg = config.Config{}
102	}
103
104	return nil
105}
106
107func runListSelect(title, description string, options []huh.Option[string]) (string, error) {
108	var choice string
109
110	selector := huh.NewSelect[string]().
111		Title(title).
112		Options(options...).
113		Value(&choice)
114
115	if description != "" {
116		selector = selector.Description(description)
117	}
118
119	if err := selector.Run(); err != nil {
120		if errors.Is(err, huh.ErrUserAborted) {
121			return choiceBack, nil
122		}
123
124		return "", err
125	}
126
127	return choice, nil
128}
129
130func parseEditIndex(choice string) (int, bool) {
131	if !strings.HasPrefix(choice, "edit:") {
132		return 0, false
133	}
134
135	idx, err := strconv.Atoi(strings.TrimPrefix(choice, "edit:"))
136	if err != nil {
137		return 0, false
138	}
139
140	return idx, true
141}
142
143var keyPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
144
145func validateKeyFormat(input string) error {
146	if input == "" {
147		return errKeyRequired
148	}
149
150	if !keyPattern.MatchString(input) {
151		return errKeyFormat
152	}
153
154	return nil
155}
156
157func validateID(input string) error {
158	if input == "" {
159		return errIDRequired
160	}
161
162	if err := validate.ID(input); err != nil {
163		return errIDFormat
164	}
165
166	return nil
167}
168
169type itemFormConfig struct {
170	itemType        string
171	namePlaceholder string
172	keyPlaceholder  string
173	keyValidator    func(string) error
174}
175
176func titleCase(s string) string {
177	if s == "" {
178		return s
179	}
180
181	return strings.ToUpper(s[:1]) + s[1:]
182}
183
184// itemAction represents the result of an action selection dialog.
185type itemAction int
186
187const (
188	itemActionNone itemAction = iota
189	itemActionEdit
190	itemActionDelete
191	itemActionGoals
192)
193
194// runActionSelect shows an action selection dialog for an item and returns the chosen action.
195func runActionSelect(title string, includeGoals bool) (itemAction, error) {
196	var action string
197
198	options := []huh.Option[string]{
199		huh.NewOption("Edit", actionEdit),
200	}
201
202	if includeGoals {
203		options = append(options, huh.NewOption("Manage goals", actionGoals))
204	}
205
206	options = append(options,
207		huh.NewOption("Delete", actionDelete),
208		huh.NewOption("Back", choiceBack),
209	)
210
211	err := huh.NewSelect[string]().
212		Title(title).
213		Options(options...).
214		Value(&action).
215		Run()
216	if err != nil {
217		if errors.Is(err, huh.ErrUserAborted) {
218			return itemActionNone, nil
219		}
220
221		return itemActionNone, err
222	}
223
224	switch action {
225	case actionEdit:
226		return itemActionEdit, nil
227	case actionDelete:
228		return itemActionDelete, nil
229	case actionGoals:
230		return itemActionGoals, nil
231	default:
232		return itemActionNone, nil
233	}
234}
235
236func runItemForm(name, key, itemID *string, cfg itemFormConfig) error {
237	form := huh.NewForm(
238		huh.NewGroup(
239			huh.NewInput().
240				Title("Name").
241				Description("Display name for this "+cfg.itemType+".").
242				Placeholder(cfg.namePlaceholder).
243				Value(name).
244				Validate(huh.ValidateNotEmpty()),
245			huh.NewInput().
246				Title("Key").
247				Description("Short alias for CLI use (lowercase, no spaces).").
248				Placeholder(cfg.keyPlaceholder).
249				Value(key).
250				Validate(cfg.keyValidator),
251			huh.NewInput().
252				Title("ID").
253				Description("Settings → 'Copy "+titleCase(cfg.itemType)+" ID' (bottom left).").
254				Placeholder("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx").
255				Value(itemID).
256				Validate(validateID),
257		),
258	)
259
260	if err := form.Run(); err != nil {
261		if errors.Is(err, huh.ErrUserAborted) {
262			return errUserAborted
263		}
264
265		return err
266	}
267
268	return nil
269}