From 70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 Mon Sep 17 00:00:00 2001 From: AlexanderCurl Date: Sat, 18 Apr 2026 17:46:06 +0100 Subject: Replaced file structures for themes in the correct format --- .../core/internal/server/wayland/gamma.go | 167 +++ .../core/internal/server/wayland/gamma_test.go | 120 ++ .../core/internal/server/wayland/geolocation.go | 50 + .../core/internal/server/wayland/handlers.go | 182 +++ .../core/internal/server/wayland/manager.go | 1256 ++++++++++++++++++++ .../core/internal/server/wayland/manager_test.go | 414 +++++++ .../core/internal/server/wayland/suncalc.go | 122 ++ .../core/internal/server/wayland/suncalc_test.go | 387 ++++++ .../core/internal/server/wayland/types.go | 216 ++++ .../core/internal/server/wayland/types_test.go | 330 +++++ 10 files changed, 3244 insertions(+) create mode 100644 raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma.go create mode 100644 raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma_test.go create mode 100644 raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/geolocation.go create mode 100644 raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/handlers.go create mode 100644 raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager.go create mode 100644 raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager_test.go create mode 100644 raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc.go create mode 100644 raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc_test.go create mode 100644 raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types.go create mode 100644 raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types_test.go (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland') diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma.go new file mode 100644 index 0000000..1fff03f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma.go @@ -0,0 +1,167 @@ +package wayland + +import ( + "math" +) + +type GammaRamp struct { + Red []uint16 + Green []uint16 + Blue []uint16 +} + +type rgb struct { + r, g, b float64 +} + +type xyz struct { + x, y, z float64 +} + +func illuminantD(temp int) (float64, float64, bool) { + var x float64 + switch { + case temp >= 2500 && temp <= 7000: + t := float64(temp) + x = 0.244063 + 0.09911e3/t + 2.9678e6/(t*t) - 4.6070e9/(t*t*t) + case temp > 7000 && temp <= 25000: + t := float64(temp) + x = 0.237040 + 0.24748e3/t + 1.9018e6/(t*t) - 2.0064e9/(t*t*t) + default: + return 0, 0, false + } + y := -3*(x*x) + 2.870*x - 0.275 + return x, y, true +} + +func planckianLocus(temp int) (float64, float64, bool) { + var x, y float64 + switch { + case temp >= 1667 && temp <= 4000: + t := float64(temp) + x = -0.2661239e9/(t*t*t) - 0.2343589e6/(t*t) + 0.8776956e3/t + 0.179910 + if temp <= 2222 { + y = -1.1064814*(x*x*x) - 1.34811020*(x*x) + 2.18555832*x - 0.20219683 + } else { + y = -0.9549476*(x*x*x) - 1.37418593*(x*x) + 2.09137015*x - 0.16748867 + } + case temp > 4000 && temp < 25000: + t := float64(temp) + x = -3.0258469e9/(t*t*t) + 2.1070379e6/(t*t) + 0.2226347e3/t + 0.240390 + y = 3.0817580*(x*x*x) - 5.87338670*(x*x) + 3.75112997*x - 0.37001483 + default: + return 0, 0, false + } + return x, y, true +} + +func srgbGamma(value, gamma float64) float64 { + if value <= 0.0031308 { + return 12.92 * value + } + return math.Pow(1.055*value, 1.0/gamma) - 0.055 +} + +func clamp01(v float64) float64 { + switch { + case v > 1.0: + return 1.0 + case v < 0.0: + return 0.0 + default: + return v + } +} + +func xyzToSRGB(c xyz) rgb { + return rgb{ + r: srgbGamma(clamp01(3.2404542*c.x-1.5371385*c.y-0.4985314*c.z), 2.2), + g: srgbGamma(clamp01(-0.9692660*c.x+1.8760108*c.y+0.0415560*c.z), 2.2), + b: srgbGamma(clamp01(0.0556434*c.x-0.2040259*c.y+1.0572252*c.z), 2.2), + } +} + +func normalizeRGB(c *rgb) { + maxw := math.Max(c.r, math.Max(c.g, c.b)) + if maxw > 0 { + c.r /= maxw + c.g /= maxw + c.b /= maxw + } +} + +func calcWhitepoint(temp int) rgb { + if temp == 6500 { + return rgb{r: 1.0, g: 1.0, b: 1.0} + } + + var wp xyz + + switch { + case temp >= 25000: + x, y, _ := illuminantD(25000) + wp.x = x + wp.y = y + case temp >= 4000: + x, y, _ := illuminantD(temp) + wp.x = x + wp.y = y + case temp >= 2500: + x1, y1, _ := illuminantD(temp) + x2, y2, _ := planckianLocus(temp) + factor := float64(4000-temp) / 1500.0 + sineFactor := (math.Cos(math.Pi*factor) + 1.0) / 2.0 + wp.x = x1*sineFactor + x2*(1.0-sineFactor) + wp.y = y1*sineFactor + y2*(1.0-sineFactor) + default: + t := temp + if t < 1667 { + t = 1667 + } + x, y, _ := planckianLocus(t) + wp.x = x + wp.y = y + } + + wp.z = 1.0 - wp.x - wp.y + + wpRGB := xyzToSRGB(wp) + normalizeRGB(&wpRGB) + return wpRGB +} + +func GenerateGammaRamp(size uint32, temp int, gamma float64) GammaRamp { + ramp := GammaRamp{ + Red: make([]uint16, size), + Green: make([]uint16, size), + Blue: make([]uint16, size), + } + + wp := calcWhitepoint(temp) + + for i := uint32(0); i < size; i++ { + val := float64(i) / float64(size-1) + ramp.Red[i] = uint16(clamp01(math.Pow(val*wp.r, 1.0/gamma)) * 65535.0) + ramp.Green[i] = uint16(clamp01(math.Pow(val*wp.g, 1.0/gamma)) * 65535.0) + ramp.Blue[i] = uint16(clamp01(math.Pow(val*wp.b, 1.0/gamma)) * 65535.0) + } + + return ramp +} + +func GenerateIdentityRamp(size uint32) GammaRamp { + ramp := GammaRamp{ + Red: make([]uint16, size), + Green: make([]uint16, size), + Blue: make([]uint16, size), + } + + for i := uint32(0); i < size; i++ { + val := uint16((float64(i) / float64(size-1)) * 65535.0) + ramp.Red[i] = val + ramp.Green[i] = val + ramp.Blue[i] = val + } + + return ramp +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma_test.go new file mode 100644 index 0000000..0bf8e71 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/gamma_test.go @@ -0,0 +1,120 @@ +package wayland + +import ( + "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +func TestGenerateGammaRamp(t *testing.T) { + tests := []struct { + name string + size uint32 + temp int + gamma float64 + }{ + {"small_warm", 16, 6500, 1.0}, + {"small_cool", 16, 4000, 1.0}, + {"large_warm", 256, 6500, 1.0}, + {"large_cool", 256, 4000, 1.0}, + {"custom_gamma", 64, 5500, 1.2}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ramp := GenerateGammaRamp(tt.size, tt.temp, tt.gamma) + + if len(ramp.Red) != int(tt.size) { + t.Errorf("expected %d red values, got %d", tt.size, len(ramp.Red)) + } + if len(ramp.Green) != int(tt.size) { + t.Errorf("expected %d green values, got %d", tt.size, len(ramp.Green)) + } + if len(ramp.Blue) != int(tt.size) { + t.Errorf("expected %d blue values, got %d", tt.size, len(ramp.Blue)) + } + + if ramp.Red[0] != 0 || ramp.Green[0] != 0 || ramp.Blue[0] != 0 { + t.Errorf("first values should be 0, got R:%d G:%d B:%d", + ramp.Red[0], ramp.Green[0], ramp.Blue[0]) + } + + lastIdx := tt.size - 1 + if ramp.Red[lastIdx] == 0 || ramp.Green[lastIdx] == 0 || ramp.Blue[lastIdx] == 0 { + t.Errorf("last values should be non-zero, got R:%d G:%d B:%d", + ramp.Red[lastIdx], ramp.Green[lastIdx], ramp.Blue[lastIdx]) + } + + for i := uint32(1); i < tt.size; i++ { + if ramp.Red[i] < ramp.Red[i-1] { + t.Errorf("red ramp not monotonic at index %d", i) + } + } + }) + } +} + +func TestCalcWhitepoint(t *testing.T) { + tests := []struct { + name string + temp int + }{ + {"very_warm", 6500}, + {"neutral", 5500}, + {"cool", 4000}, + {"very_cool", 3000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wp := calcWhitepoint(tt.temp) + + if wp.r < 0 || wp.r > 1 { + t.Errorf("red out of range: %f", wp.r) + } + if wp.g < 0 || wp.g > 1 { + t.Errorf("green out of range: %f", wp.g) + } + if wp.b < 0 || wp.b > 1 { + t.Errorf("blue out of range: %f", wp.b) + } + }) + } +} + +func TestWhitepointProgression(t *testing.T) { + temps := []int{3000, 4000, 5000, 6000, 6500} + + var prevBlue float64 + for i, temp := range temps { + wp := calcWhitepoint(temp) + if i > 0 && wp.b < prevBlue { + t.Errorf("blue should increase with temperature, %d->%d: %f->%f", + temps[i-1], temp, prevBlue, wp.b) + } + prevBlue = wp.b + } +} + +func TestClamp(t *testing.T) { + tests := []struct { + val float64 + min float64 + max float64 + expected float64 + }{ + {5, 0, 10, 5}, + {-5, 0, 10, 0}, + {15, 0, 10, 10}, + {0, 0, 10, 0}, + {10, 0, 10, 10}, + } + + for _, tt := range tests { + result := utils.Clamp(tt.val, tt.min, tt.max) + if result != tt.expected { + t.Errorf("clamp(%f, %f, %f) = %f, want %f", + tt.val, tt.min, tt.max, result, tt.expected) + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/geolocation.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/geolocation.go new file mode 100644 index 0000000..ba99b5f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/geolocation.go @@ -0,0 +1,50 @@ +package wayland + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" +) + +type ipAPIResponse struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + City string `json:"city"` +} + +func FetchIPLocation() (*float64, *float64, error) { + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Get("http://ip-api.com/json/") + if err != nil { + return nil, nil, fmt.Errorf("failed to fetch IP location: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, nil, fmt.Errorf("ip-api.com returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response: %w", err) + } + + var data ipAPIResponse + if err := json.Unmarshal(body, &data); err != nil { + return nil, nil, fmt.Errorf("failed to parse response: %w", err) + } + + if data.Lat == 0 && data.Lon == 0 { + return nil, nil, fmt.Errorf("missing location data in response") + } + + log.Infof("Fetched IP-based location: %s (%.4f, %.4f)", data.City, data.Lat, data.Lon) + return &data.Lat, &data.Lon, nil +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/handlers.go new file mode 100644 index 0000000..76b08b5 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/handlers.go @@ -0,0 +1,182 @@ +package wayland + +import ( + "encoding/json" + "fmt" + "net" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params" +) + +func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { + if manager == nil { + models.RespondError(conn, req.ID, "wayland manager not initialized") + return + } + + switch req.Method { + case "wayland.gamma.getState": + handleGetState(conn, req, manager) + case "wayland.gamma.setTemperature": + handleSetTemperature(conn, req, manager) + case "wayland.gamma.setLocation": + handleSetLocation(conn, req, manager) + case "wayland.gamma.setManualTimes": + handleSetManualTimes(conn, req, manager) + case "wayland.gamma.setUseIPLocation": + handleSetUseIPLocation(conn, req, manager) + case "wayland.gamma.setGamma": + handleSetGamma(conn, req, manager) + case "wayland.gamma.setEnabled": + handleSetEnabled(conn, req, manager) + case "wayland.gamma.subscribe": + handleSubscribe(conn, req, manager) + default: + models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) + } +} + +func handleGetState(conn net.Conn, req models.Request, manager *Manager) { + models.Respond(conn, req.ID, manager.GetState()) +} + +func handleSetTemperature(conn net.Conn, req models.Request, manager *Manager) { + var lowTemp, highTemp int + + if temp, ok := models.Get[float64](req, "temp"); ok { + lowTemp = int(temp) + highTemp = int(temp) + } else { + low, err := params.Float(req.Params, "low") + if err != nil { + models.RespondError(conn, req.ID, "missing temperature parameters (provide 'temp' or both 'low' and 'high')") + return + } + high, err := params.Float(req.Params, "high") + if err != nil { + models.RespondError(conn, req.ID, "missing temperature parameters (provide 'temp' or both 'low' and 'high')") + return + } + lowTemp = int(low) + highTemp = int(high) + } + + if err := manager.SetTemperature(lowTemp, highTemp); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "temperature set"}) +} + +func handleSetLocation(conn net.Conn, req models.Request, manager *Manager) { + lat, err := params.Float(req.Params, "latitude") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + lon, err := params.Float(req.Params, "longitude") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetLocation(lat, lon); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "location set"}) +} + +func handleSetManualTimes(conn net.Conn, req models.Request, manager *Manager) { + sunriseStr, sunriseOK := models.Get[string](req, "sunrise") + sunsetStr, sunsetOK := models.Get[string](req, "sunset") + + if !sunriseOK || !sunsetOK || sunriseStr == "" || sunsetStr == "" { + manager.ClearManualTimes() + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "manual times cleared"}) + return + } + + sunrise, err := time.Parse("15:04", sunriseStr) + if err != nil { + models.RespondError(conn, req.ID, "invalid sunrise format (use HH:MM)") + return + } + + sunset, err := time.Parse("15:04", sunsetStr) + if err != nil { + models.RespondError(conn, req.ID, "invalid sunset format (use HH:MM)") + return + } + + if err := manager.SetManualTimes(sunrise, sunset); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "manual times set"}) +} + +func handleSetUseIPLocation(conn net.Conn, req models.Request, manager *Manager) { + use, err := params.Bool(req.Params, "use") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + manager.SetUseIPLocation(use) + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "IP location preference set"}) +} + +func handleSetGamma(conn net.Conn, req models.Request, manager *Manager) { + gamma, err := params.Float(req.Params, "gamma") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.SetGamma(gamma); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "gamma set"}) +} + +func handleSetEnabled(conn net.Conn, req models.Request, manager *Manager) { + enabled, err := params.Bool(req.Params, "enabled") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + manager.SetEnabled(enabled) + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "enabled state set"}) +} + +func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) { + clientID := fmt.Sprintf("client-%p", conn) + stateChan := manager.Subscribe(clientID) + defer manager.Unsubscribe(clientID) + + initialState := manager.GetState() + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + ID: req.ID, + Result: &initialState, + }); err != nil { + return + } + + for state := range stateChan { + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + Result: &state, + }); err != nil { + return + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager.go new file mode 100644 index 0000000..3540e3d --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager.go @@ -0,0 +1,1256 @@ +package wayland + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "os" + "slices" + "syscall" + "time" + + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + "github.com/godbus/dbus/v5" + "golang.org/x/sys/unix" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" + "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_gamma_control" +) + +const animKelvinStep = 25 + +func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error) { + if err := config.Validate(); err != nil { + return nil, err + } + + if config.ElevationTwilight == 0 { + config.ElevationTwilight = -6.0 + } + if config.ElevationDaylight == 0 { + config.ElevationDaylight = 3.0 + } + + m := &Manager{ + config: config, + display: display, + ctx: display.Context(), + cmdq: make(chan cmd, 128), + stopChan: make(chan struct{}), + updateTrigger: make(chan struct{}, 1), + dirty: make(chan struct{}, 1), + dbusSignal: make(chan *dbus.Signal, 16), + } + + if err := m.setupRegistry(); err != nil { + return nil, err + } + + if err := m.setupDBusMonitor(); err != nil { + log.Warnf("Failed to setup D-Bus monitoring: %v", err) + } + + m.alive = true + m.recalcSchedule(time.Now()) + m.updateStateFromSchedule() + + m.notifierWg.Add(1) + go m.notifier() + + m.wg.Add(1) + go m.schedulerLoop() + + if m.dbusConn != nil { + m.wg.Add(1) + go m.dbusMonitor() + } + + m.wg.Add(1) + go m.waylandActor() + + if config.Enabled { + m.post(func() { + log.Info("Gamma control enabled at startup") + gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1) + m.availOutputsMu.RLock() + outs := slices.Clone(m.availableOutputs) + m.availOutputsMu.RUnlock() + if err := m.setupOutputControls(outs, gammaMgr); err != nil { + log.Errorf("Failed to initialize gamma controls: %v", err) + return + } + m.controlsInitialized = true + }) + } + + return m, nil +} + +func (m *Manager) post(fn func()) { + select { + case m.cmdq <- cmd{fn: fn}: + default: + log.Warn("Actor command queue full") + } +} + +func (m *Manager) waylandActor() { + defer m.wg.Done() + for { + select { + case <-m.stopChan: + return + case c := <-m.cmdq: + c.fn() + } + } +} + +func (m *Manager) anyOutputReady() bool { + anyReady := false + m.outputs.Range(func(_ uint32, out *outputState) bool { + if out.rampSize > 0 && !out.failed { + anyReady = true + return false // stop iteration + } + return true + }) + return anyReady +} + +func (m *Manager) setupDBusMonitor() error { + conn, err := dbus.ConnectSystemBus() + if err != nil { + return fmt.Errorf("system bus: %w", err) + } + + matchRule := "type='signal',interface='org.freedesktop.login1.Manager',member='PrepareForSleep',path='/org/freedesktop/login1'" + if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, matchRule).Err; err != nil { + conn.Close() + return fmt.Errorf("add match: %w", err) + } + + conn.Signal(m.dbusSignal) + m.dbusConn = conn + return nil +} + +func (m *Manager) setupRegistry() error { + registry, err := m.display.GetRegistry() + if err != nil { + return fmt.Errorf("get registry: %w", err) + } + m.registry = registry + + outputs := make([]*wlclient.Output, 0) + outputNames := make(map[uint32]string) + var gammaMgr *wlr_gamma_control.ZwlrGammaControlManagerV1 + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case wlr_gamma_control.ZwlrGammaControlManagerV1InterfaceName: + manager := wlr_gamma_control.NewZwlrGammaControlManagerV1(m.ctx) + version := e.Version + if version > 1 { + version = 1 + } + if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil { + gammaMgr = manager + } + case "wl_output": + output := wlclient.NewOutput(m.ctx) + version := e.Version + if version > 4 { + version = 4 + } + if err := registry.Bind(e.Name, e.Interface, version, output); err != nil { + return + } + outputID := output.ID() + output.SetNameHandler(func(ev wlclient.OutputNameEvent) { + outputNames[outputID] = ev.Name + }) + if gammaMgr != nil { + outputs = append(outputs, output) + m.addAvailableOutput(output) + } + m.outputRegNames.Store(outputID, e.Name) + + m.configMutex.RLock() + enabled := m.config.Enabled + m.configMutex.RUnlock() + + if enabled && m.controlsInitialized { + m.post(func() { + if err := m.addOutputControl(output); err != nil { + log.Warnf("Failed to add output control: %v", err) + } + }) + } + } + }) + + registry.SetGlobalRemoveHandler(func(e wlclient.RegistryGlobalRemoveEvent) { + m.post(func() { + var foundID uint32 + var foundOut *outputState + m.outputs.Range(func(id uint32, out *outputState) bool { + if out.registryName == e.Name { + foundID = id + foundOut = out + return false + } + return true + }) + if foundOut == nil { + return + } + if foundOut.gammaControl != nil { + foundOut.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy() + foundOut.gammaControl = nil + } + m.removeAvailableOutput(foundOut.output) + if foundOut.output != nil && !foundOut.output.IsZombie() { + _ = foundOut.output.Release() + } + m.outputs.Delete(foundID) + + hasOutputs := false + m.outputs.Range(func(_ uint32, _ *outputState) bool { + hasOutputs = true + return false + }) + if !hasOutputs { + m.controlsInitialized = false + } + }) + }) + + if err := m.display.Roundtrip(); err != nil { + return fmt.Errorf("roundtrip 1: %w", err) + } + if err := m.display.Roundtrip(); err != nil { + return fmt.Errorf("roundtrip 2: %w", err) + } + + if gammaMgr == nil { + return errdefs.ErrNoGammaControl + } + if len(outputs) == 0 { + return fmt.Errorf("no outputs") + } + + physicalOutputs := make([]*wlclient.Output, 0, len(outputs)) + for _, output := range outputs { + name := outputNames[output.ID()] + if len(name) >= 9 && name[:9] == "HEADLESS-" { + continue + } + physicalOutputs = append(physicalOutputs, output) + } + + m.gammaControl = gammaMgr + m.availableOutputs = physicalOutputs + return nil +} + +func (m *Manager) setupOutputControls(outputs []*wlclient.Output, manager *wlr_gamma_control.ZwlrGammaControlManagerV1) error { + for _, output := range outputs { + control, err := manager.GetGammaControl(output) + if err != nil { + continue + } + outputID := output.ID() + registryName, _ := m.outputRegNames.Load(outputID) + outState := &outputState{ + id: outputID, + registryName: registryName, + output: output, + gammaControl: control, + } + m.setupControlHandlers(outState, control) + m.outputs.Store(outputID, outState) + } + return nil +} + +func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_control.ZwlrGammaControlV1) { + outputID := state.id + + control.SetGammaSizeHandler(func(e wlr_gamma_control.ZwlrGammaControlV1GammaSizeEvent) { + size := e.Size + m.post(func() { + if out, ok := m.outputs.Load(outputID); ok { + out.rampSize = size + out.failed = false + out.retryCount = 0 + } + m.lastAppliedTemp = 0 + m.applyCurrentTemp("gamma_size") + }) + }) + + control.SetFailedHandler(func(_ wlr_gamma_control.ZwlrGammaControlV1FailedEvent) { + m.post(func() { + out, ok := m.outputs.Load(outputID) + if !ok { + return + } + if ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok && ctrl != nil && !ctrl.IsZombie() { + ctrl.Destroy() + } + out.gammaControl = nil + out.failed = true + out.rampSize = 0 + out.retryCount++ + out.lastFailTime = time.Now() + + if !m.outputStillValid(out) { + return + } + + backoff := time.Duration(300<= 10: + return nil + case !m.outputStillValid(out): + return nil + } + if _, ok := m.outputs.Load(out.id); !ok { + return nil + } + + gammaMgr, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1) + if !ok { + return fmt.Errorf("no gamma manager") + } + + if existing, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok && existing != nil && !existing.IsZombie() { + existing.Destroy() + out.gammaControl = nil + } + + control, err := gammaMgr.GetGammaControl(out.output) + if err != nil { + if isConnectionDeadErr(err) { + m.markConnectionDead(err) + } + return err + } + + m.setupControlHandlers(out, control) + out.gammaControl = control + out.failed = false + return nil +} + +func (m *Manager) markConnectionDead(err error) { + if m.connectionDead.Swap(true) { + return + } + log.Errorf("gamma: wayland connection appears dead (%v); pausing gamma operations", err) +} + +func (m *Manager) recalcSchedule(now time.Time) { + m.configMutex.RLock() + config := m.config + m.configMutex.RUnlock() + + m.scheduleMutex.Lock() + defer m.scheduleMutex.Unlock() + + dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + alreadyValid := !m.schedule.times.Sunrise.IsZero() + if m.schedule.calcDay.Equal(dayStart) && alreadyValid { + return + } + + var times SunTimes + var cond SunCondition + + if config.ManualSunrise != nil && config.ManualSunset != nil { + dur := time.Hour + if config.ManualDuration != nil { + dur = *config.ManualDuration + } + sunrise := time.Date(now.Year(), now.Month(), now.Day(), + config.ManualSunrise.Hour(), config.ManualSunrise.Minute(), config.ManualSunrise.Second(), 0, now.Location()) + sunset := time.Date(now.Year(), now.Month(), now.Day(), + config.ManualSunset.Hour(), config.ManualSunset.Minute(), config.ManualSunset.Second(), 0, now.Location()) + times = SunTimes{ + Dawn: sunrise.Add(-dur), + Sunrise: sunrise, + Sunset: sunset, + Night: sunset.Add(dur), + } + cond = SunNormal + } else { + lat, lon := m.getLocation() + if lat == nil || lon == nil { + m.gammaState = StateStatic + return + } + times, cond = CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight) + } + + m.schedule.calcDay = dayStart + m.schedule.times = times + m.schedule.condition = cond + + switch cond { + case SunNormal: + m.gammaState = StateNormal + tempDiff := config.HighTemp - config.LowTemp + if tempDiff > 0 { + dawnDur := times.Sunrise.Sub(times.Dawn) + nightDur := times.Night.Sub(times.Sunset) + m.schedule.dawnStepTime = time.Duration(max(1, int(dawnDur.Seconds())*animKelvinStep/tempDiff)) * time.Second + m.schedule.nightStepTime = time.Duration(max(1, int(nightDur.Seconds())*animKelvinStep/tempDiff)) * time.Second + } + case SunMidnightSun: + m.gammaState = StateStatic + case SunPolarNight: + m.gammaState = StateStatic + } +} + +func (m *Manager) SetGeoClient(client geolocation.Client) { + m.geoClient = client +} + +func (m *Manager) getLocation() (*float64, *float64) { + m.configMutex.RLock() + config := m.config + m.configMutex.RUnlock() + + if config.Latitude != nil && config.Longitude != nil { + return config.Latitude, config.Longitude + } + if !config.UseIPLocation { + return nil, nil + } + if m.geoClient == nil { + return nil, nil + } + + m.locationMutex.RLock() + if m.cachedIPLat != nil && m.cachedIPLon != nil { + lat, lon := m.cachedIPLat, m.cachedIPLon + m.locationMutex.RUnlock() + return lat, lon + } + m.locationMutex.RUnlock() + + location, err := m.geoClient.GetLocation() + if err != nil { + return nil, nil + } + + m.locationMutex.Lock() + m.cachedIPLat = &location.Latitude + m.cachedIPLon = &location.Longitude + m.locationMutex.Unlock() + return m.cachedIPLat, m.cachedIPLon +} + +func (m *Manager) hasValidSchedule() bool { + m.scheduleMutex.RLock() + defer m.scheduleMutex.RUnlock() + return !m.schedule.times.Sunrise.IsZero() +} + +func (m *Manager) getSunPosition(now time.Time) float64 { + m.scheduleMutex.RLock() + sched := m.schedule + state := m.gammaState + m.scheduleMutex.RUnlock() + + if sched.times.Sunrise.IsZero() { + return 1.0 + } + + switch state { + case StateStatic: + if sched.condition == SunMidnightSun { + return 1.0 + } + return 0.0 + case StateNormal: + return m.getSunPositionNormal(now, sched.times) + } + return 1.0 +} + +func (m *Manager) getSunPositionNormal(now time.Time, times SunTimes) float64 { + if now.Before(times.Dawn) { + return 0.0 + } + if now.Before(times.Sunrise) { + return interpolate(now, times.Dawn, times.Sunrise) + } + if now.Before(times.Sunset) { + return 1.0 + } + if now.Before(times.Night) { + return interpolate(now, times.Night, times.Sunset) + } + return 0.0 +} + +func interpolate(now time.Time, start, stop time.Time) float64 { + if start.Equal(stop) { + return 1.0 + } + pos := float64(now.Sub(start)) / float64(stop.Sub(start)) + switch { + case pos > 1.0: + return 1.0 + case pos < 0.0: + return 0.0 + default: + return pos + } +} + +func (m *Manager) getTempFromPosition(pos float64) int { + m.configMutex.RLock() + low, high := m.config.LowTemp, m.config.HighTemp + m.configMutex.RUnlock() + return low + int(float64(high-low)*pos) +} + +func (m *Manager) getNextDeadline(now time.Time) time.Time { + m.scheduleMutex.RLock() + sched := m.schedule + state := m.gammaState + m.scheduleMutex.RUnlock() + + switch state { + case StateStatic: + return m.tomorrow(now) + case StateNormal: + return m.getDeadlineNormal(now, sched) + default: + return m.tomorrow(now) + } +} + +func (m *Manager) getDeadlineNormal(now time.Time, sched sunSchedule) time.Time { + times := sched.times + switch { + case now.Before(times.Dawn): + return times.Dawn + case now.Before(times.Sunrise): + return now.Add(sched.dawnStepTime) + case now.Before(times.Sunset): + return times.Sunset + case now.Before(times.Night): + return now.Add(sched.nightStepTime) + default: + return m.tomorrowDawn(now) + } +} + +func (m *Manager) tomorrowDawn(now time.Time) time.Time { + tomorrow := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) + + m.configMutex.RLock() + config := m.config + m.configMutex.RUnlock() + + if config.ManualSunrise != nil { + dur := time.Hour + if config.ManualDuration != nil { + dur = *config.ManualDuration + } + return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), + config.ManualSunrise.Hour(), config.ManualSunrise.Minute(), config.ManualSunrise.Second(), 0, tomorrow.Location()).Add(-dur) + } + + lat, lon := m.getLocation() + if lat == nil || lon == nil { + return tomorrow + } + + times, cond := CalculateSunTimesWithTwilight(*lat, *lon, tomorrow, config.ElevationTwilight, config.ElevationDaylight) + if cond != SunNormal { + return tomorrow + } + return times.Dawn +} + +func (m *Manager) tomorrow(now time.Time) time.Time { + return time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) +} + +func (m *Manager) schedulerLoop() { + defer m.wg.Done() + + m.configMutex.RLock() + enabled := m.config.Enabled + m.configMutex.RUnlock() + + if enabled { + m.post(func() { m.applyCurrentTemp("startup") }) + } + + var timer *time.Timer + for { + m.configMutex.RLock() + enabled := m.config.Enabled + m.configMutex.RUnlock() + + now := time.Now() + m.recalcSchedule(now) + + var waitDur time.Duration + if enabled { + deadline := m.getNextDeadline(now) + waitDur = time.Until(deadline) + if waitDur < time.Second { + waitDur = time.Second + } + } else { + waitDur = 24 * time.Hour + } + + if timer != nil { + timer.Stop() + } + timer = time.NewTimer(waitDur) + + select { + case <-m.stopChan: + timer.Stop() + return + case <-m.updateTrigger: + timer.Stop() + m.scheduleMutex.Lock() + m.schedule.calcDay = time.Time{} + m.scheduleMutex.Unlock() + m.recalcSchedule(time.Now()) + m.updateStateFromSchedule() + m.configMutex.RLock() + enabled := m.config.Enabled + m.configMutex.RUnlock() + if enabled { + m.post(func() { m.applyCurrentTemp("updateTrigger") }) + } + case <-timer.C: + m.configMutex.RLock() + enabled := m.config.Enabled + m.configMutex.RUnlock() + if enabled { + m.post(func() { m.applyCurrentTemp("timer") }) + } + } + } +} + +func (m *Manager) applyCurrentTemp(_ string) { + if !m.controlsInitialized || !m.anyOutputReady() { + return + } + + // Ensure schedule is up-to-date (handles display wake after overnight sleep) + m.recalcSchedule(time.Now()) + + m.configMutex.RLock() + low, high := m.config.LowTemp, m.config.HighTemp + m.configMutex.RUnlock() + + if low == high { + m.applyGamma(low) + m.updateStateFromSchedule() + return + } + + if !m.hasValidSchedule() { + m.updateStateFromSchedule() + return + } + + now := time.Now() + pos := m.getSunPosition(now) + temp := m.getTempFromPosition(pos) + + m.applyGamma(temp) + m.updateStateFromSchedule() +} + +func (m *Manager) applyGamma(temp int) { + m.configMutex.RLock() + gamma := m.config.Gamma + m.configMutex.RUnlock() + + switch { + case m.connectionDead.Load(): + return + case !m.controlsInitialized: + return + case m.lastAppliedTemp == temp && m.lastAppliedGamma == gamma: + return + } + + var outs []*outputState + m.outputs.Range(func(_ uint32, out *outputState) bool { + outs = append(outs, out) + return true + }) + if len(outs) == 0 { + return + } + + type job struct { + out *outputState + data []byte + } + var jobs []job + + for _, out := range outs { + switch { + case out.failed: + continue + case out.rampSize == 0: + continue + case out.gammaControl == nil: + continue + case !m.outputStillValid(out): + continue + } + ramp := GenerateGammaRamp(out.rampSize, temp, gamma) + buf := bytes.NewBuffer(make([]byte, 0, int(out.rampSize)*6)) + for _, v := range ramp.Red { + binary.Write(buf, binary.LittleEndian, v) + } + for _, v := range ramp.Green { + binary.Write(buf, binary.LittleEndian, v) + } + for _, v := range ramp.Blue { + binary.Write(buf, binary.LittleEndian, v) + } + jobs = append(jobs, job{out: out, data: buf.Bytes()}) + } + + for _, j := range jobs { + err := m.setGammaBytes(j.out, j.data) + if err == nil { + continue + } + log.Warnf("gamma: failed to set output %d: %v", j.out.id, err) + j.out.failed = true + j.out.rampSize = 0 + if isConnectionDeadErr(err) { + m.markConnectionDead(err) + return + } + } + + m.lastAppliedTemp = temp + m.lastAppliedGamma = gamma +} + +func (m *Manager) setGammaBytes(out *outputState, data []byte) error { + if out.gammaControl == nil { + return fmt.Errorf("no gamma control") + } + ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1) + if !ok || ctrl == nil || ctrl.IsZombie() { + return fmt.Errorf("gamma control invalid") + } + + fd, err := MemfdCreate("gamma-ramp", 0) + if err != nil { + return err + } + defer syscall.Close(fd) + + if err := syscall.Ftruncate(fd, int64(len(data))); err != nil { + return err + } + + dupFd, err := syscall.Dup(fd) + if err != nil { + return err + } + f := os.NewFile(uintptr(dupFd), "gamma") + defer f.Close() + + if _, err := f.Write(data); err != nil { + return err + } + syscall.Seek(fd, 0, 0) + + return ctrl.SetGamma(fd) +} + +func (m *Manager) updateStateFromSchedule() { + now := time.Now() + + m.configMutex.RLock() + config := m.config + m.configMutex.RUnlock() + + m.scheduleMutex.RLock() + times := m.schedule.times + m.scheduleMutex.RUnlock() + + var pos float64 + var temp int + var isDay bool + var deadline time.Time + + if times.Sunrise.IsZero() { + pos = 1.0 + temp = config.HighTemp + isDay = true + deadline = m.tomorrow(now) + } else { + pos = m.getSunPosition(now) + temp = m.getTempFromPosition(pos) + deadline = m.getNextDeadline(now) + isDay = now.After(times.Sunrise) && now.Before(times.Sunset) + } + + newState := State{ + Config: config, + CurrentTemp: temp, + NextTransition: deadline, + SunriseTime: times.Sunrise, + SunsetTime: times.Sunset, + DawnTime: times.Dawn, + NightTime: times.Night, + IsDay: isDay, + SunPosition: pos, + } + + m.stateMutex.Lock() + m.state = &newState + m.stateMutex.Unlock() + + m.notifySubscribers() +} + +func (m *Manager) notifier() { + defer m.notifierWg.Done() + const minGap = 100 * time.Millisecond + timer := time.NewTimer(minGap) + timer.Stop() + var pending bool + + for { + select { + case <-m.stopChan: + timer.Stop() + return + case <-m.dirty: + if pending { + continue + } + pending = true + timer.Reset(minGap) + case <-timer.C: + if !pending { + continue + } + currentState := m.GetState() + if m.lastNotified != nil && !stateChanged(m.lastNotified, ¤tState) { + pending = false + continue + } + m.subscribers.Range(func(_ string, ch chan State) bool { + select { + case ch <- currentState: + default: + } + return true + }) + stateCopy := currentState + m.lastNotified = &stateCopy + pending = false + } + } +} + +func (m *Manager) dbusMonitor() { + defer m.wg.Done() + for { + select { + case <-m.stopChan: + return + case sig := <-m.dbusSignal: + if sig == nil { + continue + } + m.handleDBusSignal(sig) + } + } +} + +func (m *Manager) handleDBusSignal(sig *dbus.Signal) { + switch { + case sig.Name != "org.freedesktop.login1.Manager.PrepareForSleep": + return + case len(sig.Body) == 0: + return + } + preparing, ok := sig.Body[0].(bool) + if !ok || preparing { + return + } + m.configMutex.RLock() + enabled := m.config.Enabled + m.configMutex.RUnlock() + if !enabled { + return + } + time.AfterFunc(500*time.Millisecond, func() { + m.post(m.handleResume) + }) +} + +func (m *Manager) handleResume() { + m.configMutex.RLock() + stillEnabled := m.config.Enabled + m.configMutex.RUnlock() + + switch { + case !stillEnabled: + return + case !m.controlsInitialized: + return + case m.connectionDead.Load(): + return + } + + // Compositors (Niri, Hyprland, wlroots-based) re-apply the cached gamma + // ramp to DRM on resume; gamma_control objects stay valid. We just need + // to force a resend so the schedule catches up with the current time of + // day — the original #1235 regression was caused by lastAppliedTemp + // matching and the send being skipped. + m.recalcSchedule(time.Now()) + m.lastAppliedTemp = 0 + m.applyCurrentTemp("resume") +} + +func (m *Manager) triggerUpdate() { + select { + case m.updateTrigger <- struct{}{}: + default: + } +} + +func (m *Manager) SetConfig(config Config) error { + if err := config.Validate(); err != nil { + return err + } + m.configMutex.Lock() + m.config = config + m.configMutex.Unlock() + m.triggerUpdate() + return nil +} + +func (m *Manager) SetTemperature(low, high int) error { + m.configMutex.Lock() + if m.config.LowTemp == low && m.config.HighTemp == high { + m.configMutex.Unlock() + return nil + } + m.config.LowTemp = low + m.config.HighTemp = high + err := m.config.Validate() + m.configMutex.Unlock() + if err != nil { + return err + } + m.triggerUpdate() + return nil +} + +func (m *Manager) SetLocation(lat, lon float64) error { + m.configMutex.Lock() + if m.config.Latitude != nil && m.config.Longitude != nil && + *m.config.Latitude == lat && *m.config.Longitude == lon && !m.config.UseIPLocation { + m.configMutex.Unlock() + return nil + } + m.config.Latitude = &lat + m.config.Longitude = &lon + m.config.UseIPLocation = false + err := m.config.Validate() + m.configMutex.Unlock() + if err != nil { + return err + } + m.triggerUpdate() + return nil +} + +func (m *Manager) SetUseIPLocation(use bool) { + m.configMutex.Lock() + if m.config.UseIPLocation == use { + m.configMutex.Unlock() + return + } + m.config.UseIPLocation = use + if use { + m.config.Latitude = nil + m.config.Longitude = nil + } + m.configMutex.Unlock() + + if use { + m.locationMutex.Lock() + m.cachedIPLat = nil + m.cachedIPLon = nil + m.locationMutex.Unlock() + } + m.triggerUpdate() +} + +func (m *Manager) SetManualTimes(sunrise, sunset time.Time) error { + m.configMutex.Lock() + if m.config.ManualSunrise != nil && m.config.ManualSunset != nil && + m.config.ManualSunrise.Hour() == sunrise.Hour() && m.config.ManualSunrise.Minute() == sunrise.Minute() && + m.config.ManualSunset.Hour() == sunset.Hour() && m.config.ManualSunset.Minute() == sunset.Minute() { + m.configMutex.Unlock() + return nil + } + m.config.ManualSunrise = &sunrise + m.config.ManualSunset = &sunset + err := m.config.Validate() + m.configMutex.Unlock() + if err != nil { + return err + } + m.triggerUpdate() + return nil +} + +func (m *Manager) ClearManualTimes() { + m.configMutex.Lock() + if m.config.ManualSunrise == nil && m.config.ManualSunset == nil { + m.configMutex.Unlock() + return + } + m.config.ManualSunrise = nil + m.config.ManualSunset = nil + m.configMutex.Unlock() + m.triggerUpdate() +} + +func (m *Manager) SetGamma(gamma float64) error { + m.configMutex.Lock() + if m.config.Gamma == gamma { + m.configMutex.Unlock() + return nil + } + m.config.Gamma = gamma + err := m.config.Validate() + m.configMutex.Unlock() + if err != nil { + return err + } + m.triggerUpdate() + return nil +} + +func (m *Manager) SetEnabled(enabled bool) { + m.configMutex.Lock() + wasEnabled := m.config.Enabled + if wasEnabled == enabled { + m.configMutex.Unlock() + return + } + m.config.Enabled = enabled + highTemp := m.config.HighTemp + m.configMutex.Unlock() + + switch { + case enabled && !m.controlsInitialized: + m.post(func() { + gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1) + m.availOutputsMu.RLock() + outs := slices.Clone(m.availableOutputs) + m.availOutputsMu.RUnlock() + if err := m.setupOutputControls(outs, gammaMgr); err != nil { + log.Errorf("gamma: failed to create controls: %v", err) + return + } + m.controlsInitialized = true + m.triggerUpdate() + }) + case enabled && !wasEnabled: + m.triggerUpdate() + case !enabled && m.controlsInitialized: + m.post(func() { + m.outputs.Range(func(id uint32, out *outputState) bool { + if out.gammaControl != nil { + out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy() + } + return true + }) + m.outputs.Range(func(key uint32, _ *outputState) bool { + m.outputs.Delete(key) + return true + }) + m.controlsInitialized = false + }) + _ = highTemp + } +} + +func (m *Manager) Close() { + close(m.stopChan) + m.wg.Wait() + m.notifierWg.Wait() + + m.subscribers.Range(func(key string, ch chan State) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) + + m.outputs.Range(func(_ uint32, out *outputState) bool { + if ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok { + ctrl.Destroy() + } + return true + }) + m.outputs.Range(func(key uint32, _ *outputState) bool { + m.outputs.Delete(key) + return true + }) + + if manager, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1); ok { + manager.Destroy() + } + + if m.dbusConn != nil { + m.dbusConn.RemoveSignal(m.dbusSignal) + m.dbusConn.Close() + } +} + +func MemfdCreate(name string, flags int) (int, error) { + fd, err := unix.MemfdCreate(name, flags) + if err != nil { + return -1, err + } + return fd, nil +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager_test.go new file mode 100644 index 0000000..8c00129 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/manager_test.go @@ -0,0 +1,414 @@ +package wayland + +import ( + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient" +) + +func TestManager_ActorSerializesOutputStateAccess(t *testing.T) { + m := &Manager{ + cmdq: make(chan cmd, 8192), + stopChan: make(chan struct{}), + } + + m.wg.Add(1) + go m.waylandActor() + + state := &outputState{ + id: 1, + registryName: 100, + rampSize: 256, + } + m.outputs.Store(state.id, state) + + var wg sync.WaitGroup + const goroutines = 50 + const iterations = 100 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.post(func() { + if out, ok := m.outputs.Load(state.id); ok { + out.rampSize = uint32(j) + out.failed = j%2 == 0 + out.retryCount = j + out.lastFailTime = time.Now() + } + }) + } + }(i) + } + + wg.Wait() + + done := make(chan struct{}) + m.post(func() { close(done) }) + <-done + + close(m.stopChan) + m.wg.Wait() +} + +func TestManager_ConcurrentSubscriberAccess(t *testing.T) { + m := &Manager{ + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + updateTrigger: make(chan struct{}, 1), + } + + var wg sync.WaitGroup + const goroutines = 20 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + subID := string(rune('a' + id)) + ch := m.Subscribe(subID) + assert.NotNil(t, ch) + time.Sleep(time.Millisecond) + m.Unsubscribe(subID) + }(i) + } + + wg.Wait() +} + +func TestManager_ConcurrentGetState(t *testing.T) { + m := &Manager{ + state: &State{ + CurrentTemp: 5000, + IsDay: true, + }, + } + + var wg sync.WaitGroup + const goroutines = 50 + const iterations = 100 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + s := m.GetState() + assert.GreaterOrEqual(t, s.CurrentTemp, 0) + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.stateMutex.Lock() + m.state = &State{ + CurrentTemp: 4000 + i*100, + IsDay: j%2 == 0, + } + m.stateMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_ConcurrentConfigAccess(t *testing.T) { + m := &Manager{ + config: DefaultConfig(), + } + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 100 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.configMutex.RLock() + _ = m.config.LowTemp + _ = m.config.HighTemp + _ = m.config.Enabled + m.configMutex.RUnlock() + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.configMutex.Lock() + m.config.LowTemp = 3000 + j + m.config.HighTemp = 7000 - j + m.config.Enabled = j%2 == 0 + m.configMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_SyncmapOutputsConcurrentAccess(t *testing.T) { + m := &Manager{} + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 50 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + key := uint32(id) + + for j := 0; j < iterations; j++ { + state := &outputState{ + id: key, + rampSize: uint32(j), + failed: j%2 == 0, + } + m.outputs.Store(key, state) + + if loaded, ok := m.outputs.Load(key); ok { + assert.Equal(t, key, loaded.id) + } + + m.outputs.Range(func(k uint32, v *outputState) bool { + _ = v.rampSize + _ = v.failed + return true + }) + } + + m.outputs.Delete(key) + }(i) + } + + wg.Wait() +} + +func TestManager_LocationCacheConcurrentAccess(t *testing.T) { + m := &Manager{} + + var wg sync.WaitGroup + const goroutines = 20 + const iterations = 100 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.locationMutex.RLock() + _ = m.cachedIPLat + _ = m.cachedIPLon + m.locationMutex.RUnlock() + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + lat := float64(40 + i) + lon := float64(-74 + j) + m.locationMutex.Lock() + m.cachedIPLat = &lat + m.cachedIPLon = &lon + m.locationMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_ScheduleConcurrentAccess(t *testing.T) { + now := time.Now() + m := &Manager{ + schedule: sunSchedule{ + times: SunTimes{ + Dawn: now, + Sunrise: now.Add(time.Hour), + Sunset: now.Add(12 * time.Hour), + Night: now.Add(13 * time.Hour), + }, + }, + } + + var wg sync.WaitGroup + const goroutines = 20 + const iterations = 100 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.scheduleMutex.RLock() + _ = m.schedule.times.Dawn + _ = m.schedule.times.Sunrise + _ = m.schedule.times.Sunset + _ = m.schedule.condition + m.scheduleMutex.RUnlock() + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.scheduleMutex.Lock() + m.schedule.times.Dawn = time.Now() + m.schedule.times.Sunrise = time.Now().Add(time.Hour) + m.schedule.condition = SunNormal + m.scheduleMutex.Unlock() + } + }() + } + + wg.Wait() +} + +func TestInterpolate_EdgeCases(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + now time.Time + start time.Time + stop time.Time + expected float64 + }{ + { + name: "same start and stop", + now: now, + start: now, + stop: now, + expected: 1.0, + }, + { + name: "now before start", + now: now, + start: now.Add(time.Hour), + stop: now.Add(2 * time.Hour), + expected: 0.0, + }, + { + name: "now after stop", + now: now.Add(3 * time.Hour), + start: now, + stop: now.Add(time.Hour), + expected: 1.0, + }, + { + name: "now at midpoint", + now: now.Add(30 * time.Minute), + start: now, + stop: now.Add(time.Hour), + expected: 0.5, + }, + { + name: "now equals start", + now: now, + start: now, + stop: now.Add(time.Hour), + expected: 0.0, + }, + { + name: "now equals stop", + now: now.Add(time.Hour), + start: now, + stop: now.Add(time.Hour), + expected: 1.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := interpolate(tt.now, tt.start, tt.stop) + assert.InDelta(t, tt.expected, result, 0.01) + }) + } +} + +func TestGenerateGammaRamp_ZeroSize(t *testing.T) { + ramp := GenerateGammaRamp(0, 5000, 1.0) + assert.Empty(t, ramp.Red) + assert.Empty(t, ramp.Green) + assert.Empty(t, ramp.Blue) +} + +func TestGenerateGammaRamp_ValidSizes(t *testing.T) { + sizes := []uint32{1, 256, 1024} + temps := []int{1000, 4000, 6500, 10000} + gammas := []float64{0.5, 1.0, 2.0} + + for _, size := range sizes { + for _, temp := range temps { + for _, gamma := range gammas { + ramp := GenerateGammaRamp(size, temp, gamma) + assert.Len(t, ramp.Red, int(size)) + assert.Len(t, ramp.Green, int(size)) + assert.Len(t, ramp.Blue, int(size)) + } + } + } +} + +func TestNotifySubscribers_NonBlocking(t *testing.T) { + m := &Manager{ + dirty: make(chan struct{}, 1), + } + + for i := 0; i < 10; i++ { + m.notifySubscribers() + } + + assert.Len(t, m.dirty, 1) +} + +func TestNewManager_GetRegistryError(t *testing.T) { + mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t) + + mockDisplay.EXPECT().Context().Return(nil) + mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry")) + + config := DefaultConfig() + _, err := NewManager(mockDisplay, config) + assert.Error(t, err) + assert.Contains(t, err.Error(), "get registry") +} + +func TestNewManager_InvalidConfig(t *testing.T) { + mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t) + + config := Config{ + LowTemp: 500, + HighTemp: 6500, + Gamma: 1.0, + } + + _, err := NewManager(mockDisplay, config) + assert.Error(t, err) +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc.go new file mode 100644 index 0000000..c978a8e --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc.go @@ -0,0 +1,122 @@ +package wayland + +import ( + "math" + "time" +) + +const ( + degToRad = math.Pi / 180.0 + radToDeg = 180.0 / math.Pi +) + +type SunCondition int + +const ( + SunNormal SunCondition = iota + SunMidnightSun + SunPolarNight +) + +type SunTimes struct { + Dawn time.Time + Sunrise time.Time + Sunset time.Time + Night time.Time +} + +func daysInYear(year int) int { + if (year%4 == 0 && year%100 != 0) || year%400 == 0 { + return 366 + } + return 365 +} + +func dateOrbitAngle(t time.Time) float64 { + return 2 * math.Pi / float64(daysInYear(t.Year())) * float64(t.YearDay()-1) +} + +func equationOfTime(orbitAngle float64) float64 { + return 4 * (0.000075 + + 0.001868*math.Cos(orbitAngle) - + 0.032077*math.Sin(orbitAngle) - + 0.014615*math.Cos(2*orbitAngle) - + 0.040849*math.Sin(2*orbitAngle)) +} + +func sunDeclination(orbitAngle float64) float64 { + return 0.006918 - + 0.399912*math.Cos(orbitAngle) + + 0.070257*math.Sin(orbitAngle) - + 0.006758*math.Cos(2*orbitAngle) + + 0.000907*math.Sin(2*orbitAngle) - + 0.002697*math.Cos(3*orbitAngle) + + 0.00148*math.Sin(3*orbitAngle) +} + +func sunHourAngle(latRad, declination, targetSunRad float64) float64 { + return math.Acos(math.Cos(targetSunRad)/ + math.Cos(latRad)*math.Cos(declination) - + math.Tan(latRad)*math.Tan(declination)) +} + +func hourAngleToSeconds(hourAngle, eqtime float64) float64 { + return radToDeg * (4.0*math.Pi - 4*hourAngle - eqtime) * 60 +} + +func sunCondition(latRad, declination float64) SunCondition { + signLat := latRad >= 0 + signDecl := declination >= 0 + if signLat == signDecl { + return SunMidnightSun + } + return SunPolarNight +} + +func CalculateSunTimesWithTwilight(lat, lon float64, date time.Time, elevTwilight, elevDaylight float64) (SunTimes, SunCondition) { + latRad := lat * degToRad + elevTwilightRad := (90.833 - elevTwilight) * degToRad + elevDaylightRad := (90.833 - elevDaylight) * degToRad + + utc := date.UTC() + orbitAngle := dateOrbitAngle(utc) + decl := sunDeclination(orbitAngle) + eqtime := equationOfTime(orbitAngle) + + haTwilight := sunHourAngle(latRad, decl, elevTwilightRad) + haDaylight := sunHourAngle(latRad, decl, elevDaylightRad) + + if math.IsNaN(haTwilight) || math.IsNaN(haDaylight) { + cond := sunCondition(latRad, decl) + return SunTimes{}, cond + } + + dayStart := time.Date(utc.Year(), utc.Month(), utc.Day(), 0, 0, 0, 0, time.UTC) + lonOffset := time.Duration(-lon*4) * time.Minute + + dawnSecs := hourAngleToSeconds(math.Abs(haTwilight), eqtime) + sunriseSecs := hourAngleToSeconds(math.Abs(haDaylight), eqtime) + sunsetSecs := hourAngleToSeconds(-math.Abs(haDaylight), eqtime) + nightSecs := hourAngleToSeconds(-math.Abs(haTwilight), eqtime) + + return SunTimes{ + Dawn: dayStart.Add(time.Duration(dawnSecs)*time.Second + lonOffset).In(date.Location()), + Sunrise: dayStart.Add(time.Duration(sunriseSecs)*time.Second + lonOffset).In(date.Location()), + Sunset: dayStart.Add(time.Duration(sunsetSecs)*time.Second + lonOffset).In(date.Location()), + Night: dayStart.Add(time.Duration(nightSecs)*time.Second + lonOffset).In(date.Location()), + }, SunNormal +} + +func CalculateSunTimes(lat, lon float64, date time.Time) SunTimes { + times, cond := CalculateSunTimesWithTwilight(lat, lon, date, -6.0, 3.0) + switch cond { + case SunMidnightSun: + dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + dayEnd := dayStart.Add(24*time.Hour - time.Second) + return SunTimes{Dawn: dayStart, Sunrise: dayStart, Sunset: dayEnd, Night: dayEnd} + case SunPolarNight: + dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + return SunTimes{Dawn: dayStart, Sunrise: dayStart, Sunset: dayStart, Night: dayStart} + } + return times +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc_test.go new file mode 100644 index 0000000..b2815d9 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/suncalc_test.go @@ -0,0 +1,387 @@ +package wayland + +import ( + "math" + "testing" + "time" +) + +func calculateTemperature(config Config, now time.Time) int { + if !config.Enabled { + return config.HighTemp + } + + var sunrise, sunset time.Time + + if config.ManualSunrise != nil && config.ManualSunset != nil { + year, month, day := now.Date() + loc := now.Location() + + sunrise = time.Date(year, month, day, + config.ManualSunrise.Hour(), + config.ManualSunrise.Minute(), + config.ManualSunrise.Second(), 0, loc) + sunset = time.Date(year, month, day, + config.ManualSunset.Hour(), + config.ManualSunset.Minute(), + config.ManualSunset.Second(), 0, loc) + + if sunset.Before(sunrise) { + sunset = sunset.Add(24 * time.Hour) + } + } else if config.UseIPLocation { + lat, lon, err := FetchIPLocation() + if err != nil { + return config.HighTemp + } + times := CalculateSunTimes(*lat, *lon, now) + sunrise = times.Sunrise + sunset = times.Sunset + } else if config.Latitude != nil && config.Longitude != nil { + times := CalculateSunTimes(*config.Latitude, *config.Longitude, now) + sunrise = times.Sunrise + sunset = times.Sunset + } else { + return config.HighTemp + } + + if now.Before(sunrise) || now.After(sunset) { + return config.LowTemp + } + return config.HighTemp +} + +func calculateNextTransition(config Config, now time.Time) time.Time { + if !config.Enabled { + return now.Add(24 * time.Hour) + } + + var sunrise, sunset time.Time + + if config.ManualSunrise != nil && config.ManualSunset != nil { + year, month, day := now.Date() + loc := now.Location() + + sunrise = time.Date(year, month, day, + config.ManualSunrise.Hour(), + config.ManualSunrise.Minute(), + config.ManualSunrise.Second(), 0, loc) + sunset = time.Date(year, month, day, + config.ManualSunset.Hour(), + config.ManualSunset.Minute(), + config.ManualSunset.Second(), 0, loc) + + if sunset.Before(sunrise) { + sunset = sunset.Add(24 * time.Hour) + } + } else if config.UseIPLocation { + lat, lon, err := FetchIPLocation() + if err != nil { + return now.Add(24 * time.Hour) + } + times := CalculateSunTimes(*lat, *lon, now) + sunrise = times.Sunrise + sunset = times.Sunset + } else if config.Latitude != nil && config.Longitude != nil { + times := CalculateSunTimes(*config.Latitude, *config.Longitude, now) + sunrise = times.Sunrise + sunset = times.Sunset + } else { + return now.Add(24 * time.Hour) + } + + if now.Before(sunrise) { + return sunrise + } + if now.Before(sunset) { + return sunset + } + + if config.ManualSunrise != nil && config.ManualSunset != nil { + year, month, day := now.Add(24 * time.Hour).Date() + loc := now.Location() + nextSunrise := time.Date(year, month, day, + config.ManualSunrise.Hour(), + config.ManualSunrise.Minute(), + config.ManualSunrise.Second(), 0, loc) + return nextSunrise + } + + if config.UseIPLocation { + lat, lon, err := FetchIPLocation() + if err != nil { + return now.Add(24 * time.Hour) + } + nextDayTimes := CalculateSunTimes(*lat, *lon, now.Add(24*time.Hour)) + return nextDayTimes.Sunrise + } + + if config.Latitude != nil && config.Longitude != nil { + nextDayTimes := CalculateSunTimes(*config.Latitude, *config.Longitude, now.Add(24*time.Hour)) + return nextDayTimes.Sunrise + } + + return now.Add(24 * time.Hour) +} + +func TestCalculateSunTimes(t *testing.T) { + tests := []struct { + name string + lat float64 + lon float64 + date time.Time + checkFunc func(*testing.T, SunTimes) + }{ + { + name: "new_york_summer", + lat: 40.7128, + lon: -74.0060, + date: time.Date(2024, 6, 21, 12, 0, 0, 0, time.Local), + checkFunc: func(t *testing.T, times SunTimes) { + if times.Sunrise.Hour() < 4 || times.Sunrise.Hour() > 6 { + t.Logf("sunrise: %v", times.Sunrise) + } + if times.Sunset.Hour() < 19 || times.Sunset.Hour() > 21 { + t.Logf("sunset: %v", times.Sunset) + } + if !times.Sunset.After(times.Sunrise) { + t.Error("sunset should be after sunrise") + } + }, + }, + { + name: "london_winter", + lat: 51.5074, + lon: -0.1278, + date: time.Date(2024, 12, 21, 12, 0, 0, 0, time.UTC), + checkFunc: func(t *testing.T, times SunTimes) { + if times.Sunrise.Hour() < 7 || times.Sunrise.Hour() > 9 { + t.Errorf("unexpected sunrise hour: %d", times.Sunrise.Hour()) + } + if times.Sunset.Hour() < 15 || times.Sunset.Hour() > 17 { + t.Errorf("unexpected sunset hour: %d", times.Sunset.Hour()) + } + }, + }, + { + name: "equator_equinox", + lat: 0.0, + lon: 0.0, + date: time.Date(2024, 3, 20, 12, 0, 0, 0, time.UTC), + checkFunc: func(t *testing.T, times SunTimes) { + if times.Sunrise.Hour() < 5 || times.Sunrise.Hour() > 7 { + t.Errorf("unexpected sunrise hour: %d", times.Sunrise.Hour()) + } + if times.Sunset.Hour() < 17 || times.Sunset.Hour() > 19 { + t.Errorf("unexpected sunset hour: %d", times.Sunset.Hour()) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + times := CalculateSunTimes(tt.lat, tt.lon, tt.date) + tt.checkFunc(t, times) + }) + } +} + +func TestCalculateTemperature(t *testing.T) { + lat := 40.7128 + lon := -74.0060 + date := time.Date(2024, 6, 21, 0, 0, 0, 0, time.Local) + + config := Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: &lat, + Longitude: &lon, + Enabled: true, + } + + times := CalculateSunTimes(lat, lon, date) + + tests := []struct { + name string + timeFunc func() time.Time + wantTemp int + }{ + { + name: "midnight", + timeFunc: func() time.Time { return times.Sunrise.Add(-4 * time.Hour) }, + wantTemp: 4000, + }, + { + name: "sunrise", + timeFunc: func() time.Time { return times.Sunrise }, + wantTemp: 6500, + }, + { + name: "noon", + timeFunc: func() time.Time { return times.Sunrise.Add(6 * time.Hour) }, + wantTemp: 6500, + }, + { + name: "after_sunset_transition", + timeFunc: func() time.Time { return times.Sunset.Add(2 * time.Hour) }, + wantTemp: 4000, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + temp := calculateTemperature(config, tt.timeFunc()) + + if math.Abs(float64(temp-tt.wantTemp)) > 500 { + t.Errorf("temperature = %d, want approximately %d", temp, tt.wantTemp) + } + }) + } +} + +func TestCalculateTemperatureManualTimes(t *testing.T) { + sunrise := time.Date(0, 1, 1, 6, 30, 0, 0, time.Local) + sunset := time.Date(0, 1, 1, 18, 30, 0, 0, time.Local) + + config := Config{ + LowTemp: 4000, + HighTemp: 6500, + ManualSunrise: &sunrise, + ManualSunset: &sunset, + Enabled: true, + } + + tests := []struct { + name string + time time.Time + want int + }{ + {"before_sunrise", time.Date(2024, 1, 1, 3, 0, 0, 0, time.Local), 4000}, + {"at_sunrise", time.Date(2024, 1, 1, 6, 30, 0, 0, time.Local), 6500}, + {"midday", time.Date(2024, 1, 1, 12, 0, 0, 0, time.Local), 6500}, + {"at_sunset", time.Date(2024, 1, 1, 18, 30, 0, 0, time.Local), 6500}, + {"after_sunset", time.Date(2024, 1, 1, 22, 0, 0, 0, time.Local), 4000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + temp := calculateTemperature(config, tt.time) + if math.Abs(float64(temp-tt.want)) > 500 { + t.Errorf("temperature = %d, want approximately %d", temp, tt.want) + } + }) + } +} + +func TestCalculateTemperatureDisabled(t *testing.T) { + lat := 40.7128 + lon := -74.0060 + + config := Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: &lat, + Longitude: &lon, + Enabled: false, + } + + temp := calculateTemperature(config, time.Now()) + if temp != 6500 { + t.Errorf("disabled should return high temp, got %d", temp) + } +} + +func TestCalculateNextTransition(t *testing.T) { + lat := 40.7128 + lon := -74.0060 + date := time.Date(2024, 6, 21, 0, 0, 0, 0, time.Local) + + config := Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: &lat, + Longitude: &lon, + Enabled: true, + } + + times := CalculateSunTimes(lat, lon, date) + + tests := []struct { + name string + now time.Time + checkFunc func(*testing.T, time.Time) + }{ + { + name: "before_sunrise", + now: times.Sunrise.Add(-2 * time.Hour), + checkFunc: func(t *testing.T, next time.Time) { + if !next.Equal(times.Sunrise) && !next.After(times.Sunrise.Add(-1*time.Minute)) { + t.Error("next transition should be at or near sunrise") + } + }, + }, + { + name: "after_sunrise", + now: times.Sunrise.Add(2 * time.Hour), + checkFunc: func(t *testing.T, next time.Time) { + if !next.After(times.Sunrise) { + t.Error("next transition should be after sunrise") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + next := calculateNextTransition(config, tt.now) + tt.checkFunc(t, next) + }) + } +} + +func TestSunTimesWithTwilight(t *testing.T) { + lat := 40.7128 + lon := -74.0060 + date := time.Date(2024, 6, 21, 12, 0, 0, 0, time.Local) + + times, cond := CalculateSunTimesWithTwilight(lat, lon, date, -6.0, 3.0) + + if cond != SunNormal { + t.Errorf("expected SunNormal, got %v", cond) + } + if !times.Dawn.Before(times.Sunrise) { + t.Error("dawn should be before sunrise") + } + if !times.Sunrise.Before(times.Sunset) { + t.Error("sunrise should be before sunset") + } + if !times.Sunset.Before(times.Night) { + t.Error("sunset should be before night") + } +} + +func TestSunConditions(t *testing.T) { + tests := []struct { + name string + lat float64 + date time.Time + expected SunCondition + }{ + { + name: "normal_conditions", + lat: 40.0, + date: time.Date(2024, 6, 21, 12, 0, 0, 0, time.UTC), + expected: SunNormal, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, cond := CalculateSunTimesWithTwilight(tt.lat, 0, tt.date, -6.0, 3.0) + if cond != tt.expected { + t.Errorf("expected condition %v, got %v", tt.expected, cond) + } + }) + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types.go new file mode 100644 index 0000000..31bac15 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types.go @@ -0,0 +1,216 @@ +package wayland + +import ( + "math" + "sync" + "sync/atomic" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" + "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" + "github.com/godbus/dbus/v5" +) + +type GammaState int + +const ( + StateNormal GammaState = iota + StateTransition + StateStatic +) + +type Config struct { + Outputs []string + LowTemp int + HighTemp int + Latitude *float64 + Longitude *float64 + UseIPLocation bool + ManualSunrise *time.Time + ManualSunset *time.Time + ManualDuration *time.Duration + Gamma float64 + Enabled bool + ElevationTwilight float64 + ElevationDaylight float64 +} + +type State struct { + Config Config `json:"config"` + CurrentTemp int `json:"currentTemp"` + NextTransition time.Time `json:"nextTransition"` + SunriseTime time.Time `json:"sunriseTime"` + SunsetTime time.Time `json:"sunsetTime"` + DawnTime time.Time `json:"dawnTime"` + NightTime time.Time `json:"nightTime"` + IsDay bool `json:"isDay"` + SunPosition float64 `json:"sunPosition"` +} + +type cmd struct { + fn func() +} + +type sunSchedule struct { + times SunTimes + condition SunCondition + dawnStepTime time.Duration + nightStepTime time.Duration + calcDay time.Time +} + +type Manager struct { + config Config + configMutex sync.RWMutex + state *State + stateMutex sync.RWMutex + + display wlclient.WaylandDisplay + ctx *wlclient.Context + registry *wlclient.Registry + gammaControl any + availableOutputs []*wlclient.Output + availOutputsMu sync.RWMutex + outputRegNames syncmap.Map[uint32, uint32] + outputs syncmap.Map[uint32, *outputState] + controlsInitialized bool + connectionDead atomic.Bool + + cmdq chan cmd + alive bool + + stopChan chan struct{} + updateTrigger chan struct{} + wg sync.WaitGroup + + schedule sunSchedule + scheduleMutex sync.RWMutex + gammaState GammaState + + cachedIPLat *float64 + cachedIPLon *float64 + locationMutex sync.RWMutex + + subscribers syncmap.Map[string, chan State] + dirty chan struct{} + notifierWg sync.WaitGroup + lastNotified *State + + dbusConn *dbus.Conn + dbusSignal chan *dbus.Signal + + geoClient geolocation.Client + + lastAppliedTemp int + lastAppliedGamma float64 +} + +type outputState struct { + id uint32 + registryName uint32 + output *wlclient.Output + gammaControl any + rampSize uint32 + failed bool + isVirtual bool + retryCount int + lastFailTime time.Time +} + +func DefaultConfig() Config { + return Config{ + Outputs: []string{}, + LowTemp: 4000, + HighTemp: 6500, + Gamma: 1.0, + Enabled: false, + ElevationTwilight: -6.0, + ElevationDaylight: 3.0, + } +} + +func (c *Config) Validate() error { + if c.LowTemp < 1000 || c.LowTemp > 10000 { + return errdefs.ErrInvalidTemperature + } + if c.HighTemp < 1000 || c.HighTemp > 10000 { + return errdefs.ErrInvalidTemperature + } + if c.LowTemp > c.HighTemp { + return errdefs.ErrInvalidTemperature + } + if c.Gamma <= 0 || c.Gamma > 10 { + return errdefs.ErrInvalidGamma + } + if c.Latitude != nil && (math.Abs(*c.Latitude) > 90) { + return errdefs.ErrInvalidLocation + } + if c.Longitude != nil && (math.Abs(*c.Longitude) > 180) { + return errdefs.ErrInvalidLocation + } + if (c.Latitude != nil) != (c.Longitude != nil) { + return errdefs.ErrInvalidLocation + } + if (c.ManualSunrise != nil) != (c.ManualSunset != nil) { + return errdefs.ErrInvalidManualTimes + } + return nil +} + +func (m *Manager) GetState() State { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + if m.state == nil { + return State{} + } + return *m.state +} + +func (m *Manager) Subscribe(id string) chan State { + ch := make(chan State, 64) + m.subscribers.Store(id, ch) + return ch +} + +func (m *Manager) Unsubscribe(id string) { + if val, ok := m.subscribers.LoadAndDelete(id); ok { + close(val) + } +} + +func (m *Manager) notifySubscribers() { + select { + case m.dirty <- struct{}{}: + default: + } +} + +func stateChanged(old, new *State) bool { + if old == nil || new == nil { + return true + } + if old.CurrentTemp != new.CurrentTemp { + return true + } + if old.IsDay != new.IsDay { + return true + } + if !old.NextTransition.Equal(new.NextTransition) { + return true + } + if !old.SunriseTime.Equal(new.SunriseTime) { + return true + } + if !old.SunsetTime.Equal(new.SunsetTime) { + return true + } + if old.Config.Enabled != new.Config.Enabled { + return true + } + if old.SunPosition != new.SunPosition { + return true + } + return false +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types_test.go new file mode 100644 index 0000000..f5bbb42 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wayland/types_test.go @@ -0,0 +1,330 @@ +package wayland + +import ( + "testing" + "time" +) + +func TestConfigValidate(t *testing.T) { + tests := []struct { + name string + config Config + wantErr bool + }{ + { + name: "valid_default", + config: DefaultConfig(), + wantErr: false, + }, + { + name: "valid_with_location", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: floatPtr(40.7128), + Longitude: floatPtr(-74.0060), + Gamma: 1.0, + Enabled: true, + }, + wantErr: false, + }, + { + name: "valid_manual_times", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + ManualSunrise: timePtr(time.Date(0, 1, 1, 6, 30, 0, 0, time.Local)), + ManualSunset: timePtr(time.Date(0, 1, 1, 18, 30, 0, 0, time.Local)), + Gamma: 1.0, + Enabled: true, + }, + wantErr: false, + }, + { + name: "invalid_low_temp_too_low", + config: Config{ + LowTemp: 500, + HighTemp: 6500, + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_low_temp_too_high", + config: Config{ + LowTemp: 15000, + HighTemp: 20000, + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_high_temp_too_low", + config: Config{ + LowTemp: 4000, + HighTemp: 500, + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "valid_temps_equal", + config: Config{ + LowTemp: 5000, + HighTemp: 5000, + Gamma: 1.0, + }, + wantErr: false, + }, + { + name: "invalid_temps_reversed", + config: Config{ + LowTemp: 6500, + HighTemp: 4000, + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_gamma_zero", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Gamma: 0, + }, + wantErr: true, + }, + { + name: "invalid_gamma_negative", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Gamma: -1.0, + }, + wantErr: true, + }, + { + name: "invalid_gamma_too_high", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Gamma: 15.0, + }, + wantErr: true, + }, + { + name: "invalid_latitude_too_high", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: floatPtr(100), + Longitude: floatPtr(0), + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_latitude_too_low", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: floatPtr(-100), + Longitude: floatPtr(0), + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_longitude_too_high", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: floatPtr(40), + Longitude: floatPtr(200), + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_longitude_too_low", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: floatPtr(40), + Longitude: floatPtr(-200), + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_latitude_without_longitude", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Latitude: floatPtr(40), + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_longitude_without_latitude", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Longitude: floatPtr(-74), + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_sunrise_without_sunset", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + ManualSunrise: timePtr(time.Date(0, 1, 1, 6, 30, 0, 0, time.Local)), + Gamma: 1.0, + }, + wantErr: true, + }, + { + name: "invalid_sunset_without_sunrise", + config: Config{ + LowTemp: 4000, + HighTemp: 6500, + ManualSunset: timePtr(time.Date(0, 1, 1, 18, 30, 0, 0, time.Local)), + Gamma: 1.0, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestDefaultConfig(t *testing.T) { + config := DefaultConfig() + + if config.LowTemp != 4000 { + t.Errorf("default low temp = %d, want 4000", config.LowTemp) + } + if config.HighTemp != 6500 { + t.Errorf("default high temp = %d, want 6500", config.HighTemp) + } + if config.Gamma != 1.0 { + t.Errorf("default gamma = %f, want 1.0", config.Gamma) + } + if config.Enabled { + t.Error("default should be disabled") + } + if config.Latitude != nil { + t.Error("default should not have latitude") + } + if config.Longitude != nil { + t.Error("default should not have longitude") + } +} + +func TestStateChanged(t *testing.T) { + baseState := &State{ + CurrentTemp: 5000, + NextTransition: time.Now(), + SunriseTime: time.Now().Add(6 * time.Hour), + SunsetTime: time.Now().Add(18 * time.Hour), + IsDay: true, + Config: DefaultConfig(), + } + + tests := []struct { + name string + old *State + new *State + wantChanged bool + }{ + { + name: "nil_old", + old: nil, + new: baseState, + wantChanged: true, + }, + { + name: "nil_new", + old: baseState, + new: nil, + wantChanged: true, + }, + { + name: "same_state", + old: baseState, + new: baseState, + wantChanged: false, + }, + { + name: "temp_changed", + old: baseState, + new: &State{ + CurrentTemp: 6000, + NextTransition: baseState.NextTransition, + SunriseTime: baseState.SunriseTime, + SunsetTime: baseState.SunsetTime, + IsDay: baseState.IsDay, + Config: baseState.Config, + }, + wantChanged: true, + }, + { + name: "is_day_changed", + old: baseState, + new: &State{ + CurrentTemp: baseState.CurrentTemp, + NextTransition: baseState.NextTransition, + SunriseTime: baseState.SunriseTime, + SunsetTime: baseState.SunsetTime, + IsDay: false, + Config: baseState.Config, + }, + wantChanged: true, + }, + { + name: "enabled_changed", + old: baseState, + new: &State{ + CurrentTemp: baseState.CurrentTemp, + NextTransition: baseState.NextTransition, + SunriseTime: baseState.SunriseTime, + SunsetTime: baseState.SunsetTime, + IsDay: baseState.IsDay, + Config: Config{ + LowTemp: 4000, + HighTemp: 6500, + Gamma: 1.0, + Enabled: true, + }, + }, + wantChanged: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + changed := stateChanged(tt.old, tt.new) + if changed != tt.wantChanged { + t.Errorf("stateChanged() = %v, want %v", changed, tt.wantChanged) + } + }) + } +} + +func floatPtr(f float64) *float64 { + return &f +} + +func timePtr(t time.Time) *time.Time { + return &t +} -- cgit v1.3