Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions ezsway/core/profile_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,21 @@ def ok(self) -> bool:


def verify_output_state(wm: WMAdapter, unique_id: str, *, want_wh: Optional[str] = None,
want_pos: Optional[str] = None, want_disabled: bool = False,
want_pos: Optional[str] = None, want_scale: Optional[float] = None,
want_transform: Optional[str] = None, want_disabled: bool = False,
retries: int = _APPLY_VERIFY_RETRIES,
delay: float = _APPLY_VERIFY_DELAY_SECONDS) -> bool:
"""Polls wm.get_outputs() up to `retries` times, confirming a monitor
actually reached the requested state -- a sway IPC "success: true" reply
does not guarantee the change actually took effect (the class of bug
this whole tool exists to catch). Shared between ProfileManager
(save/load) and the GUI's drag-and-drop ArrangeCanvas.Apply, which
previously reimplemented "call enable_output" without this check at all.
(save/load), the GUI's drag-and-drop ArrangeCanvas.Apply, and the TUI's
display-settings editor.

want_scale/want_transform added alongside want_wh/want_pos so a
scale/rotation change gets the same real verification as a position
change -- checking only mode/position would have silently reported
success on a scale or transform change that the WM actually rejected.

A WM-unreachable blip during the poll itself is treated as "try again
next retry", not a hard failure -- a transient IPC drop while polling
Expand All @@ -81,6 +87,10 @@ def verify_output_state(wm: WMAdapter, unique_id: str, *, want_wh: Optional[str]
continue
if want_pos is not None and f"{live.pos_x} {live.pos_y}" != want_pos:
continue
if want_scale is not None and abs(live.scale - want_scale) > 1e-6:
continue
if want_transform is not None and live.transform != want_transform:
continue
return True
return False

Expand Down
64 changes: 62 additions & 2 deletions ezsway/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@

from ..core.errors import EzSwayError
from ..core.monitor_manager import MonitorManager
from ..core.profile_manager import ProfileManager
from ..core.profile_manager import ProfileManager, verify_output_state
from ..core.setup_wizard import SetupWizard
from ..core.wm_adapter import WMFactory
from ..core.wm_adapter import VALID_TRANSFORMS, WMFactory
from .arrange import run_arrange

MAIN_MENU_CHOICES = [
"Load a profile",
"Arrange displays (move with arrow keys)",
"Edit display settings (scale/rotation)",
"Save current layout as new profile",
"Setup Wizard (capture current layout)",
"Set up a new display (activate/deactivate)",
Expand Down Expand Up @@ -126,6 +127,62 @@ def describe(m):
_ok(f"Deactivated {m.name}.")


def _edit_display_settings(wm):
"""Lets you change a connected monitor's scale or transform (rotation).
The arrange screens (GUI drag canvas, TUI arrow-key screen) only ever
exposed position -- scale/transform were only changeable by hand-editing
a profile JSON or dropping to raw swaymsg. Position and mode are left
untouched (not what this screen is for)."""
try:
monitors = wm.get_outputs()
except EzSwayError as e:
_error(f"Cannot query displays: {e}")
return
if not monitors:
_error("No displays detected.")
return

choices = [f"{m.name} ({m.make} {m.model} {m.serial}) - scale {m.scale}, transform {m.transform}"
for m in monitors]
monitor_map = dict(zip(choices, monitors))
answer = _ask(questionary.select("Which display?", choices=choices + ["(cancel)"]))
if answer is None or answer == "(cancel)":
return
m = monitor_map[answer]

scale_str = _ask(questionary.text(f"Scale (current {m.scale}):", default=str(m.scale)))
if scale_str is None:
return
try:
new_scale = float(scale_str)
except ValueError:
_error(f"Invalid scale {scale_str!r} -- must be a number.")
return

transform_choices = sorted(VALID_TRANSFORMS)
new_transform = _ask(questionary.select(
f"Transform (current {m.transform}):",
choices=transform_choices,
default=m.transform if m.transform in transform_choices else transform_choices[0],
))
if new_transform is None:
return

mode = f"{int(m.width)}x{int(m.height)}"
position = f"{m.pos_x} {m.pos_y}"
try:
wm.enable_output(m.name, mode=mode, position=position, scale=new_scale, transform=new_transform)
except EzSwayError as e:
_error(str(e))
return

if verify_output_state(wm, m.unique_id, want_wh=mode, want_pos=position,
want_scale=new_scale, want_transform=new_transform):
_ok(f"Updated {m.name}: scale={new_scale}, transform={new_transform!r}.")
else:
_error(f"Command accepted but {m.name} did not verify applied (checked scale + transform too).")


def run_tui():
wm = WMFactory.create_adapter()
pm = ProfileManager(wm)
Expand Down Expand Up @@ -175,6 +232,9 @@ def run_tui():
elif choice == "Arrange displays (move with arrow keys)":
run_arrange(wm, pm)

elif choice == "Edit display settings (scale/rotation)":
_edit_display_settings(wm)

elif choice == "Set up a new display (activate/deactivate)":
_manage_monitors(manager)

Expand Down
35 changes: 34 additions & 1 deletion tests/test_profile_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
ProfileNotFoundError,
ProfileWriteError,
)
from ezsway.core.profile_manager import ProfileManager, validate_backup_id, validate_label
from ezsway.core.profile_manager import ProfileManager, validate_backup_id, validate_label, verify_output_state
from ezsway.core.wm_adapter import Monitor, WMAdapter


Expand Down Expand Up @@ -477,6 +477,39 @@ def test_load_profile_also_takes_the_lock(self):
lock_file.close()


class TestVerifyOutputStateScaleTransform(unittest.TestCase):
"""want_scale/want_transform were added so a scale or rotation change
gets the same real re-query verification as a position change --
previously verify_output_state only checked mode/position, so a caller
changing scale/transform had no way to confirm the WM actually applied
it, not just accepted the IPC command."""

def test_matching_scale_and_transform_verified(self):
m = make_monitor(name="DP-1")
m.scale, m.transform = 1.5, "90"
wm = FakeWMAdapter([m])
self.assertTrue(verify_output_state(wm, m.unique_id, want_scale=1.5, want_transform="90",
retries=1, delay=0))

def test_mismatched_scale_not_verified(self):
m = make_monitor(name="DP-1")
m.scale = 1.0
wm = FakeWMAdapter([m])
self.assertFalse(verify_output_state(wm, m.unique_id, want_scale=2.0, retries=1, delay=0))

def test_mismatched_transform_not_verified(self):
m = make_monitor(name="DP-1")
m.transform = "normal"
wm = FakeWMAdapter([m])
self.assertFalse(verify_output_state(wm, m.unique_id, want_transform="180", retries=1, delay=0))

def test_scale_close_enough_within_float_tolerance_verified(self):
m = make_monitor(name="DP-1")
m.scale = 1.2999999523162842 # real float32-ish drift seen from live sway data
wm = FakeWMAdapter([m])
self.assertTrue(verify_output_state(wm, m.unique_id, want_scale=1.3, retries=1, delay=0))


class TestFindAutoMatch(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
Expand Down
53 changes: 52 additions & 1 deletion tests/test_tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

sys.path.append(os.getcwd())

from ezsway.tui.app import _ask, _manage_monitors
from ezsway.tui.app import _ask, _edit_display_settings, _manage_monitors


class TestAskEofHandling(unittest.TestCase):
Expand Down Expand Up @@ -97,5 +97,56 @@ def test_declining_confirm_takes_no_action(self):
self.manager.activate_monitor.assert_not_called()


class TestEditDisplaySettings(unittest.TestCase):
"""Regression coverage for the previously-missing way to change a
monitor's scale or rotation -- the arrange screens (GUI drag canvas,
TUI arrow-key screen) only ever exposed position; scale/transform were
only changeable by hand-editing a profile JSON or raw swaymsg."""

def setUp(self):
self.wm = MagicMock()
self.mon = _fake_monitor(name="DP-1", active=True)
self.mon.scale = 1.0
self.mon.transform = "normal"
self.mon.width, self.mon.height = 1920, 1080
self.mon.pos_x, self.mon.pos_y = 0, 0
self.wm.get_outputs.return_value = [self.mon]

def test_no_displays_shows_error_and_asks_nothing(self):
self.wm.get_outputs.return_value = []
with patch("ezsway.tui.app._ask") as mock_ask:
_edit_display_settings(self.wm)
mock_ask.assert_not_called()
self.wm.enable_output.assert_not_called()

def test_full_flow_applies_new_scale_and_transform(self):
answers = ["DP-1 (Dell M1 S1) - scale 1.0, transform normal", "1.5", "90"]
with patch("ezsway.tui.app._ask", side_effect=answers), \
patch("ezsway.tui.app.verify_output_state", return_value=True) as mock_verify:
_edit_display_settings(self.wm)
self.wm.enable_output.assert_called_once_with(
"DP-1", mode="1920x1080", position="0 0", scale=1.5, transform="90")
mock_verify.assert_called_once()
self.assertEqual(mock_verify.call_args.kwargs["want_scale"], 1.5)
self.assertEqual(mock_verify.call_args.kwargs["want_transform"], "90")

def test_cancel_at_monitor_selection_takes_no_action(self):
with patch("ezsway.tui.app._ask", side_effect=["(cancel)"]):
_edit_display_settings(self.wm)
self.wm.enable_output.assert_not_called()

def test_invalid_scale_rejected_before_any_wm_call(self):
answers = ["DP-1 (Dell M1 S1) - scale 1.0, transform normal", "not-a-number"]
with patch("ezsway.tui.app._ask", side_effect=answers):
_edit_display_settings(self.wm)
self.wm.enable_output.assert_not_called()

def test_cancel_at_transform_prompt_takes_no_action(self):
answers = ["DP-1 (Dell M1 S1) - scale 1.0, transform normal", "1.5", None]
with patch("ezsway.tui.app._ask", side_effect=answers):
_edit_display_settings(self.wm)
self.wm.enable_output.assert_not_called()


if __name__ == "__main__":
unittest.main()
Loading