diff --git a/internal/config/config.go b/internal/config/config.go index 653cd85..0a422d8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,6 +37,7 @@ func DefaultConfig() Config { HighCPUThreshold: 75, HighCPUDuration: 30 * time.Second, HighCPUCooldown: 10 * time.Minute, + Theme: "system", // 👈 Default fallback for new installations }, Rules: make([]core.AppRule, 0), } @@ -62,6 +63,10 @@ func MigrateConfig(in Config) (Config, error) { in.Preferences.TopProcessesSort = core.NormalizeTopProcessesSortMode(in.Preferences.TopProcessesSort) in.Preferences.CPUDisplayMode = core.NormalizeCPUDisplayMode(in.Preferences.CPUDisplayMode) in.Preferences.CPUGraphWindow = core.NormalizeCPUGraphWindow(in.Preferences.CPUGraphWindow) + + // 👈 Clean up and normalize the theme input text + in.Preferences.Theme = core.NormalizeThemeMode(in.Preferences.Theme) + if in.Preferences.HighCPUThreshold == 0 { in.Preferences.HighCPUThreshold = defaults.Preferences.HighCPUThreshold } @@ -149,6 +154,13 @@ func (store Store) LoadConfig() (Config, error) { migrated.Preferences.CPUGraphWindow = DefaultConfig().Preferences.CPUGraphWindow changed = true } + + // 👈 Backfill legacy installations that don't have a theme selected yet + if !configHasPreference(payload, "theme") { + migrated.Preferences.Theme = DefaultConfig().Preferences.Theme + changed = true + } + if !configHasPreference(payload, "wakeGrace") { migrated.Preferences.WakeGrace = DefaultConfig().Preferences.WakeGrace if hasLegacyStartupGrace { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 223a65b..c5d9f49 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -39,6 +39,9 @@ func TestLoadConfigReturnsDefaultsWhenMissing(t *testing.T) { if cfg.Preferences.CPUDisplayMode != core.CPUDisplayModePerCoreProcess { t.Fatalf("CPU display mode = %q, want %q", cfg.Preferences.CPUDisplayMode, core.CPUDisplayModePerCoreProcess) } + if cfg.Preferences.Theme != "system" { + t.Fatalf("theme = %q, want %q", cfg.Preferences.Theme, "system") + } } func TestSaveConfigWritesAtomicallyReadableConfig(t *testing.T) { @@ -148,7 +151,6 @@ func TestLoadConfigBackfillsMissingPreferences(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "config.json"), payload, 0o644); err != nil { t.Fatalf("write config: %v", err) } - cfg, err := store.LoadConfig() if err != nil { t.Fatalf("load: %v", err) @@ -171,6 +173,9 @@ func TestLoadConfigBackfillsMissingPreferences(t *testing.T) { if cfg.Preferences.CPUDisplayMode != core.CPUDisplayModePerCoreProcess { t.Fatalf("CPU display mode = %q, want %q", cfg.Preferences.CPUDisplayMode, core.CPUDisplayModePerCoreProcess) } + if cfg.Preferences.Theme != "system" { + t.Fatal("theme should be backfilled to system") + } updated, err := os.ReadFile(filepath.Join(dir, "config.json")) if err != nil { t.Fatalf("read updated config: %v", err) @@ -193,6 +198,9 @@ func TestLoadConfigBackfillsMissingPreferences(t *testing.T) { if !bytes.Contains(updated, []byte(`"cpuDisplayMode": "per_core_process"`)) { t.Fatalf("updated config missing cpuDisplayMode: %s", updated) } + if !bytes.Contains(updated, []byte(`"theme": "system"`)) { + t.Fatalf("updated config missing theme: %s", updated) + } if bytes.Contains(updated, []byte("startupGrace")) { t.Fatalf("updated config should drop legacy startupGrace: %s", updated) } diff --git a/internal/core/rules.go b/internal/core/rules.go index 3a5da38..80a3420 100644 --- a/internal/core/rules.go +++ b/internal/core/rules.go @@ -1,8 +1,9 @@ package core -import "slices" - -import "time" +import ( + "slices" + "time" +) type RuleMode string type RuleTrackingLocation string @@ -141,6 +142,7 @@ type GlobalPreferences struct { HighCPUThreshold float64 `json:"highCPUThreshold"` HighCPUDuration time.Duration `json:"highCPUDuration"` HighCPUCooldown time.Duration `json:"highCPUCooldown"` + Theme string `json:"theme"` } const ( @@ -193,3 +195,12 @@ func NormalizeCPUDisplayMode(mode string) string { type Clock interface { Now() time.Time } + +func NormalizeThemeMode(mode string) string { + switch mode { + case "light", "dark": + return mode + default: + return "system" + } +} diff --git a/internal/macos/menu/menu_bar_bridge.m b/internal/macos/menu/menu_bar_bridge.m index b130db4..80f9602 100644 --- a/internal/macos/menu/menu_bar_bridge.m +++ b/internal/macos/menu/menu_bar_bridge.m @@ -1,5 +1,6 @@ #import #import +#import #import #import #import @@ -112,6 +113,9 @@ - (void)drawRect:(NSRect)dirtyRect { @end static BOOL OpenTamerCommandShouldKeepMenuOpen(NSString *command) { + if ([command hasPrefix:@"pref-string|theme|"]) { + return NO; + } return [command hasPrefix:@"pref-"] || [command hasPrefix:@"graph-window|"]; } @@ -236,7 +240,6 @@ - (instancetype)initWithFrame:(NSRect)frame { if (self == nil) { return nil; } - self.appearance = [NSAppearance appearanceNamed:NSAppearanceNameAqua]; self.actions = [[NSMutableArray alloc] init]; return self; } @@ -262,7 +265,11 @@ - (NSView *)hitTest:(NSPoint)point { } - (void)drawRect:(NSRect)dirtyRect { - [[NSColor colorWithCalibratedWhite:0.985 alpha:1.0] setFill]; + if ([self.effectiveAppearance.name containsString:@"Dark"]) { + [[NSColor colorWithCalibratedWhite:0.12 alpha:1.0] setFill]; + } else { + [[NSColor colorWithCalibratedWhite:0.985 alpha:1.0] setFill]; + } NSRectFill(self.bounds); for (NSDictionary *action in self.actions) { @@ -383,7 +390,12 @@ - (void)drawToggleAction:(NSDictionary *)action { - (void)drawButtonAction:(NSDictionary *)action { NSRect bounds = [action[@"frame"] rectValue]; NSString *title = [action[@"title"] isKindOfClass:NSString.class] ? action[@"title"] : @""; - [[NSColor colorWithCalibratedWhite:0.92 alpha:1.0] setFill]; + + if ([self.effectiveAppearance.name containsString:@"Dark"]) { + [[NSColor colorWithCalibratedWhite:0.22 alpha:1.0] setFill]; + } else { + [[NSColor colorWithCalibratedWhite:0.92 alpha:1.0] setFill]; + } [[NSBezierPath bezierPathWithRoundedRect:bounds xRadius:6 yRadius:6] fill]; NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init]; @@ -400,7 +412,12 @@ - (void)drawButtonAction:(NSDictionary *)action { - (void)drawProcessAction:(NSDictionary *)action { NSRect bounds = NSInsetRect([action[@"frame"] rectValue], 2, 2); - [[NSColor colorWithCalibratedWhite:1.0 alpha:0.72] setFill]; + + if ([self.effectiveAppearance.name containsString:@"Dark"]) { + [[NSColor colorWithCalibratedWhite:0.18 alpha:0.72] setFill]; + } else { + [[NSColor colorWithCalibratedWhite:1.0 alpha:0.72] setFill]; + } [[NSBezierPath bezierPathWithRoundedRect:bounds xRadius:6 yRadius:6] fill]; NSDictionary *row = [action[@"row"] isKindOfClass:NSDictionary.class] ? action[@"row"] : @{}; @@ -435,11 +452,11 @@ - (void)drawProcessAction:(NSDictionary *)action { }; CGFloat valueWidth = [kind isEqualToString:@"managed"] ? 162 : 116; - NSRect valueRect = NSMakeRect(NSMaxX(bounds) - valueWidth - 8, NSMinY(bounds) + 6, valueWidth, 16); + NSRect valueRect = NSMakeRect(NSMaxX(bounds) - valueWidth - 8, NSMinY(bounds) + 2, valueWidth, 16); CGFloat nameX = NSMinX(bounds) + 8; id graphColorIndex = action[@"graphColorIndex"]; if (graphColorIndex != nil && graphColorIndex != [NSNull null] && [graphColorIndex respondsToSelector:@selector(unsignedIntegerValue)]) { - NSRect dotRect = NSMakeRect(nameX, NSMinY(bounds) + 8, 8, 8); + NSRect dotRect = NSMakeRect(nameX, NSMinY(bounds) + 6, 8, 8); NSColor *dotColor = OpenTamerGraphColor([graphColorIndex unsignedIntegerValue]); NSBezierPath *dotPath = [NSBezierPath bezierPathWithOvalInRect:dotRect]; BOOL graphLineHidden = [action[@"graphLineHidden"] boolValue]; @@ -461,7 +478,7 @@ - (void)drawProcessAction:(NSDictionary *)action { } nameX += 16; } - NSRect nameRect = NSMakeRect(nameX, NSMinY(bounds) + 6, MAX((CGFloat)0, NSMinX(valueRect) - nameX - 2), 16); + NSRect nameRect = NSMakeRect(nameX, NSMinY(bounds) + 2, MAX((CGFloat)0, NSMinX(valueRect) - nameX - 2), 16); [name drawInRect:nameRect withAttributes:nameAttrs]; [right drawInRect:valueRect withAttributes:valueAttrs]; } @@ -574,7 +591,12 @@ - (void)drawYAxisForPlot:(NSRect)plot maximum:(double)maximum { - (void)drawRect:(NSRect)dirtyRect { NSRect bounds = NSInsetRect(self.bounds, 1, 1); - [[NSColor colorWithCalibratedWhite:1.0 alpha:1.0] setFill]; + + if ([self.effectiveAppearance.name containsString:@"Dark"]) { + [[NSColor colorWithCalibratedWhite:0.15 alpha:1.0] setFill]; + } else { + [[NSColor colorWithCalibratedWhite:1.0 alpha:1.0] setFill]; + } [[NSBezierPath bezierPathWithRoundedRect:bounds xRadius:8 yRadius:8] fill]; NSRect plot = NSMakeRect(NSMinX(bounds) + 12, NSMinY(bounds) + 14, NSWidth(bounds) - 58, NSHeight(bounds) - 28); @@ -675,6 +697,19 @@ - (instancetype)initWithState:(NSDictionary *)state { - (void)install { self.statusItem = [NSStatusBar.systemStatusBar statusItemWithLength:NSVariableStatusItemLength]; self.statusItem.menu = nil; + + // 💡 Apply user configuration choices directly to the application context on startup boots + if (@available(macOS 10.14, *)) { + NSAppearance *appAppearance = nil; + NSString *theme = [self stringPreference:@"theme" fallback:@"system"]; + if ([theme isEqualToString:@"dark"]) { + appAppearance = [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua]; + } else if ([theme isEqualToString:@"light"]) { + appAppearance = [NSAppearance appearanceNamed:NSAppearanceNameAqua]; + } + NSApp.appearance = appAppearance; + } + [self updateStatusTitle]; [self updateTrackedStatusItems]; [self rebuildMenu]; @@ -683,12 +718,30 @@ - (void)install { - (void)updateWithState:(NSDictionary *)state { self.state = state ?: @{}; [self pruneHiddenGraphAppKeys]; + + if (@available(macOS 10.14, *)) { + NSAppearance *appAppearance = nil; + NSString *theme = [self stringPreference:@"theme" fallback:@"system"]; + if ([theme isEqualToString:@"dark"]) { + appAppearance = [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua]; + } else if ([theme isEqualToString:@"light"]) { + appAppearance = [NSAppearance appearanceNamed:NSAppearanceNameAqua]; + } + NSApp.appearance = appAppearance; + } + [self updateStatusTitle]; [self updateTrackedStatusItems]; + if (self.primaryPopover.isShown) { + self.primaryPopover.appearance = NSApp.appearance; + if (self.primaryPopover.contentViewController.view.window) { + self.primaryPopover.contentViewController.view.window.appearance = NSApp.appearance; + } [self refreshPrimaryPopoverContent]; return; } + if (self.menuVisible) { self.needsMenuRebuild = YES; } else { @@ -1664,6 +1717,12 @@ - (NSMenu *)preferencesMenu { labels:@[@"Per-Core Process CPU", @"System Normalized CPU"] values:@[@"per_core_process", @"system_normalized"] toMenu:generalMenu]; + [self addStringPreferenceWithTitle:@"Theme" + key:@"theme" + fallback:@"system" + labels:@[@"System theme", @"Light", @"Dark"] + values:@[@"system", @"light", @"dark"] + toMenu:generalMenu]; [self addDurationPreferenceWithTitle:@"Wake Grace" key:@"wakeGrace" fallback:30 @@ -1909,6 +1968,8 @@ - (OpenTamerPanelView *)primaryPanelView { OpenTamerPanelView *view = [[OpenTamerPanelView alloc] initWithFrame:NSMakeRect(0, 0, width, height)]; view.controller = (id)self; + view.appearance = NSApp.appearance; + CGFloat y = 12; NSDictionary *graphColorIndexesByAppKey = [self graphColorIndexesByAppKey]; @@ -1944,6 +2005,7 @@ - (OpenTamerPanelView *)primaryPanelView { y += 18; OpenTamerCPUGraphView *graph = [[OpenTamerCPUGraphView alloc] initWithFrame:NSMakeRect(padding, y, width - padding * 2, 118)]; + graph.appearance = NSApp.appearance; graph.lines = [self cpuGraphLines]; graph.hiddenAppKeys = [self.hiddenGraphAppKeys copy]; graph.currentCPU = [self cpuGraphCurrentCPU]; @@ -2009,7 +2071,7 @@ - (void)showPrimaryPopover { popover.behavior = NSPopoverBehaviorTransient; popover.animates = NO; popover.delegate = self; - popover.appearance = [NSAppearance appearanceNamed:NSAppearanceNameAqua]; + popover.appearance = NSApp.appearance; NSViewController *viewController = [[NSViewController alloc] init]; viewController.view = [self primaryPanelView]; diff --git a/internal/macos/menu/menu_bar_darwin.go b/internal/macos/menu/menu_bar_darwin.go index 2673de2..26b9246 100644 --- a/internal/macos/menu/menu_bar_darwin.go +++ b/internal/macos/menu/menu_bar_darwin.go @@ -3,7 +3,7 @@ package menu /* -#cgo darwin LDFLAGS: -framework Cocoa -framework ServiceManagement +#cgo darwin LDFLAGS: -framework Cocoa -framework ServiceManagement -framework QuartzCore #include #include "menu_bar_bridge.h" */ diff --git a/internal/macos/menu/menu_bar_preferences_test.go b/internal/macos/menu/menu_bar_preferences_test.go index 66c0847..1ea1c25 100644 --- a/internal/macos/menu/menu_bar_preferences_test.go +++ b/internal/macos/menu/menu_bar_preferences_test.go @@ -43,6 +43,7 @@ func TestPreferencesMenuExposesExpectedConfigKeys(t *testing.T) { "highCPUThreshold", "showMenuBarIcon", "statsInterval", + "theme", "topProcessesSort", "wakeGrace", } diff --git a/internal/ui/preferences.go b/internal/ui/preferences.go index 4a5cb39..8f514f3 100644 --- a/internal/ui/preferences.go +++ b/internal/ui/preferences.go @@ -43,6 +43,7 @@ const ( PreferenceHighCPUThreshold PreferenceField = "highCPUThreshold" PreferenceHighCPUDuration PreferenceField = "highCPUDuration" PreferenceHighCPUCooldown PreferenceField = "highCPUCooldown" + PreferenceTheme PreferenceField = "theme" ) type PreferencesCommand struct { @@ -192,6 +193,12 @@ func setStringPreference(preferences *core.GlobalPreferences, field PreferenceFi return fmt.Errorf("unsupported CPU display mode %q", value) } preferences.CPUDisplayMode = normalized + case PreferenceTheme: // 👈 Add this entire block! + normalized := core.NormalizeThemeMode(value) + if normalized != value { + return fmt.Errorf("unsupported theme mode %q", value) + } + preferences.Theme = normalized default: return fmt.Errorf("unsupported string preference %q", field) } diff --git a/opentamer b/opentamer new file mode 100755 index 0000000..58906f2 Binary files /dev/null and b/opentamer differ