From 3413cb7b891624b8f3b4784939a207f43d931f43 Mon Sep 17 00:00:00 2001 From: purian23 Date: Sat, 24 Jan 2026 16:38:45 -0500 Subject: [PATCH] feat: Create new Auto theme mode based on region / time of day --- core/internal/server/router.go | 10 + core/internal/server/server.go | 68 ++ core/internal/server/thememode/handlers.go | 154 +++++ core/internal/server/thememode/manager.go | 432 ++++++++++++ core/internal/server/thememode/types.go | 23 + core/internal/server/wayland/manager.go | 11 + quickshell/Common/SessionData.qml | 43 ++ quickshell/Common/Theme.qml | 632 +++++++++++++++++- quickshell/Common/settings/SessionSpec.js | 8 + .../Modules/Settings/ThemeColorsTab.qml | 362 ++++++++++ quickshell/Services/DMSService.qml | 7 +- 11 files changed, 1747 insertions(+), 3 deletions(-) create mode 100644 core/internal/server/thememode/handlers.go create mode 100644 core/internal/server/thememode/manager.go create mode 100644 core/internal/server/thememode/types.go diff --git a/core/internal/server/router.go b/core/internal/server/router.go index 87b639f5..cbfb735f 100644 --- a/core/internal/server/router.go +++ b/core/internal/server/router.go @@ -19,6 +19,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" @@ -44,6 +45,15 @@ func RouteRequest(conn net.Conn, req models.Request) { return } + if strings.HasPrefix(req.Method, "theme.auto.") { + if themeModeManager == nil { + models.RespondError(conn, req.ID, "theme mode manager not initialized") + return + } + thememode.HandleRequest(conn, req, themeModeManager) + return + } + if strings.HasPrefix(req.Method, "loginctl.") { if loginctlManager == nil { models.RespondError(conn, req.ID, "loginctl manager not initialized") diff --git a/core/internal/server/server.go b/core/internal/server/server.go index 9294d488..bec5084a 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -28,6 +28,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" @@ -68,6 +69,7 @@ var evdevManager *evdev.Manager var clipboardManager *clipboard.Manager var dbusManager *serverDbus.Manager var wlContext *wlcontext.SharedContext +var themeModeManager *thememode.Manager const dbusClientID = "dms-dbus-client" @@ -380,6 +382,14 @@ func InitializeDbusManager() error { return nil } +func InitializeThemeModeManager() error { + manager := thememode.NewManager() + themeModeManager = manager + + log.Info("Theme mode automation manager initialized") + return nil +} + func handleConnection(conn net.Conn) { defer conn.Close() @@ -457,6 +467,10 @@ func getCapabilities() Capabilities { caps = append(caps, "clipboard") } + if themeModeManager != nil { + caps = append(caps, "theme.auto") + } + if dbusManager != nil { caps = append(caps, "dbus") } @@ -519,6 +533,10 @@ func getServerInfo() ServerInfo { caps = append(caps, "clipboard") } + if themeModeManager != nil { + caps = append(caps, "theme.auto") + } + if dbusManager != nil { caps = append(caps, "dbus") } @@ -791,6 +809,38 @@ func handleSubscribe(conn net.Conn, req models.Request) { }() } + if shouldSubscribe("theme.auto") && themeModeManager != nil { + wg.Add(1) + themeAutoChan := themeModeManager.Subscribe(clientID + "-theme-auto") + go func() { + defer wg.Done() + defer themeModeManager.Unsubscribe(clientID + "-theme-auto") + + initialState := themeModeManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "theme.auto", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-themeAutoChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "theme.auto", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + if shouldSubscribe("bluetooth") && bluezManager != nil { wg.Add(1) bluezChan := bluezManager.Subscribe(clientID + "-bluetooth") @@ -1251,6 +1301,9 @@ func cleanupManagers() { if dbusManager != nil { dbusManager.Close() } + if themeModeManager != nil { + themeModeManager.Close() + } if wlContext != nil { wlContext.Close() } @@ -1346,6 +1399,15 @@ func Start(printDocs bool) error { log.Info(" wayland.gamma.setGamma - Set gamma value (params: gamma)") log.Info(" wayland.gamma.setEnabled - Enable/disable gamma control (params: enabled)") log.Info(" wayland.gamma.subscribe - Subscribe to gamma state changes (streaming)") + log.Info("Theme automation:") + log.Info(" theme.auto.getState - Get current theme automation state") + log.Info(" theme.auto.setEnabled - Enable/disable theme automation (params: enabled)") + log.Info(" theme.auto.setMode - Set automation mode (params: mode [time|location])") + log.Info(" theme.auto.setSchedule - Set time schedule (params: startHour, startMinute, endHour, endMinute)") + log.Info(" theme.auto.setLocation - Set location (params: latitude, longitude)") + log.Info(" theme.auto.setUseIPLocation - Use IP location (params: use)") + log.Info(" theme.auto.trigger - Trigger immediate re-evaluation") + log.Info(" theme.auto.subscribe - Subscribe to theme automation state changes (streaming)") log.Info("Bluetooth:") log.Info(" bluetooth.getState - Get current bluetooth state") log.Info(" bluetooth.startDiscovery - Start device discovery") @@ -1503,6 +1565,12 @@ func Start(printDocs bool) error { log.Debugf("WlrOutput manager unavailable: %v", err) } + if err := InitializeThemeModeManager(); err != nil { + log.Warnf("Theme mode manager unavailable: %v", err) + } else { + notifyCapabilityChange() + } + fatalErrChan := make(chan error, 1) if wlrOutputManager != nil { go func() { diff --git a/core/internal/server/thememode/handlers.go b/core/internal/server/thememode/handlers.go new file mode 100644 index 00000000..68918756 --- /dev/null +++ b/core/internal/server/thememode/handlers.go @@ -0,0 +1,154 @@ +package thememode + +import ( + "encoding/json" + "fmt" + "net" + + "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, "theme mode manager not initialized") + return + } + + switch req.Method { + case "theme.auto.getState": + handleGetState(conn, req, manager) + case "theme.auto.setEnabled": + handleSetEnabled(conn, req, manager) + case "theme.auto.setMode": + handleSetMode(conn, req, manager) + case "theme.auto.setSchedule": + handleSetSchedule(conn, req, manager) + case "theme.auto.setLocation": + handleSetLocation(conn, req, manager) + case "theme.auto.setUseIPLocation": + handleSetUseIPLocation(conn, req, manager) + case "theme.auto.trigger": + handleTrigger(conn, req, manager) + case "theme.auto.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 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: "theme auto enabled set"}) +} + +func handleSetMode(conn net.Conn, req models.Request, manager *Manager) { + mode, err := params.String(req.Params, "mode") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if mode != "time" && mode != "location" { + models.RespondError(conn, req.ID, "invalid mode") + return + } + + manager.SetMode(mode) + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto mode set"}) +} + +func handleSetSchedule(conn net.Conn, req models.Request, manager *Manager) { + startHour, err := params.Int(req.Params, "startHour") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + startMinute, err := params.Int(req.Params, "startMinute") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + endHour, err := params.Int(req.Params, "endHour") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + endMinute, err := params.Int(req.Params, "endMinute") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := manager.ValidateSchedule(startHour, startMinute, endHour, endMinute); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + manager.SetSchedule(startHour, startMinute, endHour, endMinute) + models.Respond(conn, req.ID, manager.GetState()) +} + +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 + } + + manager.SetLocation(lat, lon) + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto location 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: "theme auto IP location set"}) +} + +func handleTrigger(conn net.Conn, req models.Request, manager *Manager) { + manager.TriggerUpdate() + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "theme auto update triggered"}) +} + +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/core/internal/server/thememode/manager.go b/core/internal/server/thememode/manager.go new file mode 100644 index 00000000..232c23bb --- /dev/null +++ b/core/internal/server/thememode/manager.go @@ -0,0 +1,432 @@ +package thememode + +import ( + "errors" + "sync" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +const ( + defaultStartHour = 18 + defaultStartMinute = 0 + defaultEndHour = 6 + defaultEndMinute = 0 + defaultElevationTwilight = -6.0 + defaultElevationDaylight = 3.0 +) + +type Manager struct { + config Config + configMutex sync.RWMutex + + state *State + stateMutex sync.RWMutex + + subscribers syncmap.Map[string, chan State] + + locationMutex sync.RWMutex + cachedIPLat *float64 + cachedIPLon *float64 + + stopChan chan struct{} + updateTrigger chan struct{} + wg sync.WaitGroup +} + +func NewManager() *Manager { + m := &Manager{ + config: Config{ + Enabled: false, + Mode: "time", + StartHour: defaultStartHour, + StartMinute: defaultStartMinute, + EndHour: defaultEndHour, + EndMinute: defaultEndMinute, + ElevationTwilight: defaultElevationTwilight, + ElevationDaylight: defaultElevationDaylight, + }, + stopChan: make(chan struct{}), + updateTrigger: make(chan struct{}, 1), + } + + m.updateState(time.Now()) + + m.wg.Add(1) + go m.schedulerLoop() + + return m +} + +func (m *Manager) GetState() State { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + if m.state == nil { + return State{Config: m.getConfig()} + } + stateCopy := *m.state + return stateCopy +} + +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) SetEnabled(enabled bool) { + m.configMutex.Lock() + if m.config.Enabled == enabled { + m.configMutex.Unlock() + return + } + m.config.Enabled = enabled + m.configMutex.Unlock() + m.TriggerUpdate() +} + +func (m *Manager) SetMode(mode string) { + m.configMutex.Lock() + if m.config.Mode == mode { + m.configMutex.Unlock() + return + } + m.config.Mode = mode + m.configMutex.Unlock() + m.TriggerUpdate() +} + +func (m *Manager) SetSchedule(startHour, startMinute, endHour, endMinute int) { + m.configMutex.Lock() + changed := m.config.StartHour != startHour || + m.config.StartMinute != startMinute || + m.config.EndHour != endHour || + m.config.EndMinute != endMinute + if !changed { + m.configMutex.Unlock() + return + } + m.config.StartHour = startHour + m.config.StartMinute = startMinute + m.config.EndHour = endHour + m.config.EndMinute = endMinute + m.configMutex.Unlock() + m.TriggerUpdate() +} + +func (m *Manager) SetLocation(lat, lon float64) { + 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 + } + m.config.Latitude = &lat + m.config.Longitude = &lon + m.config.UseIPLocation = false + m.configMutex.Unlock() + + m.locationMutex.Lock() + m.cachedIPLat = nil + m.cachedIPLon = nil + m.locationMutex.Unlock() + + m.TriggerUpdate() +} + +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) TriggerUpdate() { + select { + case m.updateTrigger <- struct{}{}: + default: + } +} + +func (m *Manager) Close() { + select { + case <-m.stopChan: + return + default: + close(m.stopChan) + } + m.wg.Wait() + m.subscribers.Range(func(key string, ch chan State) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) +} + +func (m *Manager) schedulerLoop() { + defer m.wg.Done() + + var timer *time.Timer + for { + config := m.getConfig() + now := time.Now() + var isLight bool + var next time.Time + if config.Enabled { + isLight, next = m.computeSchedule(now, config) + } else { + m.stateMutex.RLock() + if m.state != nil { + isLight = m.state.IsLight + } + m.stateMutex.RUnlock() + next = now.Add(24 * time.Hour) + } + + m.updateStateWithValues(config, isLight, next) + + waitDur := time.Until(next) + if !config.Enabled { + waitDur = 24 * time.Hour + } + if waitDur < time.Second { + waitDur = time.Second + } + + if timer != nil { + timer.Stop() + } + timer = time.NewTimer(waitDur) + + select { + case <-m.stopChan: + timer.Stop() + return + case <-m.updateTrigger: + timer.Stop() + continue + case <-timer.C: + continue + } + } +} + +func (m *Manager) updateState(now time.Time) { + config := m.getConfig() + var isLight bool + var next time.Time + if config.Enabled { + isLight, next = m.computeSchedule(now, config) + } else { + m.stateMutex.RLock() + if m.state != nil { + isLight = m.state.IsLight + } + m.stateMutex.RUnlock() + next = now.Add(24 * time.Hour) + } + m.updateStateWithValues(config, isLight, next) +} + +func (m *Manager) updateStateWithValues(config Config, isLight bool, next time.Time) { + newState := State{ + Config: config, + IsLight: isLight, + NextTransition: next, + } + + m.stateMutex.Lock() + if m.state != nil && statesEqual(m.state, &newState) { + m.stateMutex.Unlock() + return + } + m.state = &newState + m.stateMutex.Unlock() + + m.notifySubscribers() +} + +func (m *Manager) notifySubscribers() { + state := m.GetState() + m.subscribers.Range(func(key string, ch chan State) bool { + select { + case ch <- state: + default: + } + return true + }) +} + +func (m *Manager) getConfig() Config { + m.configMutex.RLock() + defer m.configMutex.RUnlock() + return m.config +} + +func (m *Manager) getLocation(config Config) (*float64, *float64) { + if config.Latitude != nil && config.Longitude != nil { + return config.Latitude, config.Longitude + } + if !config.UseIPLocation { + 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() + + lat, lon, err := wayland.FetchIPLocation() + if err != nil { + return nil, nil + } + + m.locationMutex.Lock() + m.cachedIPLat = lat + m.cachedIPLon = lon + m.locationMutex.Unlock() + + return lat, lon +} + +func statesEqual(a, b *State) bool { + if a == nil || b == nil { + return a == b + } + if a.IsLight != b.IsLight || !a.NextTransition.Equal(b.NextTransition) { + return false + } + return a.Config == b.Config +} + +func (m *Manager) computeSchedule(now time.Time, config Config) (bool, time.Time) { + if config.Mode == "location" { + return m.computeLocationSchedule(now, config) + } + return computeTimeSchedule(now, config) +} + +func computeTimeSchedule(now time.Time, config Config) (bool, time.Time) { + startMinutes := config.StartHour*60 + config.StartMinute + endMinutes := config.EndHour*60 + config.EndMinute + currentMinutes := now.Hour()*60 + now.Minute() + + startTime := time.Date(now.Year(), now.Month(), now.Day(), config.StartHour, config.StartMinute, 0, 0, now.Location()) + endTime := time.Date(now.Year(), now.Month(), now.Day(), config.EndHour, config.EndMinute, 0, 0, now.Location()) + + if startMinutes == endMinutes { + next := startTime + if !next.After(now) { + next = next.Add(24 * time.Hour) + } + return true, next + } + + if startMinutes < endMinutes { + if currentMinutes < startMinutes { + return true, startTime + } + if currentMinutes >= endMinutes { + return true, startTime.Add(24 * time.Hour) + } + return false, endTime + } + + if currentMinutes >= startMinutes { + return false, endTime.Add(24 * time.Hour) + } + if currentMinutes < endMinutes { + return false, endTime + } + return true, startTime +} + +func (m *Manager) computeLocationSchedule(now time.Time, config Config) (bool, time.Time) { + lat, lon := m.getLocation(config) + if lat == nil || lon == nil { + currentIsLight := false + m.stateMutex.RLock() + if m.state != nil { + currentIsLight = m.state.IsLight + } + m.stateMutex.RUnlock() + return currentIsLight, now.Add(10 * time.Minute) + } + + times, cond := wayland.CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight) + if cond != wayland.SunNormal { + if cond == wayland.SunMidnightSun { + return true, startOfNextDay(now) + } + return false, startOfNextDay(now) + } + + if now.Before(times.Sunrise) { + return false, times.Sunrise + } + if now.Before(times.Sunset) { + return true, times.Sunset + } + + nextDay := startOfNextDay(now) + nextTimes, nextCond := wayland.CalculateSunTimesWithTwilight(*lat, *lon, nextDay, config.ElevationTwilight, config.ElevationDaylight) + if nextCond != wayland.SunNormal { + if nextCond == wayland.SunMidnightSun { + return true, startOfNextDay(nextDay) + } + return false, startOfNextDay(nextDay) + } + + return false, nextTimes.Sunrise +} + +func startOfNextDay(t time.Time) time.Time { + next := t.Add(24 * time.Hour) + return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, next.Location()) +} + +func validateHourMinute(hour, minute int) bool { + if hour < 0 || hour > 23 { + return false + } + if minute < 0 || minute > 59 { + return false + } + return true +} + +func (m *Manager) ValidateSchedule(startHour, startMinute, endHour, endMinute int) error { + if !validateHourMinute(startHour, startMinute) || !validateHourMinute(endHour, endMinute) { + return errInvalidTime + } + return nil +} + +var errInvalidTime = errors.New("invalid schedule time") diff --git a/core/internal/server/thememode/types.go b/core/internal/server/thememode/types.go new file mode 100644 index 00000000..72e64688 --- /dev/null +++ b/core/internal/server/thememode/types.go @@ -0,0 +1,23 @@ +package thememode + +import "time" + +type Config struct { + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + StartHour int `json:"startHour"` + StartMinute int `json:"startMinute"` + EndHour int `json:"endHour"` + EndMinute int `json:"endMinute"` + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` + UseIPLocation bool `json:"useIPLocation"` + ElevationTwilight float64 `json:"elevationTwilight"` + ElevationDaylight float64 `json:"elevationDaylight"` +} + +type State struct { + Config Config `json:"config"` + IsLight bool `json:"isLight"` + NextTransition time.Time `json:"nextTransition"` +} diff --git a/core/internal/server/wayland/manager.go b/core/internal/server/wayland/manager.go index e8f8ae32..5b6a9099 100644 --- a/core/internal/server/wayland/manager.go +++ b/core/internal/server/wayland/manager.go @@ -392,11 +392,15 @@ func (m *Manager) recalcSchedule(now time.Time) { cond = SunNormal } else { lat, lon := m.getLocation() + log.Errorf("@@@ recalcSchedule: lat=%v, lon=%v @@@", lat, lon) if lat == nil || lon == nil { + log.Errorf("@@@ recalcSchedule: NO LOCATION, setting StateStatic @@@") m.gammaState = StateStatic return } + log.Errorf("@@@ recalcSchedule: calculating sun times for lat=%.4f, lon=%.4f @@@", *lat, *lon) times, cond = CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight) + log.Errorf("@@@ recalcSchedule: sunrise=%v, sunset=%v @@@", times.Sunrise, times.Sunset) } m.schedule.calcDay = dayStart @@ -626,6 +630,7 @@ func (m *Manager) schedulerLoop() { m.schedule.calcDay = time.Time{} m.scheduleMutex.Unlock() m.recalcSchedule(time.Now()) + m.updateStateFromSchedule() m.configMutex.RLock() enabled := m.config.Enabled m.configMutex.RUnlock() @@ -774,16 +779,21 @@ func (m *Manager) updateStateFromSchedule() { config := m.config m.configMutex.RUnlock() + log.Errorf("@@@ updateStateFromSchedule: config.Lat=%v, config.Lon=%v @@@", config.Latitude, config.Longitude) + m.scheduleMutex.RLock() times := m.schedule.times m.scheduleMutex.RUnlock() + log.Errorf("@@@ updateStateFromSchedule: times.Sunrise=%v, times.Sunset=%v @@@", times.Sunrise, times.Sunset) + var pos float64 var temp int var isDay bool var deadline time.Time if times.Sunrise.IsZero() { + log.Errorf("@@@ updateStateFromSchedule: Sunrise is ZERO, defaulting isDay=true @@@") pos = 1.0 temp = config.HighTemp isDay = true @@ -793,6 +803,7 @@ func (m *Manager) updateStateFromSchedule() { temp = m.getTempFromPosition(pos) deadline = m.getNextDeadline(now) isDay = now.After(times.Sunrise) && now.Before(times.Sunset) + log.Errorf("@@@ updateStateFromSchedule: isDay=%v (now=%v, sunrise=%v, sunset=%v) @@@", isDay, now.Format("15:04:05"), times.Sunrise.Format("15:04:05"), times.Sunset.Format("15:04:05")) } newState := State{ diff --git a/quickshell/Common/SessionData.qml b/quickshell/Common/SessionData.qml index 577c2f4b..09a73b31 100644 --- a/quickshell/Common/SessionData.qml +++ b/quickshell/Common/SessionData.qml @@ -82,6 +82,14 @@ Singleton { property bool nightModeUseIPLocation: false property string nightModeLocationProvider: "" + property bool themeModeAutoEnabled: false + property string themeModeAutoMode: "time" + property int themeModeStartHour: 18 + property int themeModeStartMinute: 0 + property int themeModeEndHour: 6 + property int themeModeEndMinute: 0 + property bool themeModeShareGammaSettings: true + property var pinnedApps: [] property var barPinnedApps: [] property int dockLauncherPosition: 0 @@ -754,6 +762,41 @@ Singleton { saveSettings(); } + function setThemeModeAutoEnabled(enabled) { + themeModeAutoEnabled = enabled; + saveSettings(); + } + + function setThemeModeAutoMode(mode) { + themeModeAutoMode = mode; + saveSettings(); + } + + function setThemeModeStartHour(hour) { + themeModeStartHour = hour; + saveSettings(); + } + + function setThemeModeStartMinute(minute) { + themeModeStartMinute = minute; + saveSettings(); + } + + function setThemeModeEndHour(hour) { + themeModeEndHour = hour; + saveSettings(); + } + + function setThemeModeEndMinute(minute) { + themeModeEndMinute = minute; + saveSettings(); + } + + function setThemeModeShareGammaSettings(share) { + themeModeShareGammaSettings = share; + saveSettings(); + } + function setPinnedApps(apps) { pinnedApps = apps; saveSettings(); diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index da9f32a6..3fd0ab50 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -94,6 +94,12 @@ Singleton { property var matugenColors: ({}) property var _pendingGenerateParams: null + // Theme automation + property bool themeModeAutomationActive: false + + // Watch for DMSService connection to retry location setup + property bool dmsServiceWasDisconnected: true + readonly property var dank16: { const raw = matugenColors?.dank16; if (!raw) @@ -125,6 +131,8 @@ Singleton { readonly property string currentThemeId: customThemeRawData?.id || "" Component.onCompleted: { + console.warn("THEME AUTOMATION DEBUG: Component.onCompleted STARTING"); + console.error("THEME AUTOMATION DEBUG: This is an error test"); Quickshell.execDetached(["mkdir", "-p", stateDir]); Proc.runCommand("matugenCheck", ["which", "matugen"], (output, code) => { matugenAvailable = (code === 0) && !envDisableMatugen; @@ -176,6 +184,277 @@ Singleton { if (typeof SettingsData !== "undefined" && SettingsData.currentThemeName) { switchTheme(SettingsData.currentThemeName, false, false); } + + if (typeof SessionData !== "undefined" && SessionData.themeModeAutoEnabled) { + console.error("*** THEME STARTUP: themeModeAutoEnabled = true, starting automation ***"); + console.error("*** THEME STARTUP: themeModeAutoMode =", SessionData.themeModeAutoMode); + console.error("*** THEME STARTUP: current isLightMode =", root.isLightMode); + console.error("*** THEME STARTUP: DisplayService.gammaIsDay =", DisplayService?.gammaIsDay); + startThemeModeAutomation(); + console.error("*** THEME STARTUP: automation started ***"); + } else { + console.error("*** THEME STARTUP: automation NOT enabled ***"); + if (typeof SessionData !== "undefined") { + console.error("*** THEME STARTUP: themeModeAutoEnabled =", SessionData.themeModeAutoEnabled); + } + } + } + + Connections { + target: SessionData + enabled: typeof SessionData !== "undefined" + + function onThemeModeAutoEnabledChanged() { + if (SessionData.themeModeAutoEnabled) { + root.startThemeModeAutomation(); + } else { + root.stopThemeModeAutomation(); + } + } + + function onThemeModeAutoModeChanged() { + if (root.themeModeAutomationActive) { + root.evaluateThemeMode(); + root.syncTimeThemeSchedule(); + root.syncLocationThemeSchedule(); + } + } + + function onThemeModeStartHourChanged() { + if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) { + root.evaluateThemeMode(); + root.syncTimeThemeSchedule(); + } + } + + function onThemeModeStartMinuteChanged() { + if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) { + root.evaluateThemeMode(); + root.syncTimeThemeSchedule(); + } + } + + function onThemeModeEndHourChanged() { + if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) { + root.evaluateThemeMode(); + root.syncTimeThemeSchedule(); + } + } + + function onThemeModeEndMinuteChanged() { + if (root.themeModeAutomationActive && !SessionData.themeModeShareGammaSettings) { + root.evaluateThemeMode(); + root.syncTimeThemeSchedule(); + } + } + + function onThemeModeShareGammaSettingsChanged() { + if (root.themeModeAutomationActive) { + root.evaluateThemeMode(); + root.syncTimeThemeSchedule(); + root.syncLocationThemeSchedule(); + } + } + + function onNightModeStartHourChanged() { + if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) { + root.evaluateThemeMode(); + root.syncTimeThemeSchedule(); + } + } + + function onNightModeStartMinuteChanged() { + if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) { + root.evaluateThemeMode(); + root.syncTimeThemeSchedule(); + } + } + + function onNightModeEndHourChanged() { + if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) { + root.evaluateThemeMode(); + root.syncTimeThemeSchedule(); + } + } + + function onNightModeEndMinuteChanged() { + if (root.themeModeAutomationActive && SessionData.themeModeShareGammaSettings) { + root.evaluateThemeMode(); + root.syncTimeThemeSchedule(); + } + } + + function onLatitudeChanged() { + if (root.themeModeAutomationActive && SessionData.themeModeAutoMode === "location") { + // Update backend with new coordinates + if (!SessionData.nightModeUseIPLocation && + SessionData.latitude !== 0.0 && + SessionData.longitude !== 0.0 && + typeof DMSService !== "undefined") { + DMSService.sendRequest("wayland.gamma.setLocation", { + "latitude": SessionData.latitude, + "longitude": SessionData.longitude + }); + } + root.evaluateThemeMode(); + root.syncLocationThemeSchedule(); + } + } + + function onLongitudeChanged() { + if (root.themeModeAutomationActive && SessionData.themeModeAutoMode === "location") { + // Update backend with new coordinates + if (!SessionData.nightModeUseIPLocation && + SessionData.latitude !== 0.0 && + SessionData.longitude !== 0.0 && + typeof DMSService !== "undefined") { + DMSService.sendRequest("wayland.gamma.setLocation", { + "latitude": SessionData.latitude, + "longitude": SessionData.longitude + }); + } + root.evaluateThemeMode(); + root.syncLocationThemeSchedule(); + } + } + + function onNightModeUseIPLocationChanged() { + if (root.themeModeAutomationActive && SessionData.themeModeAutoMode === "location") { + // Update backend with IP location preference + if (typeof DMSService !== "undefined") { + DMSService.sendRequest("wayland.gamma.setUseIPLocation", { + "use": SessionData.nightModeUseIPLocation + }, response => { + if (!response.error && !SessionData.nightModeUseIPLocation && + SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) { + // If switching from IP to manual, send coordinates + DMSService.sendRequest("wayland.gamma.setLocation", { + "latitude": SessionData.latitude, + "longitude": SessionData.longitude + }); + } + }); + } + root.evaluateThemeMode(); + root.syncLocationThemeSchedule(); + } + } + } + + // React to gamma backend's isDay state changes for location-based mode + // Note: gamma backend calculates isDay independently from whether + // gamma control is enabled for display adjustments + // Use gammaIsDay property instead of gammaState object for proper change notifications + Connections { + target: DisplayService + enabled: typeof DisplayService !== "undefined" && + typeof SessionData !== "undefined" && + SessionData.themeModeAutoEnabled && + SessionData.themeModeAutoMode === "location" && + !themeAutoBackendAvailable() + + function onGammaIsDayChanged() { + console.error("!!! onGammaIsDayChanged FIRED !!!"); + console.error("!!! gammaIsDay =", DisplayService.gammaIsDay); + console.error("!!! current isLightMode =", root.isLightMode); + console.error("!!! Will switch?", root.isLightMode !== DisplayService.gammaIsDay); + if (root.isLightMode !== DisplayService.gammaIsDay) { + console.error("!!! CALLING setLightMode from onGammaIsDayChanged"); + root.setLightMode(DisplayService.gammaIsDay, true, true); + } + } + } + + Connections { + target: DMSService + enabled: typeof DMSService !== "undefined" && typeof SessionData !== "undefined" + + function onLoginctlEvent(event) { + console.error("### onLoginctlEvent FIRED:", event.event, "###"); + // Only handle if automation is enabled + if (!SessionData.themeModeAutoEnabled) return; + + // Re-evaluate theme mode when system wakes up or unlocks + // This ensures time-based and location-based (non-shared) modes stay accurate + if (event.event === "unlock" || event.event === "resume") { + if (!themeAutoBackendAvailable()) { + root.evaluateThemeMode(); + return; + } + DMSService.sendRequest("theme.auto.trigger", {}); + } + } + + function onThemeAutoStateUpdate(data) { + if (!SessionData.themeModeAutoEnabled) { + return; + } + applyThemeAutoState(data); + } + + function onConnectionStateChanged() { + console.error("@@@ onConnectionStateChanged FIRED @@@"); + console.error("@@@ DMSService.isConnected =", DMSService.isConnected); + console.error("@@@ SessionData.themeModeAutoEnabled =", SessionData.themeModeAutoEnabled); + console.error("@@@ SessionData.themeModeAutoMode =", SessionData.themeModeAutoMode); + console.error("@@@ SessionData.latitude =", SessionData.latitude); + console.error("@@@ SessionData.longitude =", SessionData.longitude); + + if (DMSService.isConnected && SessionData.themeModeAutoMode === "time") { + root.syncTimeThemeSchedule(); + } + + if (DMSService.isConnected && SessionData.themeModeAutoMode === "location") { + root.syncLocationThemeSchedule(); + } + + if (themeAutoBackendAvailable() && SessionData.themeModeAutoEnabled) { + DMSService.sendRequest("theme.auto.getState", null, response => { + if (response && response.result) { + applyThemeAutoState(response.result); + } + }); + } + + // Only handle if automation is enabled + if (!SessionData.themeModeAutoEnabled) { + console.error("@@@ Automation not enabled, skipping @@@"); + return; + } + + // When DMSService connects, retry location initialization if in location mode + if (DMSService.isConnected && SessionData.themeModeAutoMode === "location") { + console.error("@@@ RETRYING location setup @@@"); + if (SessionData.nightModeUseIPLocation) { + console.error("@@@ Using IP location @@@"); + DMSService.sendRequest("wayland.gamma.setUseIPLocation", { + "use": true + }, response => { + if (!response.error) { + console.log("Theme automation: IP location enabled after connection"); + } + }); + } else if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) { + console.error("@@@ Using manual coordinates:", SessionData.latitude, SessionData.longitude, "@@@"); + DMSService.sendRequest("wayland.gamma.setUseIPLocation", { + "use": false + }, response => { + console.error("@@@ setUseIPLocation(false) response:", JSON.stringify(response), "@@@"); + if (!response.error) { + console.error("@@@ Sending setLocation request @@@"); + DMSService.sendRequest("wayland.gamma.setLocation", { + "latitude": SessionData.latitude, + "longitude": SessionData.longitude + }, locationResponse => { + console.error("@@@ setLocation response:", JSON.stringify(locationResponse), "@@@"); + }); + } + }); + } else { + console.error("@@@ No location configured - nightModeUseIPLocation:", SessionData.nightModeUseIPLocation, "@@@"); + } + } + } } function applyGreeterTheme(themeName) { @@ -534,17 +813,27 @@ Singleton { } function setLightMode(light, savePrefs = true, enableTransition = false) { + console.error(">>> setLightMode CALLED: light =", light, ", savePrefs =", savePrefs, ", enableTransition =", enableTransition); + console.error(">>> BEFORE: root.isLightMode =", root.isLightMode); + if (enableTransition) { screenTransition(); lightModeTransitionTimer.lightMode = light; lightModeTransitionTimer.savePrefs = savePrefs; lightModeTransitionTimer.restart(); + console.error(">>> setLightMode: Starting transition timer"); return; } const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode); - if (savePrefs && typeof SessionData !== "undefined" && !isGreeterMode) + console.error(">>> setLightMode: isGreeterMode =", isGreeterMode, ", will save =", savePrefs && typeof SessionData !== "undefined" && !isGreeterMode); + + if (savePrefs && typeof SessionData !== "undefined" && !isGreeterMode) { + console.error(">>> setLightMode: Calling SessionData.setLightMode(", light, ")"); SessionData.setLightMode(light); + console.error(">>> setLightMode: AFTER SessionData.setLightMode - root.isLightMode =", root.isLightMode); + } + if (!isGreeterMode) { // Skip with matugen because, our script runner will do it. if (!matugenAvailable) { @@ -552,6 +841,8 @@ Singleton { } generateSystemThemesFromCurrentTheme(); } + + console.error(">>> setLightMode DONE: root.isLightMode =", root.isLightMode); } function toggleLightMode(savePrefs = true) { @@ -1453,4 +1744,343 @@ Singleton { root.switchTheme(defaultTheme, true, false); } } + + // Theme mode automation functions + function themeAutoBackendAvailable() { + return typeof DMSService !== "undefined" && + DMSService.isConnected && + Array.isArray(DMSService.capabilities) && + DMSService.capabilities.includes("theme.auto"); + } + + function applyThemeAutoState(state) { + if (!state) { + return; + } + if (state.config && state.config.mode && state.config.mode !== SessionData.themeModeAutoMode) { + return; + } + if (state.isLight !== undefined && root.isLightMode !== state.isLight) { + root.setLightMode(state.isLight, true, true); + } + } + + function syncTimeThemeSchedule() { + if (typeof SessionData === "undefined" || typeof DMSService === "undefined") { + return; + } + + if (!DMSService.isConnected) { + return; + } + + const timeModeActive = SessionData.themeModeAutoEnabled && SessionData.themeModeAutoMode === "time"; + + if (!timeModeActive) { + return; + } + + DMSService.sendRequest("theme.auto.setMode", {"mode": "time"}); + + const shareSettings = SessionData.themeModeShareGammaSettings; + const startHour = shareSettings ? SessionData.nightModeStartHour : SessionData.themeModeStartHour; + const startMinute = shareSettings ? SessionData.nightModeStartMinute : SessionData.themeModeStartMinute; + const endHour = shareSettings ? SessionData.nightModeEndHour : SessionData.themeModeEndHour; + const endMinute = shareSettings ? SessionData.nightModeEndMinute : SessionData.themeModeEndMinute; + + DMSService.sendRequest("theme.auto.setSchedule", { + "startHour": startHour, + "startMinute": startMinute, + "endHour": endHour, + "endMinute": endMinute + }, response => { + if (response && response.error) { + console.error("Theme automation: Failed to sync time schedule:", response.error); + } + }); + + DMSService.sendRequest("theme.auto.setEnabled", {"enabled": true}); + DMSService.sendRequest("theme.auto.trigger", {}); + } + + function syncLocationThemeSchedule() { + if (typeof SessionData === "undefined" || typeof DMSService === "undefined") { + return; + } + + if (!DMSService.isConnected) { + return; + } + + const locationModeActive = SessionData.themeModeAutoEnabled && SessionData.themeModeAutoMode === "location"; + + if (!locationModeActive) { + return; + } + + DMSService.sendRequest("theme.auto.setMode", {"mode": "location"}); + + if (SessionData.nightModeUseIPLocation) { + DMSService.sendRequest("theme.auto.setUseIPLocation", {"use": true}); + } else { + DMSService.sendRequest("theme.auto.setUseIPLocation", {"use": false}); + if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) { + DMSService.sendRequest("theme.auto.setLocation", { + "latitude": SessionData.latitude, + "longitude": SessionData.longitude + }); + } + } + + DMSService.sendRequest("theme.auto.setEnabled", {"enabled": true}); + DMSService.sendRequest("theme.auto.trigger", {}); + } + + function evaluateThemeMode() { + if (typeof SessionData === "undefined" || !SessionData.themeModeAutoEnabled) { + return; + } + + if (themeAutoBackendAvailable()) { + DMSService.sendRequest("theme.auto.getState", null, response => { + if (response && response.result) { + applyThemeAutoState(response.result); + } + }); + return; + } + + const mode = SessionData.themeModeAutoMode; + + // Location mode uses gamma backend or JavaScript fallback + // The Connections block also handles real-time gamma state updates + if (mode === "location") { + evaluateLocationBasedThemeMode(); + } else { + // Time-based mode + evaluateTimeBasedThemeMode(); + } + } + + function evaluateLocationBasedThemeMode() { + console.error("=== THEME AUTOMATION DEBUG: evaluateLocationBasedThemeMode START ==="); + console.error("THEME AUTO: DisplayService exists?", typeof DisplayService !== "undefined"); + console.error("THEME AUTO: gammaState =", JSON.stringify(DisplayService?.gammaState || {})); + console.error("THEME AUTO: gammaIsDay =", DisplayService?.gammaIsDay); + console.error("THEME AUTO: current Theme.isLightMode =", root.isLightMode); + + // When using IP location or manual coordinates, the gamma backend + // can calculate sun position and isDay independently from whether + // gamma control is enabled for display adjustments + + // Try to use gamma backend's isDay state (works with both IP and manual location) + // Use gammaIsDay property for reliable value access + if (typeof DisplayService !== "undefined") { + const shouldBeLight = DisplayService.gammaIsDay; + console.error("THEME AUTO: shouldBeLight =", shouldBeLight); + console.error("THEME AUTO: Will switch?", root.isLightMode !== shouldBeLight); + if (root.isLightMode !== shouldBeLight) { + console.error("THEME AUTO: CALLING setLightMode(", shouldBeLight, ", true, true)"); + root.setLightMode(shouldBeLight, true, true); + console.error("THEME AUTO: AFTER setLightMode - isLightMode =", root.isLightMode); + } else { + console.error("THEME AUTO: No change needed - already in correct mode"); + } + console.error("=== THEME AUTOMATION DEBUG: evaluateLocationBasedThemeMode END ==="); + return; + } + + // Fallback: Use JavaScript sun calculation with manual coordinates + // This is less accurate but works if backend isn't available + if (!SessionData.nightModeUseIPLocation && + SessionData.latitude !== 0.0 && + SessionData.longitude !== 0.0) { + const shouldBeLight = calculateIsDaytime( + SessionData.latitude, + SessionData.longitude + ); + if (root.isLightMode !== shouldBeLight) { + root.setLightMode(shouldBeLight, true, true); + } + return; + } + + // Warn about missing configuration + if (root.themeModeAutomationActive) { + if (SessionData.nightModeUseIPLocation) { + console.warn("Theme automation: Waiting for IP location from backend"); + } else { + console.warn("Theme automation: Location mode requires coordinates"); + } + } + } + + function evaluateTimeBasedThemeMode() { + const shareSettings = SessionData.themeModeShareGammaSettings; + + // Get time settings (shared or independent) + const startHour = shareSettings ? + SessionData.nightModeStartHour : SessionData.themeModeStartHour; + const startMinute = shareSettings ? + SessionData.nightModeStartMinute : SessionData.themeModeStartMinute; + const endHour = shareSettings ? + SessionData.nightModeEndHour : SessionData.themeModeEndHour; + const endMinute = shareSettings ? + SessionData.nightModeEndMinute : SessionData.themeModeEndMinute; + + const now = new Date(); + const currentMinutes = now.getHours() * 60 + now.getMinutes(); + const startMinutes = startHour * 60 + startMinute; + const endMinutes = endHour * 60 + endMinute; + + // Light mode is OUTSIDE the dark period + let shouldBeLight; + if (startMinutes < endMinutes) { + // Normal case: dark period within same day (e.g., 01:00-05:00) + shouldBeLight = currentMinutes < startMinutes || currentMinutes >= endMinutes; + } else { + // Overnight case: dark period crosses midnight (e.g., 18:00-06:00) + shouldBeLight = currentMinutes >= endMinutes && currentMinutes < startMinutes; + } + + if (root.isLightMode !== shouldBeLight) { + root.setLightMode(shouldBeLight, true, true); + } + } + + function calculateIsDaytime(lat, lng) { + const now = new Date(); + const start = new Date(now.getFullYear(), 0, 0); + const diff = now - start; + const dayOfYear = Math.floor(diff / 86400000); + const latRad = lat * Math.PI / 180; + + // Solar declination approximation + const declination = 23.45 * Math.sin((360/365) * (dayOfYear - 81) * Math.PI / 180); + const declinationRad = declination * Math.PI / 180; + + // Hour angle at sunrise/sunset + const cosHourAngle = -Math.tan(latRad) * Math.tan(declinationRad); + + // Handle polar conditions + if (cosHourAngle > 1) { + return false; // Polar night + } + if (cosHourAngle < -1) { + return true; // Midnight sun + } + + const hourAngle = Math.acos(cosHourAngle); + const hourAngleDeg = hourAngle * 180 / Math.PI; + + // Sunrise/sunset in decimal hours (solar noon ± hour angle) + const sunriseHour = 12 - hourAngleDeg / 15; + const sunsetHour = 12 + hourAngleDeg / 15; + + // Adjust for longitude (rough approximation) + const timeZoneOffset = now.getTimezoneOffset() / 60; + const localSunrise = sunriseHour - lng / 15 - timeZoneOffset; + const localSunset = sunsetHour - lng / 15 - timeZoneOffset; + + const currentHour = now.getHours() + now.getMinutes() / 60; + + // Normalize hours to 0-24 range + const normalizeSunrise = ((localSunrise % 24) + 24) % 24; + const normalizeSunset = ((localSunset % 24) + 24) % 24; + + return currentHour >= normalizeSunrise && currentHour < normalizeSunset; + } + + // Helper function to send location to backend + function sendLocationToBackend() { + if (typeof SessionData === "undefined" || typeof DMSService === "undefined") { + console.error("$$$ sendLocationToBackend: SessionData or DMSService unavailable $$$"); + return false; + } + + if (!DMSService.isConnected) { + console.error("$$$ sendLocationToBackend: DMSService not connected yet $$$"); + return false; + } + + console.error("$$$ sendLocationToBackend: SENDING location to backend $$$"); + + if (SessionData.nightModeUseIPLocation) { + console.error("$$$ Using IP location $$$"); + DMSService.sendRequest("wayland.gamma.setUseIPLocation", {"use": true}, response => { + console.error("$$$ IP location response:", JSON.stringify(response), "$$$"); + }); + return true; + } else if (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) { + console.error("$$$ Using manual coords:", SessionData.latitude, SessionData.longitude, "$$$"); + DMSService.sendRequest("wayland.gamma.setUseIPLocation", {"use": false}, response => { + if (!response.error) { + DMSService.sendRequest("wayland.gamma.setLocation", { + "latitude": SessionData.latitude, + "longitude": SessionData.longitude + }, locResp => { + console.error("$$$ setLocation response:", JSON.stringify(locResp), "$$$"); + }); + } + }); + return true; + } + + console.error("$$$ sendLocationToBackend: No location configured $$$"); + return false; + } + + Timer { + id: locationRetryTimer + interval: 1000 + repeat: true + running: false + property int retryCount: 0 + + onTriggered: { + console.error("$$$ locationRetryTimer triggered, attempt", retryCount + 1, "$$$"); + if (root.sendLocationToBackend()) { + console.error("$$$ Location sent successfully, stopping retry timer $$$"); + stop(); + retryCount = 0; + root.evaluateThemeMode(); + } else { + retryCount++; + if (retryCount >= 10) { + console.error("$$$ Giving up after 10 retries $$$"); + stop(); + retryCount = 0; + } + } + } + } + + function startThemeModeAutomation() { + console.error("XYZABC NEW startThemeModeAutomation VERSION XYZABC"); + root.themeModeAutomationActive = true; + + root.syncTimeThemeSchedule(); + root.syncLocationThemeSchedule(); + + // Try to send location immediately + const sent = root.sendLocationToBackend(); + console.error("XYZABC sendLocationToBackend returned:", sent, "XYZABC"); + + // If it failed (likely because DMSService not connected), retry + if (!sent && typeof SessionData !== "undefined" && SessionData.themeModeAutoMode === "location") { + console.error("XYZABC Starting retry timer XYZABC"); + locationRetryTimer.start(); + } else { + console.error("XYZABC Calling evaluateThemeMode XYZABC"); + // Evaluate theme mode immediately + root.evaluateThemeMode(); + } + } + + function stopThemeModeAutomation() { + root.themeModeAutomationActive = false; + if (typeof DMSService !== "undefined" && DMSService.isConnected) { + DMSService.sendRequest("theme.auto.setEnabled", {"enabled": false}); + } + } } diff --git a/quickshell/Common/settings/SessionSpec.js b/quickshell/Common/settings/SessionSpec.js index 4acb24f1..145452db 100644 --- a/quickshell/Common/settings/SessionSpec.js +++ b/quickshell/Common/settings/SessionSpec.js @@ -35,6 +35,14 @@ var SPEC = { nightModeUseIPLocation: { def: false }, nightModeLocationProvider: { def: "" }, + themeModeAutoEnabled: { def: false }, + themeModeAutoMode: { def: "time" }, + themeModeStartHour: { def: 18 }, + themeModeStartMinute: { def: 0 }, + themeModeEndHour: { def: 6 }, + themeModeEndMinute: { def: 0 }, + themeModeShareGammaSettings: { def: true }, + weatherLocation: { def: "New York, NY" }, weatherCoordinates: { def: "40.7128,-74.0060" }, diff --git a/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml index 260311db..42ae19e0 100644 --- a/quickshell/Modules/Settings/ThemeColorsTab.qml +++ b/quickshell/Modules/Settings/ThemeColorsTab.qml @@ -962,6 +962,368 @@ Item { } } + SettingsCard { + tab: "theme" + tags: ["automatic", "color", "mode", "schedule", "sunrise", "sunset"] + title: I18n.tr("Automatic Color Mode") + settingKey: "automaticColorMode" + iconName: "schedule" + + Column { + width: parent.width + spacing: Theme.spacingM + + DankToggle { + id: themeModeAutoToggle + width: parent.width + text: I18n.tr("Enable Automatic Switching") + description: I18n.tr("Automatically switch between light and dark modes based on time or sunrise/sunset") + checked: SessionData.themeModeAutoEnabled + onToggled: checked => { + SessionData.setThemeModeAutoEnabled(checked); + } + + Connections { + target: SessionData + function onThemeModeAutoEnabledChanged() { + themeModeAutoToggle.checked = SessionData.themeModeAutoEnabled; + } + } + } + + Column { + width: parent.width + spacing: Theme.spacingM + visible: SessionData.themeModeAutoEnabled + + DankToggle { + width: parent.width + text: I18n.tr("Share Gamma Control Settings") + description: I18n.tr("Use the same time and location settings as gamma control") + checked: SessionData.themeModeShareGammaSettings + onToggled: checked => { + SessionData.setThemeModeShareGammaSettings(checked); + } + } + + Item { + width: parent.width + height: 45 + Theme.spacingM + + DankTabBar { + id: themeModeTabBar + width: 200 + height: 45 + anchors.horizontalCenter: parent.horizontalCenter + model: [ + { "text": "Time", "icon": "access_time" }, + { "text": "Location", "icon": "place" } + ] + + Component.onCompleted: { + currentIndex = SessionData.themeModeAutoMode === "location" ? 1 : 0; + Qt.callLater(updateIndicator); + } + + onTabClicked: index => { + SessionData.setThemeModeAutoMode(index === 1 ? "location" : "time"); + currentIndex = index; + } + + Connections { + target: SessionData + function onThemeModeAutoModeChanged() { + themeModeTabBar.currentIndex = SessionData.themeModeAutoMode === "location" ? 1 : 0; + Qt.callLater(themeModeTabBar.updateIndicator); + } + } + } + } + + Column { + width: parent.width + spacing: Theme.spacingM + visible: SessionData.themeModeAutoMode === "time" && !SessionData.themeModeShareGammaSettings + + Column { + spacing: Theme.spacingXS + anchors.horizontalCenter: parent.horizontalCenter + + Row { + spacing: Theme.spacingM + + StyledText { + text: "" + width: 80 + height: 20 + } + + StyledText { + text: I18n.tr("Hour") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: 70 + horizontalAlignment: Text.AlignHCenter + } + + StyledText { + text: I18n.tr("Minute") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: 70 + horizontalAlignment: Text.AlignHCenter + } + } + + Row { + spacing: Theme.spacingM + + StyledText { + text: I18n.tr("Dark Start") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + width: 80 + height: 40 + verticalAlignment: Text.AlignVCenter + } + + DankDropdown { + dropdownWidth: 70 + currentValue: SessionData.themeModeStartHour.toString() + options: { + var hours = []; + for (var i = 0; i < 24; i++) hours.push(i.toString()); + return hours; + } + onValueChanged: value => { + SessionData.setThemeModeStartHour(parseInt(value)); + } + } + + DankDropdown { + dropdownWidth: 70 + currentValue: SessionData.themeModeStartMinute.toString().padStart(2, '0') + options: { + var minutes = []; + for (var i = 0; i < 60; i += 5) { + minutes.push(i.toString().padStart(2, '0')); + } + return minutes; + } + onValueChanged: value => { + SessionData.setThemeModeStartMinute(parseInt(value)); + } + } + } + + Row { + spacing: Theme.spacingM + + StyledText { + text: I18n.tr("Light Start") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + width: 80 + height: 40 + verticalAlignment: Text.AlignVCenter + } + + DankDropdown { + dropdownWidth: 70 + currentValue: SessionData.themeModeEndHour.toString() + options: { + var hours = []; + for (var i = 0; i < 24; i++) hours.push(i.toString()); + return hours; + } + onValueChanged: value => { + SessionData.setThemeModeEndHour(parseInt(value)); + } + } + + DankDropdown { + dropdownWidth: 70 + currentValue: SessionData.themeModeEndMinute.toString().padStart(2, '0') + options: { + var minutes = []; + for (var i = 0; i < 60; i += 5) { + minutes.push(i.toString().padStart(2, '0')); + } + return minutes; + } + onValueChanged: value => { + SessionData.setThemeModeEndMinute(parseInt(value)); + } + } + } + } + + StyledText { + text: I18n.tr("Light mode will be active from Light Start to Dark Start") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + } + + Column { + width: parent.width + spacing: Theme.spacingM + visible: SessionData.themeModeAutoMode === "location" && !SessionData.themeModeShareGammaSettings + + DankToggle { + id: themeModeIpLocationToggle + width: parent.width + text: I18n.tr("Use IP Location") + description: I18n.tr("Automatically detect location based on IP address") + checked: SessionData.nightModeUseIPLocation || false + onToggled: checked => { + SessionData.setNightModeUseIPLocation(checked); + } + + Connections { + target: SessionData + function onNightModeUseIPLocationChanged() { + themeModeIpLocationToggle.checked = SessionData.nightModeUseIPLocation; + } + } + } + + Column { + width: parent.width + spacing: Theme.spacingM + visible: !SessionData.nightModeUseIPLocation + + StyledText { + text: I18n.tr("Manual Coordinates") + font.pixelSize: Theme.fontSizeMedium + color: Theme.surfaceText + horizontalAlignment: Text.AlignHCenter + width: parent.width + } + + Row { + spacing: Theme.spacingL + anchors.horizontalCenter: parent.horizontalCenter + + Column { + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Latitude") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + DankTextField { + width: 120 + height: 40 + text: SessionData.latitude.toString() + placeholderText: "0.0" + onEditingFinished: { + const lat = parseFloat(text); + if (!isNaN(lat) && lat >= -90 && lat <= 90 && lat !== SessionData.latitude) { + SessionData.setLatitude(lat); + } + } + } + } + + Column { + spacing: Theme.spacingXS + + StyledText { + text: I18n.tr("Longitude") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + DankTextField { + width: 120 + height: 40 + text: SessionData.longitude.toString() + placeholderText: "0.0" + onEditingFinished: { + const lon = parseFloat(text); + if (!isNaN(lon) && lon >= -180 && lon <= 180 && lon !== SessionData.longitude) { + SessionData.setLongitude(lon); + } + } + } + } + } + + StyledText { + text: I18n.tr("Uses sunrise/sunset times to automatically adjust theme mode based on your location.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + width: parent.width + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + } + } + + StyledText { + text: I18n.tr("Light mode will be active from sunrise to sunset") + font.pixelSize: Theme.fontSizeSmall + color: Theme.primary + width: parent.width + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + visible: SessionData.nightModeUseIPLocation || (SessionData.latitude !== 0.0 && SessionData.longitude !== 0.0) + } + } + + StyledText { + width: parent.width + text: I18n.tr("Using shared settings from Gamma Control") + font.pixelSize: Theme.fontSizeSmall + color: Theme.primary + wrapMode: Text.WordWrap + horizontalAlignment: Text.AlignHCenter + visible: SessionData.themeModeShareGammaSettings + } + + Rectangle { + width: parent.width + height: statusColumn.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: Theme.surfaceContainerHigh + + Column { + id: statusColumn + anchors.centerIn: parent + spacing: Theme.spacingXS + + DankIcon { + name: SessionData.isLightMode ? "light_mode" : "dark_mode" + size: Theme.iconSize + color: SessionData.isLightMode ? "#FFA726" : "#7E57C2" + anchors.horizontalCenter: parent.horizontalCenter + } + + StyledText { + text: SessionData.isLightMode ? I18n.tr("Light Mode Active") : I18n.tr("Dark Mode Active") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + anchors.horizontalCenter: parent.horizontalCenter + } + + StyledText { + text: I18n.tr("Automation: ") + (SessionData.themeModeAutoEnabled ? I18n.tr("Enabled") : I18n.tr("Disabled")) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + anchors.horizontalCenter: parent.horizontalCenter + } + } + } + } + } + } + SettingsCard { tab: "theme" tags: ["light", "dark", "mode", "appearance"] diff --git a/quickshell/Services/DMSService.qml b/quickshell/Services/DMSService.qml index ee416e75..fbd885b9 100644 --- a/quickshell/Services/DMSService.qml +++ b/quickshell/Services/DMSService.qml @@ -56,6 +56,7 @@ Singleton { signal wlrOutputStateUpdate(var data) signal evdevStateUpdate(var data) signal gammaStateUpdate(var data) + signal themeAutoStateUpdate(var data) signal openUrlRequested(string url) signal appPickerRequested(var data) signal screensaverStateUpdate(var data) @@ -64,7 +65,7 @@ Singleton { property bool screensaverInhibited: false property var screensaverInhibitors: [] - property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser", "dbus"] + property var activeSubscriptions: ["network", "network.credentials", "loginctl", "freedesktop", "freedesktop.screensaver", "gamma", "theme.auto", "bluetooth", "bluetooth.pairing", "dwl", "brightness", "wlroutput", "evdev", "browser", "dbus"] Component.onCompleted: { if (socketPath && socketPath.length > 0) { @@ -304,7 +305,7 @@ Singleton { excludeServices = [excludeServices]; } - const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "cups", "dwl", "brightness", "extworkspace", "browser", "dbus"]; + const allServices = ["network", "loginctl", "freedesktop", "gamma", "theme.auto", "bluetooth", "cups", "dwl", "brightness", "extworkspace", "browser", "dbus"]; const filtered = allServices.filter(s => !excludeServices.includes(s)); subscribe(filtered); } @@ -373,6 +374,8 @@ Singleton { evdevStateUpdate(data); } else if (service === "gamma") { gammaStateUpdate(data); + } else if (service === "theme.auto") { + themeAutoStateUpdate(data); } else if (service === "browser.open_requested") { if (data.target) { if (data.requestType === "url" || !data.requestType) {