Skip to content

Commit 9aebd6d

Browse files
ananthbclaude
andcommitted
Theme: follow OS appearance instead of locking to dark
Slim cosmoTheme down to the lime brand accent and a few size tweaks; delegate backgrounds, foregrounds, and variant switching to the default Fyne theme so the app follows light/dark like every other app. Replace raw color.NRGBA references (cText, cTextMute, cBorder, cSurface, …) with theme.Color(theme.ColorName…) lookups, and move semantic status colors (running=green, warn=orange, error=red) into status_colors.go so they stay constant across variants. Add a theme-change listener registry on Daemon and an AddListener wire in Run; the unified window and preferences window subscribe and rebuild on flip, so canvas primitives pick up the new variant at runtime. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fa40146 commit 9aebd6d

7 files changed

Lines changed: 140 additions & 125 deletions

File tree

internal/daemon/daemon.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ type Daemon struct {
6969
listenersMu sync.Mutex
7070
listeners map[int]func()
7171
nextListener int
72+
73+
// Theme-change listeners: fired when the OS flips light/dark, so
74+
// canvas primitives (which snapshot color at construction) get rebuilt.
75+
themeMu sync.Mutex
76+
themeListeners map[int]func()
77+
nextThemeListener int
7278
}
7379

7480
// setActiveUnifiedWindow records the currently-open main window so other
@@ -150,6 +156,16 @@ func (d *Daemon) Run() error {
150156
d.app.Settings().SetTheme(newCosmoTheme())
151157
d.app.SetIcon(appIcon())
152158

159+
prevVariant := d.app.Settings().ThemeVariant()
160+
d.app.Settings().AddListener(func(s fyne.Settings) {
161+
v := s.ThemeVariant()
162+
if v == prevVariant {
163+
return
164+
}
165+
prevVariant = v
166+
d.notifyThemeListeners()
167+
})
168+
153169
log.Printf("applet started (pid %d)", os.Getpid())
154170

155171
// Run the initial poll synchronously so the tray menu has
@@ -292,3 +308,31 @@ func (d *Daemon) notifyWorkspaceListeners() {
292308
fyne.Do(fn)
293309
}
294310
}
311+
312+
func (d *Daemon) addThemeListener(fn func()) (remove func()) {
313+
d.themeMu.Lock()
314+
defer d.themeMu.Unlock()
315+
if d.themeListeners == nil {
316+
d.themeListeners = map[int]func(){}
317+
}
318+
d.nextThemeListener++
319+
id := d.nextThemeListener
320+
d.themeListeners[id] = fn
321+
return func() {
322+
d.themeMu.Lock()
323+
delete(d.themeListeners, id)
324+
d.themeMu.Unlock()
325+
}
326+
}
327+
328+
func (d *Daemon) notifyThemeListeners() {
329+
d.themeMu.Lock()
330+
fns := make([]func(), 0, len(d.themeListeners))
331+
for _, fn := range d.themeListeners {
332+
fns = append(fns, fn)
333+
}
334+
d.themeMu.Unlock()
335+
for _, fn := range fns {
336+
fyne.Do(fn)
337+
}
338+
}

internal/daemon/gui_preferences.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ func (d *Daemon) showPreferences() {
7575
win.SetFixedSize(true)
7676
win.CenterOnScreen()
7777
win.SetContent(d.buildSettingsPanel(win))
78+
unsubscribeTheme := d.addThemeListener(func() {
79+
win.SetContent(d.buildSettingsPanel(win))
80+
})
81+
win.SetOnClosed(unsubscribeTheme)
7882
win.Show()
7983
})
8084
}
@@ -135,13 +139,13 @@ func (d *Daemon) buildHealthRow(c doctor.Check, win fyne.Window, rebuild func())
135139
var statusText string
136140
switch {
137141
case issue == nil:
138-
dotColor = cLime
142+
dotColor = statusOK
139143
statusText = "OK"
140144
case issue.Severity == doctor.SeverityError:
141-
dotColor = cRed
145+
dotColor = statusError
142146
statusText = "Error"
143147
default:
144-
dotColor = cOrange
148+
dotColor = statusWarn
145149
statusText = "Warning"
146150
}
147151
dot := canvas.NewCircle(dotColor)

internal/daemon/gui_window.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ type unifiedWindow struct {
2828
filter string
2929
filtered []string // repos matching current filter
3030
coderTargets []string
31+
32+
// currentView re-invokes the active right-panel view on theme change.
33+
currentView func()
3134
}
3235

3336
func (uw *unifiedWindow) loadRepos() {
@@ -272,6 +275,7 @@ func (uw *unifiedWindow) showWorkspaceDetail(providerName, name string) {
272275
}
273276

274277
func (uw *unifiedWindow) showCoderSummary() {
278+
uw.currentView = uw.showCoderSummary
275279
all := filterWorkspacesByProvider(uw.daemon.Workspaces(), provider.NameCoder)
276280
title := widget.NewLabel("Coder Workspaces")
277281
title.TextStyle = fyne.TextStyle{Bold: true}

internal/daemon/gui_window_cosmo.go

Lines changed: 52 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,6 @@ func (d *Daemon) newCosmoWindow() *unifiedWindow {
7575
}
7676
uw.refreshBanner()
7777
})
78-
win.SetOnClosed(func() {
79-
unsubscribe()
80-
if d.activeUnifiedWindow() == uw {
81-
d.setActiveUnifiedWindow(nil)
82-
}
83-
})
8478

8579
// Background fetch of all user repos.
8680
go func() {
@@ -96,12 +90,31 @@ func (d *Daemon) newCosmoWindow() *unifiedWindow {
9690
})
9791
}()
9892

99-
sidebar := uw.buildCosmoSidebar()
93+
rebuild := func() {
94+
sidebar := uw.buildCosmoSidebar()
95+
split := container.NewHSplit(sidebar, uw.content)
96+
split.Offset = 0.32
97+
win.SetContent(container.NewBorder(uw.banner, nil, nil, nil, split))
98+
}
99+
rebuild()
100100
uw.showCosmoWelcome()
101101

102-
split := container.NewHSplit(sidebar, uw.content)
103-
split.Offset = 0.32
104-
win.SetContent(container.NewBorder(uw.banner, nil, nil, nil, split))
102+
// Tree selection and branch expansion are reset on theme change.
103+
unsubscribeTheme := d.addThemeListener(func() {
104+
rebuild()
105+
uw.refreshBanner()
106+
if uw.currentView != nil {
107+
uw.currentView()
108+
}
109+
})
110+
111+
win.SetOnClosed(func() {
112+
unsubscribe()
113+
unsubscribeTheme()
114+
if d.activeUnifiedWindow() == uw {
115+
d.setActiveUnifiedWindow(nil)
116+
}
117+
})
105118
return uw
106119
}
107120

@@ -125,10 +138,10 @@ func (uw *unifiedWindow) refreshBanner() {
125138
// failing check. A tinted background and bold severity badge make the
126139
// banner hard to miss, so users notice cosmonaut needs their attention.
127140
func (uw *unifiedWindow) buildIssueBanner(c doctor.Check, issue *doctor.Issue) fyne.CanvasObject {
128-
accent := cOrange
141+
accent := statusWarn
129142
badgeText := "WARNING"
130143
if issue.Severity == doctor.SeverityError {
131-
accent = cRed
144+
accent = statusError
132145
badgeText = "ERROR"
133146
}
134147

@@ -141,7 +154,7 @@ func (uw *unifiedWindow) buildIssueBanner(c doctor.Check, issue *doctor.Issue) f
141154
badge.TextSize = 10
142155
badge.TextStyle = fyne.TextStyle{Monospace: true, Bold: true}
143156

144-
title := canvas.NewText(c.Title, cText)
157+
title := canvas.NewText(c.Title, theme.Color(theme.ColorNameForeground))
145158
title.TextSize = 13
146159
title.TextStyle = fyne.TextStyle{Bold: true}
147160

@@ -199,7 +212,7 @@ func (uw *unifiedWindow) buildCosmoSidebar() fyne.CanvasObject {
199212
mark.SetMinSize(fyne.NewSize(22, 22))
200213
mark.FillMode = canvas.ImageFillContain
201214

202-
title := canvas.NewText("Cosmonaut", cText)
215+
title := canvas.NewText("Cosmonaut", theme.Color(theme.ColorNameForeground))
203216
title.TextStyle = fyne.TextStyle{Bold: true}
204217
title.TextSize = 13
205218

@@ -326,11 +339,11 @@ func (uw *unifiedWindow) buildAccountFooter() fyne.CanvasObject {
326339
return "Stopped"
327340
}())
328341

329-
handle := canvas.NewText(ghUser, cText)
342+
handle := canvas.NewText(ghUser, theme.Color(theme.ColorNameForeground))
330343
handle.TextSize = 12
331344
handle.TextStyle = fyne.TextStyle{Bold: true}
332345

333-
sub := canvas.NewText("github.com", cTextMute)
346+
sub := canvas.NewText("github.com", theme.Color(theme.ColorNamePlaceHolder))
334347
sub.TextSize = 10
335348
sub.TextStyle = fyne.TextStyle{Monospace: true}
336349

@@ -347,7 +360,7 @@ func (uw *unifiedWindow) buildAccountFooter() fyne.CanvasObject {
347360

348361
// thinDivider returns a 1px canvas line using the theme border color.
349362
func thinDivider() fyne.CanvasObject {
350-
r := canvas.NewRectangle(cBorder)
363+
r := canvas.NewRectangle(theme.Color(theme.ColorNameSeparator))
351364
r.SetMinSize(fyne.NewSize(1, 1))
352365
return r
353366
}
@@ -362,6 +375,7 @@ func markIconResource() fyne.Resource {
362375
// ── CODESPACE DETAIL ────────────────────────────────────────────────────
363376

364377
func (uw *unifiedWindow) showCosmoCodespaceDetail(csName, repo string) {
378+
uw.currentView = func() { uw.showCosmoCodespaceDetail(csName, repo) }
365379
var cs *codespace.Codespace
366380
for _, c := range uw.daemon.Codespaces() {
367381
if c.Name == csName {
@@ -386,7 +400,7 @@ func (uw *unifiedWindow) showCosmoCodespaceDetail(csName, repo string) {
386400
if titleText == "" {
387401
titleText = cs.Name
388402
}
389-
heroTitle := canvas.NewText(titleText, cText)
403+
heroTitle := canvas.NewText(titleText, theme.Color(theme.ColorNameForeground))
390404
heroTitle.TextSize = 16
391405
heroTitle.TextStyle = fyne.TextStyle{Bold: true}
392406

@@ -590,28 +604,29 @@ func (uw *unifiedWindow) portRow(csName, repo string, port codespace.Port) fyne.
590604
func stateColor(state string) color.Color {
591605
switch state {
592606
case "Available", "Started", "ready", "running", "connected":
593-
return cLime
607+
return statusOK
594608
case "Starting", "starting", "pending":
595-
return cOrange
609+
return statusWarn
596610
case "Error":
597-
return cRed
611+
return statusError
598612
}
599-
return cTextMute
613+
return theme.Color(theme.ColorNamePlaceHolder)
600614
}
601615

602616
// ── WELCOME ─────────────────────────────────────────────────────────────
603617

604618
func (uw *unifiedWindow) showCosmoWelcome() {
619+
uw.currentView = uw.showCosmoWelcome
605620
mark := canvas.NewImageFromResource(markIconResource())
606621
mark.SetMinSize(fyne.NewSize(56, 56))
607622
mark.FillMode = canvas.ImageFillContain
608623

609-
h := canvas.NewText("Welcome to Cosmonaut", cText)
624+
h := canvas.NewText("Welcome to Cosmonaut", theme.Color(theme.ColorNameForeground))
610625
h.TextSize = 16
611626
h.TextStyle = fyne.TextStyle{Bold: true}
612627
h.Alignment = fyne.TextAlignCenter
613628

614-
sub := canvas.NewText("Select a GitHub repo or Coder workspace to get started.", cTextMute)
629+
sub := canvas.NewText("Select a GitHub repo or Coder workspace to get started.", theme.Color(theme.ColorNamePlaceHolder))
615630
sub.TextSize = 12
616631
sub.Alignment = fyne.TextAlignCenter
617632

@@ -624,15 +639,16 @@ func (uw *unifiedWindow) showCosmoWelcome() {
624639
// ── REPO SUMMARY ───────────────────────────────────────────────────────
625640

626641
func (uw *unifiedWindow) showCosmoRepoSummary(repo string) {
642+
uw.currentView = func() { uw.showCosmoRepoSummary(repo) }
627643
all := filterWorkspacesByProvider(uw.daemon.Workspaces(), provider.NameGitHub)
628644
repoCS := provider.FilterByRepo(all, repo)
629645

630-
title := canvas.NewText(repo, cText)
646+
title := canvas.NewText(repo, theme.Color(theme.ColorNameForeground))
631647
title.TextSize = 18
632648
title.TextStyle = fyne.TextStyle{Bold: true}
633649

634650
countText := fmt.Sprintf("%d workspace(s)", len(repoCS))
635-
info := canvas.NewText(countText, cTextDim)
651+
info := canvas.NewText(countText, theme.Color(theme.ColorNamePlaceHolder))
636652
info.TextSize = 13
637653

638654
createBtn := primaryButton("Create new GitHub codespace", func() {
@@ -649,7 +665,8 @@ func (uw *unifiedWindow) showCosmoRepoSummary(repo string) {
649665
// ── CREATE ──────────────────────────────────────────────────────────────
650666

651667
func (uw *unifiedWindow) showCreateNewGeneric() {
652-
title := canvas.NewText("Create a new workspace", cText)
668+
uw.currentView = uw.showCreateNewGeneric
669+
title := canvas.NewText("Create a new workspace", theme.Color(theme.ColorNameForeground))
653670
title.TextSize = 18
654671
title.TextStyle = fyne.TextStyle{Bold: true}
655672

@@ -677,13 +694,14 @@ func (uw *unifiedWindow) showCreateNewGeneric() {
677694
}
678695

679696
func (uw *unifiedWindow) showCosmoCreateNew(repo string) {
697+
uw.currentView = func() { uw.showCosmoCreateNew(repo) }
680698
target, resolvedName := guiTargetForRepo(uw.daemon.Cfg, repo)
681699

682-
title := canvas.NewText("Create a new codespace", cText)
700+
title := canvas.NewText("Create a new codespace", theme.Color(theme.ColorNameForeground))
683701
title.TextSize = 18
684702
title.TextStyle = fyne.TextStyle{Bold: true}
685703

686-
hint := canvas.NewText("A short label makes it easier to find later.", cTextMute)
704+
hint := canvas.NewText("A short label makes it easier to find later.", theme.Color(theme.ColorNamePlaceHolder))
687705
hint.TextSize = 12
688706

689707
repoLbl := widget.NewLabel(repo)
@@ -750,6 +768,7 @@ func (uw *unifiedWindow) showCosmoCreateNew(repo string) {
750768
}
751769

752770
func (uw *unifiedWindow) showCoderWorkspaceDetail(ws provider.Workspace) {
771+
uw.currentView = func() { uw.showCoderWorkspaceDetail(ws) }
753772
target, resolvedName := guiTargetForCoderWorkspace(uw.daemon.Cfg, ws)
754773

755774
stateLbl := canvas.NewText(strings.ToUpper(ws.State), stateColor(ws.State))
@@ -761,11 +780,11 @@ func (uw *unifiedWindow) showCoderWorkspaceDetail(ws provider.Workspace) {
761780
if title == "" {
762781
title = ws.Name
763782
}
764-
heroTitle := canvas.NewText(title, cText)
783+
heroTitle := canvas.NewText(title, theme.Color(theme.ColorNameForeground))
765784
heroTitle.TextSize = 16
766785
heroTitle.TextStyle = fyne.TextStyle{Bold: true}
767786

768-
subtitle := canvas.NewText("coder", cTextMute)
787+
subtitle := canvas.NewText("coder", theme.Color(theme.ColorNamePlaceHolder))
769788
subtitle.TextSize = 11
770789
subtitle.TextStyle = fyne.TextStyle{Monospace: true}
771790

@@ -1142,7 +1161,8 @@ func coderPortTargetName(cfg *config.Config, ws provider.Workspace, fallback str
11421161
}
11431162

11441163
func (uw *unifiedWindow) showCosmoCreateNewCoder() {
1145-
title := canvas.NewText("Create a new Coder workspace", cText)
1164+
uw.currentView = uw.showCosmoCreateNewCoder
1165+
title := canvas.NewText("Create a new Coder workspace", theme.Color(theme.ColorNameForeground))
11461166
title.TextSize = 18
11471167
title.TextStyle = fyne.TextStyle{Bold: true}
11481168

internal/daemon/status_colors.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package daemon
2+
3+
import "image/color"
4+
5+
// Semantic status colors. These intentionally do NOT vary by theme
6+
// variant: "running is green, error is red" should read the same way
7+
// in light mode and dark mode. For chrome colors (text, borders,
8+
// backgrounds) use fyne.io/fyne/v2/theme accessors instead so they
9+
// follow the OS appearance.
10+
var (
11+
statusOK = color.NRGBA{0xa3, 0xe6, 0x35, 0xff} // lime
12+
statusWarn = color.NRGBA{0xf9, 0x73, 0x16, 0xff} // orange
13+
statusError = color.NRGBA{0xef, 0x44, 0x44, 0xff} // red
14+
)

0 commit comments

Comments
 (0)