Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,5 @@ tmp/
temp/
*.log
nan.exe
# Local Pi runtime state
/.atl/
15 changes: 10 additions & 5 deletions internal/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ type Session struct {

var ErrNotLoggedIn = errors.New("not logged in — run: nan auth login")

func dir() (string, error) {
// Dir is the ~/.config/nan directory, and it is exported on purpose:
// settings.json lives beside session.json, the theme reader is in internal/tui,
// and it cannot call an unexported one. Two copies of this rule would drift,
// and the drift would only show up as a theme saved in one place and looked
// for in another.
func Dir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
Expand All @@ -29,15 +34,15 @@ func dir() (string, error) {
// on Windows and is not where anything is: a member told to look there finds
// nothing, and the tilde is not something Explorer resolves.
func Path() string {
d, err := dir()
d, err := Dir()
if err != nil {
return filepath.Join(".config", "nan", "session.json")
}
return filepath.Join(d, "session.json")
}

func Load() (*Session, error) {
d, err := dir()
d, err := Dir()
if err != nil {
return nil, err
}
Expand All @@ -63,7 +68,7 @@ func Load() (*Session, error) {
// it never leaves a half-written session.json behind either, which used to
// read on the next run as not being logged in.
func Save(s *Session) error {
d, err := dir()
d, err := Dir()
if err != nil {
return err
}
Expand Down Expand Up @@ -100,7 +105,7 @@ func Save(s *Session) error {
}

func Delete() error {
d, err := dir()
d, err := Dir()
if err != nil {
return err
}
Expand Down
18 changes: 9 additions & 9 deletions internal/tui/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1059,10 +1059,10 @@ func TestBannerFitsAndCarriesTheNames(t *testing.T) {
func TestAboutFallsBackToOneLineWhenNarrow(t *testing.T) {
// A 40-column terminal has no room for the art plus the wordmark, and a
// wrapped banner is worse than no banner.
if strings.Contains(renderAbout(newLayout(40, 24)), "█") {
if strings.Contains(model{}.renderAbout(newLayout(40, 24)), "█") {
t.Error("the banner is drawn at a width where it wraps")
}
if !strings.Contains(renderAbout(newLayout(BannerWidthPlain+4, 24)), "█") {
if !strings.Contains(model{}.renderAbout(newLayout(BannerWidthPlain+4, 24)), "█") {
t.Error("the banner is missing at a width that fits it")
}
}
Expand All @@ -1074,7 +1074,7 @@ func TestHomeIsTheFirstTabAndNeedsNoNetwork(t *testing.T) {
if tabDefs[0].id != tabHome {
t.Fatalf("the first tab is %v, want Home", tabDefs[0].name)
}
m := newModel(nil, &session.Session{})
m := newModel(nil, &session.Session{}, ThemeAuto)
if cmd := m.maybeLoad(); cmd != nil {
t.Error("Home asks the API for something, so it cannot be the landing tab")
}
Expand Down Expand Up @@ -1458,7 +1458,7 @@ func setupModel(t *testing.T, sess *session.Session) model {
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("HERMES_HOME", filepath.Join(home, "hermes"))
return newModel(nil, sess)
return newModel(nil, sess, ThemeAuto)
}

// The idea this replaced was to have the Setup tab fetch the key with the
Expand Down Expand Up @@ -1668,7 +1668,7 @@ func TestAboutShowsTheSessionPathThatExists(t *testing.T) {
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)

out := renderAbout(newLayout(100, 30))
out := model{}.renderAbout(newLayout(100, 30))
if strings.Contains(out, "~/") {
t.Error("the About tab still prints a tilde path")
}
Expand Down Expand Up @@ -1875,7 +1875,7 @@ func TestSigningInGivesTheClientTheNewToken(t *testing.T) {
t.Setenv("USERPROFILE", home)
t.Setenv("HERMES_HOME", filepath.Join(home, "h"))

m := newModel(api.New(""), &session.Session{})
m := newModel(api.New(""), &session.Session{}, ThemeAuto)
if m.client.Token() != "" {
t.Fatal("this test starts from a client with no token")
}
Expand Down Expand Up @@ -2146,7 +2146,7 @@ func TestSigningInLeadsStraightToTheKey(t *testing.T) {
t.Setenv("USERPROFILE", home)
t.Setenv("HERMES_HOME", filepath.Join(home, "h"))

m := newModel(api.New(""), &session.Session{})
m := newModel(api.New(""), &session.Session{}, ThemeAuto)
m.lay = newLayout(90, 30)
if err := session.Save(&session.Session{Token: "a-token"}); err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -2267,7 +2267,7 @@ func TestSigningOutTakesTwoPressesAndClearsEverything(t *testing.T) {
if err := session.Save(sess); err != nil {
t.Fatal(err)
}
m := newModel(api.New("t"), sess)
m := newModel(api.New("t"), sess, ThemeAuto)
m.lay = newLayout(90, 30)
m.cache[tabUsage] = "somebody else's numbers"

Expand Down Expand Up @@ -2317,7 +2317,7 @@ func TestSigningOutStartsTheSetupAgain(t *testing.T) {
if err := session.Save(sess); err != nil {
t.Fatal(err)
}
m := newModel(api.New("t"), sess)
m := newModel(api.New("t"), sess, ThemeAuto)
m.lay = newLayout(90, 30)

press := func(m model, r rune) model {
Expand Down
219 changes: 219 additions & 0 deletions internal/tui/theme.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
package tui

import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"

"github.com/charmbracelet/lipgloss"

"github.com/nxssie/nan-cli/internal/session"
)

// ── palette ───────────────────────────────────────────────────────────────────

// The palette is a set of roles, not a paint box. Every token is an AdaptiveColor
// pair: the Light side is the value a light terminal wants, the Dark side the one
// a dark terminal wants, and lipgloss picks between them from the detected
// background.
//
// The roles, in declaration order:
// - cCyan is the primary accent, used for the current step and highlighted numbers.
// - cBlue is the deeper accent that grounds the wordmark and selected frames.
// - cBlueDim is the soft accent fill behind a selected block.
// - cGray is the readable middle of the text hierarchy, for secondary copy.
// - cDimGray is the muted tier for hints and inactive rows.
// - cWhite is the high-contrast tier for the loudest labels and prompts.
// - cText is the default reading colour for long runs of prose.
// - cRed is the error tone, kept apart from the accent so failures never read as
// ordinary emphasis.
var (
cCyan = lipgloss.AdaptiveColor{Light: "#6d28d9", Dark: "#a78bfa"}
cBlue = lipgloss.AdaptiveColor{Light: "#5b21b6", Dark: "#8b5cf6"}
cBlueDim = lipgloss.AdaptiveColor{Light: "#ddd6fe", Dark: "#2e1065"}
cGray = lipgloss.AdaptiveColor{Light: "#52525b", Dark: "#71717a"}
cDimGray = lipgloss.AdaptiveColor{Light: "#a1a1aa", Dark: "#52525b"}
cWhite = lipgloss.AdaptiveColor{Light: "#18181b", Dark: "#ffffff"}
cText = lipgloss.AdaptiveColor{Light: "#374151", Dark: "#cbd5e1"}
cRed = lipgloss.AdaptiveColor{Light: "#dc2626", Dark: "#ef4444"}

// modelColors are the categorical colours for per-model usage rows: the set is
// fixed so each model keeps a stable colour of its own. Each is a pair now,
// because the dark one is unreadable on a light background: the Light side is
// a deeper tone of the same hue, so the five stay distinguishable on white
// and a model keeps its identity whichever side the terminal resolves to.
modelColors = []lipgloss.AdaptiveColor{
{Light: "#7c3aed", Dark: "#8b5cf6"},
{Light: "#c026d3", Dark: "#a78bfa"},
{Light: "#059669", Dark: "#10B981"},
{Light: "#b45309", Dark: "#F59E0B"},
{Light: "#dc2626", Dark: "#EF4444"},
}
)

// ── theme mode ────────────────────────────────────────────────────────────────

// ThemeMode is which side of every AdaptiveColor the panel draws. Auto is the
// default and the only one that does not override the terminal: it lets the
// renderer's own background detection decide.
type ThemeMode string

const (
ThemeAuto ThemeMode = "auto"
ThemeDark ThemeMode = "dark"
ThemeLight ThemeMode = "light"
)

// nextTheme is the cycle the `t` key walks: auto, then dark, then light, then
// back to auto.
func nextTheme(m ThemeMode) ThemeMode {
switch m {
case ThemeDark:
return ThemeLight
case ThemeLight:
return ThemeAuto
default:
return ThemeDark
}
}

// applyTheme pins every AdaptiveColor to one side by setting the renderer's
// background flag, which is the only thing a theme setting can do: each pair is
// resolved per render from that flag, so the next frame is drawn in the new
// colours with no style rebuilt.
//
// Auto is deliberately a no-op. It leaves lipgloss's own termenv detection -
// including the dark fallback it uses when the terminal cannot be asked, which
// is what an SSH session or a multiplexer gets - exactly as it works today.
// That is what "auto" means, and overriding it here would take the choice away
// from the terminal.
func applyTheme(m ThemeMode) {
switch m {
case ThemeDark:
lipgloss.SetHasDarkBackground(true)
case ThemeLight:
lipgloss.SetHasDarkBackground(false)
}
}

// settingsPath is where the theme lives, mirroring session.Path(): the same
// config directory, resolved by session.Dir() so settings.json always lands
// beside session.json, and the same relative fallback for a home directory that
// cannot be resolved.
func settingsPath() string {
d, err := session.Dir()
if err != nil {
return filepath.Join(".config", "nan", "settings.json")
}
return filepath.Join(d, "settings.json")
}

func loadTheme() ThemeMode { return loadThemeFrom(settingsPath()) }

func saveTheme(m ThemeMode) error { return saveThemeTo(settingsPath(), m) }

// loadThemeFrom reads the saved theme and answers auto for every way of not
// having one: a missing file, an empty one, JSON that does not parse, a key
// that is not a string, or a value this build does not know. The setting is
// cosmetic - the panel works in the terminal's own colours without it - so a
// damaged file is never a reason to fail the TUI, and only a known string is
// read back.
func loadThemeFrom(path string) ThemeMode {
data, err := os.ReadFile(path)
if err != nil {
return ThemeAuto
}
var stored struct {
Theme string `json:"theme"`
}
if err := json.Unmarshal(data, &stored); err != nil {
return ThemeAuto
}
switch ThemeMode(stored.Theme) {
case ThemeDark:
return ThemeDark
case ThemeLight:
return ThemeLight
}
return ThemeAuto
}

// readSettingsConfig reads settings.json with the same refusal semantics as
// readJSONConfig - a missing or empty file is an empty map, and a file that
// will not parse is refused rather than guessed at - but it decodes every
// number as a json.Number instead of a float64.
//
// settings.json is shared, and the CLI is not its only writer: a foreign key
// holds whatever its owner put there. json.Unmarshal turns every JSON number
// into a float64, so an integer past 2^53 comes back rounded, and the
// read-modify-write that only meant to set "theme" rewrites somebody else's
// number on the way through. A json.Number keeps the literal digits, so the
// round-trip writes every foreign value back exactly as it was read.
func readSettingsConfig(path string) (map[string]any, error) {
f, err := os.Open(path)
if os.IsNotExist(err) {
return map[string]any{}, nil
}
if err != nil {
return nil, err
}
defer f.Close()

// A decoder reads a stream, so on its own it would stop at the first value
// and silently leave a second one unread; json.Unmarshal rejects that as
// trailing data. Decoding a second time is how the stream form gets the
// same strictness back: one value in the file, and the next read is EOF.
dec := json.NewDecoder(f)
dec.UseNumber()
var cfg map[string]any
if err := dec.Decode(&cfg); err != nil {
if err == io.EOF {
// An empty or whitespace-only file is one with no setting in it,
// not one that cannot be parsed.
return map[string]any{}, nil
}
return nil, fmt.Errorf("%s does not parse as JSON, so it was left exactly as it is: %w",
filepath.Base(path), err)
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
if err == nil {
err = errors.New("trailing data after the first JSON value")
}
return nil, fmt.Errorf("%s does not parse as JSON, so it was left exactly as it is: %w",
filepath.Base(path), err)
}
if cfg == nil {
cfg = map[string]any{}
}
return cfg, nil
}

// saveThemeTo writes the theme key back into the settings file, leaving every
// other key exactly where it was.
//
// It is a read-modify-write for the same reason the tool configs are: this file
// is shared and one day holds a setting this CLI does not know about, and
// writing our single key from scratch would take that setting with it. The
// reader is readSettingsConfig, whose whole point is refusing to hand back a
// file it could not parse - so a settings.json somebody is midway through
// editing is left alone rather than flattened, and the error travels out to the
// caller.
func saveThemeTo(path string, m ThemeMode) error {
if m != ThemeDark && m != ThemeLight {
m = ThemeAuto // only ever one of the three constants, whatever came in
}
cfg, err := readSettingsConfig(path)
if err != nil {
return err
}
cfg["theme"] = string(m)
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return writeConfigFile(path, data)
}
Loading
Loading