Fixed monitor hotplug, added support for output names
This commit is contained in:
parent
f450348769
commit
10eaaa9f06
@ -30,7 +30,7 @@ class ContextMngrInterface(
|
|||||||
"""Request a context switch. This will fail if the current monitor configuration is not compatible with the requested context."""
|
"""Request a context switch. This will fail if the current monitor configuration is not compatible with the requested context."""
|
||||||
print("requesting context", context)
|
print("requesting context", context)
|
||||||
try:
|
try:
|
||||||
self.workspace_tree.switch_context(context)
|
await self.workspace_tree.activate_context(self.connection, context)
|
||||||
return "OK"
|
return "OK"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return str(e)
|
return str(e)
|
||||||
|
|||||||
@ -6,6 +6,8 @@ from .utils import async_debounce
|
|||||||
|
|
||||||
class SwayConnection:
|
class SwayConnection:
|
||||||
|
|
||||||
|
last_output_set = None
|
||||||
|
|
||||||
def __init__(self, workspace_tree: WorkspaceTree):
|
def __init__(self, workspace_tree: WorkspaceTree):
|
||||||
self.connection = Connection(auto_reconnect=True)
|
self.connection = Connection(auto_reconnect=True)
|
||||||
self.workspace_tree = workspace_tree
|
self.workspace_tree = workspace_tree
|
||||||
@ -17,10 +19,17 @@ class SwayConnection:
|
|||||||
for callback in self.on_change_callbacks:
|
for callback in self.on_change_callbacks:
|
||||||
await callback(self.workspace_tree)
|
await callback(self.workspace_tree)
|
||||||
|
|
||||||
@async_debounce(2)
|
@async_debounce(0.1)
|
||||||
async def on_output(self, connection, event):
|
async def on_output(self, connection, event):
|
||||||
"""On output event, after 2 seconds, update the workspace tree"""
|
"""On output event, update the workspace tree"""
|
||||||
self.workspace_tree.update_context(connection)
|
print("Output event received", flush=True)
|
||||||
|
outputs = await connection.get_outputs()
|
||||||
|
if self.last_output_set:
|
||||||
|
if len(outputs) == len(self.last_output_set):
|
||||||
|
# If the number of outputs is the same, we can assume that the outputs are the same
|
||||||
|
return
|
||||||
|
self.last_output_set = outputs
|
||||||
|
await self.workspace_tree.update_context(connection)
|
||||||
|
|
||||||
async def on_mode(self, event):
|
async def on_mode(self, event):
|
||||||
"""On mode change event, do something. Not sure what yet."""
|
"""On mode change event, do something. Not sure what yet."""
|
||||||
@ -35,6 +44,9 @@ class SwayConnection:
|
|||||||
self.connection.on(Event.OUTPUT, self.on_output)
|
self.connection.on(Event.OUTPUT, self.on_output)
|
||||||
self.connection.on(Event.MODE, self.on_mode)
|
self.connection.on(Event.MODE, self.on_mode)
|
||||||
|
|
||||||
|
# Get initial output set
|
||||||
|
self.last_output_set = await self.connection.get_outputs()
|
||||||
|
|
||||||
# Update the workspace tree
|
# Update the workspace tree
|
||||||
await self.workspace_tree.update_context(self.connection)
|
await self.workspace_tree.update_context(self.connection)
|
||||||
await self.on_workspace()
|
await self.on_workspace()
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
|
||||||
def async_debounce(wait):
|
def async_debounce(wait):
|
||||||
@ -22,3 +23,20 @@ def async_debounce(wait):
|
|||||||
return debounced
|
return debounced
|
||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
class OutputMatch(IntEnum):
|
||||||
|
"""Enum for how an output was matched to a workspace group
|
||||||
|
|
||||||
|
This is used to break ties between contexts that have the same compatibility score. Output name matching
|
||||||
|
is considered weaker than matching by make, model, and serial number, however, it is generic and can be used
|
||||||
|
to match, say, projectors in conference rooms.
|
||||||
|
|
||||||
|
NO_MATCH: The output was not matched to the workspace group
|
||||||
|
NAME_MATCH: The output was matched by name to the workspace group
|
||||||
|
ID_MATCH: The output was matched by its make, model, and serial number to the workspace group
|
||||||
|
"""
|
||||||
|
|
||||||
|
NO_MATCH = 0
|
||||||
|
NAME_MATCH = 1
|
||||||
|
ID_MATCH = 2
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from i3ipc.replies import OutputReply
|
|||||||
from i3ipc.aio import Connection
|
from i3ipc.aio import Connection
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from .utils import OutputMatch
|
||||||
|
|
||||||
DEFAULT_TERMINAL = "alacritty"
|
DEFAULT_TERMINAL = "alacritty"
|
||||||
|
|
||||||
@ -76,6 +77,7 @@ class WorkspaceGroup:
|
|||||||
|
|
||||||
def __init__(self, output_data: dict[str, str]):
|
def __init__(self, output_data: dict[str, str]):
|
||||||
self.name = output_data["group"]
|
self.name = output_data["group"]
|
||||||
|
self.output_names = output_data.get("names", [])
|
||||||
self.make = output_data.get("make", None)
|
self.make = output_data.get("make", None)
|
||||||
self.model = output_data.get("model", None)
|
self.model = output_data.get("model", None)
|
||||||
self.serial = output_data.get("serial", None)
|
self.serial = output_data.get("serial", None)
|
||||||
@ -113,7 +115,19 @@ class WorkspaceGroup:
|
|||||||
"""Returns whether the group is active."""
|
"""Returns whether the group is active."""
|
||||||
return any(workspace.focused for workspace in self.workspaces)
|
return any(workspace.focused for workspace in self.workspaces)
|
||||||
|
|
||||||
async def configure(self, i3: Connection):
|
async def focus(self, i3: Connection):
|
||||||
|
"""Focus the group in Sway."""
|
||||||
|
if self.make and self.model and self.serial:
|
||||||
|
await i3.command(f"focus output {self.make} {self.model} {self.serial}")
|
||||||
|
elif len(self.output_names) > 0:
|
||||||
|
for name in self.output_names:
|
||||||
|
await i3.command(f"focus output {name}")
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
"No output name or make/model/serial provided, cannot focus group"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def configure(self, i3: Connection, outputs: list[OutputReply]):
|
||||||
"""Configure the group output in Sway."""
|
"""Configure the group output in Sway."""
|
||||||
transform = ""
|
transform = ""
|
||||||
mode = ""
|
mode = ""
|
||||||
@ -124,21 +138,69 @@ class WorkspaceGroup:
|
|||||||
mode = f"mode {self.mode}"
|
mode = f"mode {self.mode}"
|
||||||
if self.make and self.model and self.serial:
|
if self.make and self.model and self.serial:
|
||||||
selector = f'"{self.make} {self.model} {self.serial}"'
|
selector = f'"{self.make} {self.model} {self.serial}"'
|
||||||
|
elif len(self.output_names) > 0:
|
||||||
|
for name in self.output_names:
|
||||||
|
if name in [output.name for output in outputs]:
|
||||||
|
selector = name
|
||||||
|
break
|
||||||
|
# First, assign workspaces to the output
|
||||||
|
for workspace in self.workspaces:
|
||||||
|
await i3.command(f"workspace {workspace.index} output {selector}")
|
||||||
|
# Then, configure the output
|
||||||
await i3.command(
|
await i3.command(
|
||||||
f"output {selector} position {self.position[0]} {self.position[1]} {mode} {transform}"
|
f"output {selector} position {self.position[0]} {self.position[1]} {mode} {transform} enable"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_output_name(self, i3: Connection) -> str:
|
async def get_output_name(self, i3: Connection) -> str:
|
||||||
"""Get the name of the output in Sway."""
|
"""Get the name of the output in Sway."""
|
||||||
outputs = await i3.get_outputs()
|
outputs = await i3.get_outputs()
|
||||||
for output in outputs:
|
# If we have make, model, and serial, search by those first
|
||||||
|
if self.make and self.model and self.serial:
|
||||||
|
for output in outputs:
|
||||||
|
if (
|
||||||
|
output.make == self.make
|
||||||
|
and output.model == self.model
|
||||||
|
and output.serial == self.serial
|
||||||
|
):
|
||||||
|
print(
|
||||||
|
f"Found output {output.name} by make, model, and serial for group {self.name}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return output.name
|
||||||
|
# If we don't find an exact match for the output, search by name if we have any
|
||||||
|
if len(self.output_names) > 0:
|
||||||
|
for output in outputs:
|
||||||
|
if output.name in self.output_names:
|
||||||
|
print(
|
||||||
|
f"Found output {output.name} by name for group {self.name}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return output.name
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_match_level(self, output: OutputReply) -> OutputMatch:
|
||||||
|
"""Get the match level score for the output."""
|
||||||
|
if self.make and self.model and self.serial:
|
||||||
if (
|
if (
|
||||||
output.make == self.make
|
output.make == self.make
|
||||||
and output.model == self.model
|
and output.model == self.model
|
||||||
and output.serial == self.serial
|
and output.serial == self.serial
|
||||||
):
|
):
|
||||||
return output.name
|
print(
|
||||||
return None
|
f"Match level: ID_MATCH for {output.name} on group {self.name}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return OutputMatch.ID_MATCH
|
||||||
|
if output.name in self.output_names:
|
||||||
|
print(
|
||||||
|
f"Match level: NAME_MATCH for {output.name} on group {self.name}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return OutputMatch.NAME_MATCH
|
||||||
|
print(
|
||||||
|
f"Match level: NO_MATCH for {output.name} on group {self.name}", flush=True
|
||||||
|
)
|
||||||
|
return OutputMatch.NO_MATCH
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceContext:
|
class WorkspaceContext:
|
||||||
@ -157,6 +219,8 @@ class WorkspaceContext:
|
|||||||
self.name = name
|
self.name = name
|
||||||
for group in self.groups:
|
for group in self.groups:
|
||||||
group_object = data["groups"][group.name]
|
group_object = data["groups"][group.name]
|
||||||
|
if group_object.get("reverse", False):
|
||||||
|
group.reverse = True
|
||||||
for workspace in group_object["workspaces"]:
|
for workspace in group_object["workspaces"]:
|
||||||
workspace_obj = next(
|
workspace_obj = next(
|
||||||
(w for w in workspaces if w.index == workspace),
|
(w for w in workspaces if w.index == workspace),
|
||||||
@ -168,31 +232,53 @@ class WorkspaceContext:
|
|||||||
raise Exception(
|
raise Exception(
|
||||||
f"Error: undefined workspace {workspace} referenced in context {name} group {group.name}"
|
f"Error: undefined workspace {workspace} referenced in context {name} group {group.name}"
|
||||||
)
|
)
|
||||||
|
primary_group_name = data.get("primary", self.groups[0].name)
|
||||||
|
self.primary_group = next(
|
||||||
|
(group for group in self.groups if group.name == primary_group_name),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
def add_group(self, group: WorkspaceGroup):
|
def add_group(self, group: WorkspaceGroup):
|
||||||
"""Add a group to the context."""
|
"""Add a group to the context."""
|
||||||
self.groups.append(group)
|
self.groups.append(group)
|
||||||
|
|
||||||
|
def compatability_rank(self, outputs: list[OutputReply]) -> int:
|
||||||
|
"""Get the compatability rank of the context with the given outputs."""
|
||||||
|
|
||||||
|
result = 0
|
||||||
|
for group in self.groups:
|
||||||
|
group_result = max(
|
||||||
|
[group.get_match_level(output).value for output in outputs]
|
||||||
|
)
|
||||||
|
if group_result == 0:
|
||||||
|
return 0
|
||||||
|
result += group_result
|
||||||
|
return result
|
||||||
|
|
||||||
async def activate(self, i3: Connection):
|
async def activate(self, i3: Connection):
|
||||||
"""Activate the context in Sway."""
|
"""Activate the context in Sway."""
|
||||||
defined_displays = [
|
defined_displays = [
|
||||||
f"{group.make} {group.model} {group.serial}" for group in self.groups
|
f"{group.make} {group.model} {group.serial}" for group in self.groups
|
||||||
]
|
]
|
||||||
# First, disable all displays not defined in the context
|
outputs = await i3.get_outputs()
|
||||||
for output in await i3.get_outputs():
|
# First, close all EWW windows
|
||||||
if f"{output.make} {output.model} {output.serial}" not in defined_displays:
|
|
||||||
print("Disabling", output.name)
|
|
||||||
await i3.command(f"output {output.name} disable")
|
|
||||||
|
|
||||||
# Next, configure all displays defined in the context
|
|
||||||
for group in self.groups:
|
|
||||||
await group.configure(i3)
|
|
||||||
|
|
||||||
# Then, close all EWW windows
|
|
||||||
proc = await asyncio.create_subprocess_exec("eww", "close-all")
|
proc = await asyncio.create_subprocess_exec("eww", "close-all")
|
||||||
await proc.wait()
|
await proc.wait()
|
||||||
|
|
||||||
# Then, open all EWW windows defined in the context on the appropriate windows
|
# Second, if the focused workspace is not in the context, focus the primary output
|
||||||
|
if not self.active_group:
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
# Then, disable all displays, so we can assign workspaces to the correct ones before enabling them
|
||||||
|
for output in outputs:
|
||||||
|
await i3.command(f"output {output.name} disable")
|
||||||
|
|
||||||
|
# Next, configure all displays defined in the context
|
||||||
|
for group in self.groups:
|
||||||
|
await group.configure(i3, outputs)
|
||||||
|
|
||||||
|
# Finally, open all EWW windows defined in the context on the appropriate windows
|
||||||
for group in self.groups:
|
for group in self.groups:
|
||||||
for window in group.eww_windows:
|
for window in group.eww_windows:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
@ -265,7 +351,7 @@ class WorkspaceTree:
|
|||||||
return f"WorkspaceTree({self.current_context}, {repr(self.contexts)})"
|
return f"WorkspaceTree({self.current_context}, {repr(self.contexts)})"
|
||||||
|
|
||||||
def __json__(self):
|
def __json__(self):
|
||||||
return {context.name: context.__json__() for context in self.contexts}
|
return {self.current_context.name: self.current_context.__json__()}
|
||||||
|
|
||||||
def get_workspace(self, user_index: int) -> Workspace:
|
def get_workspace(self, user_index: int) -> Workspace:
|
||||||
"""Returns a workspace object based on the user index."""
|
"""Returns a workspace object based on the user index."""
|
||||||
@ -330,37 +416,22 @@ class WorkspaceTree:
|
|||||||
"""Activates a new context in Sway based on the current display configuration."""
|
"""Activates a new context in Sway based on the current display configuration."""
|
||||||
# First, get the current display configuration
|
# First, get the current display configuration
|
||||||
outputs = await i3.get_outputs()
|
outputs = await i3.get_outputs()
|
||||||
active_outputs = [
|
print(outputs, flush=True)
|
||||||
f'"{output.make} {output.model} {output.serial}"' for output in outputs
|
|
||||||
|
# Next, calculate match scores for each context
|
||||||
|
scores = [
|
||||||
|
(context, context.compatability_rank(outputs)) for context in self.contexts
|
||||||
]
|
]
|
||||||
|
print([f"{context.name}: {score}" for context, score in scores], flush=True)
|
||||||
|
# Sort the scores by rank
|
||||||
|
scores.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
|
||||||
# Next, find the context that matches the current display configuration.
|
# If the top context is the current context, or the rank is 0, do nothing
|
||||||
# We first want to find an exact match, and if that fails, we want to find one where
|
if scores[0][0] == self.current_context or scores[0][1] == 0:
|
||||||
# all of its required outputs are present (so extra outputs are fine, they'll be disabled).
|
return
|
||||||
# We will not return a partial match except for the case noted above.
|
|
||||||
|
|
||||||
for context in self.contexts:
|
self.current_context = scores[0][0]
|
||||||
# First pass, look for exact matches
|
await self.current_context.activate(i3)
|
||||||
context_outputs = [
|
|
||||||
f'"{group.make} {group.model} {group.serial}"'
|
|
||||||
for group in context.groups
|
|
||||||
]
|
|
||||||
if all(output in active_outputs for output in context_outputs) and all(
|
|
||||||
output in context_outputs for output in active_outputs
|
|
||||||
):
|
|
||||||
self.current_context = context
|
|
||||||
await context.activate(i3)
|
|
||||||
return
|
|
||||||
|
|
||||||
for context in self.contexts:
|
|
||||||
context_outputs = [
|
|
||||||
f'"{group.make} {group.model} {group.serial}"'
|
|
||||||
for group in context.groups
|
|
||||||
]
|
|
||||||
if all(output in active_outputs for output in context_outputs):
|
|
||||||
self.current_context = context
|
|
||||||
await context.activate(i3)
|
|
||||||
return
|
|
||||||
|
|
||||||
async def activate_context(self, i3: Connection, name: str):
|
async def activate_context(self, i3: Connection, name: str):
|
||||||
"""Activates a context by name. This will fail if the current display configuration is incompatible."""
|
"""Activates a context by name. This will fail if the current display configuration is incompatible."""
|
||||||
@ -370,18 +441,12 @@ class WorkspaceTree:
|
|||||||
)
|
)
|
||||||
|
|
||||||
outputs = await i3.get_outputs()
|
outputs = await i3.get_outputs()
|
||||||
active_outputs = [
|
|
||||||
f'"{output.make} {output.model} {output.serial}"' for output in outputs
|
|
||||||
]
|
|
||||||
|
|
||||||
context_outputs = [
|
score = context.compatability_rank(outputs)
|
||||||
f'"{group.make} {group.model} {group.serial}"' for group in context.groups
|
if score == 0:
|
||||||
]
|
|
||||||
|
|
||||||
if all(output in active_outputs for output in context_outputs):
|
|
||||||
self.current_context = context
|
|
||||||
await context.activate(i3)
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Context {name} is incompatible with the current display configuration."
|
"Context is incompatible with current display configuration."
|
||||||
)
|
)
|
||||||
|
print(f"Activating context {context.name} with score {score}.", flush=True)
|
||||||
|
self.current_context = context
|
||||||
|
await context.activate(i3)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user