1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-04-14 17:52:10 -04:00

feat: Create new Auto theme mode based on region / time of day

This commit is contained in:
purian23
2026-01-24 16:38:45 -05:00
parent 03cfa55e0b
commit 3413cb7b89
11 changed files with 1747 additions and 3 deletions

View File

@@ -19,6 +19,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/network"
serverPlugins "github.com/AvengeMedia/DankMaterialShell/core/internal/server/plugins" 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" serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
@@ -44,6 +45,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
return 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 strings.HasPrefix(req.Method, "loginctl.") {
if loginctlManager == nil { if loginctlManager == nil {
models.RespondError(conn, req.ID, "loginctl manager not initialized") models.RespondError(conn, req.ID, "loginctl manager not initialized")

View File

@@ -28,6 +28,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/network" "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/wayland"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
@@ -68,6 +69,7 @@ var evdevManager *evdev.Manager
var clipboardManager *clipboard.Manager var clipboardManager *clipboard.Manager
var dbusManager *serverDbus.Manager var dbusManager *serverDbus.Manager
var wlContext *wlcontext.SharedContext var wlContext *wlcontext.SharedContext
var themeModeManager *thememode.Manager
const dbusClientID = "dms-dbus-client" const dbusClientID = "dms-dbus-client"
@@ -380,6 +382,14 @@ func InitializeDbusManager() error {
return nil return nil
} }
func InitializeThemeModeManager() error {
manager := thememode.NewManager()
themeModeManager = manager
log.Info("Theme mode automation manager initialized")
return nil
}
func handleConnection(conn net.Conn) { func handleConnection(conn net.Conn) {
defer conn.Close() defer conn.Close()
@@ -457,6 +467,10 @@ func getCapabilities() Capabilities {
caps = append(caps, "clipboard") caps = append(caps, "clipboard")
} }
if themeModeManager != nil {
caps = append(caps, "theme.auto")
}
if dbusManager != nil { if dbusManager != nil {
caps = append(caps, "dbus") caps = append(caps, "dbus")
} }
@@ -519,6 +533,10 @@ func getServerInfo() ServerInfo {
caps = append(caps, "clipboard") caps = append(caps, "clipboard")
} }
if themeModeManager != nil {
caps = append(caps, "theme.auto")
}
if dbusManager != nil { if dbusManager != nil {
caps = append(caps, "dbus") 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 { if shouldSubscribe("bluetooth") && bluezManager != nil {
wg.Add(1) wg.Add(1)
bluezChan := bluezManager.Subscribe(clientID + "-bluetooth") bluezChan := bluezManager.Subscribe(clientID + "-bluetooth")
@@ -1251,6 +1301,9 @@ func cleanupManagers() {
if dbusManager != nil { if dbusManager != nil {
dbusManager.Close() dbusManager.Close()
} }
if themeModeManager != nil {
themeModeManager.Close()
}
if wlContext != nil { if wlContext != nil {
wlContext.Close() 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.setGamma - Set gamma value (params: gamma)")
log.Info(" wayland.gamma.setEnabled - Enable/disable gamma control (params: enabled)") log.Info(" wayland.gamma.setEnabled - Enable/disable gamma control (params: enabled)")
log.Info(" wayland.gamma.subscribe - Subscribe to gamma state changes (streaming)") 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:")
log.Info(" bluetooth.getState - Get current bluetooth state") log.Info(" bluetooth.getState - Get current bluetooth state")
log.Info(" bluetooth.startDiscovery - Start device discovery") log.Info(" bluetooth.startDiscovery - Start device discovery")
@@ -1503,6 +1565,12 @@ func Start(printDocs bool) error {
log.Debugf("WlrOutput manager unavailable: %v", err) 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) fatalErrChan := make(chan error, 1)
if wlrOutputManager != nil { if wlrOutputManager != nil {
go func() { go func() {

View File

@@ -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
}
}
}

View File

@@ -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")

View File

@@ -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"`
}

View File

@@ -392,11 +392,15 @@ func (m *Manager) recalcSchedule(now time.Time) {
cond = SunNormal cond = SunNormal
} else { } else {
lat, lon := m.getLocation() lat, lon := m.getLocation()
log.Errorf("@@@ recalcSchedule: lat=%v, lon=%v @@@", lat, lon)
if lat == nil || lon == nil { if lat == nil || lon == nil {
log.Errorf("@@@ recalcSchedule: NO LOCATION, setting StateStatic @@@")
m.gammaState = StateStatic m.gammaState = StateStatic
return return
} }
log.Errorf("@@@ recalcSchedule: calculating sun times for lat=%.4f, lon=%.4f @@@", *lat, *lon)
times, cond = CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight) 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 m.schedule.calcDay = dayStart
@@ -626,6 +630,7 @@ func (m *Manager) schedulerLoop() {
m.schedule.calcDay = time.Time{} m.schedule.calcDay = time.Time{}
m.scheduleMutex.Unlock() m.scheduleMutex.Unlock()
m.recalcSchedule(time.Now()) m.recalcSchedule(time.Now())
m.updateStateFromSchedule()
m.configMutex.RLock() m.configMutex.RLock()
enabled := m.config.Enabled enabled := m.config.Enabled
m.configMutex.RUnlock() m.configMutex.RUnlock()
@@ -774,16 +779,21 @@ func (m *Manager) updateStateFromSchedule() {
config := m.config config := m.config
m.configMutex.RUnlock() m.configMutex.RUnlock()
log.Errorf("@@@ updateStateFromSchedule: config.Lat=%v, config.Lon=%v @@@", config.Latitude, config.Longitude)
m.scheduleMutex.RLock() m.scheduleMutex.RLock()
times := m.schedule.times times := m.schedule.times
m.scheduleMutex.RUnlock() m.scheduleMutex.RUnlock()
log.Errorf("@@@ updateStateFromSchedule: times.Sunrise=%v, times.Sunset=%v @@@", times.Sunrise, times.Sunset)
var pos float64 var pos float64
var temp int var temp int
var isDay bool var isDay bool
var deadline time.Time var deadline time.Time
if times.Sunrise.IsZero() { if times.Sunrise.IsZero() {
log.Errorf("@@@ updateStateFromSchedule: Sunrise is ZERO, defaulting isDay=true @@@")
pos = 1.0 pos = 1.0
temp = config.HighTemp temp = config.HighTemp
isDay = true isDay = true
@@ -793,6 +803,7 @@ func (m *Manager) updateStateFromSchedule() {
temp = m.getTempFromPosition(pos) temp = m.getTempFromPosition(pos)
deadline = m.getNextDeadline(now) deadline = m.getNextDeadline(now)
isDay = now.After(times.Sunrise) && now.Before(times.Sunset) 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{ newState := State{

View File

@@ -82,6 +82,14 @@ Singleton {
property bool nightModeUseIPLocation: false property bool nightModeUseIPLocation: false
property string nightModeLocationProvider: "" 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 pinnedApps: []
property var barPinnedApps: [] property var barPinnedApps: []
property int dockLauncherPosition: 0 property int dockLauncherPosition: 0
@@ -754,6 +762,41 @@ Singleton {
saveSettings(); 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) { function setPinnedApps(apps) {
pinnedApps = apps; pinnedApps = apps;
saveSettings(); saveSettings();

View File

@@ -94,6 +94,12 @@ Singleton {
property var matugenColors: ({}) property var matugenColors: ({})
property var _pendingGenerateParams: null 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: { readonly property var dank16: {
const raw = matugenColors?.dank16; const raw = matugenColors?.dank16;
if (!raw) if (!raw)
@@ -125,6 +131,8 @@ Singleton {
readonly property string currentThemeId: customThemeRawData?.id || "" readonly property string currentThemeId: customThemeRawData?.id || ""
Component.onCompleted: { 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]); Quickshell.execDetached(["mkdir", "-p", stateDir]);
Proc.runCommand("matugenCheck", ["which", "matugen"], (output, code) => { Proc.runCommand("matugenCheck", ["which", "matugen"], (output, code) => {
matugenAvailable = (code === 0) && !envDisableMatugen; matugenAvailable = (code === 0) && !envDisableMatugen;
@@ -176,6 +184,277 @@ Singleton {
if (typeof SettingsData !== "undefined" && SettingsData.currentThemeName) { if (typeof SettingsData !== "undefined" && SettingsData.currentThemeName) {
switchTheme(SettingsData.currentThemeName, false, false); 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) { function applyGreeterTheme(themeName) {
@@ -534,17 +813,27 @@ Singleton {
} }
function setLightMode(light, savePrefs = true, enableTransition = false) { 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) { if (enableTransition) {
screenTransition(); screenTransition();
lightModeTransitionTimer.lightMode = light; lightModeTransitionTimer.lightMode = light;
lightModeTransitionTimer.savePrefs = savePrefs; lightModeTransitionTimer.savePrefs = savePrefs;
lightModeTransitionTimer.restart(); lightModeTransitionTimer.restart();
console.error(">>> setLightMode: Starting transition timer");
return; return;
} }
const isGreeterMode = (typeof SessionData !== "undefined" && SessionData.isGreeterMode); 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); SessionData.setLightMode(light);
console.error(">>> setLightMode: AFTER SessionData.setLightMode - root.isLightMode =", root.isLightMode);
}
if (!isGreeterMode) { if (!isGreeterMode) {
// Skip with matugen because, our script runner will do it. // Skip with matugen because, our script runner will do it.
if (!matugenAvailable) { if (!matugenAvailable) {
@@ -552,6 +841,8 @@ Singleton {
} }
generateSystemThemesFromCurrentTheme(); generateSystemThemesFromCurrentTheme();
} }
console.error(">>> setLightMode DONE: root.isLightMode =", root.isLightMode);
} }
function toggleLightMode(savePrefs = true) { function toggleLightMode(savePrefs = true) {
@@ -1453,4 +1744,343 @@ Singleton {
root.switchTheme(defaultTheme, true, false); 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});
}
}
} }

View File

@@ -35,6 +35,14 @@ var SPEC = {
nightModeUseIPLocation: { def: false }, nightModeUseIPLocation: { def: false },
nightModeLocationProvider: { def: "" }, 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" }, weatherLocation: { def: "New York, NY" },
weatherCoordinates: { def: "40.7128,-74.0060" }, weatherCoordinates: { def: "40.7128,-74.0060" },

View File

@@ -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 { SettingsCard {
tab: "theme" tab: "theme"
tags: ["light", "dark", "mode", "appearance"] tags: ["light", "dark", "mode", "appearance"]

View File

@@ -56,6 +56,7 @@ Singleton {
signal wlrOutputStateUpdate(var data) signal wlrOutputStateUpdate(var data)
signal evdevStateUpdate(var data) signal evdevStateUpdate(var data)
signal gammaStateUpdate(var data) signal gammaStateUpdate(var data)
signal themeAutoStateUpdate(var data)
signal openUrlRequested(string url) signal openUrlRequested(string url)
signal appPickerRequested(var data) signal appPickerRequested(var data)
signal screensaverStateUpdate(var data) signal screensaverStateUpdate(var data)
@@ -64,7 +65,7 @@ Singleton {
property bool screensaverInhibited: false property bool screensaverInhibited: false
property var screensaverInhibitors: [] 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: { Component.onCompleted: {
if (socketPath && socketPath.length > 0) { if (socketPath && socketPath.length > 0) {
@@ -304,7 +305,7 @@ Singleton {
excludeServices = [excludeServices]; 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)); const filtered = allServices.filter(s => !excludeServices.includes(s));
subscribe(filtered); subscribe(filtered);
} }
@@ -373,6 +374,8 @@ Singleton {
evdevStateUpdate(data); evdevStateUpdate(data);
} else if (service === "gamma") { } else if (service === "gamma") {
gammaStateUpdate(data); gammaStateUpdate(data);
} else if (service === "theme.auto") {
themeAutoStateUpdate(data);
} else if (service === "browser.open_requested") { } else if (service === "browser.open_requested") {
if (data.target) { if (data.target) {
if (data.requestType === "url" || !data.requestType) { if (data.requestType === "url" || !data.requestType) {