diff --git a/go.mod b/go.mod index 2e0c08e..5877cea 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,9 @@ require ( github.com/bnema/zerowrap v1.4.0 github.com/charmbracelet/x/ansi v0.11.7 github.com/cli/go-gh/v2 v2.13.0 + github.com/fsnotify/fsnotify v1.9.0 github.com/go-git/go-git/v5 v5.19.1 + github.com/godbus/dbus/v5 v5.2.2 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/pelletier/go-toml/v2 v2.2.4 github.com/spf13/cobra v1.10.2 @@ -43,7 +45,6 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.12.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index e35d114..9ed5468 100644 --- a/go.sum +++ b/go.sum @@ -97,6 +97,8 @@ github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhE github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= diff --git a/internal/adapters/in/cli/root.go b/internal/adapters/in/cli/root.go index 5991d9b..1430b63 100644 --- a/internal/adapters/in/cli/root.go +++ b/internal/adapters/in/cli/root.go @@ -44,12 +44,15 @@ func NewRootCommand(cfg *viper.Viper, run RunFunc) (*cobra.Command, error) { flags.Int("context-lines", 3, "Number of unchanged context lines to keep around changes") flags.String("log-level", "info", "Log level (trace, debug, info, warn, error, disabled)") flags.String("log-file", "", "Write logs to this file instead of the default XDG state log") + flags.String("theme", "auto", "Theme mode: auto, dark, or light") + flags.String("config", "", "Path to a config file (defaults to XDG config when present)") flags.Duration("provider-sync-interval", 2*time.Minute, "Interval for active review provider background sync") cfg.SetDefault("repo-path", ".") cfg.SetDefault("context-lines", 3) cfg.SetDefault("diff-mode", string(core.DiffModeBranch)) cfg.SetDefault("startup-detect", true) cfg.SetDefault("log-level", "info") + cfg.SetDefault("theme", "auto") cfg.SetDefault("provider-sync-interval", 2*time.Minute) if err := cfg.BindPFlag("repo-path", flags.Lookup("repo-path")); err != nil { return nil, fmt.Errorf("bind repo-path flag: %w", err) @@ -63,6 +66,12 @@ func NewRootCommand(cfg *viper.Viper, run RunFunc) (*cobra.Command, error) { if err := cfg.BindPFlag("log-file", flags.Lookup("log-file")); err != nil { return nil, fmt.Errorf("bind log-file flag: %w", err) } + if err := cfg.BindPFlag("theme", flags.Lookup("theme")); err != nil { + return nil, fmt.Errorf("bind theme flag: %w", err) + } + if err := cfg.BindPFlag("config", flags.Lookup("config")); err != nil { + return nil, fmt.Errorf("bind config flag: %w", err) + } if err := cfg.BindPFlag("provider-sync-interval", flags.Lookup("provider-sync-interval")); err != nil { return nil, fmt.Errorf("bind provider-sync-interval flag: %w", err) } diff --git a/internal/adapters/in/cli/root_test.go b/internal/adapters/in/cli/root_test.go index c19aeaf..6d53a86 100644 --- a/internal/adapters/in/cli/root_test.go +++ b/internal/adapters/in/cli/root_test.go @@ -19,12 +19,14 @@ func TestNewRootCommandExecutesRunFuncAndBindsConfig(t *testing.T) { args []string expectRepo string expectContext int + expectTheme string }{ { - name: "binds repo path and context lines", - args: []string{"--repo-path", "/tmp/repo", "--context-lines", "2"}, + name: "binds repo path, context lines, and theme", + args: []string{"--repo-path", "/tmp/repo", "--context-lines", "2", "--theme", "light"}, expectRepo: "/tmp/repo", expectContext: 2, + expectTheme: "light", }, } @@ -52,6 +54,7 @@ func TestNewRootCommandExecutesRunFuncAndBindsConfig(t *testing.T) { assert.True(t, called) assert.Equal(t, tt.expectRepo, cfg.GetString("repo-path")) assert.Equal(t, tt.expectContext, cfg.GetInt("context-lines")) + assert.Equal(t, tt.expectTheme, cfg.GetString("theme")) assert.Equal(t, string(core.DiffModeBranch), cfg.GetString("diff-mode")) }) } diff --git a/internal/adapters/in/systemtheme/portal.go b/internal/adapters/in/systemtheme/portal.go new file mode 100644 index 0000000..468655a --- /dev/null +++ b/internal/adapters/in/systemtheme/portal.go @@ -0,0 +1,98 @@ +package systemtheme + +import ( + "context" + "errors" + "fmt" + + "github.com/godbus/dbus/v5" + + "ero/internal/core" +) + +const ( + portalBusName = "org.freedesktop.portal.Desktop" + portalObject = "/org/freedesktop/portal/desktop" + portalInterface = "org.freedesktop.portal.Settings" + portalNamespace = "org.freedesktop.appearance" + portalKey = "color-scheme" +) + +// PortalReader reads the desktop light/dark preference from the XDG Desktop +// Portal settings API. +type PortalReader struct { + Connect func() (*dbus.Conn, error) +} + +func (r PortalReader) CurrentPreference(ctx context.Context) (core.SystemThemePreference, error) { + conn, err := r.connect() + if err != nil { + return core.SystemThemeUnknown, err + } + defer func() { _ = conn.Close() }() + value, err := readPortalColorScheme(ctx, conn.Object(portalBusName, dbus.ObjectPath(portalObject))) + if err != nil { + return core.SystemThemeUnknown, err + } + return core.ParseSystemThemePreference(value), nil +} + +func (r PortalReader) connect() (*dbus.Conn, error) { + if r.Connect != nil { + return r.Connect() + } + return dbus.ConnectSessionBus() +} + +type portalCaller interface { + CallWithContext(ctx context.Context, method string, flags dbus.Flags, args ...any) *dbus.Call +} + +func readPortalColorScheme(ctx context.Context, obj portalCaller) (uint32, error) { + var value dbus.Variant + err := obj.CallWithContext(ctx, portalInterface+".ReadOne", 0, portalNamespace, portalKey).Store(&value) + if err == nil { + return variantUint32(value) + } + if isUnknownMethodError(err) { + if err := obj.CallWithContext(ctx, portalInterface+".Read", 0, portalNamespace, portalKey).Store(&value); err != nil { + return 0, err + } + return variantUint32(value) + } + return 0, err +} + +func isUnknownMethodError(err error) bool { + var dbusErr dbus.Error + if !errors.As(err, &dbusErr) { + return false + } + return dbusErr.Name == "org.freedesktop.DBus.Error.UnknownMethod" +} + +func variantUint32(variant dbus.Variant) (uint32, error) { + value := variant.Value() + for { + switch typed := value.(type) { + case uint32: + return typed, nil + case dbus.Variant: + value = typed.Value() + case uint: + return uint32(typed), nil + case int32: + if typed < 0 { + return 0, fmt.Errorf("portal color-scheme variant was negative: %d", typed) + } + return uint32(typed), nil + case int: + if typed < 0 { + return 0, fmt.Errorf("portal color-scheme variant was negative: %d", typed) + } + return uint32(typed), nil + default: + return 0, fmt.Errorf("portal color-scheme variant carried %T, want uint32", value) + } + } +} diff --git a/internal/adapters/in/tui/comment_editor.go b/internal/adapters/in/tui/comment_editor.go index 2b30563..b5ca295 100644 --- a/internal/adapters/in/tui/comment_editor.go +++ b/internal/adapters/in/tui/comment_editor.go @@ -6,13 +6,18 @@ import ( "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" -) -var ( - commentEditorStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("62")).Padding(0, 1) - commentEditorTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("81")) + "ero/internal/adapters/in/tui/theme" ) +func commentEditorStyle() lipgloss.Style { + return theme.SearchPaneStyle +} + +func commentEditorTitleStyle() lipgloss.Style { + return theme.SearchPaneTitleStyle +} + type CommentEditorAction string const ( @@ -76,9 +81,9 @@ func (e CommentEditor) ViewWithWidth(availableWidth int) string { input := e.input input.SetWidth(max(width-4, 20)) lines := []string{ - commentEditorTitleStyle.Render("Add review comment"), + commentEditorTitleStyle().Render("Add review comment"), input.View(), renderKeyHints([]KeyHint{{Key: commentSubmitKeyLabel(), Label: "submit"}, {Key: "esc", Label: "cancel"}}), } - return commentEditorStyle.Width(width).Render(strings.Join(lines, "\n")) + return commentEditorStyle().Width(width).Render(strings.Join(lines, "\n")) } diff --git a/internal/adapters/in/tui/component/statusbar.go b/internal/adapters/in/tui/component/statusbar.go index e728fa9..8a5a246 100644 --- a/internal/adapters/in/tui/component/statusbar.go +++ b/internal/adapters/in/tui/component/statusbar.go @@ -211,12 +211,12 @@ func renderNerdFontProviderSync(model StatusModel) string { } var b strings.Builder b.WriteString(theme.StatusBaseStyle.Render(" ")) - b.WriteString(theme.StatusBaseStyle.Foreground(lipgloss.Color("248")).Render(providerGlyph(provider))) + b.WriteString(theme.StatusBaseStyle.Foreground(lipgloss.Color(theme.ColorStatusInfo)).Render(providerGlyph(provider))) b.WriteString(theme.StatusBaseStyle.Render(" ")) b.WriteString(theme.StatusBaseStyle.Foreground(providerStatusDotColor(model.ProviderSync.Status)).Render(nerdFontSyncDot)) for _, part := range nerdFontProviderSyncTextParts(model) { b.WriteString(theme.StatusBaseStyle.Render(" ")) - b.WriteString(theme.StatusBaseStyle.Foreground(lipgloss.Color("248")).Render(part)) + b.WriteString(theme.StatusBaseStyle.Foreground(lipgloss.Color(theme.ColorStatusInfo)).Render(part)) } b.WriteString(theme.StatusBaseStyle.Render(" ")) return b.String() @@ -250,15 +250,15 @@ func providerGlyph(provider string) string { func providerStatusDotColor(status core.ProviderSyncStatus) color.Color { switch status { case core.ProviderSyncStatusSynced: - return lipgloss.Color("#3fb950") + return lipgloss.Color(theme.CurrentPalette().AddedMarkerFg) case core.ProviderSyncStatusFailed: - return lipgloss.Color("#ff7b72") + return lipgloss.Color(theme.CurrentPalette().DeletedMarkerFg) case core.ProviderSyncStatusBackingOff: - return lipgloss.Color("#ffa657") + return lipgloss.Color(theme.ColorWarning) case core.ProviderSyncStatusLoadingCache, core.ProviderSyncStatusSyncing: - return lipgloss.Color("#58a6ff") + return lipgloss.Color(theme.ColorAccent) default: - return lipgloss.Color("81") + return lipgloss.Color(theme.ColorAccent) } } diff --git a/internal/adapters/in/tui/markdown_renderer.go b/internal/adapters/in/tui/markdown_renderer.go index 7d66c2e..f73df14 100644 --- a/internal/adapters/in/tui/markdown_renderer.go +++ b/internal/adapters/in/tui/markdown_renderer.go @@ -10,6 +10,7 @@ import ( glamouransi "charm.land/glamour/v2/ansi" "ero/internal/adapters/in/tui/theme" + "ero/internal/core" ) type MarkdownTheme string @@ -19,6 +20,13 @@ const ( MarkdownThemeLight MarkdownTheme = "light" ) +func markdownThemeForAppearance(appearance core.ThemeAppearance) MarkdownTheme { + if appearance == core.ThemeAppearanceLight { + return MarkdownThemeLight + } + return MarkdownThemeDark +} + type markdownTermRenderer interface { Render(markdown string) (string, error) } @@ -57,6 +65,14 @@ func NewMarkdownRendererWithFactory(factory markdownRendererFactory) *MarkdownRe } } +func (r *MarkdownRenderer) Clear() { + if r == nil { + return + } + r.renderers = map[markdownRendererConfig]markdownTermRenderer{} + r.entries = map[markdownRendererCacheKey]string{} +} + func (r *MarkdownRenderer) Render(markdown string, width int, theme MarkdownTheme) string { if r == nil { return safeMarkdownFallback(markdown) @@ -113,16 +129,17 @@ func newGlamourTermRenderer(width int, theme MarkdownTheme) (markdownTermRendere } func eroMarkdownStyle(markdownTheme MarkdownTheme) glamouransi.StyleConfig { - text := theme.ColorStatusInfo - muted := theme.ColorMutedText - heading := theme.ColorAccent - section := theme.ColorWarning - codeBackground := theme.ColorCodeBg + appearance := core.ThemeAppearanceDark if markdownTheme == MarkdownThemeLight { - text = theme.ColorStatusBase - muted = "244" - codeBackground = "#f6f8fa" + appearance = core.ThemeAppearanceLight } + palette := theme.PaletteForAppearance(appearance) + text := palette.ColorStatusInfo + muted := palette.ColorMutedText + heading := palette.ColorAccent + section := palette.ColorWarning + codeBackground := palette.ColorCodeBg + codeTheme := palette.MarkdownCodeTheme bold := true italic := true underline := true @@ -144,7 +161,7 @@ func eroMarkdownStyle(markdownTheme MarkdownTheme) glamouransi.StyleConfig { Link: glamouransi.StylePrimitive{Color: &heading, Underline: &underline}, LinkText: glamouransi.StylePrimitive{Color: &heading, Underline: &underline}, Code: glamouransi.StyleBlock{StylePrimitive: glamouransi.StylePrimitive{Color: §ion, BackgroundColor: &codeBackground}}, - CodeBlock: glamouransi.StyleCodeBlock{StyleBlock: glamouransi.StyleBlock{StylePrimitive: glamouransi.StylePrimitive{Color: &text, BackgroundColor: &codeBackground}, Margin: &zeroIndent}, Theme: "github-dark"}, + CodeBlock: glamouransi.StyleCodeBlock{StyleBlock: glamouransi.StyleBlock{StylePrimitive: glamouransi.StylePrimitive{Color: &text, BackgroundColor: &codeBackground}, Margin: &zeroIndent}, Theme: codeTheme}, BlockQuote: glamouransi.StyleBlock{StylePrimitive: glamouransi.StylePrimitive{Color: &muted}, Indent: "eIndent, IndentToken: "eToken}, } } diff --git a/internal/adapters/in/tui/markdown_renderer_test.go b/internal/adapters/in/tui/markdown_renderer_test.go index 4f1c156..3b48615 100644 --- a/internal/adapters/in/tui/markdown_renderer_test.go +++ b/internal/adapters/in/tui/markdown_renderer_test.go @@ -60,6 +60,22 @@ func TestMarkdownRendererColorsHeadingsAndFencedCodeBlocks(t *testing.T) { } } +func TestMarkdownRendererUsesLightCodeTheme(t *testing.T) { + renderer := NewMarkdownRenderer() + + got := renderer.Render("```go\nfmt.Println(\"hi\")\n```", 80, MarkdownThemeLight) + plain := regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`).ReplaceAllString(got, "") + + if !strings.Contains(plain, "fmt.Println") { + t.Fatalf("expected rendered fenced code block to include code, got %q", got) + } + // These ANSI colors come from the current light Chroma style; update them if + // the light palette's MarkdownCodeTheme changes. + if !strings.Contains(got, "38;5;61") || !strings.Contains(got, "38;5;23") { + t.Fatalf("expected light code highlighting in rendered markdown, got %q", got) + } +} + func TestMarkdownRendererRendersFencedCodeBlocks(t *testing.T) { renderer := NewMarkdownRenderer() diff --git a/internal/adapters/in/tui/model.go b/internal/adapters/in/tui/model.go index 50a1c95..1a326c3 100644 --- a/internal/adapters/in/tui/model.go +++ b/internal/adapters/in/tui/model.go @@ -3,6 +3,7 @@ package tui import ( "context" "fmt" + "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -16,11 +17,18 @@ import ( ) const ( - defaultWidth = 100 - defaultHeight = 24 - contextStep = 10 + defaultWidth = 100 + defaultHeight = 24 + contextStep = 10 + themeDetectionInterval = 2 * time.Second ) +type ModelConfig struct { + ThemeMode core.ThemeMode + SystemTheme core.SystemThemePreference + ThemeModeChanges <-chan core.ThemeMode +} + type reviewLoader interface { LoadReview(request core.ReviewRequest) ([]core.ReviewFile, error) } @@ -39,6 +47,13 @@ type copyFeedbackExpiredMsg struct { id int } +type themeConfigChangedMsg struct { + mode core.ThemeMode + ok bool +} + +type themeDetectionTickMsg struct{ generation uint64 } + type clipboardCopiedMsg struct { text string lineCount int @@ -93,54 +108,59 @@ type activeProviderSwitchedMsg struct { type activeProviderPollDueMsg struct{ generation int64 } type Model struct { - title string - files []core.ReviewFile - loader reviewLoader - request core.ReviewRequest - loading bool - loadError string - selectedFile int - selectedContext int - width int - height int - reviewViewport ReviewPane - reviewAnchors ReviewAnchors - activeFilePath string - cursorRow int - selectionAnchorRow *int - reviewRows []ReviewRow - reviewExpanderRows map[presenter.ReviewExpanderAnchor]int - selectableRows []int - clipboardWriter ports.ClipboardWriter - lastCopiedText string - copyFeedback string - copyFeedbackID int - diffMode core.DiffMode - nerdFont bool - helpActive bool - search searchState - reviewDraft *core.ReviewDraft - commentEditor *InlineCommentEditor - reviewContext core.ReviewContext - reviewProviders []ports.ReviewProviderClient - activeProvider activeProviderController - providerCatalog []ports.ReviewProviderDescriptor - activeProviderKey string - activeRuntimeID string - activeRuntimeInfo core.ReviewProviderInfo - providerSyncState core.ProviderSyncState - providerOverview *core.ProviderOverview - remoteThreads []core.RemoteReviewThread - providerInfos []core.ReviewProviderInfo - providerInfoByClient map[ports.ReviewProviderClient]core.ReviewProviderInfo - providerPicker providerPickerState - publish publishState - prSheet prSheetState - markdownRenderer *MarkdownRenderer - ctx context.Context - reviewLineCache *render.ReviewLineCache - cachedEditorWidth int - cachedEditorLines []string + title string + files []core.ReviewFile + loader reviewLoader + request core.ReviewRequest + loading bool + loadError string + selectedFile int + selectedContext int + width int + height int + reviewViewport ReviewPane + reviewAnchors ReviewAnchors + activeFilePath string + cursorRow int + selectionAnchorRow *int + reviewRows []ReviewRow + reviewExpanderRows map[presenter.ReviewExpanderAnchor]int + selectableRows []int + clipboardWriter ports.ClipboardWriter + lastCopiedText string + copyFeedback string + copyFeedbackID int + diffMode core.DiffMode + nerdFont bool + helpActive bool + search searchState + reviewDraft *core.ReviewDraft + commentEditor *InlineCommentEditor + reviewContext core.ReviewContext + reviewProviders []ports.ReviewProviderClient + activeProvider activeProviderController + providerCatalog []ports.ReviewProviderDescriptor + activeProviderKey string + activeRuntimeID string + activeRuntimeInfo core.ReviewProviderInfo + providerSyncState core.ProviderSyncState + providerOverview *core.ProviderOverview + remoteThreads []core.RemoteReviewThread + providerInfos []core.ReviewProviderInfo + providerInfoByClient map[ports.ReviewProviderClient]core.ReviewProviderInfo + providerPicker providerPickerState + publish publishState + prSheet prSheetState + markdownRenderer *MarkdownRenderer + ctx context.Context + themeMode core.ThemeMode + themeAppearance core.ThemeAppearance + systemTheme core.SystemThemePreference + themeDetectionGeneration uint64 + themeModeChanges <-chan core.ThemeMode + reviewLineCache *render.ReviewLineCache + cachedEditorWidth int + cachedEditorLines []string } func NewModel(files []core.ReviewFile) Model { @@ -168,12 +188,20 @@ func NewModelWithReviewProvidersContext(ctx context.Context, files []core.Review } func NewModelWithActiveProviderContext(ctx context.Context, files []core.ReviewFile, terminal ports.Terminal, loader reviewLoader, request core.ReviewRequest, clipboardWriter ports.ClipboardWriter, reviewContext core.ReviewContext, activeProvider activeProviderController, providers []ports.ReviewProviderClient) Model { + return NewModelWithActiveProviderContextConfig(ctx, files, terminal, loader, request, clipboardWriter, reviewContext, activeProvider, providers, ModelConfig{}) +} + +func NewModelWithActiveProviderContextConfig(ctx context.Context, files []core.ReviewFile, terminal ports.Terminal, loader reviewLoader, request core.ReviewRequest, clipboardWriter ports.ClipboardWriter, reviewContext core.ReviewContext, activeProvider activeProviderController, providers []ports.ReviewProviderClient, config ModelConfig) Model { if ctx == nil { ctx = context.Background() } if request.DiffMode == "" { request.DiffMode = core.DiffModeBranch } + themeMode := core.ThemeModeDark + if config.ThemeMode != "" { + themeMode = core.ParseThemeMode(string(config.ThemeMode)) + } m := Model{ title: "ero", files: sortedReviewFiles(files), @@ -196,6 +224,10 @@ func NewModelWithActiveProviderContext(ctx context.Context, files []core.ReviewF remoteThreads: nil, markdownRenderer: NewMarkdownRenderer(), ctx: ctx, + themeMode: themeMode, + themeAppearance: core.ResolveThemeAppearance(themeMode, config.SystemTheme, core.ThemeAppearanceLight), + systemTheme: config.SystemTheme, + themeModeChanges: config.ThemeModeChanges, reviewLineCache: render.NewReviewLineCache(), } if terminal != nil { @@ -206,18 +238,112 @@ func NewModelWithActiveProviderContext(ctx context.Context, files []core.ReviewF return m } +func (m Model) ThemeMode() core.ThemeMode { + return m.themeMode +} + +func (m Model) ThemeAppearance() core.ThemeAppearance { + return m.themeAppearance +} + +func (m Model) watchThemeConfigCmd() tea.Cmd { + if m.themeModeChanges == nil { + return nil + } + return func() tea.Msg { + mode, ok := <-m.themeModeChanges + return themeConfigChangedMsg{mode: mode, ok: ok} + } +} + +func (m Model) scheduleThemeDetectionCmd() tea.Cmd { + generation := m.themeDetectionGeneration + return tea.Tick(themeDetectionInterval, func(time.Time) tea.Msg { + return themeDetectionTickMsg{generation: generation} + }) +} + +func (m *Model) applyThemeMode(mode core.ThemeMode) tea.Cmd { + previousMode := m.themeMode + m.themeMode = mode + if previousMode != m.themeMode && m.themeMode == core.ThemeModeAuto { + m.themeDetectionGeneration++ + } + appearance := core.ResolveThemeAppearance(m.themeMode, m.systemTheme, m.themeAppearance) + if m.applyThemeAppearance(appearance) { + m.syncReviewViewport() + } + cmds := []tea.Cmd{m.watchThemeConfigCmd()} + if m.themeMode == core.ThemeModeAuto { + cmds = append(cmds, tea.RequestBackgroundColor) + if previousMode != core.ThemeModeAuto { + cmds = append(cmds, m.scheduleThemeDetectionCmd()) + } + } + return tea.Batch(cmds...) +} + +func (m *Model) applySystemThemePreference(preference core.SystemThemePreference) bool { + if m.themeMode != core.ThemeModeAuto { + return false + } + m.systemTheme = preference + appearance := core.ResolveThemeAppearance(m.themeMode, m.systemTheme, m.themeAppearance) + changed := m.applyThemeAppearance(appearance) + if changed { + m.syncReviewViewport() + } + return changed +} + +func (m *Model) applyThemeAppearance(appearance core.ThemeAppearance) bool { + if appearance != core.ThemeAppearanceLight { + appearance = core.ThemeAppearanceDark + } + modelChanged := m.themeAppearance != appearance + themeChanged := theme.ApplyAppearance(appearance) + m.themeAppearance = appearance + if !modelChanged && !themeChanged { + return false + } + if m.reviewLineCache != nil { + m.reviewLineCache.Clear() + } + if m.markdownRenderer != nil { + m.markdownRenderer.Clear() + } + return true +} + func (m Model) Init() tea.Cmd { - if m.activeProvider != nil { - return m.startActiveProviderCmd() + cmds := []tea.Cmd{m.watchThemeConfigCmd()} + if m.themeMode == core.ThemeModeAuto { + cmds = append(cmds, tea.RequestBackgroundColor, m.scheduleThemeDetectionCmd()) } - if len(m.reviewProviders) == 0 { - return nil + if m.activeProvider != nil { + cmds = append(cmds, m.startActiveProviderCmd()) + } else if len(m.reviewProviders) > 0 { + cmds = append(cmds, m.loadReviewProvidersCmd()) } - return m.loadReviewProvidersCmd() + return tea.Batch(cmds...) } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case themeConfigChangedMsg: + if !msg.ok { + m.themeModeChanges = nil + return m, nil + } + return m, m.applyThemeMode(msg.mode) + case tea.BackgroundColorMsg: + m.applySystemThemePreference(core.SystemThemePreferenceFromDarkBackground(msg.IsDark())) + return m, nil + case themeDetectionTickMsg: + if m.themeMode != core.ThemeModeAuto || msg.generation != m.themeDetectionGeneration { + return m, nil + } + return m, tea.Batch(tea.RequestBackgroundColor, m.scheduleThemeDetectionCmd()) case reviewLoadedMsg: m.loading = false m.loadError = "" @@ -497,6 +623,7 @@ func (m Model) unpublishedDraftCommentCount() int { } func (m Model) View() tea.View { + m.applyThemeAppearance(m.themeAppearance) review := m.reviewViewport.View(m.reviewVisualState()) if m.loading { review = theme.MutedStyle.Render("Loading diff…") + "\n" + review @@ -540,6 +667,7 @@ func (m Model) View() tea.View { view := tea.NewView(content) view.AltScreen = true view.MouseMode = tea.MouseModeCellMotion + view.BackgroundColor = lipgloss.Color(theme.CurrentPalette().ColorBackground) if m.commentEditor != nil { view.KeyboardEnhancements.ReportAllKeysAsEscapeCodes = true view.KeyboardEnhancements.ReportAssociatedText = true diff --git a/internal/adapters/in/tui/model_theme_test.go b/internal/adapters/in/tui/model_theme_test.go new file mode 100644 index 0000000..1077018 --- /dev/null +++ b/internal/adapters/in/tui/model_theme_test.go @@ -0,0 +1,134 @@ +package tui + +import ( + "context" + "image/color" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "ero/internal/adapters/in/tui/theme" + "ero/internal/core" +) + +func TestModelAutoUsesInitialSystemThemePreference(t *testing.T) { + tests := []struct { + name string + preference core.SystemThemePreference + want core.ThemeAppearance + }{ + {name: "dark system", preference: core.SystemThemePreferDark, want: core.ThemeAppearanceDark}, + {name: "light system", preference: core.SystemThemePreferLight, want: core.ThemeAppearanceLight}, + {name: "unknown system falls back light", preference: core.SystemThemeUnknown, want: core.ThemeAppearanceLight}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originalAppearance := theme.CurrentAppearance() + t.Cleanup(func() { theme.ApplyAppearance(originalAppearance) }) + + model := NewModelWithActiveProviderContextConfig(context.Background(), nil, nil, nil, core.ReviewRequest{}, nil, core.ReviewContext{}, nil, nil, ModelConfig{ThemeMode: core.ThemeModeAuto, SystemTheme: tt.preference}) + + assert.Equal(t, tt.want, model.ThemeAppearance()) + }) + } +} + +func TestModelAutoThemeFollowsBackgroundColorMessages(t *testing.T) { + theme.ApplyAppearance(core.ThemeAppearanceDark) + t.Cleanup(func() { theme.ApplyAppearance(core.ThemeAppearanceDark) }) + + model := NewModelWithActiveProviderContextConfig(context.Background(), nil, nil, nil, core.ReviewRequest{}, nil, core.ReviewContext{}, nil, nil, ModelConfig{ThemeMode: core.ThemeModeAuto}) + updated, _ := model.Update(tea.BackgroundColorMsg{Color: color.RGBA{R: 255, G: 255, B: 255, A: 255}}) + model = updated.(Model) + + assert.Equal(t, core.ThemeAppearanceLight, model.ThemeAppearance()) + assert.Equal(t, core.ThemeAppearanceLight, theme.CurrentAppearance()) + + updated, _ = model.Update(tea.BackgroundColorMsg{Color: color.RGBA{A: 255}}) + model = updated.(Model) + + assert.Equal(t, core.ThemeAppearanceDark, model.ThemeAppearance()) + assert.Equal(t, core.ThemeAppearanceDark, theme.CurrentAppearance()) +} + +func TestModelForcedThemeIgnoresBackgroundColorMessages(t *testing.T) { + theme.ApplyAppearance(core.ThemeAppearanceDark) + t.Cleanup(func() { theme.ApplyAppearance(core.ThemeAppearanceDark) }) + + model := NewModelWithActiveProviderContextConfig(context.Background(), nil, nil, nil, core.ReviewRequest{}, nil, core.ReviewContext{}, nil, nil, ModelConfig{ThemeMode: core.ThemeModeDark}) + updated, _ := model.Update(tea.BackgroundColorMsg{Color: color.RGBA{R: 255, G: 255, B: 255, A: 255}}) + model = updated.(Model) + + assert.Equal(t, core.ThemeAppearanceDark, model.ThemeAppearance()) + assert.Equal(t, core.ThemeAppearanceDark, theme.CurrentAppearance()) +} + +func TestModelIgnoresStaleAutoThemeDetectionTicks(t *testing.T) { + theme.ApplyAppearance(core.ThemeAppearanceDark) + t.Cleanup(func() { theme.ApplyAppearance(core.ThemeAppearanceDark) }) + + model := NewModelWithActiveProviderContextConfig(context.Background(), nil, nil, nil, core.ReviewRequest{}, nil, core.ReviewContext{}, nil, nil, ModelConfig{ThemeMode: core.ThemeModeAuto}) + initialGeneration := model.themeDetectionGeneration + + updated, _ := model.Update(themeConfigChangedMsg{mode: core.ThemeModeAuto, ok: true}) + model = updated.(Model) + assert.Equal(t, initialGeneration, model.themeDetectionGeneration) + + updated, _ = model.Update(themeConfigChangedMsg{mode: core.ThemeModeDark, ok: true}) + model = updated.(Model) + updated, _ = model.Update(themeConfigChangedMsg{mode: core.ThemeModeAuto, ok: true}) + model = updated.(Model) + require.NotEqual(t, initialGeneration, model.themeDetectionGeneration) + + updated, cmd := model.Update(themeDetectionTickMsg{generation: initialGeneration}) + model = updated.(Model) + assert.Nil(t, cmd) + assert.Equal(t, core.ThemeModeAuto, model.ThemeMode()) + + updated, cmd = model.Update(themeDetectionTickMsg{generation: model.themeDetectionGeneration}) + model = updated.(Model) + assert.NotNil(t, cmd) + assert.Equal(t, core.ThemeModeAuto, model.ThemeMode()) +} + +func TestModelLightViewSetsLightBackgroundForUnstyledAreas(t *testing.T) { + theme.ApplyAppearance(core.ThemeAppearanceDark) + t.Cleanup(func() { theme.ApplyAppearance(core.ThemeAppearanceDark) }) + + model := NewModelWithActiveProviderContextConfig(context.Background(), []core.ReviewFile{{ + Path: "demo.go", + Sections: []core.ReviewSection{{ID: "changed", Kind: core.SectionKindChanged, Lines: []core.ReviewLine{{NewLineNumber: 1, Kind: core.LineKindUnchanged, Content: "short"}}}}, + }}, nil, nil, core.ReviewRequest{}, nil, core.ReviewContext{}, nil, nil, ModelConfig{ThemeMode: core.ThemeModeLight}) + + view := model.View() + + require.NotNil(t, view.BackgroundColor) + assert.Contains(t, view.Content, "48;2;234;238;242") + assert.Contains(t, view.Content, "48;5;252") +} + +func TestModelAppliesLiveThemeConfigChanges(t *testing.T) { + theme.ApplyAppearance(core.ThemeAppearanceDark) + t.Cleanup(func() { theme.ApplyAppearance(core.ThemeAppearanceDark) }) + + changes := make(chan core.ThemeMode, 1) + model := NewModelWithActiveProviderContextConfig(context.Background(), nil, nil, nil, core.ReviewRequest{}, nil, core.ReviewContext{}, nil, nil, ModelConfig{ThemeMode: core.ThemeModeDark, ThemeModeChanges: changes}) + changes <- core.ThemeModeLight + + msg := model.watchThemeConfigCmd()() + changed, ok := msg.(themeConfigChangedMsg) + require.True(t, ok) + require.True(t, changed.ok) + assert.Equal(t, core.ThemeModeLight, changed.mode) + + updated, cmd := model.Update(changed) + model = updated.(Model) + + assert.Equal(t, core.ThemeModeLight, model.ThemeMode()) + assert.Equal(t, core.ThemeAppearanceLight, model.ThemeAppearance()) + assert.Equal(t, core.ThemeAppearanceLight, theme.CurrentAppearance()) + assert.NotNil(t, cmd) +} diff --git a/internal/adapters/in/tui/pr_sheet.go b/internal/adapters/in/tui/pr_sheet.go index 060b63f..2028e51 100644 --- a/internal/adapters/in/tui/pr_sheet.go +++ b/internal/adapters/in/tui/pr_sheet.go @@ -110,21 +110,21 @@ func (m Model) providerOverviewLines(overview *core.ProviderOverview) []string { if strings.TrimSpace(overview.Body) != "" { lines = append(lines, "Body", "") - lines = append(lines, renderPRSheetMarkdown(m.markdownRenderer, overview.Body, contentWidth)...) + lines = append(lines, m.renderPRSheetMarkdown(overview.Body, contentWidth)...) lines = append(lines, "") } lines = append(lines, "Issue comments: "+strconv.Itoa(len(overview.Comments))) for _, comment := range overview.Comments { lines = append(lines, "", commentHeader(comment.Author, comment.CreatedAt)) - lines = append(lines, renderPRSheetMarkdown(m.markdownRenderer, comment.Body, contentWidth)...) + lines = append(lines, m.renderPRSheetMarkdown(comment.Body, contentWidth)...) } lines = append(lines, "", "Review summaries: "+strconv.Itoa(len(overview.Reviews))) for _, review := range overview.Reviews { lines = append(lines, "", reviewSummaryHeader(review)) if strings.TrimSpace(review.Body) != "" { - lines = append(lines, renderPRSheetMarkdown(m.markdownRenderer, review.Body, contentWidth)...) + lines = append(lines, m.renderPRSheetMarkdown(review.Body, contentWidth)...) } } return trimTrailingBlankLines(lines) @@ -134,8 +134,8 @@ func (m Model) prSheetLineCount() int { return len(m.prSheetLines()) } -func renderPRSheetMarkdown(renderer *MarkdownRenderer, markdown string, width int) []string { - rendered := sanitizeRenderedMarkdown(renderer.Render(markdown, width, MarkdownThemeDark)) +func (m Model) renderPRSheetMarkdown(markdown string, width int) []string { + rendered := sanitizeRenderedMarkdown(m.markdownRenderer.Render(markdown, width, markdownThemeForAppearance(m.themeAppearance))) if strings.TrimSpace(safeMarkdownFallback(rendered)) == "" { return []string{"(empty)"} } diff --git a/internal/adapters/in/tui/render/line.go b/internal/adapters/in/tui/render/line.go index d2be5ae..f7f6f3b 100644 --- a/internal/adapters/in/tui/render/line.go +++ b/internal/adapters/in/tui/render/line.go @@ -47,6 +47,7 @@ func applySyntaxHighlighting(content string, tokens []core.SyntaxToken, baseStyl var result strings.Builder lastEnd := 0 background := baseStyle.GetBackground() + chroma := chromaStyle() for _, token := range tokens { start := min(max(token.Start, 0), len(runes)) @@ -58,7 +59,7 @@ func applySyntaxHighlighting(content string, tokens []core.SyntaxToken, baseStyl if start > lastEnd { result.WriteString(baseStyle.Render(string(runes[lastEnd:start]))) } - tokenStyle := StyleForSyntaxToken(token) + tokenStyle := styleForSyntaxToken(token, chroma) if background != nil { tokenStyle = tokenStyle.Background(background) } @@ -74,9 +75,13 @@ func applySyntaxHighlighting(content string, tokens []core.SyntaxToken, baseStyl } func StyleForSyntaxToken(token core.SyntaxToken) lipgloss.Style { + return styleForSyntaxToken(token, chromaStyle()) +} + +func styleForSyntaxToken(token core.SyntaxToken, chroma *basechroma.Style) lipgloss.Style { if token.SourceType != "" { if tokenType, err := basechroma.TokenTypeString(token.SourceType); err == nil { - entry := githubDarkStyle().Get(tokenType) + entry := chroma.Get(tokenType) style := lipgloss.NewStyle() if entry.Colour.IsSet() { style = style.Foreground(lipgloss.Color(entry.Colour.String())) @@ -96,8 +101,8 @@ func StyleForSyntaxToken(token core.SyntaxToken) lipgloss.Style { return StyleForToken(token.Type) } -func githubDarkStyle() *basechroma.Style { - style := styles.Get("github-dark") +func chromaStyle() *basechroma.Style { + style := styles.Get(theme.CurrentPalette().ChromaStyle) if style == nil { return styles.Fallback } diff --git a/internal/adapters/in/tui/render/line_cache.go b/internal/adapters/in/tui/render/line_cache.go index 4977612..d589226 100644 --- a/internal/adapters/in/tui/render/line_cache.go +++ b/internal/adapters/in/tui/render/line_cache.go @@ -5,6 +5,7 @@ import ( "strings" "ero/internal/adapters/in/tui/presenter" + "ero/internal/adapters/in/tui/theme" "ero/internal/core" ) @@ -23,6 +24,7 @@ type reviewLineCacheKey struct { Content string Tokens string NumberWidth int + Appearance core.ThemeAppearance } func NewReviewLineCache() *ReviewLineCache { @@ -71,6 +73,7 @@ func newReviewLineCacheKey(row presenter.ReviewRow, numberWidth int) reviewLineC Content: line.Content, Tokens: syntaxTokensKey(line.SyntaxTokens), NumberWidth: numberWidth, + Appearance: theme.CurrentAppearance(), } } diff --git a/internal/adapters/in/tui/render/line_cache_test.go b/internal/adapters/in/tui/render/line_cache_test.go index b4e3044..06035ea 100644 --- a/internal/adapters/in/tui/render/line_cache_test.go +++ b/internal/adapters/in/tui/render/line_cache_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "ero/internal/adapters/in/tui/presenter" + "ero/internal/adapters/in/tui/theme" "ero/internal/core" ) @@ -44,6 +45,28 @@ func TestReviewLineCacheInvalidatesWhenLineContentTokensOrWidthChange(t *testing assert.Equal(t, 4, calls) } +func TestReviewLineCacheInvalidatesWhenThemeAppearanceChanges(t *testing.T) { + originalAppearance := theme.CurrentAppearance() + t.Cleanup(func() { theme.ApplyAppearance(originalAppearance) }) + + row := presenter.ReviewRow{Kind: presenter.ReviewRowKindLine, FileIndex: 0, SectionIndex: 0, LineIndex: 0, Line: core.ReviewLine{ + NewLineNumber: 1, + Content: "func main() {}", + Kind: core.LineKindAdded, + SyntaxTokens: []core.SyntaxToken{{Start: 0, End: 4, Type: core.SemanticTokenText, SourceType: "KeywordDeclaration"}}, + }} + cache := NewReviewLineCache() + + theme.ApplyAppearance(core.ThemeAppearanceDark) + dark := cache.Render(row, 4) + theme.ApplyAppearance(core.ThemeAppearanceLight) + light := cache.Render(row, 4) + + assert.NotEqual(t, dark, light) + assert.Contains(t, dark, "38;2;255;123;114") + assert.Contains(t, light, "38;2;207;34;46") +} + func TestReviewLineCacheClearDropsEntries(t *testing.T) { t.Parallel() diff --git a/internal/adapters/in/tui/render/line_test.go b/internal/adapters/in/tui/render/line_test.go index 4a29517..2856878 100644 --- a/internal/adapters/in/tui/render/line_test.go +++ b/internal/adapters/in/tui/render/line_test.go @@ -7,6 +7,7 @@ import ( "charm.land/lipgloss/v2" "github.com/stretchr/testify/assert" + "ero/internal/adapters/in/tui/theme" "ero/internal/core" ) @@ -69,6 +70,28 @@ func TestReviewLine(t *testing.T) { } } +func TestReviewLineUsesLightChromaStyleWhenThemeIsLight(t *testing.T) { + originalAppearance := theme.CurrentAppearance() + theme.ApplyAppearance(core.ThemeAppearanceLight) + t.Cleanup(func() { theme.ApplyAppearance(originalAppearance) }) + + rendered := ReviewLine(core.ReviewLine{ + NewLineNumber: 5, + Content: "func main() {}", + Kind: core.LineKindAdded, + SyntaxTokens: []core.SyntaxToken{ + {Start: 0, End: 4, Type: core.SemanticTokenText, SourceType: "KeywordDeclaration"}, + {Start: 5, End: 9, Type: core.SemanticTokenText, SourceType: "NameFunction"}, + }, + }, 4) + + assert.Contains(t, rendered, "48;2;218;251;225") + assert.Contains(t, rendered, "38;2;207;34;46") + assert.Contains(t, rendered, "38;2;102;57;186") + assert.NotContains(t, rendered, "48;2;1;18;9") + assert.Contains(t, stripANSI(rendered), "+ func main() {}") +} + func TestApplySyntaxHighlightingSkipsAlreadyRenderedTokenRanges(t *testing.T) { t.Parallel() diff --git a/internal/adapters/in/tui/render/review_row.go b/internal/adapters/in/tui/render/review_row.go index 4721ac4..9717070 100644 --- a/internal/adapters/in/tui/render/review_row.go +++ b/internal/adapters/in/tui/render/review_row.go @@ -113,11 +113,11 @@ func (r *ReviewRowRenderer) Gutter(_ presenter.ReviewRow, rowIndex int, state Re if marker, ok := state.CommentMarkers[rowIndex]; ok { switch marker { case CommentMarkerStart: - return InlineCommentIconStyle.Render(r.config.CommentStartMarker) + return inlineCommentIconStyle().Render(r.config.CommentStartMarker) case CommentMarkerEnd: - return InlineCommentIconStyle.Render(r.config.CommentEndMarker) + return inlineCommentIconStyle().Render(r.config.CommentEndMarker) default: - return InlineCommentIconStyle.Render(r.config.CommentBodyMarker) + return inlineCommentIconStyle().Render(r.config.CommentBodyMarker) } } if rowIndex == state.CursorRow { @@ -167,30 +167,30 @@ func (r *ReviewRowRenderer) renderMessage(row presenter.ReviewRow) string { func (r *ReviewRowRenderer) renderComment(row presenter.ReviewRow) string { if row.Annotation.LineIndex == 0 { - return r.annotationIndent(row) + inlineCommentStyle.Render(InlineCommentIconStyle.Render(r.config.CommentIcon)+" "+InlineCommentIDStyle.Render(displayReviewCommentID(row.Annotation.Comment.ID))) + return r.annotationIndent(row) + inlineCommentStyle().Render(inlineCommentIconStyle().Render(r.config.CommentIcon)+" "+renderReviewCommentID(row.Annotation.Comment.ID)) } - return r.annotationIndent(row) + inlineCommentStyle.Render(InlineCommentBodyStyle.Render(row.Annotation.Body)) + return r.annotationIndent(row) + inlineCommentStyle().Render(inlineCommentBodyStyle().Render(row.Annotation.Body)) } func (r *ReviewRowRenderer) renderRemoteThread(row presenter.ReviewRow) string { thread := row.Annotation.RemoteThread if thread.Unmapped { - return inlineCommentStyle.Render(unmappedRemoteThreadSummary(thread)) + return inlineCommentStyle().Render(unmappedRemoteThreadSummary(thread)) } if row.Annotation.LineIndex == 0 { - return r.annotationIndent(row) + inlineCommentStyle.Render(InlineCommentIconStyle.Render(r.config.CommentIcon)+" "+InlineCommentIDStyle.Render(providerThreadLabel(thread))+" "+inlineCommentMutedStyle.Render("remote read-only")) + return r.annotationIndent(row) + inlineCommentStyle().Render(inlineCommentIconStyle().Render(r.config.CommentIcon)+" "+inlineCommentIDStyle().Render(providerThreadLabel(thread))+" "+inlineCommentMutedStyle().Render("remote read-only")) } author := row.Annotation.Author if author == "" { author = "remote" } - return r.annotationIndent(row) + inlineCommentStyle.Render(InlineCommentBodyStyle.Render(author+": "+row.Annotation.Body)) + return r.annotationIndent(row) + inlineCommentStyle().Render(inlineCommentBodyStyle().Render(author+": "+row.Annotation.Body)) } func (r *ReviewRowRenderer) renderEditor(row presenter.ReviewRow) string { indent := r.annotationIndent(row) if row.Annotation.LineIndex == 0 { - return indent + inlineCommentMutedStyle.Render("commenting "+formatReviewLineRange(row.Annotation.Editor.Range)) + return indent + inlineCommentMutedStyle().Render("commenting "+formatReviewLineRange(row.Annotation.Editor.Range)) } if r.config.EditorLineRenderer == nil { return "" @@ -229,19 +229,56 @@ func contextLocationLabel(position presenter.ReviewContextPosition) string { } } +func inlineCommentStyle() lipgloss.Style { + return lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(lipgloss.Color(theme.CurrentPalette().HelpBorderFg)).PaddingLeft(1) +} + +type dynamicStyle struct { + style func() lipgloss.Style +} + +func (s dynamicStyle) Render(text string) string { + if s.style == nil { + return lipgloss.NewStyle().Render(text) + } + return s.style().Render(text) +} + var ( - inlineCommentStyle = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, false, true).BorderForeground(lipgloss.Color("62")).PaddingLeft(1) - InlineCommentIconStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("81")).Bold(true) - InlineCommentIDStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("229")).Bold(true) - InlineCommentBodyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("248")) - inlineCommentMutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + InlineCommentIconStyle = dynamicStyle{style: inlineCommentIconStyle} + InlineCommentIDStyle = dynamicStyle{style: inlineCommentIDStyle} + InlineCommentBodyStyle = dynamicStyle{style: inlineCommentBodyStyle} ) +func inlineCommentIconStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorAccent)).Bold(true) +} + +func inlineCommentIDStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorWarning)).Bold(true) +} + +func inlineCommentBodyStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorStatusInfo)) +} + +func inlineCommentMutedStyle() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color(theme.ColorMutedText)) +} + func displayReviewCommentID(id string) string { if number, ok := strings.CutPrefix(id, "comment-"); ok && number != "" { return "#" + number } - return inlineCommentMutedStyle.Render(id) + return id +} + +func renderReviewCommentID(id string) string { + displayID := displayReviewCommentID(id) + if strings.HasPrefix(displayID, "#") { + return inlineCommentIDStyle().Render(displayID) + } + return inlineCommentMutedStyle().Render(displayID) } func providerThreadLabel(thread core.RemoteReviewThread) string { diff --git a/internal/adapters/in/tui/review_pane.go b/internal/adapters/in/tui/review_pane.go index 0c87e67..b69d196 100644 --- a/internal/adapters/in/tui/review_pane.go +++ b/internal/adapters/in/tui/review_pane.go @@ -157,9 +157,15 @@ func (p ReviewPane) renderRow(row presenter.ReviewRow, rowIndex int, state rende return "" } gutter := p.renderer.Gutter(row, rowIndex, state) - content := p.truncateContent(p.renderer.Render(row, rowIndex, state), lipgloss.Width(gutter)) + gutterWidth := lipgloss.Width(gutter) + contentWidth := max(p.width-gutterWidth, 0) + content := p.truncateContent(p.renderer.Render(row, rowIndex, state), gutterWidth) style := p.renderer.Style(rowIndex, state) - content = style.Render(content) + if style.GetBackground() != nil { + content = style.Width(contentWidth).Render(content) + } else { + content = style.Render(content) + } return gutter + content } diff --git a/internal/adapters/in/tui/review_pane_test.go b/internal/adapters/in/tui/review_pane_test.go index 8361cde..76c842b 100644 --- a/internal/adapters/in/tui/review_pane_test.go +++ b/internal/adapters/in/tui/review_pane_test.go @@ -76,6 +76,23 @@ func TestReviewPaneSyncsRendererToContentWidthOnCreateAndResize(t *testing.T) { assert.Equal(t, 10, renderer.widths[len(renderer.widths)-1]) } +func TestReviewPaneFillsStyledRowsAcrossContentWidth(t *testing.T) { + t.Parallel() + + pane := NewReviewPane(ReviewPaneConfig{ + Width: 8, + Height: 1, + Renderer: reviewPaneRendererFunc(func(row presenter.ReviewRow, rowIndex int, state render.ReviewVisualState) string { + return "x" + }), + }) + pane.SetRows(reviewPaneRows(1)) + + view := stripANSI(pane.View(render.ReviewVisualState{CursorRow: 0})) + + assert.Equal(t, nerdIconArrowRight+" x ", view) +} + func TestReviewPaneTruncatesContentWithinRemainingGutterWidth(t *testing.T) { t.Parallel() @@ -133,6 +150,9 @@ func (f reviewPaneRendererFunc) Gutter(row presenter.ReviewRow, rowIndex int, st } func (f reviewPaneRendererFunc) Style(rowIndex int, state render.ReviewVisualState) lipgloss.Style { + if rowIndex == state.CursorRow { + return lipgloss.NewStyle().Background(lipgloss.Color("#eeeeee")) + } return lipgloss.NewStyle() } diff --git a/internal/adapters/in/tui/startup_prompt.go b/internal/adapters/in/tui/startup_prompt.go index ac36ee3..726bdf8 100644 --- a/internal/adapters/in/tui/startup_prompt.go +++ b/internal/adapters/in/tui/startup_prompt.go @@ -10,14 +10,24 @@ import ( "ero/internal/core" ) -type StartupPrompt struct{} +type StartupPrompt struct { + appearance core.ThemeAppearance +} func NewStartupPrompt() StartupPrompt { - return StartupPrompt{} + return StartupPrompt{appearance: core.ThemeAppearanceDark} +} + +func (p StartupPrompt) WithAppearance(appearance core.ThemeAppearance) StartupPrompt { + if appearance != core.ThemeAppearanceLight { + appearance = core.ThemeAppearanceDark + } + p.appearance = appearance + return p } func (p StartupPrompt) PromptLocalChangeMode() (core.DiffMode, error) { - model := newStartupPromptModel() + model := newStartupPromptModel(p.appearance) result, err := tea.NewProgram(model).Run() if err != nil { return "", err @@ -40,13 +50,22 @@ type startupPromptOption struct { } type startupPromptModel struct { - options []startupPromptOption - selected int - cancelled bool + options []startupPromptOption + selected int + cancelled bool + appearance core.ThemeAppearance } -func newStartupPromptModel() startupPromptModel { +func newStartupPromptModel(appearances ...core.ThemeAppearance) startupPromptModel { + appearance := core.ThemeAppearanceDark + if len(appearances) > 0 { + appearance = appearances[0] + } + if appearance != core.ThemeAppearanceLight { + appearance = core.ThemeAppearanceDark + } return startupPromptModel{ + appearance: appearance, options: []startupPromptOption{ {mode: core.DiffModeStaged, key: "s", label: "Staged changes", description: "What will be included in your next commit"}, {mode: core.DiffModeWorking, key: "u", label: "Unstaged/untracked changes", description: "Worktree changes not yet staged, including new files"}, @@ -92,21 +111,22 @@ func (m startupPromptModel) shortcutIndex(key string) (int, bool) { } func (m startupPromptModel) View() tea.View { - title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")).Render("Mixed local changes detected") - subtitle := theme.MutedStyle.Render("Choose the diff scope to review") + styles := theme.StylesForAppearance(m.appearance) + title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(styles.Palette.ColorFunction)).Render("Mixed local changes detected") + subtitle := styles.MutedStyle.Render("Choose the diff scope to review") lines := []string{title, subtitle, ""} for i, option := range m.options { cursor := " " labelStyle := lipgloss.NewStyle().Bold(false) if i == m.selected { cursor = "▸ " - labelStyle = labelStyle.Bold(true).Foreground(lipgloss.Color("86")) + labelStyle = labelStyle.Bold(true).Foreground(lipgloss.Color(styles.Palette.AddedMarkerFg)) } - shortcut := theme.MutedStyle.Render("(" + option.key + ")") + shortcut := styles.MutedStyle.Render("(" + option.key + ")") lines = append(lines, cursor+labelStyle.Render(option.label)+" "+shortcut) - lines = append(lines, " "+theme.MutedStyle.Render(option.description)) + lines = append(lines, " "+styles.MutedStyle.Render(option.description)) } - lines = append(lines, "", theme.MutedStyle.Render("↑/↓ move • "+enterKeyLabel()+" select • q quit")) + lines = append(lines, "", styles.MutedStyle.Render("↑/↓ move • "+enterKeyLabel()+" select • q quit")) return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, lines...)) } diff --git a/internal/adapters/in/tui/theme/styles.go b/internal/adapters/in/tui/theme/styles.go index 4a9857c..e77b1fc 100644 --- a/internal/adapters/in/tui/theme/styles.go +++ b/internal/adapters/in/tui/theme/styles.go @@ -1,59 +1,396 @@ package theme -import "charm.land/lipgloss/v2" - -const ( - ColorText = "#c9d1d9" - ColorMutedText = "#8b949e" - ColorAccent = "#58a6ff" - ColorWarning = "#ffa657" - ColorKeyword = "#ff7b72" - ColorFunction = "#d2a8ff" - ColorType = "#ffa657" - ColorString = "#a5d6ff" - ColorNumber = "#79c0ff" - ColorStatusBase = "236" - ColorStatusInfo = "248" - ColorCodeBg = "#1f2a44" +import ( + "sync" + + "charm.land/lipgloss/v2" + + "ero/internal/core" +) + +// Palette contains every color Ero uses to render the TUI. The dark palette is +// the original hard-coded Ero palette kept byte-for-byte where existing styles +// depended on exact colors. +type Palette struct { + Appearance core.ThemeAppearance + + ColorBackground string + ColorText string + ColorMutedText string + ColorAccent string + ColorWarning string + ColorKeyword string + ColorFunction string + ColorType string + ColorString string + ColorNumber string + ColorStatusBase string + ColorStatusInfo string + ColorCodeBg string + + FileHeaderFg string + FileRuleFg string + AddedLineBg string + DeletedLineBg string + AddedMarkerFg string + DeletedMarkerFg string + CursorRowBg string + SelectedRowBg string + CommentRangeRowBg string + StatusBaseFg string + StatusAppBg string + StatusAppFg string + StatusModeFg string + StatusKeyFg string + StatusHintTextFg string + SearchBorderFg string + SearchTitleFg string + SearchSelectedFg string + SearchSelectedBg string + HelpBorderFg string + HelpTitleFg string + HelpSectionFg string + HelpKeyFg string + HelpLabelFg string + ChromaStyle string + MarkdownCodeTheme string +} + +type Styles struct { + Palette Palette + + FileHeaderStyle lipgloss.Style + FileRuleStyle lipgloss.Style + PanelTitleStyle lipgloss.Style + MutedStyle lipgloss.Style + AddedLineStyle lipgloss.Style + DeletedLineStyle lipgloss.Style + AddedMarkerStyle lipgloss.Style + DeletedMarkerStyle lipgloss.Style + LineNumberStyle lipgloss.Style + SelectedExpander lipgloss.Style + CursorRowStyle lipgloss.Style + SelectedRowStyle lipgloss.Style + CommentRangeRowStyle lipgloss.Style + KeywordStyle lipgloss.Style + FunctionStyle lipgloss.Style + TypeStyle lipgloss.Style + NameStyle lipgloss.Style + StringStyle lipgloss.Style + NumberStyle lipgloss.Style + CommentStyle lipgloss.Style + OperatorStyle lipgloss.Style + PunctuationStyle lipgloss.Style + StatusBaseStyle lipgloss.Style + StatusAppStyle lipgloss.Style + StatusModeStyle lipgloss.Style + StatusInfoStyle lipgloss.Style + StatusKeyStyle lipgloss.Style + StatusHintTextStyle lipgloss.Style + + SearchPaneStyle lipgloss.Style + SearchPaneTitleStyle lipgloss.Style + SearchSelectedRowStyle lipgloss.Style + + HelpPaneStyle lipgloss.Style + HelpPaneTitleStyle lipgloss.Style + HelpSectionStyle lipgloss.Style + HelpKeyStyle lipgloss.Style + HelpLabelStyle lipgloss.Style +} + +var darkPalette = Palette{ + Appearance: core.ThemeAppearanceDark, + + ColorBackground: "#000000", + ColorText: "#c9d1d9", + ColorMutedText: "#8b949e", + ColorAccent: "#58a6ff", + ColorWarning: "#ffa657", + ColorKeyword: "#ff7b72", + ColorFunction: "#d2a8ff", + ColorType: "#ffa657", + ColorString: "#a5d6ff", + ColorNumber: "#79c0ff", + ColorStatusBase: "236", + ColorStatusInfo: "248", + ColorCodeBg: "#1f2a44", + + FileHeaderFg: "15", + FileRuleFg: "8", + AddedLineBg: "#011209", + DeletedLineBg: "#1f0101", + AddedMarkerFg: "#3fb950", + DeletedMarkerFg: "#ff7b72", + CursorRowBg: "#1f2a44", + SelectedRowBg: "#25351f", + CommentRangeRowBg: "#201a35", + StatusBaseFg: "252", + StatusAppBg: "62", + StatusAppFg: "230", + StatusModeFg: "229", + StatusKeyFg: "81", + StatusHintTextFg: "244", + SearchBorderFg: "62", + SearchTitleFg: "81", + SearchSelectedFg: "230", + SearchSelectedBg: "62", + HelpBorderFg: "62", + HelpTitleFg: "81", + HelpSectionFg: "229", + HelpKeyFg: "81", + HelpLabelFg: "252", + ChromaStyle: "github-dark", + MarkdownCodeTheme: "github-dark", +} + +var lightPalette = Palette{ + Appearance: core.ThemeAppearanceLight, + + ColorBackground: "#ffffff", + ColorText: "#24292f", + ColorMutedText: "#57606a", + ColorAccent: "#0969da", + ColorWarning: "#9a6700", + ColorKeyword: "#cf222e", + ColorFunction: "#8250df", + ColorType: "#953800", + ColorString: "#0a3069", + ColorNumber: "#0550ae", + ColorStatusBase: "252", + ColorStatusInfo: "239", + ColorCodeBg: "#f6f8fa", + + FileHeaderFg: "#24292f", + FileRuleFg: "#d0d7de", + AddedLineBg: "#dafbe1", + DeletedLineBg: "#ffebe9", + AddedMarkerFg: "#1a7f37", + DeletedMarkerFg: "#cf222e", + CursorRowBg: "#eaeef2", + SelectedRowBg: "#ddf4ff", + CommentRangeRowBg: "#fff8c5", + StatusBaseFg: "#24292f", + StatusAppBg: "#0969da", + StatusAppFg: "#ffffff", + StatusModeFg: "#24292f", + StatusKeyFg: "#0969da", + StatusHintTextFg: "#57606a", + SearchBorderFg: "#0969da", + SearchTitleFg: "#0969da", + SearchSelectedFg: "#ffffff", + SearchSelectedBg: "#0969da", + HelpBorderFg: "#0969da", + HelpTitleFg: "#0969da", + HelpSectionFg: "#9a6700", + HelpKeyFg: "#0969da", + HelpLabelFg: "#24292f", + ChromaStyle: "github", + MarkdownCodeTheme: "github", +} + +var ( + mu sync.RWMutex + currentAppearance = core.ThemeAppearanceDark + currentPalette = darkPalette ) var ( - FileHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) - FileRuleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - PanelTitleStyle = lipgloss.NewStyle().Bold(true).Underline(true) - MutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) - AddedLineStyle = lipgloss.NewStyle().Background(lipgloss.Color("#011209")).Foreground(lipgloss.Color(ColorText)) - DeletedLineStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1f0101")).Foreground(lipgloss.Color(ColorText)) - AddedMarkerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#3fb950")).Bold(true) - DeletedMarkerStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorKeyword)).Bold(true) - LineNumberStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorMutedText)) - SelectedExpander = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ColorAccent)) - CursorRowStyle = lipgloss.NewStyle().Background(lipgloss.Color(ColorCodeBg)) - SelectedRowStyle = lipgloss.NewStyle().Background(lipgloss.Color("#25351f")) - CommentRangeRowStyle = lipgloss.NewStyle().Background(lipgloss.Color("#201a35")) - KeywordStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorKeyword)) - FunctionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorFunction)) - TypeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorType)) - NameStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)) - StringStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorString)) - NumberStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorNumber)) - CommentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorMutedText)).Italic(true) - OperatorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorKeyword)) - PunctuationStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(ColorText)) - StatusBaseStyle = lipgloss.NewStyle().Background(lipgloss.Color(ColorStatusBase)).Foreground(lipgloss.Color("252")) - StatusAppStyle = StatusBaseStyle.Bold(true).Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230")).Padding(0, 1) - StatusModeStyle = StatusBaseStyle.Foreground(lipgloss.Color("229")).Padding(0, 1) - StatusInfoStyle = StatusBaseStyle.Foreground(lipgloss.Color(ColorStatusInfo)).Padding(0, 1) - StatusKeyStyle = StatusBaseStyle.Foreground(lipgloss.Color("81")).Bold(true) - StatusHintTextStyle = StatusBaseStyle.Foreground(lipgloss.Color("244")) - - SearchPaneStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("62")).Padding(0, 1) - SearchPaneTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("81")) - SearchSelectedRowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("230")).Background(lipgloss.Color("62")) - - HelpPaneStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("62")).Padding(0, 2) - HelpPaneTitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("81")) - HelpSectionStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")) - HelpKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("81")).Bold(true) - HelpLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + ColorText = darkPalette.ColorText + ColorMutedText = darkPalette.ColorMutedText + ColorAccent = darkPalette.ColorAccent + ColorWarning = darkPalette.ColorWarning + ColorKeyword = darkPalette.ColorKeyword + ColorFunction = darkPalette.ColorFunction + ColorType = darkPalette.ColorType + ColorString = darkPalette.ColorString + ColorNumber = darkPalette.ColorNumber + ColorStatusBase = darkPalette.ColorStatusBase + ColorStatusInfo = darkPalette.ColorStatusInfo + ColorCodeBg = darkPalette.ColorCodeBg ) + +var ( + FileHeaderStyle lipgloss.Style + FileRuleStyle lipgloss.Style + PanelTitleStyle lipgloss.Style + MutedStyle lipgloss.Style + AddedLineStyle lipgloss.Style + DeletedLineStyle lipgloss.Style + AddedMarkerStyle lipgloss.Style + DeletedMarkerStyle lipgloss.Style + LineNumberStyle lipgloss.Style + SelectedExpander lipgloss.Style + CursorRowStyle lipgloss.Style + SelectedRowStyle lipgloss.Style + CommentRangeRowStyle lipgloss.Style + KeywordStyle lipgloss.Style + FunctionStyle lipgloss.Style + TypeStyle lipgloss.Style + NameStyle lipgloss.Style + StringStyle lipgloss.Style + NumberStyle lipgloss.Style + CommentStyle lipgloss.Style + OperatorStyle lipgloss.Style + PunctuationStyle lipgloss.Style + StatusBaseStyle lipgloss.Style + StatusAppStyle lipgloss.Style + StatusModeStyle lipgloss.Style + StatusInfoStyle lipgloss.Style + StatusKeyStyle lipgloss.Style + StatusHintTextStyle lipgloss.Style + + SearchPaneStyle lipgloss.Style + SearchPaneTitleStyle lipgloss.Style + SearchSelectedRowStyle lipgloss.Style + + HelpPaneStyle lipgloss.Style + HelpPaneTitleStyle lipgloss.Style + HelpSectionStyle lipgloss.Style + HelpKeyStyle lipgloss.Style + HelpLabelStyle lipgloss.Style +) + +func init() { + applyPalette(darkPalette) +} + +func CurrentAppearance() core.ThemeAppearance { + mu.RLock() + defer mu.RUnlock() + return currentAppearance +} + +func CurrentPalette() Palette { + mu.RLock() + defer mu.RUnlock() + return currentPalette +} + +// ApplyAppearance updates the package-level style variables for the selected +// appearance. Call it from the main Bubble Tea event loop before rendering so +// style reads remain serialized with theme switches. +func ApplyAppearance(appearance core.ThemeAppearance) bool { + mu.Lock() + defer mu.Unlock() + palette := paletteForAppearance(appearance) + if currentAppearance == palette.Appearance { + return false + } + applyPalette(palette) + return true +} + +func PaletteForAppearance(appearance core.ThemeAppearance) Palette { + if appearance == core.ThemeAppearanceLight { + return lightPalette + } + return darkPalette +} + +func StylesForAppearance(appearance core.ThemeAppearance) Styles { + return stylesForPalette(PaletteForAppearance(appearance)) +} + +func paletteForAppearance(appearance core.ThemeAppearance) Palette { + return PaletteForAppearance(appearance) +} + +func applyPalette(p Palette) { + currentAppearance = p.Appearance + currentPalette = p + + ColorText = p.ColorText + ColorMutedText = p.ColorMutedText + ColorAccent = p.ColorAccent + ColorWarning = p.ColorWarning + ColorKeyword = p.ColorKeyword + ColorFunction = p.ColorFunction + ColorType = p.ColorType + ColorString = p.ColorString + ColorNumber = p.ColorNumber + ColorStatusBase = p.ColorStatusBase + ColorStatusInfo = p.ColorStatusInfo + ColorCodeBg = p.ColorCodeBg + + styles := stylesForPalette(p) + FileHeaderStyle = styles.FileHeaderStyle + FileRuleStyle = styles.FileRuleStyle + PanelTitleStyle = styles.PanelTitleStyle + MutedStyle = styles.MutedStyle + AddedLineStyle = styles.AddedLineStyle + DeletedLineStyle = styles.DeletedLineStyle + AddedMarkerStyle = styles.AddedMarkerStyle + DeletedMarkerStyle = styles.DeletedMarkerStyle + LineNumberStyle = styles.LineNumberStyle + SelectedExpander = styles.SelectedExpander + CursorRowStyle = styles.CursorRowStyle + SelectedRowStyle = styles.SelectedRowStyle + CommentRangeRowStyle = styles.CommentRangeRowStyle + KeywordStyle = styles.KeywordStyle + FunctionStyle = styles.FunctionStyle + TypeStyle = styles.TypeStyle + NameStyle = styles.NameStyle + StringStyle = styles.StringStyle + NumberStyle = styles.NumberStyle + CommentStyle = styles.CommentStyle + OperatorStyle = styles.OperatorStyle + PunctuationStyle = styles.PunctuationStyle + StatusBaseStyle = styles.StatusBaseStyle + StatusAppStyle = styles.StatusAppStyle + StatusModeStyle = styles.StatusModeStyle + StatusInfoStyle = styles.StatusInfoStyle + StatusKeyStyle = styles.StatusKeyStyle + StatusHintTextStyle = styles.StatusHintTextStyle + SearchPaneStyle = styles.SearchPaneStyle + SearchPaneTitleStyle = styles.SearchPaneTitleStyle + SearchSelectedRowStyle = styles.SearchSelectedRowStyle + HelpPaneStyle = styles.HelpPaneStyle + HelpPaneTitleStyle = styles.HelpPaneTitleStyle + HelpSectionStyle = styles.HelpSectionStyle + HelpKeyStyle = styles.HelpKeyStyle + HelpLabelStyle = styles.HelpLabelStyle +} + +func stylesForPalette(p Palette) Styles { + statusBaseStyle := lipgloss.NewStyle().Background(lipgloss.Color(p.ColorStatusBase)).Foreground(lipgloss.Color(p.StatusBaseFg)) + return Styles{ + Palette: p, + FileHeaderStyle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(p.FileHeaderFg)), + FileRuleStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.FileRuleFg)), + PanelTitleStyle: lipgloss.NewStyle().Bold(true).Underline(true), + MutedStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.ColorMutedText)), + AddedLineStyle: lipgloss.NewStyle().Background(lipgloss.Color(p.AddedLineBg)).Foreground(lipgloss.Color(p.ColorText)), + DeletedLineStyle: lipgloss.NewStyle().Background(lipgloss.Color(p.DeletedLineBg)).Foreground(lipgloss.Color(p.ColorText)), + AddedMarkerStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.AddedMarkerFg)).Bold(true), + DeletedMarkerStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.DeletedMarkerFg)).Bold(true), + LineNumberStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.ColorMutedText)), + SelectedExpander: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(p.ColorAccent)), + CursorRowStyle: lipgloss.NewStyle().Background(lipgloss.Color(p.CursorRowBg)), + SelectedRowStyle: lipgloss.NewStyle().Background(lipgloss.Color(p.SelectedRowBg)), + CommentRangeRowStyle: lipgloss.NewStyle().Background(lipgloss.Color(p.CommentRangeRowBg)), + KeywordStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.ColorKeyword)), + FunctionStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.ColorFunction)), + TypeStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.ColorType)), + NameStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.ColorText)), + StringStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.ColorString)), + NumberStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.ColorNumber)), + CommentStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.ColorMutedText)).Italic(true), + OperatorStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.ColorKeyword)), + PunctuationStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.ColorText)), + StatusBaseStyle: statusBaseStyle, + StatusAppStyle: statusBaseStyle.Bold(true).Background(lipgloss.Color(p.StatusAppBg)).Foreground(lipgloss.Color(p.StatusAppFg)).Padding(0, 1), + StatusModeStyle: statusBaseStyle.Foreground(lipgloss.Color(p.StatusModeFg)).Padding(0, 1), + StatusInfoStyle: statusBaseStyle.Foreground(lipgloss.Color(p.ColorStatusInfo)).Padding(0, 1), + StatusKeyStyle: statusBaseStyle.Foreground(lipgloss.Color(p.StatusKeyFg)).Bold(true), + StatusHintTextStyle: statusBaseStyle.Foreground(lipgloss.Color(p.StatusHintTextFg)), + SearchPaneStyle: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(p.SearchBorderFg)).Padding(0, 1), + SearchPaneTitleStyle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(p.SearchTitleFg)), + SearchSelectedRowStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.SearchSelectedFg)).Background(lipgloss.Color(p.SearchSelectedBg)), + HelpPaneStyle: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(p.HelpBorderFg)).Padding(0, 2), + HelpPaneTitleStyle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(p.HelpTitleFg)), + HelpSectionStyle: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(p.HelpSectionFg)), + HelpKeyStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.HelpKeyFg)).Bold(true), + HelpLabelStyle: lipgloss.NewStyle().Foreground(lipgloss.Color(p.HelpLabelFg)), + } +} diff --git a/internal/adapters/in/tui/theme/styles_test.go b/internal/adapters/in/tui/theme/styles_test.go new file mode 100644 index 0000000..99ec739 --- /dev/null +++ b/internal/adapters/in/tui/theme/styles_test.go @@ -0,0 +1,38 @@ +package theme + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "ero/internal/core" +) + +func TestApplyAppearanceKeepsDarkPaletteAsDefault(t *testing.T) { + ApplyAppearance(core.ThemeAppearanceDark) + changed := ApplyAppearance(core.ThemeAppearanceDark) + t.Cleanup(func() { ApplyAppearance(core.ThemeAppearanceDark) }) + + assert.False(t, changed) + assert.Equal(t, core.ThemeAppearanceDark, CurrentAppearance()) + assert.Equal(t, "#000000", CurrentPalette().ColorBackground) + assert.Equal(t, "#c9d1d9", ColorText) + assert.Equal(t, "#011209", CurrentPalette().AddedLineBg) + assert.Equal(t, "github-dark", CurrentPalette().ChromaStyle) +} + +func TestApplyAppearanceSwitchesToCompleteLightPalette(t *testing.T) { + ApplyAppearance(core.ThemeAppearanceDark) + t.Cleanup(func() { ApplyAppearance(core.ThemeAppearanceDark) }) + + changed := ApplyAppearance(core.ThemeAppearanceLight) + + assert.True(t, changed) + assert.Equal(t, core.ThemeAppearanceLight, CurrentAppearance()) + assert.Equal(t, "#ffffff", CurrentPalette().ColorBackground) + assert.Equal(t, "#24292f", ColorText) + assert.Equal(t, "#dafbe1", CurrentPalette().AddedLineBg) + assert.Equal(t, "#0969da", ColorAccent) + assert.Equal(t, "github", CurrentPalette().ChromaStyle) + assert.Equal(t, "github", CurrentPalette().MarkdownCodeTheme) +} diff --git a/internal/app/app.go b/internal/app/app.go index ea14f02..74cfe78 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/viper" "ero/internal/adapters/in/cli" + "ero/internal/adapters/in/systemtheme" "ero/internal/adapters/in/terminal" "ero/internal/adapters/in/tui" clipboardadapter "ero/internal/adapters/out/clipboard" @@ -32,6 +33,11 @@ type startupPrompt interface { PromptLocalChangeMode() (core.DiffMode, error) } +type themedStartupPrompt interface { + startupPrompt + WithAppearance(core.ThemeAppearance) tui.StartupPrompt +} + type tuiRunner interface { Run(model tea.Model) error } @@ -48,7 +54,7 @@ func New() (*App, error) { reviewLoader := core.NewReviewLoader(repositoryLoader, repositoryLoader, syntaxTokenizer, repositoryLoader) runner := tui.NewRunner() clipboardWriter := clipboardadapter.NewSystemWriter() - return newAppWithClipboard(cfg, reviewLoader, runner, repositoryLoader, tui.NewStartupPrompt(), terminal.IsInteractive, clipboardWriter) + return newAppWithClipboard(cfg, reviewLoader, runner, repositoryLoader, tui.NewStartupPrompt(), terminal.IsInteractive, clipboardWriter, systemtheme.PortalReader{}) } func newApp(cfg *viper.Viper, loader reviewLoader, runner tuiRunner) (*App, error) { @@ -56,10 +62,10 @@ func newApp(cfg *viper.Viper, loader reviewLoader, runner tuiRunner) (*App, erro } func newAppWithStartup(cfg *viper.Viper, loader reviewLoader, runner tuiRunner, startupReader ports.StartupStateReader[core.StartupState], prompt startupPrompt, isInteractive func() bool) (*App, error) { - return newAppWithClipboard(cfg, loader, runner, startupReader, prompt, isInteractive, nil) + return newAppWithClipboard(cfg, loader, runner, startupReader, prompt, isInteractive, nil, nil) } -func newAppWithClipboard(cfg *viper.Viper, loader reviewLoader, runner tuiRunner, startupReader ports.StartupStateReader[core.StartupState], prompt startupPrompt, isInteractive func() bool, clipboardWriter ports.ClipboardWriter) (*App, error) { +func newAppWithClipboard(cfg *viper.Viper, loader reviewLoader, runner tuiRunner, startupReader ports.StartupStateReader[core.StartupState], prompt startupPrompt, isInteractive func() bool, clipboardWriter ports.ClipboardWriter, systemThemeReader ports.SystemThemeReader) (*App, error) { if cfg == nil { cfg = viper.New() } @@ -71,6 +77,10 @@ func newAppWithClipboard(cfg *viper.Viper, loader reviewLoader, runner tuiRunner } root, err := cli.NewRootCommand(cfg, func() error { + if err := loadRuntimeConfig(cfg); err != nil { + return fmt.Errorf("load config: %w", err) + } + themeModeChanges := watchThemeConfigChanges(cfg) log, logPath, cleanupLogs, err := logging.Init(logging.Config{Level: cfg.GetString("log-level"), Path: cfg.GetString("log-file")}) if err != nil { return fmt.Errorf("initialize logging: %w", err) @@ -79,6 +89,18 @@ func newAppWithClipboard(cfg *viper.Viper, loader reviewLoader, runner tuiRunner ctx := zerowrap.WithCtx(context.Background(), log) log.Info().Str("log_path", logPath).Msg("ero started") + initialThemeMode := core.ParseThemeMode(cfg.GetString("theme")) + initialSystemTheme := core.SystemThemeUnknown + if initialThemeMode == core.ThemeModeAuto && systemThemeReader != nil { + preference, err := systemThemeReader.CurrentPreference(ctx) + if err == nil { + initialSystemTheme = preference + } else { + log.Debug().Err(err).Msg("system theme detection failed") + } + } + initialThemeAppearance := core.ResolveThemeAppearance(initialThemeMode, initialSystemTheme, core.ThemeAppearanceLight) + initialRequest := core.ReviewRequest{ RepoPath: cfg.GetString("repo-path"), ContextLines: cfg.GetInt("context-lines"), @@ -89,7 +111,11 @@ func newAppWithClipboard(cfg *viper.Viper, loader reviewLoader, runner tuiRunner UpstreamRef: cfg.GetString("upstream-ref"), } if cfg.GetBool("startup-detect") { - request, err := resolveStartupRequest(initialRequest, startupReader, prompt, isInteractive) + startupPrompt := prompt + if themed, ok := prompt.(themedStartupPrompt); ok { + startupPrompt = themed.WithAppearance(initialThemeAppearance) + } + request, err := resolveStartupRequest(initialRequest, startupReader, startupPrompt, isInteractive) if err != nil { return err } @@ -115,7 +141,11 @@ func newAppWithClipboard(cfg *viper.Viper, loader reviewLoader, runner tuiRunner } reviewContext := buildReviewContext(initialRequest, files, metadata, version) var compatibilityProviders []ports.ReviewProviderClient - err = runner.Run(tui.NewModelWithActiveProviderContext(ctx, files, terminal.NewCapabilities(), loader, initialRequest, clipboardWriter, reviewContext, activeProvider, compatibilityProviders)) + err = runner.Run(tui.NewModelWithActiveProviderContextConfig(ctx, files, terminal.NewCapabilities(), loader, initialRequest, clipboardWriter, reviewContext, activeProvider, compatibilityProviders, tui.ModelConfig{ + ThemeMode: initialThemeMode, + SystemTheme: initialSystemTheme, + ThemeModeChanges: themeModeChanges, + })) if err != nil { log.Error().Err(err).Msg("tui exited with error") return err diff --git a/internal/app/app_test.go b/internal/app/app_test.go index ad5a3a5..93df82f 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "errors" + "os" + "path/filepath" "testing" tea "charm.land/bubbletea/v2" @@ -11,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "ero/internal/adapters/in/tui" "ero/internal/core" "ero/internal/ports" "ero/internal/ports/mocks" @@ -246,6 +249,85 @@ func TestRunLoadsReviewAndRunsTUIWithConfig(t *testing.T) { } } +func TestRunPassesInitialSystemThemeToTUI(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + preference core.SystemThemePreference + wantMode core.ThemeMode + want core.ThemeAppearance + }{ + {name: "auto follows dark system", preference: core.SystemThemePreferDark, wantMode: core.ThemeModeAuto, want: core.ThemeAppearanceDark}, + {name: "auto follows light system", preference: core.SystemThemePreferLight, wantMode: core.ThemeModeAuto, want: core.ThemeAppearanceLight}, + {name: "explicit dark ignores light system", args: []string{"--theme", "dark"}, preference: core.SystemThemePreferLight, wantMode: core.ThemeModeDark, want: core.ThemeAppearanceDark}, + {name: "explicit light ignores dark system", args: []string{"--theme", "light"}, preference: core.SystemThemePreferDark, wantMode: core.ThemeModeLight, want: core.ThemeAppearanceLight}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := viper.New() + loader := &fakeReviewLoader{files: minimalReviewFiles()} + runner := &fakeRunner{} + application, err := newAppWithClipboard(cfg, loader, runner, nil, nil, func() bool { return false }, nil, fakeSystemThemeReader{preference: tt.preference}) + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + err = application.Run(tt.args, &stdout, &stderr) + require.NoError(t, err) + + model, ok := runner.model.(tui.Model) + require.True(t, ok) + assert.Equal(t, tt.wantMode, model.ThemeMode()) + assert.Equal(t, tt.want, model.ThemeAppearance()) + }) + } +} + +func TestRunPassesThemeModeFromFlagToTUI(t *testing.T) { + t.Parallel() + + cfg := viper.New() + loader := &fakeReviewLoader{files: minimalReviewFiles()} + runner := &fakeRunner{} + application, err := newApp(cfg, loader, runner) + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + err = application.Run([]string{"--theme", "light"}, &stdout, &stderr) + require.NoError(t, err) + + model, ok := runner.model.(tui.Model) + require.True(t, ok) + assert.Equal(t, core.ThemeModeLight, model.ThemeMode()) +} + +func TestRunLoadsThemeModeFromConfigFile(t *testing.T) { + t.Parallel() + + configPath := filepath.Join(t.TempDir(), "ero.toml") + require.NoError(t, os.WriteFile(configPath, []byte("theme = \"dark\"\n"), 0o600)) + cfg := viper.New() + loader := &fakeReviewLoader{files: minimalReviewFiles()} + runner := &fakeRunner{} + application, err := newApp(cfg, loader, runner) + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + err = application.Run([]string{"--config", configPath}, &stdout, &stderr) + require.NoError(t, err) + + model, ok := runner.model.(tui.Model) + require.True(t, ok) + assert.Equal(t, core.ThemeModeDark, model.ThemeMode()) +} + func TestBuildReviewProvidersUsesDescriptorsAndFactory(t *testing.T) { t.Parallel() @@ -326,6 +408,18 @@ func minimalReviewFiles() []core.ReviewFile { }} } +type fakeSystemThemeReader struct { + preference core.SystemThemePreference + err error +} + +func (f fakeSystemThemeReader) CurrentPreference(context.Context) (core.SystemThemePreference, error) { + if f.err != nil { + return core.SystemThemeUnknown, f.err + } + return f.preference, nil +} + type fakeStartupPrompt struct { mode core.DiffMode err error diff --git a/internal/app/config.go b/internal/app/config.go new file mode 100644 index 0000000..57cdacc --- /dev/null +++ b/internal/app/config.go @@ -0,0 +1,67 @@ +package app + +import ( + "errors" + "os" + "path/filepath" + + "github.com/fsnotify/fsnotify" + "github.com/spf13/viper" + + "ero/internal/core" +) + +func loadRuntimeConfig(cfg *viper.Viper) error { + if cfg == nil { + return nil + } + if configPath := cfg.GetString("config"); configPath != "" { + cfg.SetConfigFile(configPath) + } else if cfg.ConfigFileUsed() == "" { + cfg.SetConfigName("config") + cfg.SetConfigType("toml") + if configDir, err := os.UserConfigDir(); err == nil && configDir != "" { + cfg.AddConfigPath(filepath.Join(configDir, "ero")) + } + } + + err := cfg.ReadInConfig() + if err == nil { + return nil + } + var notFound viper.ConfigFileNotFoundError + if errors.As(err, ¬Found) && cfg.GetString("config") == "" { + return nil + } + return err +} + +// watchThemeConfigChanges starts Viper's process-lifetime config watcher and +// emits the latest theme mode whenever the config file changes. Viper does not +// expose a watcher stop hook, so Ero intentionally keeps this alive until exit. +func watchThemeConfigChanges(cfg *viper.Viper) <-chan core.ThemeMode { + if cfg == nil || cfg.ConfigFileUsed() == "" { + return nil + } + changes := make(chan core.ThemeMode, 1) + cfg.OnConfigChange(func(fsnotify.Event) { + if err := cfg.ReadInConfig(); err != nil { + return + } + mode := core.ParseThemeMode(cfg.GetString("theme")) + select { + case changes <- mode: + default: + select { + case <-changes: + default: + } + select { + case changes <- mode: + default: + } + } + }) + cfg.WatchConfig() + return changes +} diff --git a/internal/core/theme.go b/internal/core/theme.go new file mode 100644 index 0000000..f1377f6 --- /dev/null +++ b/internal/core/theme.go @@ -0,0 +1,95 @@ +package core + +import "strings" + +// ThemeMode describes how Ero chooses its active color palette. +type ThemeMode string + +const ( + ThemeModeAuto ThemeMode = "auto" + ThemeModeLight ThemeMode = "light" + ThemeModeDark ThemeMode = "dark" +) + +// SystemThemePreference is the light/dark preference detected from the terminal +// or host system. +type SystemThemePreference int + +const ( + SystemThemeUnknown SystemThemePreference = iota + SystemThemePreferDark + SystemThemePreferLight +) + +// ThemeAppearance is the concrete palette currently used for rendering. +type ThemeAppearance string + +const ( + ThemeAppearanceDark ThemeAppearance = "dark" + ThemeAppearanceLight ThemeAppearance = "light" +) + +// ParseThemeMode normalizes a theme mode string to a valid ThemeMode. +// It trims whitespace, lowercases input, and returns ThemeModeAuto for empty or +// unrecognized values. +func ParseThemeMode(raw string) ThemeMode { + switch strings.ToLower(strings.TrimSpace(raw)) { + case string(ThemeModeLight): + return ThemeModeLight + case string(ThemeModeDark): + return ThemeModeDark + case "", string(ThemeModeAuto): + return ThemeModeAuto + default: + return ThemeModeAuto + } +} + +// SystemThemePreferenceFromDarkBackground converts terminal background color +// detection into the corresponding SystemThemePreference. +func SystemThemePreferenceFromDarkBackground(isDark bool) SystemThemePreference { + if isDark { + return SystemThemePreferDark + } + return SystemThemePreferLight +} + +// ParseSystemThemePreference normalizes a raw XDG portal color-scheme value. +// The portal uses 0 for no preference, 1 for prefer-dark, and 2 for +// prefer-light. +func ParseSystemThemePreference(raw uint32) SystemThemePreference { + switch raw { + case 1: + return SystemThemePreferDark + case 2: + return SystemThemePreferLight + case 0: + return SystemThemeUnknown + default: + return SystemThemeUnknown + } +} + +// ResolveThemeAppearance chooses the concrete light or dark appearance. Explicit +// light/dark modes win, auto follows the detected system preference, then the +// fallback appearance, and finally dark when nothing else is available. +func ResolveThemeAppearance(mode ThemeMode, preference SystemThemePreference, fallback ThemeAppearance) ThemeAppearance { + switch mode { + case ThemeModeLight: + return ThemeAppearanceLight + case ThemeModeDark: + return ThemeAppearanceDark + default: + switch preference { + case SystemThemePreferLight: + return ThemeAppearanceLight + case SystemThemePreferDark: + return ThemeAppearanceDark + default: + if fallback == ThemeAppearanceLight { + return ThemeAppearanceLight + } + return ThemeAppearanceDark + } + } +} diff --git a/internal/core/theme_test.go b/internal/core/theme_test.go new file mode 100644 index 0000000..2547885 --- /dev/null +++ b/internal/core/theme_test.go @@ -0,0 +1,84 @@ +package core + +import "testing" + +func TestParseThemeMode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + want ThemeMode + }{ + {name: "empty defaults auto", raw: "", want: ThemeModeAuto}, + {name: "auto", raw: "auto", want: ThemeModeAuto}, + {name: "light", raw: "light", want: ThemeModeLight}, + {name: "dark", raw: "dark", want: ThemeModeDark}, + {name: "trims and lowercases", raw: " Light ", want: ThemeModeLight}, + {name: "unknown defaults auto", raw: "system", want: ThemeModeAuto}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := ParseThemeMode(tt.raw); got != tt.want { + t.Fatalf("ParseThemeMode(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} + +func TestParseSystemThemePreference(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw uint32 + want SystemThemePreference + }{ + {name: "no preference", raw: 0, want: SystemThemeUnknown}, + {name: "prefer dark", raw: 1, want: SystemThemePreferDark}, + {name: "prefer light", raw: 2, want: SystemThemePreferLight}, + {name: "unknown", raw: 99, want: SystemThemeUnknown}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := ParseSystemThemePreference(tt.raw); got != tt.want { + t.Fatalf("ParseSystemThemePreference(%d) = %v, want %v", tt.raw, got, tt.want) + } + }) + } +} + +func TestResolveThemeAppearance(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mode ThemeMode + preference SystemThemePreference + fallback ThemeAppearance + want ThemeAppearance + }{ + {name: "force light ignores dark preference", mode: ThemeModeLight, preference: SystemThemePreferDark, fallback: ThemeAppearanceDark, want: ThemeAppearanceLight}, + {name: "force dark ignores light preference", mode: ThemeModeDark, preference: SystemThemePreferLight, fallback: ThemeAppearanceLight, want: ThemeAppearanceDark}, + {name: "auto follows dark preference", mode: ThemeModeAuto, preference: SystemThemePreferDark, fallback: ThemeAppearanceLight, want: ThemeAppearanceDark}, + {name: "auto follows light preference", mode: ThemeModeAuto, preference: SystemThemePreferLight, fallback: ThemeAppearanceDark, want: ThemeAppearanceLight}, + {name: "auto keeps light fallback without preference", mode: ThemeModeAuto, preference: SystemThemeUnknown, fallback: ThemeAppearanceLight, want: ThemeAppearanceLight}, + {name: "auto defaults dark without preference", mode: ThemeModeAuto, preference: SystemThemeUnknown, fallback: "", want: ThemeAppearanceDark}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := ResolveThemeAppearance(tt.mode, tt.preference, tt.fallback); got != tt.want { + t.Fatalf("ResolveThemeAppearance(%q, %v, %q) = %q, want %q", tt.mode, tt.preference, tt.fallback, got, tt.want) + } + }) + } +} diff --git a/internal/ports/theme.go b/internal/ports/theme.go new file mode 100644 index 0000000..5882831 --- /dev/null +++ b/internal/ports/theme.go @@ -0,0 +1,12 @@ +package ports + +import ( + "context" + + "ero/internal/core" +) + +// SystemThemeReader reads the host system light/dark preference. +type SystemThemeReader interface { + CurrentPreference(context.Context) (core.SystemThemePreference, error) +}