Skip to content
Merged
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
155 changes: 81 additions & 74 deletions internal/theme/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ type Theme struct {
RenamedFg string
UntrackedFg string

// Card
CardBg string

// Chrome
BorderFg string
StatusBarBg string
Expand All @@ -55,86 +58,90 @@ var Themes = map[string]Theme{
"light": LightTheme(),
}

// DarkTheme returns a GitHub Dark-inspired theme.
// DarkTheme returns a Catppuccin Mocha-inspired pastel dark theme.
func DarkTheme() Theme {
return Theme{
Bg: "#0d1117",
Fg: "#c9d1d9",

AddedFg: "#3fb950",
AddedBg: "#12261e",
RemovedFg: "#f85149",
RemovedBg: "#2d1214",
HunkFg: "#58a6ff",

LineNumFg: "#484f58",
LineNumAddedFg: "#2ea043",
LineNumRemovedFg: "#da3633",

HeaderBg: "#161b22",
HeaderFg: "#58a6ff",

HunkBg: "#161b22",

SelectedBg: "#161b22",
SelectedFg: "#f0f6fc",
StagedFg: "#3fb950",
ModifiedFg: "#d29922",
AddedFileFg: "#3fb950",
DeletedFg: "#f85149",
RenamedFg: "#d2a8ff",
UntrackedFg: "#8b949e",

BorderFg: "#30363d",
StatusBarBg: "#161b22",
StatusBarFg: "#8b949e",
HelpKeyFg: "#58a6ff",
HelpDescFg: "#8b949e",

AccentFg: "#58a6ff",

ChromaStyle: "github-dark",
Bg: "#1e1e2e",
Fg: "#e0e0f0",

AddedFg: "#a6e3a1",
AddedBg: "#1e3a2c",
RemovedFg: "#f38ba8",
RemovedBg: "#3b1d2e",
HunkFg: "#6c5ce7",

LineNumFg: "#585b70",
LineNumAddedFg: "#a6e3a1",
LineNumRemovedFg: "#f38ba8",

HeaderBg: "#282a3a",
HeaderFg: "#c678dd",

HunkBg: "#252636",

CardBg: "#232336",

SelectedBg: "#3d2b5a",
SelectedFg: "#c678dd",
StagedFg: "#50fa7b",
ModifiedFg: "#fab387",
AddedFileFg: "#a6e3a1",
DeletedFg: "#f38ba8",
RenamedFg: "#cba6f7",
UntrackedFg: "#7f849c",

BorderFg: "#6c5ce7",
StatusBarBg: "#1a1a2e",
StatusBarFg: "#b4befe",
HelpKeyFg: "#c678dd",
HelpDescFg: "#9399b2",

AccentFg: "#c678dd",

ChromaStyle: "catppuccin-mocha",
}
}

// LightTheme returns a GitHub Light-inspired theme.
// LightTheme returns a Catppuccin Latte-inspired pastel light theme.
func LightTheme() Theme {
return Theme{
Bg: "#ffffff",
Fg: "#1f2328",

AddedFg: "#1a7f37",
AddedBg: "#dafbe1",
RemovedFg: "#cf222e",
RemovedBg: "#ffebe9",
HunkFg: "#0969da",

LineNumFg: "#8c959f",
LineNumAddedFg: "#1a7f37",
LineNumRemovedFg: "#cf222e",

HeaderBg: "#f6f8fa",
HeaderFg: "#0969da",

HunkBg: "#f6f8fa",

SelectedBg: "#f6f8fa",
SelectedFg: "#1f2328",
StagedFg: "#1a7f37",
ModifiedFg: "#9a6700",
AddedFileFg: "#1a7f37",
DeletedFg: "#cf222e",
RenamedFg: "#8250df",
UntrackedFg: "#656d76",

BorderFg: "#d0d7de",
StatusBarBg: "#f6f8fa",
StatusBarFg: "#656d76",
HelpKeyFg: "#0969da",
HelpDescFg: "#656d76",

AccentFg: "#0969da",

ChromaStyle: "github",
Bg: "#eff1f5",
Fg: "#4c4f69",

AddedFg: "#1a7f2a",
AddedBg: "#e6f5e4",
RemovedFg: "#d20f39",
RemovedBg: "#fde4e8",
HunkFg: "#1e66f5",

LineNumFg: "#9ca0b0",
LineNumAddedFg: "#1a7f2a",
LineNumRemovedFg: "#d20f39",

HeaderBg: "#e6e9ef",
HeaderFg: "#8839ef",

HunkBg: "#e6e9ef",

CardBg: "#e6e9ef",

SelectedBg: "#d4c4f0",
SelectedFg: "#4c4f69",
StagedFg: "#087f23",
ModifiedFg: "#fe640b",
AddedFileFg: "#1a7f2a",
DeletedFg: "#d20f39",
RenamedFg: "#8839ef",
UntrackedFg: "#8c8fa1",

BorderFg: "#8839ef",
StatusBarBg: "#e6e9ef",
StatusBarFg: "#6c6f85",
HelpKeyFg: "#8839ef",
HelpDescFg: "#8c8fa1",

AccentFg: "#8839ef",

ChromaStyle: "catppuccin-latte",
}
}
113 changes: 110 additions & 3 deletions internal/theme/theme_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package theme

import (
"math"
"reflect"
"strings"
"regexp"
"strconv"
"testing"
)

Expand Down Expand Up @@ -41,8 +43,8 @@ func TestLightTheme_NonEmpty(t *testing.T) {
func TestDarkTheme_ChromaStyle(t *testing.T) {
t.Parallel()
th := DarkTheme()
if !strings.Contains(th.ChromaStyle, "dark") {
t.Errorf("dark theme ChromaStyle=%q, expected to contain 'dark'", th.ChromaStyle)
if th.ChromaStyle == "" {
t.Error("dark theme ChromaStyle should not be empty")
}
}

Expand All @@ -54,6 +56,111 @@ func TestLightTheme_ChromaStyle(t *testing.T) {
}
}

var hexColorRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)

func checkValidHex(t *testing.T, th Theme, label string) {
t.Helper()
v := reflect.ValueOf(th)
typ := v.Type()
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
name := typ.Field(i).Name
if field.Kind() != reflect.String || name == "ChromaStyle" {
continue
}
if !hexColorRe.MatchString(field.String()) {
t.Errorf("%s.%s = %q is not valid #RRGGBB", label, name, field.String())
}
}
}

func TestDarkTheme_ValidHex(t *testing.T) {
t.Parallel()
checkValidHex(t, DarkTheme(), "DarkTheme")
}

func TestLightTheme_ValidHex(t *testing.T) {
t.Parallel()
checkValidHex(t, LightTheme(), "LightTheme")
}

// relativeLuminance computes WCAG relative luminance from a hex color.
func relativeLuminance(hex string) float64 {
r, _ := strconv.ParseInt(hex[1:3], 16, 64)
g, _ := strconv.ParseInt(hex[3:5], 16, 64)
b, _ := strconv.ParseInt(hex[5:7], 16, 64)
linearize := func(c int64) float64 {
s := float64(c) / 255.0
if s <= 0.04045 {
return s / 12.92
}
return math.Pow((s+0.055)/1.055, 2.4)
}
return 0.2126*linearize(r) + 0.7152*linearize(g) + 0.0722*linearize(b)
}

// contrastRatio computes WCAG contrast ratio between two hex colors.
func contrastRatio(hex1, hex2 string) float64 {
l1 := relativeLuminance(hex1)
l2 := relativeLuminance(hex2)
if l1 < l2 {
l1, l2 = l2, l1
}
return (l1 + 0.05) / (l2 + 0.05)
}

type contrastPair struct {
fg, bg string
minRatio float64
label string
}

func checkContrast(t *testing.T, th Theme, label string) {
t.Helper()
pairs := []contrastPair{
{th.Fg, th.Bg, 4.5, "Fg/Bg"},
{th.AddedFg, th.AddedBg, 3.0, "AddedFg/AddedBg"},
{th.RemovedFg, th.RemovedBg, 3.0, "RemovedFg/RemovedBg"},
{th.HeaderFg, th.HeaderBg, 3.0, "HeaderFg/HeaderBg"},
{th.SelectedFg, th.SelectedBg, 3.0, "SelectedFg/SelectedBg"},
{th.StatusBarFg, th.StatusBarBg, 3.0, "StatusBarFg/StatusBarBg"},
{th.Fg, th.CardBg, 4.5, "Fg/CardBg"},
{th.HelpKeyFg, th.Bg, 3.0, "HelpKeyFg/Bg"},
}
for _, p := range pairs {
ratio := contrastRatio(p.fg, p.bg)
if ratio < p.minRatio {
t.Errorf("%s %s: contrast %.2f < %.1f (fg=%s bg=%s)",
label, p.label, ratio, p.minRatio, p.fg, p.bg)
}
}
}

func TestDarkTheme_ContrastRatios(t *testing.T) {
t.Parallel()
checkContrast(t, DarkTheme(), "DarkTheme")
}

func TestLightTheme_ContrastRatios(t *testing.T) {
t.Parallel()
checkContrast(t, LightTheme(), "LightTheme")
}

// TestContrastRatio_KnownValues verifies the formula against known WCAG values.
func TestContrastRatio_KnownValues(t *testing.T) {
t.Parallel()
// Black on white = 21:1
ratio := contrastRatio("#ffffff", "#000000")
if math.Abs(ratio-21.0) > 0.1 {
t.Errorf("white/black contrast = %.2f, want ~21.0", ratio)
}
// Same color = 1:1
ratio = contrastRatio("#888888", "#888888")
if math.Abs(ratio-1.0) > 0.01 {
t.Errorf("same color contrast = %.2f, want 1.0", ratio)
}
}

func TestThemes_DarkEqualsFunction(t *testing.T) {
t.Parallel()
if !reflect.DeepEqual(Themes["dark"], DarkTheme()) {
Expand Down
4 changes: 2 additions & 2 deletions internal/ui/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ func RenderSplitDiff(parsed ParsedDiff, filename string, styles Styles, t theme.
left := renderSplitSide(sl.Left, filename, styles, t, panelW, true)
right := renderSplitSide(sl.Right, filename, styles, t, panelW, false)
b.WriteString(left)
b.WriteString(styles.Border.Render("│"))
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(t.BorderFg)).Render("│"))
b.WriteString(right)
b.WriteByte('\n')
}
Expand All @@ -356,7 +356,7 @@ func RenderNewFileSplit(content, filename string, styles Styles, t theme.Theme,
left := renderSplitSide(nil, filename, styles, t, panelW, true)
right := renderSplitSide(&dl, filename, styles, t, panelW, false)
b.WriteString(left)
b.WriteString(styles.Border.Render("│"))
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(t.BorderFg)).Render("│"))
b.WriteString(right)
b.WriteByte('\n')
}
Expand Down
20 changes: 12 additions & 8 deletions internal/ui/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ func (m LogModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.viewport = viewport.New(m.width, m.height-2)
m.viewport = viewport.New(m.width-2, m.height-4)
m.ready = true
case logLoadedMsg:
m.commits = msg.commits
Expand Down Expand Up @@ -189,10 +189,12 @@ func (m LogModel) View() string {
}

func (m LogModel) viewList() string {
mainH := m.height - 2
contentH := m.height - 4 // card borders + status + help
cardW := m.width - 2 // inner width (card adds 2 for borders)

var b strings.Builder
for i, c := range m.commits {
if i >= mainH {
if i >= contentH {
break
}
line := m.renderCommitLine(c, i == m.cursor)
Expand All @@ -202,11 +204,11 @@ func (m LogModel) viewList() string {
}
}

main := lipgloss.NewStyle().Width(m.width).Height(mainH).Render(b.String())
card := renderCard(m.theme, "Commits", b.String(), true, cardW, contentH)
status := m.styles.StatusBar.Width(m.width).Render(
fmt.Sprintf(" %d commits", len(m.commits)))
help := m.renderLogHelp(false)
return lipgloss.JoinVertical(lipgloss.Left, main, status, help)
return lipgloss.JoinVertical(lipgloss.Left, card, status, help)
}

func (m LogModel) renderCommitLine(c git.Commit, selected bool) string {
Expand All @@ -220,14 +222,16 @@ func (m LogModel) renderCommitLine(c git.Commit, selected bool) string {
}

func (m LogModel) viewDiff() string {
mainH := m.height - 2
diff := lipgloss.NewStyle().Width(m.width).Height(mainH).Render(m.viewport.View())
contentH := m.height - 4
cardW := m.width - 2

c := m.commits[m.cursor]
title := c.Short + " " + c.Subject
card := renderCard(m.theme, title, m.viewport.View(), true, cardW, contentH)
status := m.styles.StatusBar.Width(m.width).Render(
fmt.Sprintf(" %s %s — %s", c.Short, c.Subject, c.Author))
help := m.renderLogHelp(true)
return lipgloss.JoinVertical(lipgloss.Left, diff, status, help)
return lipgloss.JoinVertical(lipgloss.Left, card, status, help)
}

func (m LogModel) renderLogHelp(inDiff bool) string {
Expand Down
Loading