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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 9 additions & 1 deletion internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
}
Expand Down
17 changes: 14 additions & 3 deletions internal/core/rules.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
package core

import "slices"

import "time"
import (
"slices"
"time"
)

type RuleMode string
type RuleTrackingLocation string
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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"
}
}
80 changes: 71 additions & 9 deletions internal/macos/menu/menu_bar_bridge.m
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#import <Cocoa/Cocoa.h>
#import <ServiceManagement/ServiceManagement.h>
#import <QuartzCore/QuartzCore.h>
#import <math.h>
#import <stdlib.h>
#import <string.h>
Expand Down Expand Up @@ -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|"];
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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];
Expand All @@ -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"] : @{};
Expand Down Expand Up @@ -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];
Expand All @@ -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];
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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];
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1909,6 +1968,8 @@ - (OpenTamerPanelView *)primaryPanelView {

OpenTamerPanelView *view = [[OpenTamerPanelView alloc] initWithFrame:NSMakeRect(0, 0, width, height)];
view.controller = (id<OpenTamerPanelActionHandling>)self;
view.appearance = NSApp.appearance;

CGFloat y = 12;
NSDictionary *graphColorIndexesByAppKey = [self graphColorIndexesByAppKey];

Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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];
Expand Down
2 changes: 1 addition & 1 deletion internal/macos/menu/menu_bar_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
package menu

/*
#cgo darwin LDFLAGS: -framework Cocoa -framework ServiceManagement
#cgo darwin LDFLAGS: -framework Cocoa -framework ServiceManagement -framework QuartzCore
#include <stdlib.h>
#include "menu_bar_bridge.h"
*/
Expand Down
1 change: 1 addition & 0 deletions internal/macos/menu/menu_bar_preferences_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func TestPreferencesMenuExposesExpectedConfigKeys(t *testing.T) {
"highCPUThreshold",
"showMenuBarIcon",
"statsInterval",
"theme",
"topProcessesSort",
"wakeGrace",
}
Expand Down
7 changes: 7 additions & 0 deletions internal/ui/preferences.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const (
PreferenceHighCPUThreshold PreferenceField = "highCPUThreshold"
PreferenceHighCPUDuration PreferenceField = "highCPUDuration"
PreferenceHighCPUCooldown PreferenceField = "highCPUCooldown"
PreferenceTheme PreferenceField = "theme"
)

type PreferencesCommand struct {
Expand Down Expand Up @@ -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)
}
Expand Down
Binary file added opentamer
Binary file not shown.