More proper loop handling

This commit is contained in:
Gürkan 2023-12-15 00:58:37 +01:00
parent db0d3c0da3
commit f56e3f0994
3 changed files with 75 additions and 59 deletions

View file

@ -9,7 +9,7 @@ This is a helper tool to apply vague Xrandr configurations
I wrote this as a reaction to all other xrandr automation tools which requires you to exactly define your setup (e.g. which monitor has which damn fingerprint, what input name it has etc.) and then failing to set up since you plugged a cable on different port.
Come on dude, I just want to define some non-specific settings. It should not be that hard to say something like:
Come on dude, I just want to define some non-specific settings. Something like:
- If I plug 1 extra monitor, my preference list:
1. Switch off the laptop's monitor and only use plugged monitor
@ -43,12 +43,12 @@ For the testing/development purposes, you can use poetry:
Please see [example config](loose/example_config.yaml) for examples and explanations.
(This was _not_ easy to write btw)
Also:
![protip: it was hard](https://paste.gurkan.in/good-sculpin.jpg)
### TODO
- Ensure default "preferred" resolution works in case of no specific config
- Implement pre/post commands (good for setting DPI etc.)
- Add tests
- Add gotchas about aliases (I had a lot of assumptions while writing this, which I need to document later)

View file

@ -29,8 +29,7 @@ PY_MAJOR_VERSION = 3
PY_MINOR_VERSION = 10
# Can't believe I don't have a portable way to do get the real version
# Poetry™ bullshit, has to be synced with pyproject.toml
VERSION = '0.0.5'
CONFIG_VERSION = f'{VERSION}.4'
VERSION = '0.0.6'
def get_parser(print_help: bool) -> argparse.Namespace:
@ -163,10 +162,13 @@ def validate_config(config: Dict, logger: logging.Logger):
# _print_and_exit(config['on_screen_count'])
if has_loops(config['on_screen_count']):
loop_fail, message = has_loops(config['on_screen_count'])
if loop_fail:
logger.error(
'Config file has loops, please do not refer "below/above/left-of/right-of" '
'directions bi-directionally between screens (or refer to itself).'
'Config file has loops, please remember we don\'t allow '
'"below/above/left-of/right-of" definitions bi-directionally '
'between screens (or self-references). Your detected issue was:\n\n'
f'{message}'
)
exit(1)
@ -191,7 +193,7 @@ def validate_config(config: Dict, logger: logging.Logger):
exit(1)
def has_loops(on_screen_config) -> bool:
def has_loops(on_screen_config) -> Tuple[bool, str]:
"""Detects whether there is a loop in the graph, for position references
We don't allow:
@ -200,57 +202,71 @@ def has_loops(on_screen_config) -> bool:
Credit: ChatGPT4 (No way in hell I can write this myself)
"""
# Function to build graph from configuration and check for loops
def build_graph_and_check_loops(screens):
# Create an adjacency list to represent the graph
graph = defaultdict(list)
# Iterate through each screen ID and their properties
for screen_id, properties in screens.items():
# Iterate through properties to find directional references
for direction, ref_id in properties.items():
# Check only directional properties
if direction in ['above', 'below', 'left-of', 'right-of']:
# Check for self-reference
if ref_id == screen_id:
# Return True for loop detected and a message
return True, f"There is a self-reference to screen object itself"
# Add a directed edge from current screen to referenced screen
graph[screen_id].append(ref_id)
graph = defaultdict(dict)
# Build the graph with details about each directional relationship
for screens in on_screen_config.values():
for screen_dict in screens:
for screen_id, properties in screen_dict.items():
for direction, ref_id in properties.items():
if direction in ['above', 'below', 'left-of', 'right-of']:
if ref_id == screen_id: # Rule: No screen can refer to itself
return True
if ref_id not in graph[screen_id].values():
graph[screen_id][direction] = ref_id
else:
# Rule: If referred screen ref_ids a different direction back to the screen_id
return True
# Check the reverse direction for a bidirectional link
opposite_dir = {
'above': 'below',
'below': 'above',
'right-of': 'left-of',
'left-of': 'right-of'
}[direction]
# Rule: No bidirectional direct references allowed
if graph.get(ref_id, {}).get(opposite_dir) == screen_id:
return True
# Perform DFS to detect whether there is a loop in the graph
def dfs(node_id, visited, rec_stack):
# If the node_id is in the recursion stack, then we have found a loop
if node_id in rec_stack:
return True
# If the node_id is visited and not in the recursion stack, then no loop is found in this path
if node_id in visited:
return False
visited.add(node_id)
rec_stack.add(node_id)
# Perform DFS for adjacent nodes
for neighbor_id in graph[node_id].values():
if dfs(neighbor_id, visited, rec_stack):
return True
# Function for performing Depth-First Search
def dfs(node_id, graph, visited, rec_stack):
# If the node is in the recursion stack, a loop is detected
if node_id in rec_stack:
return True, "There are objects referring to each other within same config section"
# If the node was already visited, skip it
if node_id in visited:
return False, ""
# Mark the node as visited and add to recursion stack
visited.add(node_id)
rec_stack.add(node_id)
# Recursively visit all adjacent nodes
for neighbor_id in graph[node_id]:
has_loop, message = dfs(neighbor_id, graph, visited, rec_stack)
if has_loop:
# If a loop is detected in the DFS, propagate the result up
return True, message
# Remove the current node from recursion stack after DFS completes
rec_stack.remove(node_id)
return False, ""
# Remove node_id from the recursion stack before backtracking
rec_stack.remove(node_id)
return False
# Sets to keep track of visited nodes and the recursion stack
visited, rec_stack = set(), set()
# Obtain a list of the nodes to iterate over without changing the dict's size
nodes = list(graph.keys())
# Perform DFS on each node
for node_id in nodes:
if node_id not in visited:
has_loop, message = dfs(node_id, graph, visited, rec_stack)
if has_loop:
# If a loop is found, return True and the accompanying message
return True, message.format(config=node_id)
# If no loops are found in the graph, return False with an empty message
return False, ""
visited, rec_stack = set(), set()
nodes = list(graph.keys()) # Create a static list of nodes to prevent RuntimeError during iteration
for node_id in nodes:
if dfs(node_id, visited, rec_stack):
return True # Loop detected
# Iterate over each screen configuration index and the corresponding sections
for _, screens_list in on_screen_config.items():
for _, screen_section in enumerate(screens_list):
# Ensure the section items are dictionaries before processing
screens = {k: v for k, v in screen_section.items() if isinstance(v, dict)}
# Use the helper function to check for loops in the current section
has_loop, message = build_graph_and_check_loops(screens)
if has_loop:
# If a loop is detected, return the information immediately
return True, message
return False # No loops detected in the graph
# Return False and an empty message if no loops are found in any configuration
return False, ""
def _replace_none_with_dict(d):
@ -730,13 +746,13 @@ def main():
):
conf['is_current'] = True
main_dict['CONFIG_VERSION'] = CONFIG_VERSION
main_dict['VERSION'] = VERSION
# Check if save file exists
try:
previous_dict = load_from_disk(save_file)
# If loose itself is updated, we will start from scratch
if previous_dict['CONFIG_VERSION'] != CONFIG_VERSION:
if 'VERSION' not in previous_dict or previous_dict['VERSION'] != VERSION:
logger.debug('Config version mismatch. Scraping the old config.')
raise FileNotFoundError
# Compare loaded xrandr output with the current one

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "loose"
version = "0.0.5"
version = "0.0.6"
description = "A simple tool to apply XrandR configurations"
authors = ["Gürkan <gitoperations@gurkan.in>"]