1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-05-15 08:42:47 -04:00

workspace/ext-ws: drop custom ext-workspace in favor of quickshell

WindowManager implementation
This commit is contained in:
bbedward
2026-05-14 10:34:35 -04:00
parent 71438530a8
commit fb5198fd0b
11 changed files with 48 additions and 3227 deletions
File diff suppressed because it is too large Load Diff
@@ -1,422 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="ext_workspace_v1">
<copyright>
Copyright © 2019 Christopher Billington
Copyright © 2020 Ilia Bozhinov
Copyright © 2022 Victoria Brekenfeld
Permission to use, copy, modify, distribute, and sell this
software and its documentation for any purpose is hereby granted
without fee, provided that the above copyright notice appear in
all copies and that both that copyright notice and this permission
notice appear in supporting documentation, and that the name of
the copyright holders not be used in advertising or publicity
pertaining to distribution of the software without specific,
written prior permission. The copyright holders make no
representations about the suitability of this software for any
purpose. It is provided "as is" without express or implied
warranty.
THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
</copyright>
<interface name="ext_workspace_manager_v1" version="1">
<description summary="list and control workspaces">
Workspaces, also called virtual desktops, are groups of surfaces. A
compositor with a concept of workspaces may only show some such groups of
surfaces (those of 'active' workspaces) at a time. 'Activating' a
workspace is a request for the compositor to display that workspace's
surfaces as normal, whereas the compositor may hide or otherwise
de-emphasise surfaces that are associated only with 'inactive' workspaces.
Workspaces are grouped by which sets of outputs they correspond to, and
may contain surfaces only from those outputs. In this way, it is possible
for each output to have its own set of workspaces, or for all outputs (or
any other arbitrary grouping) to share workspaces. Compositors may
optionally conceptually arrange each group of workspaces in an
N-dimensional grid.
The purpose of this protocol is to enable the creation of taskbars and
docks by providing them with a list of workspaces and their properties,
and allowing them to activate and deactivate workspaces.
After a client binds the ext_workspace_manager_v1, each workspace will be
sent via the workspace event.
</description>
<event name="workspace_group">
<description summary="a workspace group has been created">
This event is emitted whenever a new workspace group has been created.
All initial details of the workspace group (outputs) will be
sent immediately after this event via the corresponding events in
ext_workspace_group_handle_v1 and ext_workspace_handle_v1.
</description>
<arg name="workspace_group" type="new_id" interface="ext_workspace_group_handle_v1"/>
</event>
<event name="workspace">
<description summary="workspace has been created">
This event is emitted whenever a new workspace has been created.
All initial details of the workspace (name, coordinates, state) will
be sent immediately after this event via the corresponding events in
ext_workspace_handle_v1.
Workspaces start off unassigned to any workspace group.
</description>
<arg name="workspace" type="new_id" interface="ext_workspace_handle_v1"/>
</event>
<request name="commit">
<description summary="all requests about the workspaces have been sent">
The client must send this request after it has finished sending other
requests. The compositor must process a series of requests preceding a
commit request atomically.
This allows changes to the workspace properties to be seen as atomic,
even if they happen via multiple events, and even if they involve
multiple ext_workspace_handle_v1 objects, for example, deactivating one
workspace and activating another.
</description>
</request>
<event name="done">
<description summary="all information about the workspaces and workspace groups has been sent">
This event is sent after all changes in all workspaces and workspace groups have been
sent.
This allows changes to one or more ext_workspace_group_handle_v1
properties and ext_workspace_handle_v1 properties
to be seen as atomic, even if they happen via multiple events.
In particular, an output moving from one workspace group to
another sends an output_enter event and an output_leave event to the two
ext_workspace_group_handle_v1 objects in question. The compositor sends
the done event only after updating the output information in both
workspace groups.
</description>
</event>
<event name="finished" type="destructor">
<description summary="the compositor has finished with the workspace_manager">
This event indicates that the compositor is done sending events to the
ext_workspace_manager_v1. The server will destroy the object
immediately after sending this request.
</description>
</event>
<request name="stop">
<description summary="stop sending events">
Indicates the client no longer wishes to receive events for new
workspace groups. However the compositor may emit further workspace
events, until the finished event is emitted. The compositor is expected
to send the finished event eventually once the stop request has been processed.
The client must not send any requests after this one, doing so will raise a wl_display
invalid_object error.
</description>
</request>
</interface>
<interface name="ext_workspace_group_handle_v1" version="1">
<description summary="a workspace group assigned to a set of outputs">
A ext_workspace_group_handle_v1 object represents a workspace group
that is assigned a set of outputs and contains a number of workspaces.
The set of outputs assigned to the workspace group is conveyed to the client via
output_enter and output_leave events, and its workspaces are conveyed with
workspace events.
For example, a compositor which has a set of workspaces for each output may
advertise a workspace group (and its workspaces) per output, whereas a compositor
where a workspace spans all outputs may advertise a single workspace group for all
outputs.
</description>
<enum name="group_capabilities" bitfield="true">
<entry name="create_workspace" value="1" summary="create_workspace request is available"/>
</enum>
<event name="capabilities">
<description summary="compositor capabilities">
This event advertises the capabilities supported by the compositor. If
a capability isn't supported, clients should hide or disable the UI
elements that expose this functionality. For instance, if the
compositor doesn't advertise support for creating workspaces, a button
triggering the create_workspace request should not be displayed.
The compositor will ignore requests it doesn't support. For instance,
a compositor which doesn't advertise support for creating workspaces will ignore
create_workspace requests.
Compositors must send this event once after creation of an
ext_workspace_group_handle_v1. When the capabilities change, compositors
must send this event again.
</description>
<arg name="capabilities" type="uint" summary="capabilities" enum="group_capabilities"/>
</event>
<event name="output_enter">
<description summary="output assigned to workspace group">
This event is emitted whenever an output is assigned to the workspace
group or a new `wl_output` object is bound by the client, which was already
assigned to this workspace_group.
</description>
<arg name="output" type="object" interface="wl_output"/>
</event>
<event name="output_leave">
<description summary="output removed from workspace group">
This event is emitted whenever an output is removed from the workspace
group.
</description>
<arg name="output" type="object" interface="wl_output"/>
</event>
<event name="workspace_enter">
<description summary="workspace added to workspace group">
This event is emitted whenever a workspace is assigned to this group.
A workspace may only ever be assigned to a single group at a single point
in time, but can be re-assigned during it's lifetime.
</description>
<arg name="workspace" type="object" interface="ext_workspace_handle_v1"/>
</event>
<event name="workspace_leave">
<description summary="workspace removed from workspace group">
This event is emitted whenever a workspace is removed from this group.
</description>
<arg name="workspace" type="object" interface="ext_workspace_handle_v1"/>
</event>
<event name="removed">
<description summary="this workspace group has been removed">
This event is send when the group associated with the ext_workspace_group_handle_v1
has been removed. After sending this request the compositor will immediately consider
the object inert. Any requests will be ignored except the destroy request.
It is guaranteed there won't be any more events referencing this
ext_workspace_group_handle_v1.
The compositor must remove all workspaces belonging to a workspace group
via a workspace_leave event before removing the workspace group.
</description>
</event>
<request name="create_workspace">
<description summary="create a new workspace">
Request that the compositor create a new workspace with the given name
and assign it to this group.
There is no guarantee that the compositor will create a new workspace,
or that the created workspace will have the provided name.
</description>
<arg name="workspace" type="string"/>
</request>
<request name="destroy" type="destructor">
<description summary="destroy the ext_workspace_group_handle_v1 object">
Destroys the ext_workspace_group_handle_v1 object.
This request should be send either when the client does not want to
use the workspace group object any more or after the removed event to finalize
the destruction of the object.
</description>
</request>
</interface>
<interface name="ext_workspace_handle_v1" version="1">
<description summary="a workspace handing a group of surfaces">
A ext_workspace_handle_v1 object represents a workspace that handles a
group of surfaces.
Each workspace has:
- a name, conveyed to the client with the name event
- potentially an id conveyed with the id event
- a list of states, conveyed to the client with the state event
- and optionally a set of coordinates, conveyed to the client with the
coordinates event
The client may request that the compositor activate or deactivate the workspace.
Each workspace can belong to only a single workspace group.
Depending on the compositor policy, there might be workspaces with
the same name in different workspace groups, but these workspaces are still
separate (e.g. one of them might be active while the other is not).
</description>
<event name="id">
<description summary="workspace id">
If this event is emitted, it will be send immediately after the
ext_workspace_handle_v1 is created or when an id is assigned to
a workspace (at most once during it's lifetime).
An id will never change during the lifetime of the `ext_workspace_handle_v1`
and is guaranteed to be unique during it's lifetime.
Ids are not human-readable and shouldn't be displayed, use `name` for that purpose.
Compositors are expected to only send ids for workspaces likely stable across multiple
sessions and can be used by clients to store preferences for workspaces. Workspaces without
ids should be considered temporary and any data associated with them should be deleted once
the respective object is lost.
</description>
<arg name="id" type="string"/>
</event>
<event name="name">
<description summary="workspace name changed">
This event is emitted immediately after the ext_workspace_handle_v1 is
created and whenever the name of the workspace changes.
A name is meant to be human-readable and can be displayed to a user.
Unlike the id it is neither stable nor unique.
</description>
<arg name="name" type="string"/>
</event>
<event name="coordinates">
<description summary="workspace coordinates changed">
This event is used to organize workspaces into an N-dimensional grid
within a workspace group, and if supported, is emitted immediately after
the ext_workspace_handle_v1 is created and whenever the coordinates of
the workspace change. Compositors may not send this event if they do not
conceptually arrange workspaces in this way. If compositors simply
number workspaces, without any geometric interpretation, they may send
1D coordinates, which clients should not interpret as implying any
geometry. Sending an empty array means that the compositor no longer
orders the workspace geometrically.
Coordinates have an arbitrary number of dimensions N with an uint32
position along each dimension. By convention if N > 1, the first
dimension is X, the second Y, the third Z, and so on. The compositor may
chose to utilize these events for a more novel workspace layout
convention, however. No guarantee is made about the grid being filled or
bounded; there may be a workspace at coordinate 1 and another at
coordinate 1000 and none in between. Within a workspace group, however,
workspaces must have unique coordinates of equal dimensionality.
</description>
<arg name="coordinates" type="array"/>
</event>
<enum name="state" bitfield="true">
<description summary="types of states on the workspace">
The different states that a workspace can have.
</description>
<entry name="active" value="1" summary="the workspace is active"/>
<entry name="urgent" value="2" summary="the workspace requests attention"/>
<entry name="hidden" value="4">
<description summary="the workspace is not visible">
The workspace is not visible in its workspace group, and clients
attempting to visualize the compositor workspace state should not
display such workspaces.
</description>
</entry>
</enum>
<event name="state">
<description summary="the state of the workspace changed">
This event is emitted immediately after the ext_workspace_handle_v1 is
created and each time the workspace state changes, either because of a
compositor action or because of a request in this protocol.
Missing states convey the opposite meaning, e.g. an unset active bit
means the workspace is currently inactive.
</description>
<arg name="state" type="uint" enum="state"/>
</event>
<enum name="workspace_capabilities" bitfield="true">
<entry name="activate" value="1" summary="activate request is available"/>
<entry name="deactivate" value="2" summary="deactivate request is available"/>
<entry name="remove" value="4" summary="remove request is available"/>
<entry name="assign" value="8" summary="assign request is available"/>
</enum>
<event name="capabilities">
<description summary="compositor capabilities">
This event advertises the capabilities supported by the compositor. If
a capability isn't supported, clients should hide or disable the UI
elements that expose this functionality. For instance, if the
compositor doesn't advertise support for removing workspaces, a button
triggering the remove request should not be displayed.
The compositor will ignore requests it doesn't support. For instance,
a compositor which doesn't advertise support for remove will ignore
remove requests.
Compositors must send this event once after creation of an
ext_workspace_handle_v1 . When the capabilities change, compositors
must send this event again.
</description>
<arg name="capabilities" type="uint" summary="capabilities" enum="workspace_capabilities"/>
</event>
<event name="removed">
<description summary="this workspace has been removed">
This event is send when the workspace associated with the ext_workspace_handle_v1
has been removed. After sending this request, the compositor will immediately consider
the object inert. Any requests will be ignored except the destroy request.
It is guaranteed there won't be any more events referencing this
ext_workspace_handle_v1.
The compositor must only remove a workspaces not currently belonging to any
workspace_group.
</description>
</event>
<request name="destroy" type="destructor">
<description summary="destroy the ext_workspace_handle_v1 object">
Destroys the ext_workspace_handle_v1 object.
This request should be made either when the client does not want to
use the workspace object any more or after the remove event to finalize
the destruction of the object.
</description>
</request>
<request name="activate">
<description summary="activate the workspace">
Request that this workspace be activated.
There is no guarantee the workspace will be actually activated, and
behaviour may be compositor-dependent. For example, activating a
workspace may or may not deactivate all other workspaces in the same
group.
</description>
</request>
<request name="deactivate">
<description summary="deactivate the workspace">
Request that this workspace be deactivated.
There is no guarantee the workspace will be actually deactivated.
</description>
</request>
<request name="assign">
<description summary="assign workspace to group">
Requests that this workspace is assigned to the given workspace group.
There is no guarantee the workspace will be assigned.
</description>
<arg name="workspace_group" type="object" interface="ext_workspace_group_handle_v1"/>
</request>
<request name="remove">
<description summary="remove the workspace">
Request that this workspace be removed.
There is no guarantee the workspace will be actually removed.
</description>
</request>
</interface>
</protocol>
@@ -1,134 +0,0 @@
package extworkspace
import (
"encoding/json"
"fmt"
"net"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
)
type SuccessResult struct {
Success bool `json:"success"`
Message string `json:"message"`
}
func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
if manager == nil {
models.RespondError(conn, req.ID, "extworkspace manager not initialized")
return
}
switch req.Method {
case "extworkspace.getState":
handleGetState(conn, req, manager)
case "extworkspace.activateWorkspace":
handleActivateWorkspace(conn, req, manager)
case "extworkspace.deactivateWorkspace":
handleDeactivateWorkspace(conn, req, manager)
case "extworkspace.removeWorkspace":
handleRemoveWorkspace(conn, req, manager)
case "extworkspace.createWorkspace":
handleCreateWorkspace(conn, req, manager)
case "extworkspace.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) {
state := manager.GetState()
models.Respond(conn, req.ID, state)
}
func handleActivateWorkspace(conn net.Conn, req models.Request, manager *Manager) {
groupID := models.GetOr(req, "groupID", "")
workspaceID, ok := models.Get[string](req, "workspaceID")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
return
}
if err := manager.ActivateWorkspace(groupID, workspaceID); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace activated"})
}
func handleDeactivateWorkspace(conn net.Conn, req models.Request, manager *Manager) {
groupID := models.GetOr(req, "groupID", "")
workspaceID, ok := models.Get[string](req, "workspaceID")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
return
}
if err := manager.DeactivateWorkspace(groupID, workspaceID); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace deactivated"})
}
func handleRemoveWorkspace(conn net.Conn, req models.Request, manager *Manager) {
groupID := models.GetOr(req, "groupID", "")
workspaceID, ok := models.Get[string](req, "workspaceID")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'workspaceID' parameter")
return
}
if err := manager.RemoveWorkspace(groupID, workspaceID); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace removed"})
}
func handleCreateWorkspace(conn net.Conn, req models.Request, manager *Manager) {
groupID, ok := models.Get[string](req, "groupID")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'groupID' parameter")
return
}
workspaceName, ok := models.Get[string](req, "name")
if !ok {
models.RespondError(conn, req.ID, "missing or invalid 'name' parameter")
return
}
if err := manager.CreateWorkspace(groupID, workspaceName); err != nil {
models.RespondError(conn, req.ID, err.Error())
return
}
models.Respond(conn, req.ID, SuccessResult{Success: true, Message: "workspace create requested"})
}
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
}
}
}
@@ -1,598 +0,0 @@
package extworkspace
import (
"fmt"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_workspace"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
)
func CheckCapability() bool {
display, err := wlclient.Connect("")
if err != nil {
return false
}
defer display.Destroy()
registry, err := display.GetRegistry()
if err != nil {
return false
}
defer registry.Destroy()
found := false
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
if e.Interface == ext_workspace.ExtWorkspaceManagerV1InterfaceName {
found = true
}
})
// Roundtrip to ensure all registry events are processed
if err := display.Roundtrip(); err != nil {
return false
}
return found
}
func NewManager(display wlclient.WaylandDisplay) (*Manager, error) {
m := &Manager{
display: display,
ctx: display.Context(),
cmdq: make(chan cmd, 128),
stopChan: make(chan struct{}),
dirty: make(chan struct{}, 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("ExtWorkspace actor command queue full, dropping command")
}
}
func (m *Manager) waylandActor() {
defer m.wg.Done()
for {
select {
case <-m.stopChan:
return
case c := <-m.cmdq:
c.fn()
}
}
}
func (m *Manager) setupRegistry() error {
log.Info("ExtWorkspace: 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 == "wl_output" {
output := wlclient.NewOutput(m.ctx)
if err := registry.Bind(e.Name, e.Interface, 4, output); err == nil {
outputID := output.ID()
output.SetNameHandler(func(ev wlclient.OutputNameEvent) {
m.outputNames.Store(outputID, ev.Name)
log.Debugf("ExtWorkspace: Output %d (%s) name received", outputID, ev.Name)
m.post(func() {
m.updateState()
})
})
}
return
}
if e.Interface == ext_workspace.ExtWorkspaceManagerV1InterfaceName {
log.Infof("ExtWorkspace: found %s", ext_workspace.ExtWorkspaceManagerV1InterfaceName)
manager := ext_workspace.NewExtWorkspaceManagerV1(m.ctx)
version := e.Version
if version > 1 {
version = 1
}
manager.SetWorkspaceGroupHandler(func(e ext_workspace.ExtWorkspaceManagerV1WorkspaceGroupEvent) {
m.handleWorkspaceGroup(e)
})
manager.SetWorkspaceHandler(func(e ext_workspace.ExtWorkspaceManagerV1WorkspaceEvent) {
m.handleWorkspace(e)
})
manager.SetDoneHandler(func(e ext_workspace.ExtWorkspaceManagerV1DoneEvent) {
log.Debug("ExtWorkspace: done event received")
m.post(func() {
m.updateState()
})
})
manager.SetFinishedHandler(func(e ext_workspace.ExtWorkspaceManagerV1FinishedEvent) {
log.Info("ExtWorkspace: finished event received")
})
if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil {
m.manager = manager
log.Info("ExtWorkspace: manager bound successfully")
} else {
log.Errorf("ExtWorkspace: failed to bind manager: %v", err)
}
}
})
log.Info("ExtWorkspace: registry setup complete (events will be processed async)")
return nil
}
func (m *Manager) handleWorkspaceGroup(e ext_workspace.ExtWorkspaceManagerV1WorkspaceGroupEvent) {
handle := e.WorkspaceGroup
groupID := handle.ID()
log.Debugf("ExtWorkspace: New workspace group (id=%d)", groupID)
group := &workspaceGroupState{
id: groupID,
handle: handle,
outputIDs: make(map[uint32]bool),
workspaceIDs: make([]uint32, 0),
}
m.groups.Store(groupID, group)
handle.SetCapabilitiesHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1CapabilitiesEvent) {
log.Debugf("ExtWorkspace: Group %d capabilities: %d", groupID, e.Capabilities)
})
handle.SetOutputEnterHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1OutputEnterEvent) {
outputID := e.Output.ID()
log.Debugf("ExtWorkspace: Group %d output enter (output=%d)", groupID, outputID)
m.post(func() {
group.outputIDs[outputID] = true
m.updateState()
})
})
handle.SetOutputLeaveHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1OutputLeaveEvent) {
outputID := e.Output.ID()
log.Debugf("ExtWorkspace: Group %d output leave (output=%d)", groupID, outputID)
m.post(func() {
delete(group.outputIDs, outputID)
m.updateState()
})
})
handle.SetWorkspaceEnterHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1WorkspaceEnterEvent) {
workspaceID := e.Workspace.ID()
log.Debugf("ExtWorkspace: Group %d workspace enter (workspace=%d)", groupID, workspaceID)
m.post(func() {
if ws, ok := m.workspaces.Load(workspaceID); ok {
ws.groupID = groupID
}
group.workspaceIDs = append(group.workspaceIDs, workspaceID)
m.updateState()
})
})
handle.SetWorkspaceLeaveHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1WorkspaceLeaveEvent) {
workspaceID := e.Workspace.ID()
log.Debugf("ExtWorkspace: Group %d workspace leave (workspace=%d)", groupID, workspaceID)
m.post(func() {
if ws, ok := m.workspaces.Load(workspaceID); ok {
ws.groupID = 0
}
for i, id := range group.workspaceIDs {
if id == workspaceID {
group.workspaceIDs = append(group.workspaceIDs[:i], group.workspaceIDs[i+1:]...)
break
}
}
m.updateState()
})
})
handle.SetRemovedHandler(func(e ext_workspace.ExtWorkspaceGroupHandleV1RemovedEvent) {
log.Debugf("ExtWorkspace: Group %d removed", groupID)
m.post(func() {
group.removed = true
m.groups.Delete(groupID)
m.wlMutex.Lock()
handle.Destroy()
m.wlMutex.Unlock()
m.updateState()
})
})
}
func (m *Manager) handleWorkspace(e ext_workspace.ExtWorkspaceManagerV1WorkspaceEvent) {
handle := e.Workspace
workspaceID := handle.ID()
log.Debugf("ExtWorkspace: New workspace (proxy_id=%d)", workspaceID)
ws := &workspaceState{
id: workspaceID,
handle: handle,
coordinates: make([]uint32, 0),
}
m.workspaces.Store(workspaceID, ws)
handle.SetIdHandler(func(e ext_workspace.ExtWorkspaceHandleV1IdEvent) {
log.Debugf("ExtWorkspace: Workspace %d id: %s", workspaceID, e.Id)
m.post(func() {
ws.workspaceID = e.Id
m.updateState()
})
})
handle.SetNameHandler(func(e ext_workspace.ExtWorkspaceHandleV1NameEvent) {
log.Debugf("ExtWorkspace: Workspace %d name: %s", workspaceID, e.Name)
m.post(func() {
ws.name = e.Name
m.updateState()
})
})
handle.SetCoordinatesHandler(func(e ext_workspace.ExtWorkspaceHandleV1CoordinatesEvent) {
coords := make([]uint32, 0)
for i := 0; i < len(e.Coordinates); i += 4 {
if i+4 <= len(e.Coordinates) {
val := uint32(e.Coordinates[i]) |
uint32(e.Coordinates[i+1])<<8 |
uint32(e.Coordinates[i+2])<<16 |
uint32(e.Coordinates[i+3])<<24
coords = append(coords, val)
}
}
log.Debugf("ExtWorkspace: Workspace %d coordinates: %v", workspaceID, coords)
m.post(func() {
ws.coordinates = coords
m.updateState()
})
})
handle.SetStateHandler(func(e ext_workspace.ExtWorkspaceHandleV1StateEvent) {
log.Debugf("ExtWorkspace: Workspace %d state: %d", workspaceID, e.State)
m.post(func() {
ws.state = e.State
m.updateState()
})
})
handle.SetCapabilitiesHandler(func(e ext_workspace.ExtWorkspaceHandleV1CapabilitiesEvent) {
log.Debugf("ExtWorkspace: Workspace %d capabilities: %d", workspaceID, e.Capabilities)
})
handle.SetRemovedHandler(func(e ext_workspace.ExtWorkspaceHandleV1RemovedEvent) {
log.Debugf("ExtWorkspace: Workspace %d removed", workspaceID)
m.post(func() {
ws.removed = true
m.workspaces.Delete(workspaceID)
m.wlMutex.Lock()
handle.Destroy()
m.wlMutex.Unlock()
m.updateState()
})
})
}
func (m *Manager) updateState() {
groups := make([]*WorkspaceGroup, 0)
m.groups.Range(func(key uint32, group *workspaceGroupState) bool {
if group.removed {
return true
}
outputs := make([]string, 0)
for outputID := range group.outputIDs {
if name, ok := m.outputNames.Load(outputID); ok && name != "" {
outputs = append(outputs, name)
}
}
workspaces := make([]*Workspace, 0)
for _, wsID := range group.workspaceIDs {
ws, exists := m.workspaces.Load(wsID)
if !exists {
continue
}
if ws.removed {
continue
}
workspace := &Workspace{
ID: ws.workspaceID,
Name: ws.name,
Coordinates: ws.coordinates,
State: ws.state,
Active: ws.state&uint32(ext_workspace.ExtWorkspaceHandleV1StateActive) != 0,
Urgent: ws.state&uint32(ext_workspace.ExtWorkspaceHandleV1StateUrgent) != 0,
Hidden: ws.state&uint32(ext_workspace.ExtWorkspaceHandleV1StateHidden) != 0,
}
workspaces = append(workspaces, workspace)
}
groupState := &WorkspaceGroup{
ID: fmt.Sprintf("group-%d", group.id),
Outputs: outputs,
Workspaces: workspaces,
}
groups = append(groups, groupState)
return true
})
newState := State{
Groups: groups,
}
m.stateMutex.Lock()
m.state = &newState
m.stateMutex.Unlock()
m.notifySubscribers()
}
func (m *Manager) notifier() {
defer m.notifierWg.Done()
const minGap = 100 * time.Millisecond
timer := time.NewTimer(minGap)
timer.Stop()
var pending bool
for {
select {
case <-m.stopChan:
timer.Stop()
return
case <-m.dirty:
if pending {
continue
}
pending = true
timer.Reset(minGap)
case <-timer.C:
if !pending {
continue
}
currentState := m.GetState()
if m.lastNotified != nil && !stateChanged(m.lastNotified, &currentState) {
pending = false
continue
}
m.subscribers.Range(func(key string, ch chan State) bool {
select {
case ch <- currentState:
default:
log.Warn("ExtWorkspace: subscriber channel full, dropping update")
}
return true
})
stateCopy := currentState
m.lastNotified = &stateCopy
pending = false
}
}
}
func (m *Manager) ActivateWorkspace(groupID, workspaceID string) error {
errChan := make(chan error, 1)
m.post(func() {
var targetGroupID uint32
if groupID != "" {
var parsedID uint32
if _, err := fmt.Sscanf(groupID, "group-%d", &parsedID); err == nil {
targetGroupID = parsedID
}
}
var found bool
m.workspaces.Range(func(key uint32, ws *workspaceState) bool {
if targetGroupID != 0 && ws.groupID != targetGroupID {
return true
}
if ws.workspaceID == workspaceID || ws.name == workspaceID {
m.wlMutex.Lock()
err := ws.handle.Activate()
if err == nil {
err = m.manager.Commit()
}
m.wlMutex.Unlock()
errChan <- err
found = true
return false
}
return true
})
if !found {
errChan <- fmt.Errorf("workspace not found: %s in group %s", workspaceID, groupID)
}
})
return <-errChan
}
func (m *Manager) DeactivateWorkspace(groupID, workspaceID string) error {
errChan := make(chan error, 1)
m.post(func() {
var targetGroupID uint32
if groupID != "" {
var parsedID uint32
if _, err := fmt.Sscanf(groupID, "group-%d", &parsedID); err == nil {
targetGroupID = parsedID
}
}
var found bool
m.workspaces.Range(func(key uint32, ws *workspaceState) bool {
if targetGroupID != 0 && ws.groupID != targetGroupID {
return true
}
if ws.workspaceID == workspaceID || ws.name == workspaceID {
m.wlMutex.Lock()
err := ws.handle.Deactivate()
if err == nil {
err = m.manager.Commit()
}
m.wlMutex.Unlock()
errChan <- err
found = true
return false
}
return true
})
if !found {
errChan <- fmt.Errorf("workspace not found: %s in group %s", workspaceID, groupID)
}
})
return <-errChan
}
func (m *Manager) RemoveWorkspace(groupID, workspaceID string) error {
errChan := make(chan error, 1)
m.post(func() {
var targetGroupID uint32
if groupID != "" {
var parsedID uint32
if _, err := fmt.Sscanf(groupID, "group-%d", &parsedID); err == nil {
targetGroupID = parsedID
}
}
var found bool
m.workspaces.Range(func(key uint32, ws *workspaceState) bool {
if targetGroupID != 0 && ws.groupID != targetGroupID {
return true
}
if ws.workspaceID == workspaceID || ws.name == workspaceID {
m.wlMutex.Lock()
err := ws.handle.Remove()
if err == nil {
err = m.manager.Commit()
}
m.wlMutex.Unlock()
errChan <- err
found = true
return false
}
return true
})
if !found {
errChan <- fmt.Errorf("workspace not found: %s in group %s", workspaceID, groupID)
}
})
return <-errChan
}
func (m *Manager) CreateWorkspace(groupID, workspaceName string) error {
errChan := make(chan error, 1)
m.post(func() {
var found bool
m.groups.Range(func(key uint32, group *workspaceGroupState) bool {
if fmt.Sprintf("group-%d", group.id) == groupID {
m.wlMutex.Lock()
err := group.handle.CreateWorkspace(workspaceName)
if err == nil {
err = m.manager.Commit()
}
m.wlMutex.Unlock()
errChan <- err
found = true
return false
}
return true
})
if !found {
errChan <- fmt.Errorf("workspace group not found: %s", groupID)
}
})
return <-errChan
}
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.workspaces.Range(func(key uint32, ws *workspaceState) bool {
if ws.handle != nil {
ws.handle.Destroy()
}
m.workspaces.Delete(key)
return true
})
m.groups.Range(func(key uint32, group *workspaceGroupState) bool {
if group.handle != nil {
group.handle.Destroy()
}
m.groups.Delete(key)
return true
})
if m.manager != nil {
m.manager.Stop()
}
}
@@ -1,392 +0,0 @@
package extworkspace
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{Groups: []*WorkspaceGroup{}}
assert.True(t, stateChanged(s, nil))
assert.True(t, stateChanged(nil, s))
}
func TestStateChanged_GroupCountDiffers(t *testing.T) {
a := &State{Groups: []*WorkspaceGroup{{ID: "group-1"}}}
b := &State{Groups: []*WorkspaceGroup{}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_GroupIDDiffers(t *testing.T) {
a := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{}, Workspaces: []*Workspace{}}}}
b := &State{Groups: []*WorkspaceGroup{{ID: "group-2", Outputs: []string{}, Workspaces: []*Workspace{}}}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_OutputCountDiffers(t *testing.T) {
a := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}, Workspaces: []*Workspace{}}}}
b := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{}, Workspaces: []*Workspace{}}}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_OutputNameDiffers(t *testing.T) {
a := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}, Workspaces: []*Workspace{}}}}
b := &State{Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"HDMI-A-1"}, Workspaces: []*Workspace{}}}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_WorkspaceCountDiffers(t *testing.T) {
a := &State{Groups: []*WorkspaceGroup{{
ID: "group-1",
Outputs: []string{},
Workspaces: []*Workspace{{ID: "1", Name: "ws1"}},
}}}
b := &State{Groups: []*WorkspaceGroup{{
ID: "group-1",
Outputs: []string{},
Workspaces: []*Workspace{},
}}}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_WorkspaceFieldsDiffer(t *testing.T) {
a := &State{Groups: []*WorkspaceGroup{{
ID: "group-1",
Outputs: []string{},
Workspaces: []*Workspace{{
ID: "1", Name: "ws1", State: 0, Active: false, Urgent: false, Hidden: false,
}},
}}}
b := &State{Groups: []*WorkspaceGroup{{
ID: "group-1",
Outputs: []string{},
Workspaces: []*Workspace{{
ID: "2", Name: "ws1", State: 0, Active: false, Urgent: false, Hidden: false,
}},
}}}
assert.True(t, stateChanged(a, b))
b.Groups[0].Workspaces[0].ID = "1"
b.Groups[0].Workspaces[0].Name = "ws2"
assert.True(t, stateChanged(a, b))
b.Groups[0].Workspaces[0].Name = "ws1"
b.Groups[0].Workspaces[0].State = 1
assert.True(t, stateChanged(a, b))
b.Groups[0].Workspaces[0].State = 0
b.Groups[0].Workspaces[0].Active = true
assert.True(t, stateChanged(a, b))
b.Groups[0].Workspaces[0].Active = false
b.Groups[0].Workspaces[0].Urgent = true
assert.True(t, stateChanged(a, b))
b.Groups[0].Workspaces[0].Urgent = false
b.Groups[0].Workspaces[0].Hidden = true
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_WorkspaceCoordinatesDiffer(t *testing.T) {
a := &State{Groups: []*WorkspaceGroup{{
ID: "group-1",
Outputs: []string{},
Workspaces: []*Workspace{{
ID: "1", Name: "ws1", Coordinates: []uint32{0, 0},
}},
}}}
b := &State{Groups: []*WorkspaceGroup{{
ID: "group-1",
Outputs: []string{},
Workspaces: []*Workspace{{
ID: "1", Name: "ws1", Coordinates: []uint32{1, 0},
}},
}}}
assert.True(t, stateChanged(a, b))
b.Groups[0].Workspaces[0].Coordinates = []uint32{0}
assert.True(t, stateChanged(a, b))
}
func TestStateChanged_Equal(t *testing.T) {
a := &State{Groups: []*WorkspaceGroup{{
ID: "group-1",
Outputs: []string{"eDP-1", "HDMI-A-1"},
Workspaces: []*Workspace{
{ID: "1", Name: "ws1", Coordinates: []uint32{0, 0}, State: 1, Active: true},
{ID: "2", Name: "ws2", Coordinates: []uint32{1, 0}, State: 0, Active: false},
},
}}}
b := &State{Groups: []*WorkspaceGroup{{
ID: "group-1",
Outputs: []string{"eDP-1", "HDMI-A-1"},
Workspaces: []*Workspace{
{ID: "1", Name: "ws1", Coordinates: []uint32{0, 0}, State: 1, Active: true},
{ID: "2", Name: "ws2", Coordinates: []uint32{1, 0}, State: 0, Active: false},
},
}}}
assert.False(t, stateChanged(a, b))
}
func TestManager_ConcurrentGetState(t *testing.T) {
m := &Manager{
state: &State{
Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}}},
},
}
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.Groups
}
}()
}
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{
Groups: []*WorkspaceGroup{{ID: "group-1", Outputs: []string{"eDP-1"}}},
}
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_SyncmapGroupsConcurrentAccess(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 := &workspaceGroupState{
id: key,
outputIDs: map[uint32]bool{1: true},
workspaceIDs: []uint32{uint32(j)},
}
m.groups.Store(key, state)
if loaded, ok := m.groups.Load(key); ok {
assert.Equal(t, key, loaded.id)
}
m.groups.Range(func(k uint32, v *workspaceGroupState) bool {
_ = v.id
_ = v.outputIDs
return true
})
}
m.groups.Delete(key)
}(i)
}
wg.Wait()
}
func TestManager_SyncmapWorkspacesConcurrentAccess(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 := &workspaceState{
id: key,
workspaceID: "ws-1",
name: "workspace",
state: uint32(j % 4),
coordinates: []uint32{uint32(j), 0},
}
m.workspaces.Store(key, state)
if loaded, ok := m.workspaces.Load(key); ok {
assert.Equal(t, key, loaded.id)
}
m.workspaces.Range(func(k uint32, v *workspaceState) bool {
_ = v.name
_ = v.state
return true
})
}
m.workspaces.Delete(key)
}(i)
}
wg.Wait()
}
func TestManager_SyncmapOutputNamesConcurrentAccess(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++ {
m.outputNames.Store(key, "eDP-1")
if loaded, ok := m.outputNames.Load(key); ok {
assert.NotEmpty(t, loaded)
}
m.outputNames.Range(func(k uint32, v string) bool {
_ = v
return true
})
}
m.outputNames.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.Groups)
assert.Empty(t, s.Groups)
}
func TestWorkspace_Fields(t *testing.T) {
ws := Workspace{
ID: "ws-1",
Name: "workspace 1",
Coordinates: []uint32{0, 0},
State: 1,
Active: true,
Urgent: false,
Hidden: false,
}
assert.Equal(t, "ws-1", ws.ID)
assert.Equal(t, "workspace 1", ws.Name)
assert.True(t, ws.Active)
assert.False(t, ws.Urgent)
assert.False(t, ws.Hidden)
}
func TestWorkspaceGroup_Fields(t *testing.T) {
group := WorkspaceGroup{
ID: "group-1",
Outputs: []string{"eDP-1", "HDMI-A-1"},
Workspaces: []*Workspace{
{ID: "ws-1", Name: "workspace 1"},
},
}
assert.Equal(t, "group-1", group.ID)
assert.Len(t, group.Outputs, 2)
assert.Len(t, group.Workspaces, 1)
}
func TestNewManager_GetRegistryError(t *testing.T) {
mockDisplay := mocks_wlclient.NewMockWaylandDisplay(t)
mockDisplay.EXPECT().Context().Return(nil)
mockDisplay.EXPECT().GetRegistry().Return(nil, errors.New("failed to get registry"))
_, err := NewManager(mockDisplay)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to get registry")
}
-169
View File
@@ -1,169 +0,0 @@
package extworkspace
import (
"sync"
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_workspace"
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
)
type Workspace struct {
ID string `json:"id"`
Name string `json:"name"`
Coordinates []uint32 `json:"coordinates"`
State uint32 `json:"state"`
Active bool `json:"active"`
Urgent bool `json:"urgent"`
Hidden bool `json:"hidden"`
}
type WorkspaceGroup struct {
ID string `json:"id"`
Outputs []string `json:"outputs"`
Workspaces []*Workspace `json:"workspaces"`
}
type State struct {
Groups []*WorkspaceGroup `json:"groups"`
}
type cmd struct {
fn func()
}
type Manager struct {
display wlclient.WaylandDisplay
ctx *wlclient.Context
registry *wlclient.Registry
manager *ext_workspace.ExtWorkspaceManagerV1
outputNames syncmap.Map[uint32, string]
groups syncmap.Map[uint32, *workspaceGroupState]
workspaces syncmap.Map[uint32, *workspaceState]
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
}
type workspaceGroupState struct {
id uint32
handle *ext_workspace.ExtWorkspaceGroupHandleV1
outputIDs map[uint32]bool
workspaceIDs []uint32
removed bool
}
type workspaceState struct {
id uint32
handle *ext_workspace.ExtWorkspaceHandleV1
workspaceID string
name string
coordinates []uint32
state uint32
groupID uint32
removed bool
}
func (m *Manager) GetState() State {
m.stateMutex.RLock()
defer m.stateMutex.RUnlock()
if m.state == nil {
return State{
Groups: []*WorkspaceGroup{},
}
}
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 ch, ok := m.subscribers.LoadAndDelete(id); ok {
close(ch)
}
}
func (m *Manager) notifySubscribers() {
select {
case m.dirty <- struct{}{}:
default:
}
}
func stateChanged(old, new *State) bool {
if old == nil || new == nil {
return true
}
if len(old.Groups) != len(new.Groups) {
return true
}
for i, newGroup := range new.Groups {
if i >= len(old.Groups) {
return true
}
oldGroup := old.Groups[i]
if oldGroup.ID != newGroup.ID {
return true
}
if len(oldGroup.Outputs) != len(newGroup.Outputs) {
return true
}
for j, newOutput := range newGroup.Outputs {
if j >= len(oldGroup.Outputs) {
return true
}
if oldGroup.Outputs[j] != newOutput {
return true
}
}
if len(oldGroup.Workspaces) != len(newGroup.Workspaces) {
return true
}
for j, newWs := range newGroup.Workspaces {
if j >= len(oldGroup.Workspaces) {
return true
}
oldWs := oldGroup.Workspaces[j]
if oldWs.ID != newWs.ID || oldWs.Name != newWs.Name || oldWs.State != newWs.State {
return true
}
if oldWs.Active != newWs.Active || oldWs.Urgent != newWs.Urgent || oldWs.Hidden != newWs.Hidden {
return true
}
if len(oldWs.Coordinates) != len(newWs.Coordinates) {
return true
}
for k, coord := range newWs.Coordinates {
if k >= len(oldWs.Coordinates) {
return true
}
if oldWs.Coordinates[k] != coord {
return true
}
}
}
}
return false
}
-22
View File
@@ -13,7 +13,6 @@ import (
serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
@@ -138,27 +137,6 @@ func RouteRequest(conn net.Conn, req models.Request) {
return
}
if strings.HasPrefix(req.Method, "extworkspace.") {
if extWorkspaceManager == nil {
if extWorkspaceAvailable.Load() {
extWorkspaceInitMutex.Lock()
if extWorkspaceManager == nil {
if err := InitializeExtWorkspaceManager(); err != nil {
extWorkspaceInitMutex.Unlock()
models.RespondError(conn, req.ID, "extworkspace manager not available")
return
}
}
extWorkspaceInitMutex.Unlock()
} else {
models.RespondError(conn, req.ID, "extworkspace manager not initialized")
return
}
}
extworkspace.HandleRequest(conn, req, extWorkspaceManager)
return
}
if strings.HasPrefix(req.Method, "wlroutput.") {
if wlrOutputManager == nil {
models.RespondError(conn, req.ID, "wlroutput manager not initialized")
-98
View File
@@ -24,7 +24,6 @@ import (
serverDbus "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dbus"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/extworkspace"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/freedesktop"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/location"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
@@ -68,7 +67,6 @@ var appPickerManager *apppicker.Manager
var cupsManager *cups.Manager
var tailscaleManager *tailscale.Manager
var dwlManager *dwl.Manager
var extWorkspaceManager *extworkspace.Manager
var brightnessManager *brightness.Manager
var wlrOutputManager *wlroutput.Manager
var evdevManager *evdev.Manager
@@ -86,8 +84,6 @@ const dbusClientID = "dms-dbus-client"
var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
var cupsSubscribers syncmap.Map[string, bool]
var cupsSubscriberCount atomic.Int32
var extWorkspaceAvailable atomic.Bool
var extWorkspaceInitMutex sync.Mutex
func getSocketDir() string {
if runtime := os.Getenv("XDG_RUNTIME_DIR"); runtime != "" {
@@ -293,30 +289,6 @@ func InitializeBrightnessManager() error {
return nil
}
func InitializeExtWorkspaceManager() error {
log.Info("Attempting to initialize ExtWorkspace...")
if wlContext == nil {
ctx, err := wlcontext.New()
if err != nil {
log.Errorf("Failed to create shared Wayland context: %v", err)
return err
}
wlContext = ctx
}
manager, err := extworkspace.NewManager(wlContext.Display())
if err != nil {
log.Debug("Failed to initialize extworkspace manager: %v", err)
return err
}
extWorkspaceManager = manager
log.Info("ExtWorkspace initialized successfully")
return nil
}
func InitializeWlrOutputManager() error {
log.Info("Attempting to initialize WlrOutput management...")
@@ -499,10 +471,6 @@ func getCapabilities() Capabilities {
caps = append(caps, "dwl")
}
if extWorkspaceAvailable.Load() {
caps = append(caps, "extworkspace")
}
if brightnessManager != nil {
caps = append(caps, "brightness")
}
@@ -573,10 +541,6 @@ func getServerInfo() ServerInfo {
caps = append(caps, "dwl")
}
if extWorkspaceAvailable.Load() {
caps = append(caps, "extworkspace")
}
if brightnessManager != nil {
caps = append(caps, "brightness")
}
@@ -1113,50 +1077,6 @@ func handleSubscribe(conn net.Conn, req models.Request) {
}()
}
if shouldSubscribe("extworkspace") {
if extWorkspaceManager == nil && extWorkspaceAvailable.Load() {
extWorkspaceInitMutex.Lock()
if extWorkspaceManager == nil {
if err := InitializeExtWorkspaceManager(); err != nil {
log.Warnf("Failed to initialize ExtWorkspace manager for subscription: %v", err)
}
}
extWorkspaceInitMutex.Unlock()
}
if extWorkspaceManager != nil {
wg.Add(1)
extWorkspaceChan := extWorkspaceManager.Subscribe(clientID + "-extworkspace")
go func() {
defer wg.Done()
defer extWorkspaceManager.Unsubscribe(clientID + "-extworkspace")
initialState := extWorkspaceManager.GetState()
select {
case eventChan <- ServiceEvent{Service: "extworkspace", Data: initialState}:
case <-stopChan:
return
}
for {
select {
case state, ok := <-extWorkspaceChan:
if !ok {
return
}
select {
case eventChan <- ServiceEvent{Service: "extworkspace", Data: state}:
case <-stopChan:
return
}
case <-stopChan:
return
}
}
}()
}
}
if shouldSubscribe("brightness") && brightnessManager != nil {
wg.Add(2)
brightnessStateChan := brightnessManager.Subscribe(clientID + "-brightness-state")
@@ -1415,9 +1335,6 @@ func cleanupManagers() {
if dwlManager != nil {
dwlManager.Close()
}
if extWorkspaceManager != nil {
extWorkspaceManager.Close()
}
if brightnessManager != nil {
brightnessManager.Close()
}
@@ -1597,13 +1514,6 @@ func Start(printDocs bool) error {
log.Info(" - appId : Focused window app ID")
log.Info(" - kbLayout : Current keyboard layout")
log.Info(" - keymode : Current keybind mode")
log.Info("ExtWorkspace:")
log.Info(" extworkspace.getState - Get current workspace state (groups, workspaces)")
log.Info(" extworkspace.activateWorkspace - Activate workspace (params: groupID, workspaceID)")
log.Info(" extworkspace.deactivateWorkspace - Deactivate workspace (params: groupID, workspaceID)")
log.Info(" extworkspace.removeWorkspace - Remove workspace (params: groupID, workspaceID)")
log.Info(" extworkspace.createWorkspace - Create workspace (params: groupID, name)")
log.Info(" extworkspace.subscribe - Subscribe to workspace state changes (streaming)")
log.Info("Brightness:")
log.Info(" brightness.getState - Get current brightness state for all devices")
log.Info(" brightness.setBrightness - Set device brightness (params: device, percent)")
@@ -1784,14 +1694,6 @@ func Start(printDocs bool) error {
log.Debugf("DWL manager unavailable: %v", err)
}
if extworkspace.CheckCapability() {
extWorkspaceAvailable.Store(true)
log.Info("ExtWorkspace capability detected and will be available on subscription")
} else {
log.Debug("ExtWorkspace capability not available")
extWorkspaceAvailable.Store(false)
}
if err := InitializeWlrOutputManager(); err != nil {
log.Debugf("WlrOutput manager unavailable: %v", err)
}
@@ -4,6 +4,7 @@ import Quickshell
import Quickshell.Widgets
import Quickshell.Hyprland
import Quickshell.I3
import Quickshell.WindowManager
import qs.Common
import qs.Services
import qs.Widgets
@@ -86,7 +87,22 @@ Item {
}
}
readonly property bool useExtWorkspace: DMSService.forceExtWorkspace || (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isDwl && !CompositorService.isSway && !CompositorService.isScroll && !CompositorService.isMiracle && ExtWorkspaceService.extWorkspaceAvailable)
readonly property var extProjection: (useExtWorkspace && parentScreen) ? WindowManager.screenProjection(parentScreen) : null
readonly property bool useExtWorkspace: {
if (Quickshell.env("DMS_FORCE_EXTWS") === "1")
return (WindowManager.windowsets?.length ?? 0) > 0;
switch (CompositorService.compositor) {
case "niri":
case "hyprland":
case "dwl":
case "sway":
case "scroll":
case "miracle":
return false;
default:
return (WindowManager.windowsets?.length ?? 0) > 0;
}
}
Connections {
target: DesktopEntries
@@ -361,7 +377,7 @@ Item {
"id": "",
"name": "",
"active": false,
"hidden": true
"_placeholder": true
};
} else if (CompositorService.isNiri) {
placeholder = {
@@ -493,33 +509,21 @@ Item {
}
function getExtWorkspaceWorkspaces() {
const groups = ExtWorkspaceService.groups;
if (!ExtWorkspaceService.extWorkspaceAvailable || groups.length === 0) {
return [
{
"id": "1",
"name": "1",
"active": false
}
];
}
const fallback = [
{
"id": "1",
"name": "1",
"active": false
}
];
if (!extProjection)
return fallback;
const group = groups.find(g => g.outputs && g.outputs.includes(root.screenName));
if (!group || !group.workspaces) {
return [
{
"id": "1",
"name": "1",
"active": false
}
];
}
let visible = group.workspaces.filter(ws => !ws.hidden);
let visible = extProjection.windowsets.filter(ws => ws.shouldDisplay);
const hasValidCoordinates = visible.some(ws => ws.coordinates && ws.coordinates.length > 0);
if (hasValidCoordinates) {
visible = visible.sort((a, b) => {
visible = visible.slice().sort((a, b) => {
const coordsA = a.coordinates || [0, 0];
const coordsB = b.coordinates || [0, 0];
if (coordsA[0] !== coordsB[0])
@@ -528,33 +532,14 @@ Item {
});
}
visible = visible.map(ws => ({
id: ws.id,
name: ws.name,
coordinates: ws.coordinates,
state: ws.state,
active: ws.active,
urgent: ws.urgent,
hidden: ws.hidden,
groupID: group.id
}));
return visible.length > 0 ? visible : [
{
"id": "1",
"name": "1",
"active": false
}
];
return visible.length > 0 ? visible : fallback;
}
function getExtWorkspaceActiveWorkspace() {
if (!ExtWorkspaceService.extWorkspaceAvailable) {
return 1;
}
const activeWs = ExtWorkspaceService.getActiveWorkspaceForOutput(root.screenName);
return activeWs ? (activeWs.id || activeWs.name || "1") : "1";
if (!extProjection)
return "";
const activeWs = extProjection.windowsets.find(ws => ws.active);
return activeWs ? (activeWs.id || activeWs.name || "") : "";
}
readonly property real dpr: parentScreen ? CompositorService.getScreenScale(parentScreen) : 1
@@ -566,7 +551,7 @@ Item {
function getRealWorkspaces() {
return root.workspaceList.filter(ws => {
if (useExtWorkspace)
return ws && (ws.id !== "" || ws.name !== "") && !ws.hidden;
return ws && !ws._placeholder;
if (CompositorService.isNiri)
return ws && ws.idx !== -1;
if (CompositorService.isHyprland)
@@ -583,8 +568,9 @@ Item {
if (!data)
return;
if (root.useExtWorkspace && (data.id || data.name)) {
ExtWorkspaceService.activateWorkspace(data.id || data.name, data.groupID || "");
if (root.useExtWorkspace) {
if (typeof data.activate === "function")
data.activate();
return;
}
@@ -649,7 +635,8 @@ Item {
}
const nextWorkspace = realWorkspaces[nextIndex];
ExtWorkspaceService.activateWorkspace(nextWorkspace.id || nextWorkspace.name, nextWorkspace.groupID || "");
if (typeof nextWorkspace.activate === "function")
nextWorkspace.activate();
} else if (CompositorService.isNiri) {
const realWorkspaces = getRealWorkspaces();
if (realWorkspaces.length < 2) {
@@ -1013,7 +1000,7 @@ Item {
}
property bool isPlaceholder: {
if (root.useExtWorkspace)
return !!(modelData && modelData.hidden);
return !!(modelData && modelData._placeholder);
if (CompositorService.isNiri)
return !!(modelData && modelData.idx === -1);
if (CompositorService.isHyprland)
@@ -1313,8 +1300,9 @@ Item {
return;
if (mouse.button === Qt.LeftButton) {
if (root.useExtWorkspace && (modelData?.id || modelData?.name)) {
ExtWorkspaceService.activateWorkspace(modelData.id || modelData.name, modelData.groupID || "");
if (root.useExtWorkspace) {
if (typeof modelData?.activate === "function")
modelData.activate();
} else if (CompositorService.isNiri) {
if (modelData && modelData.id !== undefined) {
NiriService.switchToWorkspace(modelData.id);
@@ -1941,9 +1929,9 @@ Item {
}
}
Connections {
target: ExtWorkspaceService
target: WindowManager
enabled: root.useExtWorkspace
function onStateChanged() {
function onWindowsetsChanged() {
delegateRoot.updateAllData();
}
}
@@ -1952,9 +1940,6 @@ Item {
}
Component.onCompleted: {
if (useExtWorkspace && !DMSService.activeSubscriptions.includes("extworkspace")) {
DMSService.addSubscription("extworkspace");
}
_updateBlurRegistration();
}
+2 -12
View File
@@ -23,7 +23,6 @@ Singleton {
property bool isConnected: false
property bool isConnecting: false
property bool subscribeConnected: false
readonly property bool forceExtWorkspace: false
readonly property string socketPath: Quickshell.env("DMS_SOCKET")
@@ -53,7 +52,6 @@ Singleton {
signal dwlStateUpdate(var data)
signal brightnessStateUpdate(var data)
signal brightnessDeviceUpdate(var device)
signal extWorkspaceStateUpdate(var data)
signal wlrOutputStateUpdate(var data)
signal evdevStateUpdate(var data)
signal gammaStateUpdate(var data)
@@ -288,7 +286,7 @@ Singleton {
function removeSubscription(service) {
if (activeSubscriptions.includes("all")) {
const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "dwl", "brightness", "extworkspace", "browser", "location"];
const allServices = ["network", "loginctl", "freedesktop", "gamma", "bluetooth", "dwl", "brightness", "browser", "location"];
const filtered = allServices.filter(s => s !== service);
subscribe(filtered);
} else {
@@ -310,7 +308,7 @@ Singleton {
excludeServices = [excludeServices];
}
const allServices = ["network", "loginctl", "freedesktop", "gamma", "theme.auto", "bluetooth", "cups", "dwl", "brightness", "extworkspace", "browser", "dbus", "location"];
const allServices = ["network", "loginctl", "freedesktop", "gamma", "theme.auto", "bluetooth", "cups", "dwl", "brightness", "browser", "dbus", "location"];
const filtered = allServices.filter(s => !excludeServices.includes(s));
subscribe(filtered);
}
@@ -364,8 +362,6 @@ Singleton {
if (data.device) {
brightnessDeviceUpdate(data.device);
}
} else if (service === "extworkspace") {
extWorkspaceStateUpdate(data);
} else if (service === "wlroutput") {
wlrOutputStateUpdate(data);
} else if (service === "evdev") {
@@ -752,12 +748,6 @@ Singleton {
});
}
function renameWorkspace(name, callback) {
sendRequest("extworkspace.renameWorkspace", {
"name": name
}, callback);
}
function sysupdateGetState(callback) {
sendRequest("sysupdate.getState", null, callback);
}
-279
View File
@@ -1,279 +0,0 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Services
Singleton {
id: root
readonly property var log: Log.scoped("ExtWorkspaceService")
property bool extWorkspaceAvailable: false
property var groups: []
property var _cachedWorkspaces: ({})
signal stateChanged
Connections {
target: DMSService
function onCapabilitiesReceived() {
checkCapabilities();
}
function onConnectionStateChanged() {
if (DMSService.isConnected) {
checkCapabilities();
} else {
extWorkspaceAvailable = false;
}
}
function onExtWorkspaceStateUpdate(data) {
if (extWorkspaceAvailable) {
handleStateUpdate(data);
}
}
}
Component.onCompleted: {
if (DMSService.dmsAvailable) {
checkCapabilities();
}
}
function checkCapabilities() {
if (!DMSService.capabilities || !Array.isArray(DMSService.capabilities)) {
extWorkspaceAvailable = false;
return;
}
const hasExtWorkspace = DMSService.capabilities.includes("extworkspace");
if (hasExtWorkspace && !extWorkspaceAvailable) {
if (typeof CompositorService !== "undefined") {
const useExtWorkspace = DMSService.forceExtWorkspace || (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isDwl && !CompositorService.isSway && !CompositorService.isScroll && !CompositorService.isMiracle);
if (!useExtWorkspace) {
log.info("ext-workspace available but compositor has native support");
extWorkspaceAvailable = false;
return;
}
}
extWorkspaceAvailable = true;
log.info("ext-workspace capability detected");
DMSService.addSubscription("extworkspace");
requestState();
} else if (!hasExtWorkspace) {
extWorkspaceAvailable = false;
}
}
function requestState() {
if (!DMSService.isConnected || !extWorkspaceAvailable) {
return;
}
DMSService.sendRequest("extworkspace.getState", null, response => {
if (response.result) {
handleStateUpdate(response.result);
}
});
}
function handleStateUpdate(state) {
groups = state.groups || [];
if (groups.length === 0) {
log.warn("Received empty workspace groups from backend");
} else {
log.debug("Updated with", groups.length, "workspace groups");
}
stateChanged();
}
function activateWorkspace(workspaceID, groupID = "") {
if (!DMSService.isConnected || !extWorkspaceAvailable) {
return;
}
DMSService.sendRequest("extworkspace.activateWorkspace", {
"workspaceID": workspaceID,
"groupID": groupID
}, response => {
if (response.error) {
log.warn("activateWorkspace error:", response.error);
}
});
}
function deactivateWorkspace(workspaceID, groupID = "") {
if (!DMSService.isConnected || !extWorkspaceAvailable) {
return;
}
DMSService.sendRequest("extworkspace.deactivateWorkspace", {
"workspaceID": workspaceID,
"groupID": groupID
}, response => {
if (response.error) {
log.warn("deactivateWorkspace error:", response.error);
}
});
}
function removeWorkspace(workspaceID, groupID = "") {
if (!DMSService.isConnected || !extWorkspaceAvailable) {
return;
}
DMSService.sendRequest("extworkspace.removeWorkspace", {
"workspaceID": workspaceID,
"groupID": groupID
}, response => {
if (response.error) {
log.warn("removeWorkspace error:", response.error);
}
});
}
function createWorkspace(groupID, name) {
if (!DMSService.isConnected || !extWorkspaceAvailable) {
return;
}
DMSService.sendRequest("extworkspace.createWorkspace", {
"groupID": groupID,
"name": name
}, response => {
if (response.error) {
log.warn("createWorkspace error:", response.error);
}
});
}
function getGroupForOutput(outputName) {
for (const group of groups) {
if (group.outputs && group.outputs.includes(outputName)) {
return group;
}
}
return null;
}
function getWorkspacesForOutput(outputName) {
const group = getGroupForOutput(outputName);
return group ? (group.workspaces || []) : [];
}
function getActiveWorkspaces() {
const active = [];
for (const group of groups) {
if (!group.workspaces)
continue;
for (const ws of group.workspaces) {
if (ws.active) {
active.push({
workspace: ws,
group: group,
outputs: group.outputs || []
});
}
}
}
return active;
}
function getActiveWorkspaceForOutput(outputName) {
const group = getGroupForOutput(outputName);
if (!group || !group.workspaces)
return null;
for (const ws of group.workspaces) {
if (ws.active) {
return ws;
}
}
return null;
}
function getVisibleWorkspaces(outputName) {
const workspaces = getWorkspacesForOutput(outputName);
let visible = workspaces.filter(ws => !ws.hidden);
const hasValidCoordinates = visible.some(ws => ws.coordinates && ws.coordinates.length > 0);
if (hasValidCoordinates) {
visible = visible.sort((a, b) => {
const coordsA = a.coordinates || [0, 0];
const coordsB = b.coordinates || [0, 0];
if (coordsA[0] !== coordsB[0])
return coordsA[0] - coordsB[0];
return coordsA[1] - coordsB[1];
});
}
const cacheKey = outputName;
if (!_cachedWorkspaces[cacheKey]) {
_cachedWorkspaces[cacheKey] = {
workspaces: [],
lastNames: []
};
}
const cache = _cachedWorkspaces[cacheKey];
const currentNames = visible.map(ws => ws.name || ws.id);
const namesChanged = JSON.stringify(cache.lastNames) !== JSON.stringify(currentNames);
if (namesChanged || cache.workspaces.length !== visible.length) {
cache.workspaces = visible.map(ws => ({
id: ws.id,
name: ws.name,
coordinates: ws.coordinates,
state: ws.state,
active: ws.active,
urgent: ws.urgent,
hidden: ws.hidden
}));
cache.lastNames = currentNames;
return cache.workspaces;
}
for (let i = 0; i < visible.length; i++) {
const src = visible[i];
const dst = cache.workspaces[i];
dst.id = src.id;
dst.name = src.name;
dst.coordinates = src.coordinates;
dst.state = src.state;
dst.active = src.active;
dst.urgent = src.urgent;
dst.hidden = src.hidden;
}
return cache.workspaces;
}
function getUrgentWorkspaces() {
const urgent = [];
for (const group of groups) {
if (!group.workspaces)
continue;
for (const ws of group.workspaces) {
if (ws.urgent) {
urgent.push({
workspace: ws,
group: group,
outputs: group.outputs || []
});
}
}
}
return urgent;
}
function switchToWorkspace(outputName, workspaceName) {
const workspaces = getWorkspacesForOutput(outputName);
for (const ws of workspaces) {
if (ws.name === workspaceName || ws.id === workspaceName) {
activateWorkspace(ws.name || ws.id);
return;
}
}
log.warn("workspace not found:", workspaceName);
}
}