diff options
Diffstat (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput')
4 files changed, 1380 insertions, 0 deletions
diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/handlers.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/handlers.go new file mode 100644 index 0000000..c885e13 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/handlers.go @@ -0,0 +1,282 @@ +package wlroutput + +import ( + "encoding/json" + "fmt" + "net" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" +) + +type HeadConfig struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + ModeID *uint32 `json:"modeId,omitempty"` + CustomMode *struct { + Width int32 `json:"width"` + Height int32 `json:"height"` + Refresh int32 `json:"refresh"` + } `json:"customMode,omitempty"` + Position *struct{ X, Y int32 } `json:"position,omitempty"` + Transform *int32 `json:"transform,omitempty"` + Scale *float64 `json:"scale,omitempty"` + AdaptiveSync *uint32 `json:"adaptiveSync,omitempty"` +} + +type ConfigurationRequest struct { + Heads []HeadConfig `json:"heads"` + Test bool `json:"test"` +} + +func HandleRequest(conn net.Conn, req models.Request, manager *Manager) { + if manager == nil { + models.RespondError(conn, req.ID, "wlroutput manager not initialized") + return + } + + switch req.Method { + case "wlroutput.getState": + handleGetState(conn, req, manager) + case "wlroutput.applyConfiguration": + handleApplyConfiguration(conn, req, manager, false) + case "wlroutput.testConfiguration": + handleApplyConfiguration(conn, req, manager, true) + case "wlroutput.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 handleApplyConfiguration(conn net.Conn, req models.Request, manager *Manager, test bool) { + headsParam, ok := models.Get[any](req, "heads") + if !ok { + models.RespondError(conn, req.ID, "missing 'heads' parameter") + return + } + + headsJSON, err := json.Marshal(headsParam) + if err != nil { + models.RespondError(conn, req.ID, "invalid 'heads' parameter format") + return + } + + var heads []HeadConfig + if err := json.Unmarshal(headsJSON, &heads); err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("invalid heads configuration: %v", err)) + return + } + + if err := manager.ApplyConfiguration(heads, test); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + msg := "configuration applied" + if test { + msg = "configuration test succeeded" + } + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: msg}) +} + +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 + } + } +} + +func (m *Manager) ApplyConfiguration(heads []HeadConfig, test bool) error { + if m.manager == nil { + return fmt.Errorf("output manager not initialized") + } + + resultChan := make(chan error, 1) + + m.post(func() { + m.wlMutex.Lock() + defer m.wlMutex.Unlock() + + config, err := m.manager.CreateConfiguration(m.serial) + if err != nil { + resultChan <- fmt.Errorf("failed to create configuration: %w", err) + return + } + + responded := false + + config.SetSucceededHandler(func(e wlr_output_management.ZwlrOutputConfigurationV1SucceededEvent) { + if responded { + return + } + responded = true + log.Info("WlrOutput: configuration succeeded") + config.Destroy() + resultChan <- nil + }) + + config.SetFailedHandler(func(e wlr_output_management.ZwlrOutputConfigurationV1FailedEvent) { + if responded { + return + } + responded = true + log.Warn("WlrOutput: configuration failed") + config.Destroy() + resultChan <- fmt.Errorf("compositor rejected configuration") + }) + + config.SetCancelledHandler(func(e wlr_output_management.ZwlrOutputConfigurationV1CancelledEvent) { + if responded { + return + } + responded = true + log.Warn("WlrOutput: configuration cancelled") + config.Destroy() + resultChan <- fmt.Errorf("configuration cancelled (outdated serial)") + }) + + time.AfterFunc(time.Second, func() { + m.post(func() { + if responded { + return + } + responded = true + config.Destroy() + resultChan <- fmt.Errorf("timeout waiting for configuration response") + }) + }) + + headsByName := make(map[string]*headState) + m.heads.Range(func(key uint32, head *headState) bool { + if !head.finished { + headsByName[head.name] = head + } + return true + }) + + for _, headCfg := range heads { + head, exists := headsByName[headCfg.Name] + if !exists { + config.Destroy() + resultChan <- fmt.Errorf("head not found: %s", headCfg.Name) + return + } + + if !headCfg.Enabled { + if err := config.DisableHead(head.handle); err != nil { + config.Destroy() + resultChan <- fmt.Errorf("failed to disable head %s: %w", headCfg.Name, err) + return + } + continue + } + + headConfig, err := config.EnableHead(head.handle) + if err != nil { + config.Destroy() + resultChan <- fmt.Errorf("failed to enable head %s: %w", headCfg.Name, err) + return + } + + if headCfg.ModeID != nil { + mode, exists := m.modes.Load(*headCfg.ModeID) + + if !exists { + config.Destroy() + resultChan <- fmt.Errorf("mode not found: %d", *headCfg.ModeID) + return + } + + if err := headConfig.SetMode(mode.handle); err != nil { + config.Destroy() + resultChan <- fmt.Errorf("failed to set mode for %s: %w", headCfg.Name, err) + return + } + } else if headCfg.CustomMode != nil { + if err := headConfig.SetCustomMode( + headCfg.CustomMode.Width, + headCfg.CustomMode.Height, + headCfg.CustomMode.Refresh, + ); err != nil { + config.Destroy() + resultChan <- fmt.Errorf("failed to set custom mode for %s: %w", headCfg.Name, err) + return + } + } + + if headCfg.Position != nil { + if err := headConfig.SetPosition(headCfg.Position.X, headCfg.Position.Y); err != nil { + config.Destroy() + resultChan <- fmt.Errorf("failed to set position for %s: %w", headCfg.Name, err) + return + } + } + + if headCfg.Transform != nil { + if err := headConfig.SetTransform(*headCfg.Transform); err != nil { + config.Destroy() + resultChan <- fmt.Errorf("failed to set transform for %s: %w", headCfg.Name, err) + return + } + } + + if headCfg.Scale != nil { + if err := headConfig.SetScale(*headCfg.Scale); err != nil { + config.Destroy() + resultChan <- fmt.Errorf("failed to set scale for %s: %w", headCfg.Name, err) + return + } + } + + if headCfg.AdaptiveSync != nil { + if err := headConfig.SetAdaptiveSync(*headCfg.AdaptiveSync); err != nil { + config.Destroy() + resultChan <- fmt.Errorf("failed to set adaptive sync for %s: %w", headCfg.Name, err) + return + } + } + } + + var applyErr error + if test { + applyErr = config.Test() + } else { + applyErr = config.Apply() + } + + if applyErr != nil { + responded = true + config.Destroy() + action := "apply" + if test { + action = "test" + } + resultChan <- fmt.Errorf("failed to %s configuration: %w", action, applyErr) + return + } + }) + + return <-resultChan +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager.go new file mode 100644 index 0000000..cbd89a3 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager.go @@ -0,0 +1,492 @@ +package wlroutput + +import ( + "fmt" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +func NewManager(display wlclient.WaylandDisplay) (*Manager, error) { + m := &Manager{ + display: display, + ctx: display.Context(), + cmdq: make(chan cmd, 512), + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + fatalError: make(chan error, 1), + } + + m.wg.Add(1) + go m.waylandActor() + + if err := m.setupRegistry(); err != nil { + close(m.stopChan) + m.wg.Wait() + return nil, err + } + + m.updateState() + + m.notifierWg.Add(1) + go m.notifier() + + return m, nil +} + +func (m *Manager) post(fn func()) { + select { + case m.cmdq <- cmd{fn: fn}: + default: + log.Warn("WlrOutput actor command queue full, dropping command") + } +} + +func (m *Manager) waylandActor() { + defer m.wg.Done() + defer func() { + if r := recover(); r != nil { + err := fmt.Errorf("waylandActor panic: %v", r) + log.Errorf("WlrOutput: %v", err) + + select { + case m.fatalError <- err: + default: + } + + select { + case <-m.stopChan: + default: + close(m.stopChan) + } + } + }() + + for { + select { + case <-m.stopChan: + return + case c := <-m.cmdq: + func() { + defer func() { + if r := recover(); r != nil { + log.Errorf("WlrOutput: command execution panic: %v", r) + } + }() + c.fn() + }() + } + } +} + +func (m *Manager) setupRegistry() error { + log.Info("WlrOutput: starting registry setup") + + registry, err := m.display.GetRegistry() + if err != nil { + return fmt.Errorf("failed to get registry: %w", err) + } + m.registry = registry + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + if e.Interface == wlr_output_management.ZwlrOutputManagerV1InterfaceName { + log.Infof("WlrOutput: found %s", wlr_output_management.ZwlrOutputManagerV1InterfaceName) + manager := wlr_output_management.NewZwlrOutputManagerV1(m.ctx) + version := e.Version + if version > 4 { + version = 4 + } + + manager.SetHeadHandler(func(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) { + m.handleHead(e) + }) + + manager.SetDoneHandler(func(e wlr_output_management.ZwlrOutputManagerV1DoneEvent) { + log.Debugf("WlrOutput: done event received (serial=%d)", e.Serial) + m.serial = e.Serial + m.post(func() { + m.updateState() + }) + }) + + manager.SetFinishedHandler(func(e wlr_output_management.ZwlrOutputManagerV1FinishedEvent) { + log.Info("WlrOutput: finished event received") + }) + + if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil { + m.manager = manager + log.Info("WlrOutput: manager bound successfully") + } else { + log.Errorf("WlrOutput: failed to bind manager: %v", err) + } + } + }) + + log.Info("WlrOutput: registry setup complete (events will be processed async)") + return nil +} + +func (m *Manager) handleHead(e wlr_output_management.ZwlrOutputManagerV1HeadEvent) { + handle := e.Head + headID := handle.ID() + + log.Debugf("WlrOutput: New head (id=%d)", headID) + + head := &headState{ + id: headID, + handle: handle, + modeIDs: make([]uint32, 0), + } + + m.heads.Store(headID, head) + + handle.SetNameHandler(func(e wlr_output_management.ZwlrOutputHeadV1NameEvent) { + log.Debugf("WlrOutput: Head %d name: %s", headID, e.Name) + head.name = e.Name + head.ready = true + m.post(func() { + m.updateState() + }) + }) + + handle.SetDescriptionHandler(func(e wlr_output_management.ZwlrOutputHeadV1DescriptionEvent) { + log.Debugf("WlrOutput: Head %d description: %s", headID, e.Description) + head.description = e.Description + m.post(func() { + m.updateState() + }) + }) + + handle.SetPhysicalSizeHandler(func(e wlr_output_management.ZwlrOutputHeadV1PhysicalSizeEvent) { + log.Debugf("WlrOutput: Head %d physical size: %dx%d", headID, e.Width, e.Height) + head.physicalWidth = e.Width + head.physicalHeight = e.Height + m.post(func() { + m.updateState() + }) + }) + + handle.SetModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1ModeEvent) { + m.handleMode(headID, e) + }) + + handle.SetEnabledHandler(func(e wlr_output_management.ZwlrOutputHeadV1EnabledEvent) { + log.Debugf("WlrOutput: Head %d enabled: %d", headID, e.Enabled) + head.enabled = e.Enabled != 0 + m.post(func() { + m.updateState() + }) + }) + + handle.SetCurrentModeHandler(func(e wlr_output_management.ZwlrOutputHeadV1CurrentModeEvent) { + modeID := e.Mode.ID() + log.Debugf("WlrOutput: Head %d current mode: %d", headID, modeID) + head.currentModeID = modeID + m.post(func() { + m.updateState() + }) + }) + + handle.SetPositionHandler(func(e wlr_output_management.ZwlrOutputHeadV1PositionEvent) { + log.Debugf("WlrOutput: Head %d position: %d,%d", headID, e.X, e.Y) + head.x = e.X + head.y = e.Y + m.post(func() { + m.updateState() + }) + }) + + handle.SetTransformHandler(func(e wlr_output_management.ZwlrOutputHeadV1TransformEvent) { + log.Debugf("WlrOutput: Head %d transform: %d", headID, e.Transform) + head.transform = e.Transform + m.post(func() { + m.updateState() + }) + }) + + handle.SetScaleHandler(func(e wlr_output_management.ZwlrOutputHeadV1ScaleEvent) { + log.Debugf("WlrOutput: Head %d scale: %f", headID, e.Scale) + head.scale = e.Scale + m.post(func() { + m.updateState() + }) + }) + + handle.SetMakeHandler(func(e wlr_output_management.ZwlrOutputHeadV1MakeEvent) { + log.Debugf("WlrOutput: Head %d make: %s", headID, e.Make) + head.make = e.Make + m.post(func() { + m.updateState() + }) + }) + + handle.SetModelHandler(func(e wlr_output_management.ZwlrOutputHeadV1ModelEvent) { + log.Debugf("WlrOutput: Head %d model: %s", headID, e.Model) + head.model = e.Model + m.post(func() { + m.updateState() + }) + }) + + handle.SetSerialNumberHandler(func(e wlr_output_management.ZwlrOutputHeadV1SerialNumberEvent) { + log.Debugf("WlrOutput: Head %d serial: %s", headID, e.SerialNumber) + head.serialNumber = e.SerialNumber + m.post(func() { + m.updateState() + }) + }) + + handle.SetAdaptiveSyncHandler(func(e wlr_output_management.ZwlrOutputHeadV1AdaptiveSyncEvent) { + log.Debugf("WlrOutput: Head %d adaptive sync: %d", headID, e.State) + head.adaptiveSync = e.State + head.adaptiveSyncSupported = true + m.post(func() { + m.updateState() + }) + }) + + handle.SetFinishedHandler(func(e wlr_output_management.ZwlrOutputHeadV1FinishedEvent) { + log.Debugf("WlrOutput: Head %d finished", headID) + head.finished = true + + m.heads.Delete(headID) + + m.wlMutex.Lock() + handle.Release() + m.wlMutex.Unlock() + + m.post(func() { + m.updateState() + }) + }) +} + +func (m *Manager) handleMode(headID uint32, e wlr_output_management.ZwlrOutputHeadV1ModeEvent) { + handle := e.Mode + modeID := handle.ID() + + log.Debugf("WlrOutput: Head %d new mode (id=%d)", headID, modeID) + + mode := &modeState{ + id: modeID, + handle: handle, + } + + m.modes.Store(modeID, mode) + + if head, ok := m.heads.Load(headID); ok { + head.modeIDs = append(head.modeIDs, modeID) + m.heads.Store(headID, head) + } + + handle.SetSizeHandler(func(e wlr_output_management.ZwlrOutputModeV1SizeEvent) { + log.Debugf("WlrOutput: Mode %d size: %dx%d", modeID, e.Width, e.Height) + mode.width = e.Width + mode.height = e.Height + m.post(func() { + m.updateState() + }) + }) + + handle.SetRefreshHandler(func(e wlr_output_management.ZwlrOutputModeV1RefreshEvent) { + log.Debugf("WlrOutput: Mode %d refresh: %d", modeID, e.Refresh) + mode.refresh = e.Refresh + m.post(func() { + m.updateState() + }) + }) + + handle.SetPreferredHandler(func(e wlr_output_management.ZwlrOutputModeV1PreferredEvent) { + log.Debugf("WlrOutput: Mode %d preferred", modeID) + mode.preferred = true + m.post(func() { + m.updateState() + }) + }) + + handle.SetFinishedHandler(func(e wlr_output_management.ZwlrOutputModeV1FinishedEvent) { + log.Debugf("WlrOutput: Mode %d finished", modeID) + mode.finished = true + + m.modes.Delete(modeID) + + m.wlMutex.Lock() + handle.Release() + m.wlMutex.Unlock() + + m.post(func() { + m.updateState() + }) + }) +} + +func (m *Manager) updateState() { + outputs := make([]Output, 0) + + m.heads.Range(func(key uint32, head *headState) bool { + if head.finished { + return true + } + + if !head.ready { + return true + } + + modes := make([]OutputMode, 0) + var currentMode *OutputMode + + for _, modeID := range head.modeIDs { + mode, exists := m.modes.Load(modeID) + if !exists { + continue + } + if mode.finished { + continue + } + + outMode := OutputMode{ + Width: mode.width, + Height: mode.height, + Refresh: mode.refresh, + Preferred: mode.preferred, + ID: modeID, + } + modes = append(modes, outMode) + + if modeID == head.currentModeID { + currentMode = &outMode + } + } + + output := Output{ + Name: head.name, + Description: head.description, + Make: head.make, + Model: head.model, + SerialNumber: head.serialNumber, + PhysicalWidth: head.physicalWidth, + PhysicalHeight: head.physicalHeight, + Enabled: head.enabled, + X: head.x, + Y: head.y, + Transform: head.transform, + Scale: head.scale, + CurrentMode: currentMode, + Modes: modes, + AdaptiveSync: head.adaptiveSync, + AdaptiveSyncSupported: head.adaptiveSyncSupported, + ID: head.id, + } + outputs = append(outputs, output) + return true + }) + + newState := State{ + Outputs: outputs, + Serial: m.serial, + } + + m.stateMutex.Lock() + m.state = &newState + m.stateMutex.Unlock() + + m.notifySubscribers() +} + +func (m *Manager) notifier() { + defer m.notifierWg.Done() + defer func() { + if r := recover(); r != nil { + err := fmt.Errorf("notifier panic: %v", r) + log.Errorf("WlrOutput: %v", err) + + select { + case m.fatalError <- err: + default: + } + + select { + case <-m.stopChan: + default: + close(m.stopChan) + } + } + }() + + const minGap = 100 * time.Millisecond + timer := time.NewTimer(minGap) + timer.Stop() + var pending bool + + for { + select { + case <-m.stopChan: + timer.Stop() + return + case <-m.dirty: + if pending { + continue + } + pending = true + timer.Reset(minGap) + case <-timer.C: + if !pending { + continue + } + + currentState := m.GetState() + + if m.lastNotified != nil && !stateChanged(m.lastNotified, ¤tState) { + pending = false + continue + } + + m.subscribers.Range(func(key string, ch chan State) bool { + select { + case ch <- currentState: + default: + log.Warn("WlrOutput: subscriber channel full, dropping update") + } + return true + }) + + stateCopy := currentState + m.lastNotified = &stateCopy + pending = false + } + } +} + +func (m *Manager) Close() { + close(m.stopChan) + m.wg.Wait() + m.notifierWg.Wait() + + m.subscribers.Range(func(key string, ch chan State) bool { + close(ch) + m.subscribers.Delete(key) + return true + }) + + m.modes.Range(func(key uint32, mode *modeState) bool { + if mode.handle != nil { + mode.handle.Release() + } + m.modes.Delete(key) + return true + }) + + m.heads.Range(func(key uint32, head *headState) bool { + if head.handle != nil { + head.handle.Release() + } + m.heads.Delete(key) + return true + }) + + if m.manager != nil { + m.manager.Stop() + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager_test.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager_test.go new file mode 100644 index 0000000..e90c70c --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/manager_test.go @@ -0,0 +1,414 @@ +package wlroutput + +import ( + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + mocks_wlclient "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/wlclient" +) + +func TestStateChanged_BothNil(t *testing.T) { + assert.True(t, stateChanged(nil, nil)) +} + +func TestStateChanged_OneNil(t *testing.T) { + s := &State{Serial: 1} + assert.True(t, stateChanged(s, nil)) + assert.True(t, stateChanged(nil, s)) +} + +func TestStateChanged_SerialDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{}} + b := &State{Serial: 2, Outputs: []Output{}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputCountDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1"}}} + b := &State{Serial: 1, Outputs: []Output{}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputNameDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Enabled: true}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "HDMI-A-1", Enabled: true}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputEnabledDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Enabled: true}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Enabled: false}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputPositionDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", X: 0, Y: 0}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", X: 1920, Y: 0}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputTransformDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Transform: 0}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Transform: 1}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputScaleDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Scale: 1.0}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Scale: 2.0}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_OutputAdaptiveSyncDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", AdaptiveSync: 0}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", AdaptiveSync: 1}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_CurrentModeNilVsNonNil(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: nil}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: &OutputMode{Width: 1920}}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_CurrentModeDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{ + Name: "eDP-1", + CurrentMode: &OutputMode{Width: 1920, Height: 1080, Refresh: 60000}, + }}} + b := &State{Serial: 1, Outputs: []Output{{ + Name: "eDP-1", + CurrentMode: &OutputMode{Width: 2560, Height: 1440, Refresh: 60000}, + }}} + assert.True(t, stateChanged(a, b)) + + b.Outputs[0].CurrentMode.Width = 1920 + b.Outputs[0].CurrentMode.Height = 1080 + b.Outputs[0].CurrentMode.Refresh = 144000 + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_ModesLengthDiffers(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Modes: []OutputMode{{Width: 1920}}}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", Modes: []OutputMode{{Width: 1920}, {Width: 1280}}}}} + assert.True(t, stateChanged(a, b)) +} + +func TestStateChanged_Equal(t *testing.T) { + mode := OutputMode{Width: 1920, Height: 1080, Refresh: 60000, Preferred: true} + a := &State{ + Serial: 5, + Outputs: []Output{{ + Name: "eDP-1", + Description: "Built-in display", + Make: "BOE", + Model: "0x0ABC", + SerialNumber: "12345", + PhysicalWidth: 309, + PhysicalHeight: 174, + Enabled: true, + X: 0, + Y: 0, + Transform: 0, + Scale: 1.0, + CurrentMode: &mode, + Modes: []OutputMode{mode}, + AdaptiveSync: 0, + }}, + } + b := &State{ + Serial: 5, + Outputs: []Output{{ + Name: "eDP-1", + Description: "Built-in display", + Make: "BOE", + Model: "0x0ABC", + SerialNumber: "12345", + PhysicalWidth: 309, + PhysicalHeight: 174, + Enabled: true, + X: 0, + Y: 0, + Transform: 0, + Scale: 1.0, + CurrentMode: &mode, + Modes: []OutputMode{mode}, + AdaptiveSync: 0, + }}, + } + assert.False(t, stateChanged(a, b)) +} + +func TestManager_ConcurrentGetState(t *testing.T) { + m := &Manager{ + state: &State{ + Serial: 1, + Outputs: []Output{{Name: "eDP-1", Enabled: true}}, + }, + } + + var wg sync.WaitGroup + const goroutines = 50 + const iterations = 100 + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + s := m.GetState() + _ = s.Serial + _ = s.Outputs + } + }() + } + + for i := 0; i < goroutines/2; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + for j := 0; j < iterations; j++ { + m.stateMutex.Lock() + m.state = &State{ + Serial: uint32(j), + Outputs: []Output{{Name: "eDP-1", Scale: float64(j % 3)}}, + } + m.stateMutex.Unlock() + } + }(i) + } + + wg.Wait() +} + +func TestManager_ConcurrentSubscriberAccess(t *testing.T) { + m := &Manager{ + stopChan: make(chan struct{}), + dirty: make(chan struct{}, 1), + } + + var wg sync.WaitGroup + const goroutines = 20 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + subID := string(rune('a' + id)) + ch := m.Subscribe(subID) + assert.NotNil(t, ch) + time.Sleep(time.Millisecond) + m.Unsubscribe(subID) + }(i) + } + + wg.Wait() +} + +func TestManager_SyncmapHeadsConcurrentAccess(t *testing.T) { + m := &Manager{} + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 50 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + key := uint32(id) + + for j := 0; j < iterations; j++ { + state := &headState{ + id: key, + name: "test-head", + enabled: j%2 == 0, + scale: float64(j % 3), + modeIDs: []uint32{uint32(j)}, + } + m.heads.Store(key, state) + + if loaded, ok := m.heads.Load(key); ok { + assert.Equal(t, key, loaded.id) + } + + m.heads.Range(func(k uint32, v *headState) bool { + _ = v.name + _ = v.enabled + return true + }) + } + + m.heads.Delete(key) + }(i) + } + + wg.Wait() +} + +func TestManager_SyncmapModesConcurrentAccess(t *testing.T) { + m := &Manager{} + + var wg sync.WaitGroup + const goroutines = 30 + const iterations = 50 + + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + key := uint32(id) + + for j := 0; j < iterations; j++ { + state := &modeState{ + id: key, + width: int32(1920 + j), + height: int32(1080 + j), + refresh: 60000, + preferred: j == 0, + } + m.modes.Store(key, state) + + if loaded, ok := m.modes.Load(key); ok { + assert.Equal(t, key, loaded.id) + } + + m.modes.Range(func(k uint32, v *modeState) bool { + _ = v.width + _ = v.height + return true + }) + } + + m.modes.Delete(key) + }(i) + } + + wg.Wait() +} + +func TestManager_NotifySubscribersNonBlocking(t *testing.T) { + m := &Manager{ + dirty: make(chan struct{}, 1), + } + + for i := 0; i < 10; i++ { + m.notifySubscribers() + } + + assert.Len(t, m.dirty, 1) +} + +func TestManager_PostQueueFull(t *testing.T) { + m := &Manager{ + cmdq: make(chan cmd, 2), + stopChan: make(chan struct{}), + } + + m.post(func() {}) + m.post(func() {}) + m.post(func() {}) + m.post(func() {}) + + assert.Len(t, m.cmdq, 2) +} + +func TestManager_GetStateNilState(t *testing.T) { + m := &Manager{} + + s := m.GetState() + assert.NotNil(t, s.Outputs) + assert.Equal(t, uint32(0), s.Serial) +} + +func TestManager_FatalErrorChannel(t *testing.T) { + m := &Manager{ + fatalError: make(chan error, 1), + } + + ch := m.FatalError() + assert.NotNil(t, ch) + + m.fatalError <- assert.AnError + err := <-ch + assert.Error(t, err) +} + +func TestOutputMode_Fields(t *testing.T) { + mode := OutputMode{ + Width: 1920, + Height: 1080, + Refresh: 60000, + Preferred: true, + ID: 42, + } + + assert.Equal(t, int32(1920), mode.Width) + assert.Equal(t, int32(1080), mode.Height) + assert.Equal(t, int32(60000), mode.Refresh) + assert.True(t, mode.Preferred) + assert.Equal(t, uint32(42), mode.ID) +} + +func TestOutput_Fields(t *testing.T) { + out := Output{ + Name: "eDP-1", + Description: "Built-in display", + Make: "BOE", + Model: "0x0ABC", + SerialNumber: "12345", + PhysicalWidth: 309, + PhysicalHeight: 174, + Enabled: true, + X: 0, + Y: 0, + Transform: 0, + Scale: 1.5, + AdaptiveSync: 1, + ID: 1, + } + + assert.Equal(t, "eDP-1", out.Name) + assert.Equal(t, "Built-in display", out.Description) + assert.True(t, out.Enabled) + assert.Equal(t, float64(1.5), out.Scale) + assert.Equal(t, uint32(1), out.AdaptiveSync) +} + +func TestHeadState_ModeIDsSlice(t *testing.T) { + head := &headState{ + id: 1, + modeIDs: make([]uint32, 0), + } + + head.modeIDs = append(head.modeIDs, 1, 2, 3) + assert.Len(t, head.modeIDs, 3) + assert.Equal(t, uint32(1), head.modeIDs[0]) +} + +func TestStateChanged_BothCurrentModeNil(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: nil}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1", CurrentMode: nil}}} + assert.False(t, stateChanged(a, b)) +} + +func TestStateChanged_IndexOutOfBounds(t *testing.T) { + a := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1"}}} + b := &State{Serial: 1, Outputs: []Output{{Name: "eDP-1"}, {Name: "HDMI-A-1"}}} + assert.True(t, stateChanged(a, b)) +} + +func TestNewManager_GetRegistryError(t *testing.T) { + mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t) + + mockDisplay.EXPECT().Context().Return(nil) + mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry")) + + _, err := NewManager(mockDisplay) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get registry") +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/types.go b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/types.go new file mode 100644 index 0000000..9c79b1a --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/core/internal/server/wlroutput/types.go @@ -0,0 +1,192 @@ +package wlroutput + +import ( + "sync" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/wlr_output_management" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" +) + +type OutputMode struct { + Width int32 `json:"width"` + Height int32 `json:"height"` + Refresh int32 `json:"refresh"` + Preferred bool `json:"preferred"` + ID uint32 `json:"id"` +} + +type Output struct { + Name string `json:"name"` + Description string `json:"description"` + Make string `json:"make"` + Model string `json:"model"` + SerialNumber string `json:"serialNumber"` + PhysicalWidth int32 `json:"physicalWidth"` + PhysicalHeight int32 `json:"physicalHeight"` + Enabled bool `json:"enabled"` + X int32 `json:"x"` + Y int32 `json:"y"` + Transform int32 `json:"transform"` + Scale float64 `json:"scale"` + CurrentMode *OutputMode `json:"currentMode"` + Modes []OutputMode `json:"modes"` + AdaptiveSync uint32 `json:"adaptiveSync"` + AdaptiveSyncSupported bool `json:"adaptiveSyncSupported"` + ID uint32 `json:"id"` +} + +type State struct { + Outputs []Output `json:"outputs"` + Serial uint32 `json:"serial"` +} + +type cmd struct { + fn func() +} + +type Manager struct { + display wlclient.WaylandDisplay + ctx *wlclient.Context + registry *wlclient.Registry + manager *wlr_output_management.ZwlrOutputManagerV1 + + heads syncmap.Map[uint32, *headState] + modes syncmap.Map[uint32, *modeState] + + serial uint32 + + wlMutex sync.Mutex + cmdq chan cmd + stopChan chan struct{} + wg sync.WaitGroup + + subscribers syncmap.Map[string, chan State] + dirty chan struct{} + notifierWg sync.WaitGroup + lastNotified *State + + stateMutex sync.RWMutex + state *State + + fatalError chan error +} + +type headState struct { + id uint32 + handle *wlr_output_management.ZwlrOutputHeadV1 + name string + description string + make string + model string + serialNumber string + physicalWidth int32 + physicalHeight int32 + enabled bool + x int32 + y int32 + transform int32 + scale float64 + currentModeID uint32 + modeIDs []uint32 + adaptiveSync uint32 + adaptiveSyncSupported bool + finished bool + ready bool +} + +type modeState struct { + id uint32 + handle *wlr_output_management.ZwlrOutputModeV1 + width int32 + height int32 + refresh int32 + preferred bool + finished bool +} + +func (m *Manager) GetState() State { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + if m.state == nil { + return State{ + Outputs: []Output{}, + Serial: 0, + } + } + 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) notifySubscribers() { + select { + case m.dirty <- struct{}{}: + default: + } +} + +func (m *Manager) FatalError() <-chan error { + return m.fatalError +} + +func stateChanged(old, new *State) bool { + if old == nil || new == nil { + return true + } + if old.Serial != new.Serial { + return true + } + if len(old.Outputs) != len(new.Outputs) { + return true + } + for i := range new.Outputs { + if i >= len(old.Outputs) { + return true + } + oldOut := &old.Outputs[i] + newOut := &new.Outputs[i] + if oldOut.Name != newOut.Name || oldOut.Enabled != newOut.Enabled { + return true + } + if oldOut.X != newOut.X || oldOut.Y != newOut.Y { + return true + } + if oldOut.Transform != newOut.Transform || oldOut.Scale != newOut.Scale { + return true + } + if oldOut.AdaptiveSync != newOut.AdaptiveSync || oldOut.AdaptiveSyncSupported != newOut.AdaptiveSyncSupported { + return true + } + if (oldOut.CurrentMode == nil) != (newOut.CurrentMode == nil) { + return true + } + if oldOut.CurrentMode != nil && newOut.CurrentMode != nil { + if oldOut.CurrentMode.Width != newOut.CurrentMode.Width || + oldOut.CurrentMode.Height != newOut.CurrentMode.Height || + oldOut.CurrentMode.Refresh != newOut.CurrentMode.Refresh { + return true + } + } + if len(oldOut.Modes) != len(newOut.Modes) { + return true + } + } + return false +} |