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/deeplink"
 20	"git.secluded.site/lune/internal/ui"
 21)
 22
 23const (
 24	choiceBack   = "back"
 25	choiceNext   = "next"
 26	choiceAdd    = "add"
 27	choiceDone   = "done"
 28	actionEdit   = "edit"
 29	actionDelete = "delete"
 30	actionGoals  = "goals"
 31)
 32
 33var (
 34	errNameRequired  = errors.New("name is required")
 35	errKeyRequired   = errors.New("key is required")
 36	errKeyFormat     = errors.New("key must be lowercase letters, numbers, and hyphens (e.g. 'work' or 'q1-goals')")
 37	errKeyDuplicate  = errors.New("this key is already in use")
 38	errRefRequired   = errors.New("reference is required")
 39	errRefFormat     = errors.New("expected UUID or lunatask:// deep link")
 40	errIndexOutRange = errors.New("index out of range")
 41	errBack          = errors.New("user went back")
 42)
 43
 44func configureUIPrefs(cfg *config.Config) error {
 45	color := cfg.UI.Color
 46	if color == "" {
 47		color = "auto"
 48	}
 49
 50	err := huh.NewSelect[string]().
 51		Title("Color output").
 52		Description("When should lune use colored output?").
 53		Options(
 54			huh.NewOption("Auto (detect terminal capability)", "auto"),
 55			huh.NewOption("Always", "always"),
 56			huh.NewOption("Never", "never"),
 57		).
 58		Value(&color).
 59		Run()
 60	if err != nil {
 61		if errors.Is(err, huh.ErrUserAborted) {
 62			return errQuit
 63		}
 64
 65		return err
 66	}
 67
 68	cfg.UI.Color = color
 69
 70	return nil
 71}
 72
 73func resetConfig(cmd *cobra.Command, cfg *config.Config) error {
 74	path, err := config.Path()
 75	if err != nil {
 76		return err
 77	}
 78
 79	var confirm bool
 80
 81	err = huh.NewConfirm().
 82		Title("Reset all configuration?").
 83		Description(fmt.Sprintf("This will delete %s and restart the setup wizard.", path)).
 84		Affirmative("Reset").
 85		Negative("Cancel").
 86		Value(&confirm).
 87		Run()
 88	if err != nil {
 89		if errors.Is(err, huh.ErrUserAborted) {
 90			return errQuit
 91		}
 92
 93		return err
 94	}
 95
 96	if confirm {
 97		if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
 98			return fmt.Errorf("removing config: %w", err)
 99		}
100
101		fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Configuration reset."))
102		fmt.Fprintln(cmd.OutOrStdout())
103
104		*cfg = config.Config{}
105
106		return errReset
107	}
108
109	return nil
110}
111
112func runListSelect(title, description string, options []huh.Option[string]) (string, error) {
113	var choice string
114
115	selector := huh.NewSelect[string]().
116		Title(title).
117		Options(options...).
118		Value(&choice)
119
120	if description != "" {
121		selector = selector.Description(description)
122	}
123
124	if err := selector.Run(); err != nil {
125		if errors.Is(err, huh.ErrUserAborted) {
126			return "", errQuit
127		}
128
129		return "", err
130	}
131
132	return choice, nil
133}
134
135func parseEditIndex(choice string) (int, bool) {
136	if !strings.HasPrefix(choice, "edit:") {
137		return 0, false
138	}
139
140	idx, err := strconv.Atoi(strings.TrimPrefix(choice, "edit:"))
141	if err != nil {
142		return 0, false
143	}
144
145	return idx, true
146}
147
148var keyPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
149
150func validateKeyFormat(input string) error {
151	if input == "" {
152		return errKeyRequired
153	}
154
155	if !keyPattern.MatchString(input) {
156		return errKeyFormat
157	}
158
159	return nil
160}
161
162func validateReference(input string, supportsDeepLink bool) (string, error) {
163	if input == "" {
164		return "", errRefRequired
165	}
166
167	if supportsDeepLink {
168		id, err := deeplink.ParseID(input)
169		if err != nil {
170			return "", errRefFormat
171		}
172
173		return id, nil
174	}
175
176	// Habits don't support deep links, only raw UUIDs
177	id, err := deeplink.ParseID(input)
178	if err != nil {
179		return "", errRefFormat
180	}
181
182	return id, nil
183}
184
185type itemFormConfig struct {
186	itemType         string
187	namePlaceholder  string
188	keyPlaceholder   string
189	keyValidator     func(string) error
190	supportsDeepLink bool
191}
192
193func titleCase(s string) string {
194	if s == "" {
195		return s
196	}
197
198	return strings.ToUpper(s[:1]) + s[1:]
199}
200
201// itemAction represents the result of an action selection dialog.
202type itemAction int
203
204const (
205	itemActionNone itemAction = iota
206	itemActionEdit
207	itemActionDelete
208	itemActionGoals
209)
210
211// runActionSelect shows an action selection dialog for an item and returns the chosen action.
212func runActionSelect(title string, includeGoals bool) (itemAction, error) {
213	var action string
214
215	options := []huh.Option[string]{
216		huh.NewOption("Edit", actionEdit),
217	}
218
219	if includeGoals {
220		options = append(options, huh.NewOption("Manage goals", actionGoals))
221	}
222
223	options = append(options,
224		huh.NewOption("Delete", actionDelete),
225		huh.NewOption("Back", choiceBack),
226	)
227
228	err := huh.NewSelect[string]().
229		Title(title).
230		Options(options...).
231		Value(&action).
232		Run()
233	if err != nil {
234		if errors.Is(err, huh.ErrUserAborted) {
235			return itemActionNone, errQuit
236		}
237
238		return itemActionNone, err
239	}
240
241	switch action {
242	case actionEdit:
243		return itemActionEdit, nil
244	case actionDelete:
245		return itemActionDelete, nil
246	case actionGoals:
247		return itemActionGoals, nil
248	default:
249		return itemActionNone, nil
250	}
251}
252
253func runItemForm(name, key, itemID *string, cfg itemFormConfig) error {
254	var save bool
255
256	requireNonEmpty := false
257
258	for {
259		form := buildItemFormGroup(name, key, itemID, cfg, &requireNonEmpty, &save)
260
261		if err := form.Run(); err != nil {
262			if errors.Is(err, huh.ErrUserAborted) {
263				return errBack
264			}
265
266			return err
267		}
268
269		if !save {
270			return errBack
271		}
272
273		// If any required field is empty, re-run with strict validation
274		if *name == "" || *key == "" || *itemID == "" {
275			requireNonEmpty = true
276
277			continue
278		}
279
280		// Normalise reference to UUID
281		parsedID, _ := validateReference(*itemID, cfg.supportsDeepLink)
282		*itemID = parsedID
283
284		return nil
285	}
286}
287
288func buildItemFormGroup(
289	name, key, itemID *string,
290	cfg itemFormConfig,
291	requireNonEmpty *bool,
292	save *bool,
293) *huh.Form {
294	refDescription := "Settings → 'Copy " + titleCase(cfg.itemType) + " ID' (bottom left)."
295	if cfg.supportsDeepLink {
296		refDescription = "Paste UUID or lunatask:// deep link."
297	}
298
299	return huh.NewForm(
300		huh.NewGroup(
301			huh.NewInput().
302				Title("Name").
303				Description("Display name for this "+cfg.itemType+".").
304				Placeholder(cfg.namePlaceholder).
305				Value(name).
306				Validate(makeNameValidator(requireNonEmpty)),
307			huh.NewInput().
308				Title("Key").
309				Description("Short alias for CLI use (lowercase, no spaces).").
310				Placeholder(cfg.keyPlaceholder).
311				Value(key).
312				Validate(makeKeyValidator(requireNonEmpty, cfg.keyValidator)),
313			huh.NewInput().
314				Title("Reference").
315				Description(refDescription).
316				Placeholder("xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx").
317				Value(itemID).
318				Validate(makeRefValidator(requireNonEmpty, cfg.supportsDeepLink)),
319			huh.NewConfirm().
320				Title("Save this "+cfg.itemType+"?").
321				Affirmative("Save").
322				Negative("Cancel").
323				Value(save),
324		),
325	)
326}
327
328func makeNameValidator(requireNonEmpty *bool) func(string) error {
329	return func(input string) error {
330		if *requireNonEmpty && input == "" {
331			return errNameRequired
332		}
333
334		return nil
335	}
336}
337
338func makeKeyValidator(requireNonEmpty *bool, formatValidator func(string) error) func(string) error {
339	return func(input string) error {
340		if input == "" {
341			if *requireNonEmpty {
342				return errKeyRequired
343			}
344
345			return nil
346		}
347
348		return formatValidator(input)
349	}
350}
351
352func makeRefValidator(requireNonEmpty *bool, supportsDeepLink bool) func(string) error {
353	return func(input string) error {
354		if input == "" {
355			if *requireNonEmpty {
356				return errRefRequired
357			}
358
359			return nil
360		}
361
362		_, err := validateReference(input, supportsDeepLink)
363
364		return err
365	}
366}