diff --git a/cmd/area/list.go b/cmd/area/list.go index cce15bb012c079c051196aa2d2b968d25f52ffa8..4cef09f0631b5528f1c77752ed65cedcfab4b1da 100644 --- a/cmd/area/list.go +++ b/cmd/area/list.go @@ -74,12 +74,12 @@ func outputTable(cmd *cobra.Command, areas []config.Area) error { Rows(rows...). StyleFunc(func(row, col int) lipgloss.Style { if row == table.HeaderRow { - return ui.Bold + return ui.TableHeaderStyle() } return lipgloss.NewStyle() }). - Border(lipgloss.HiddenBorder()) + Border(ui.TableBorder()) fmt.Fprintln(cmd.OutOrStdout(), tbl.Render()) diff --git a/cmd/goal/list.go b/cmd/goal/list.go index 29a2f19cd8b354fbe7bf59f82e7b24ab0a262dbd..a206f0d6220dbbdd65e3dbdb804d15fa386d4496 100644 --- a/cmd/goal/list.go +++ b/cmd/goal/list.go @@ -139,12 +139,12 @@ func outputTable(cmd *cobra.Command, goals []goalWithArea) error { Rows(rows...). StyleFunc(func(row, col int) lipgloss.Style { if row == table.HeaderRow { - return ui.Bold + return ui.TableHeaderStyle() } return lipgloss.NewStyle() }). - Border(lipgloss.HiddenBorder()) + Border(ui.TableBorder()) fmt.Fprintln(cmd.OutOrStdout(), tbl.Render()) diff --git a/cmd/init/init.go b/cmd/init/init.go index 558029b67e7ec783d4eb7f1f06b04cd0714fdaa0..5aae718badb92ac785cc62e3de5e13a84a50ab0d 100644 --- a/cmd/init/init.go +++ b/cmd/init/init.go @@ -9,6 +9,8 @@ import ( "errors" "fmt" "io" + "os" + "path/filepath" "github.com/charmbracelet/huh" "github.com/spf13/cobra" @@ -23,6 +25,12 @@ var errQuit = errors.New("user quit") // errReset signals that the user reset configuration and should run fresh setup. var errReset = errors.New("configuration reset") +// errNonInteractive signals that init was run without a terminal. +var errNonInteractive = errors.New("non-interactive terminal") + +// errConfigExists signals that a config file already exists. +var errConfigExists = errors.New("config already exists") + // wizardNav represents navigation direction in the wizard. type wizardNav int @@ -41,11 +49,34 @@ var Cmd = &cobra.Command{ This command will guide you through: - Adding areas, goals, notebooks, and habits from Lunatask - Setting default area and notebook - - Configuring and verifying your access token`, + - Configuring and verifying your access token + +Use --generate-config to create an example config file for manual editing +when running non-interactively.`, RunE: runInit, } +func init() { + Cmd.Flags().Bool("generate-config", false, "generate example config for manual editing") +} + func runInit(cmd *cobra.Command, _ []string) error { + generateConfig, _ := cmd.Flags().GetBool("generate-config") + if generateConfig { + return runGenerateConfig(cmd) + } + + if !ui.IsInteractive() { + fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("lune init requires an interactive terminal.")) + fmt.Fprintln(cmd.ErrOrStderr()) + fmt.Fprintln(cmd.ErrOrStderr(), "To configure lune non-interactively, run:") + fmt.Fprintln(cmd.ErrOrStderr(), " lune init --generate-config") + fmt.Fprintln(cmd.ErrOrStderr()) + fmt.Fprintln(cmd.ErrOrStderr(), "Then edit the generated config file manually.") + + return errNonInteractive + } + cfg, err := config.Load() if errors.Is(err, config.ErrNotFound) { cfg = &config.Config{} @@ -250,3 +281,84 @@ func saveWithSummary(cmd *cobra.Command, cfg *config.Config) error { return nil } + +// exampleConfig is a commented TOML config for manual editing. +const exampleConfig = `# lune configuration file +# See: https://git.secluded.site/lune for documentation + +# UI preferences +[ui] +# Color output: "auto" (default), "always", or "never" +color = "auto" + +# Default selections for commands +[defaults] +# Default area key (must match an area defined below) +# area = "work" + +# Default notebook key (must match a notebook defined below) +# notebook = "journal" + +# Areas of life from Lunatask +# Find IDs in Lunatask: Open area settings → "Copy Area ID" (bottom left) +# +# [[areas]] +# id = "00000000-0000-0000-0000-000000000000" +# name = "Work" +# key = "work" +# +# # Goals within this area +# [[areas.goals]] +# id = "00000000-0000-0000-0000-000000000001" +# name = "Q1 Project" +# key = "q1-project" + +# Notebooks for notes +# Find IDs in Lunatask: Open notebook settings → "Copy Notebook ID" +# +# [[notebooks]] +# id = "00000000-0000-0000-0000-000000000000" +# name = "Journal" +# key = "journal" + +# Habits to track +# Find IDs in Lunatask: Open habit settings → "Copy Habit ID" +# +# [[habits]] +# id = "00000000-0000-0000-0000-000000000000" +# name = "Exercise" +# key = "exercise" +` + +func runGenerateConfig(cmd *cobra.Command) error { + cfgPath, err := config.Path() + if err != nil { + return err + } + + if _, err := os.Stat(cfgPath); err == nil { + fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Config already exists: "+cfgPath)) + fmt.Fprintln(cmd.ErrOrStderr(), "Remove or rename it first, or edit it directly.") + + return errConfigExists + } + + dir := filepath.Dir(cfgPath) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("creating config directory: %w", err) + } + + if err := os.WriteFile(cfgPath, []byte(exampleConfig), 0o600); err != nil { + return fmt.Errorf("writing config: %w", err) + } + + fmt.Fprintln(cmd.OutOrStdout(), ui.Success.Render("Generated example config: "+cfgPath)) + fmt.Fprintln(cmd.OutOrStdout()) + fmt.Fprintln(cmd.OutOrStdout(), "Edit this file to add your Lunatask areas, notebooks, and habits.") + fmt.Fprintln(cmd.OutOrStdout(), "Then configure your access token with: lune init --generate-config") + fmt.Fprintln(cmd.OutOrStdout()) + fmt.Fprintln(cmd.OutOrStdout(), "To set your access token non-interactively, use your system keyring.") + fmt.Fprintln(cmd.OutOrStdout(), "The service is 'lune' and the key is 'access_token'.") + + return nil +} diff --git a/cmd/note/list.go b/cmd/note/list.go index 8d53896a48811bf1986a75a0163c2361cc96379e..401e7c0f77469491e2900ac8709541370f85853e 100644 --- a/cmd/note/list.go +++ b/cmd/note/list.go @@ -195,12 +195,12 @@ func outputTable(cmd *cobra.Command, notes []lunatask.Note) error { Rows(rows...). StyleFunc(func(row, col int) lipgloss.Style { if row == table.HeaderRow { - return ui.Bold + return ui.TableHeaderStyle() } return lipgloss.NewStyle() }). - Border(lipgloss.HiddenBorder()) + Border(ui.TableBorder()) fmt.Fprintln(cmd.OutOrStdout(), tbl.Render()) diff --git a/cmd/person/list.go b/cmd/person/list.go index a5be1937f92d0691b0dab3a7ac862b3f85c3f60b..a1007ca254b9b1ef9e838a085b8c6c3d27828052 100644 --- a/cmd/person/list.go +++ b/cmd/person/list.go @@ -141,12 +141,12 @@ func outputTable(cmd *cobra.Command, people []lunatask.Person) error { Rows(rows...). StyleFunc(func(row, col int) lipgloss.Style { if row == table.HeaderRow { - return ui.Bold + return ui.TableHeaderStyle() } return lipgloss.NewStyle() }). - Border(lipgloss.HiddenBorder()) + Border(ui.TableBorder()) fmt.Fprintln(cmd.OutOrStdout(), tbl.Render()) diff --git a/cmd/root.go b/cmd/root.go index 0741557540376c55c174911565d3950adc956e49..19368cadd4f38c7fa65dd95b2c60c5dcbbcfe2c1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,6 +17,8 @@ import ( "git.secluded.site/lune/cmd/note" "git.secluded.site/lune/cmd/person" "git.secluded.site/lune/cmd/task" + "git.secluded.site/lune/internal/config" + "git.secluded.site/lune/internal/ui" "github.com/charmbracelet/fang" "github.com/spf13/cobra" ) @@ -35,6 +37,21 @@ all-in-one productivity app for tasks, habits, journaling, and more. Run 'lune init' to configure your access token and get started.`, SilenceUsage: true, SilenceErrors: true, + PersistentPreRun: func(_ *cobra.Command, _ []string) { + cfg, err := config.Load() + if err != nil { + return + } + + switch cfg.UI.Color { + case "always": + ui.SetColorMode(ui.ColorAlways) + case "never": + ui.SetColorMode(ui.ColorNever) + default: + ui.SetColorMode(ui.ColorAuto) + } + }, } func init() { diff --git a/cmd/task/list.go b/cmd/task/list.go index c387d0fd6416be59b0d9f83d5e97b5ec16dcdf86..e4374772cc4e782c63932daa67c47fec7ca9b9f3 100644 --- a/cmd/task/list.go +++ b/cmd/task/list.go @@ -232,12 +232,12 @@ func outputTable(cmd *cobra.Command, tasks []lunatask.Task) error { Rows(rows...). StyleFunc(func(row, col int) lipgloss.Style { if row == table.HeaderRow { - return ui.Bold + return ui.TableHeaderStyle() } return lipgloss.NewStyle() }). - Border(lipgloss.HiddenBorder()) + Border(ui.TableBorder()) fmt.Fprintln(cmd.OutOrStdout(), tbl.Render()) diff --git a/go.mod b/go.mod index 7b95316aff982633f751b490ee6eb3f2e4d58f4a..72b8c8ea68d30e219876f959c361fe57bdfa10b2 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/klauspost/lctime v0.1.0 github.com/markusmobius/go-dateparser v1.2.4 + github.com/mattn/go-isatty v0.0.20 github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.6 ) @@ -50,7 +51,6 @@ require ( github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/magefile/mage v1.14.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect diff --git a/internal/ui/output.go b/internal/ui/output.go new file mode 100644 index 0000000000000000000000000000000000000000..a21174d397f8ffd4fb62be8dac2f98b3f999d243 --- /dev/null +++ b/internal/ui/output.go @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package ui + +import ( + "os" + "strings" + + "github.com/mattn/go-isatty" +) + +// ColorMode represents the color output mode. +type ColorMode int + +// Color mode constants controlling terminal output styling. +const ( + ColorAuto ColorMode = iota // Detect from environment/TTY + ColorAlways // Force colors on + ColorNever // Force colors off +) + +var colorMode = ColorAuto + +// SetColorMode sets the global color mode. +func SetColorMode(mode ColorMode) { + colorMode = mode +} + +// IsPlain returns true when output should be unstyled plain text. +// Detection priority: +// 1. Explicit ColorNever/ColorAlways mode +// 2. NO_COLOR env var (non-empty = plain) +// 3. FORCE_COLOR env var (non-empty = styled) +// 4. TERM=dumb (plain) +// 5. TTY detection (non-TTY = plain) +func IsPlain() bool { + switch colorMode { + case ColorNever: + return true + case ColorAlways: + return false + case ColorAuto: + return detectPlain() + } + + return detectPlain() +} + +// IsInteractive returns true when running in an interactive terminal. +// This checks TTY status regardless of color settings. +func IsInteractive() bool { + return isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) +} + +func detectPlain() bool { + // NO_COLOR takes precedence (https://no-color.org/) + if noColor := os.Getenv("NO_COLOR"); noColor != "" { + return true + } + + // FORCE_COLOR overrides TTY detection + if forceColor := os.Getenv("FORCE_COLOR"); forceColor != "" { + // FORCE_COLOR=0 or FORCE_COLOR=false means no color + lower := strings.ToLower(forceColor) + if lower == "0" || lower == "false" { + return true + } + + return false + } + + // TERM=dumb indicates minimal terminal + if term := os.Getenv("TERM"); term == "dumb" { + return true + } + + // Fall back to TTY detection + return !IsInteractive() +} diff --git a/internal/ui/spinner.go b/internal/ui/spinner.go index 18e8edb61cec10b7367fcdf8ecaea4e8b6707828..b0fed6553b0e17a5b583b7991849bce6c6857bfb 100644 --- a/internal/ui/spinner.go +++ b/internal/ui/spinner.go @@ -12,7 +12,13 @@ import ( // Spin executes fn while displaying a spinner with the given title. // Uses generics to preserve the return type of the wrapped function. +// In non-interactive mode, the function runs directly without spinner UI. func Spin[T any](title string, fn func() (T, error)) (T, error) { + // In plain/non-interactive mode, just run the function directly + if IsPlain() { + return fn() + } + var result T var fnErr error @@ -32,7 +38,13 @@ func Spin[T any](title string, fn func() (T, error)) (T, error) { // SpinVoid executes fn while displaying a spinner with the given title. // Use for functions that only return an error. +// In non-interactive mode, the function runs directly without spinner UI. func SpinVoid(title string, fn func() error) error { + // In plain/non-interactive mode, just run the function directly + if IsPlain() { + return fn() + } + var fnErr error spinErr := spinner.New(). diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 291b0a32b2855d1007541f20f65fe6811364e4cb..1cd984e86cfcd3674cf20a57ada97099c66af49f 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -12,28 +12,52 @@ import ( "github.com/klauspost/lctime" ) +// Style wraps lipgloss.Style to conditionally render based on output mode. +type Style struct { + style lipgloss.Style +} + +// Render applies the style to the given text, or returns plain text in plain mode. +func (s Style) Render(strs ...string) string { + if IsPlain() { + result := "" + for _, str := range strs { + result += str + } + + return result + } + + return s.style.Render(strs...) +} + +// Style returns the underlying lipgloss.Style for advanced use. +func (s Style) Style() lipgloss.Style { + return s.style +} + // Terminal output styles using ANSI colors for broad compatibility. var ( - Success = lipgloss.NewStyle().Foreground(lipgloss.Color("2")) // green - Warning = lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow - Error = lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // red - Bold = lipgloss.NewStyle().Bold(true) + Success = Style{lipgloss.NewStyle().Foreground(lipgloss.Color("2"))} // green + Warning = Style{lipgloss.NewStyle().Foreground(lipgloss.Color("3"))} // yellow + Error = Style{lipgloss.NewStyle().Foreground(lipgloss.Color("1"))} // red + Bold = Style{lipgloss.NewStyle().Bold(true)} ) // Heading styles with backgrounds for contrast on any theme. var ( // H1 is the primary heading style (top-level items). - H1 = lipgloss.NewStyle(). + H1 = Style{lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("0")). Background(lipgloss.Color("4")). - Padding(0, 1) + Padding(0, 1)} // H2 is the secondary heading style (nested items). - H2 = lipgloss.NewStyle(). + H2 = Style{lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("0")). Background(lipgloss.Color("6")). - Padding(0, 1) + Padding(0, 1)} ) // FormatDate formats a time.Time as a date string using the user's locale. @@ -41,3 +65,23 @@ var ( func FormatDate(t time.Time) string { return lctime.Strftime("%x", t) } + +// TableHeaderStyle returns the style for table header rows. +// Returns bold style in interactive mode, plain in non-interactive. +func TableHeaderStyle() lipgloss.Style { + if IsPlain() { + return lipgloss.NewStyle() + } + + return lipgloss.NewStyle().Bold(true) +} + +// TableBorder returns the border style for tables. +// Returns a normal border in interactive mode, hidden in non-interactive. +func TableBorder() lipgloss.Border { + if IsPlain() { + return lipgloss.HiddenBorder() + } + + return lipgloss.NormalBorder() +}