diff --git a/CHANGELOG.md b/CHANGELOG.md index 04e274d..9473680 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ GitHub Release body. The format follows - **`resoio launch --prefix` / `--proton-path`**: choose the Wine prefix (`WINEPREFIX`) and the Proton build (`PROTONPATH`) per launch. `--proton-path` takes a compatibility-tools name like `GE-Proton` (the default) or a path. +- **`resoio display set` resolution presets and `WIDTHxHEIGHT[@FPS]` shorthand**: + `set` now takes an optional positional spec, so `resoio display set fhd` (1920×1080), + `resoio display set qhd@144`, or `resoio display set 1280x720@60` work instead of + spelling out `-W/-H/-F`. Presets (case-insensitive): `hd` 1280×720, `fhd` 1920×1080, + `qhd` 2560×1440, `uhd` 3840×2160; the `@FPS` suffix is optional. The classic + `-W/--width` / `-H/--height` / `-F/--max-fps` flags still work and override the spec + field-by-field (e.g. `fhd -W 1024` keeps fhd's height but sets width 1024; `fhd@30 -F 144` keeps fhd's resolution but caps fps at 144). ### 🐛 Fixed diff --git a/docs/cli.md b/docs/cli.md index 04448ee..fff41d0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -20,7 +20,7 @@ resoio --help | `resoio mic` | Microphone | Python → Resonite | Stream audio into Resonite as a virtual mic. | | `resoio drive` | Locomotion | Python → Resonite | Interactive WASD driving (`--sprint` / `--look-rate` / `--no-wait`). | | `resoio grabber` | Grabber | unary | Grab at the desktop cursor ray hit point, then operate the held / equipped item (desktop mode only). The action positional (`grab` / `release` / `state` / `use` / `unuse` / `click` / `equip` / `dequip` / `interactive`) is required; `--hand` / `--radius` / `--button {primary,secondary}` work before or after it. `use` holds a button down until `unuse` (left-click aligns / activates a tool); `click` is press+release; both take `--strength` (analog primary press pressure 0..1, default 1.0, e.g. BrushTool/Pen, ignored for the secondary button); `equip` / `dequip` handle tools. | -| `resoio display` | Display | unary | `get` prints the current snapshot; `set` applies a partial config (`-W/--width`, `-H/--height`, `-F/--max-fps` — at least one required) and prints the post-apply snapshot. | +| `resoio display` | Display | unary | `get` prints the current snapshot; `set` applies a partial config and prints the post-apply snapshot. Resolution can be a preset (`hd`/`fhd`/`qhd`/`uhd`) or `WIDTHxHEIGHT` with an optional `@FPS` suffix (e.g. `resoio display set fhd@30`); the `-W/--width`, `-H/--height`, `-F/--max-fps` flags override the spec field by field (at least one of the spec or a flag is required). | | `resoio world` | World | unary | List / open worlds and sessions. | | `resoio context-menu` | ContextMenu | unary | Open / select the radial menu. | | `resoio dash` | Dash | unary | Drive the ESC dash overlay. | @@ -154,9 +154,12 @@ resoio world sessions --format json | jq '.[].name' resoio session users list --format json | jq '.[].user_name' resoio contact list --filter requests --format json | jq '.[].username' -# Read the display settings, then cap the background fps +# Read the display settings, then set the resolution / fps resoio display get -resoio display set --max-fps 30 +resoio display set fhd # preset -> 1920x1080 +resoio display set 1280x720@60 # explicit WIDTHxHEIGHT@FPS +resoio display set fhd -F 144 # preset resolution, fps overridden by -F +resoio display set --max-fps 30 # just cap the background fps (no resolution) # Aim with the held cursor, grab at the ray hit point, then release resoio cursor center diff --git a/python/src/resoio/cli/display.py b/python/src/resoio/cli/display.py index 8fdeefa..ffa3c6c 100644 --- a/python/src/resoio/cli/display.py +++ b/python/src/resoio/cli/display.py @@ -3,9 +3,11 @@ Nested subcommands mirror ``resoio world``: a ``display`` parent parser holds the ``get`` / ``set`` leaves, each with the shared ``-s/--socket`` parent re-attached (argparse does not inherit it) and its own handler -set via ``set_defaults(func=...)``. ``set`` applies a partial config and -then prints the post-apply snapshot best-effort: the engine applies the -config on its own thread, so the snapshot may briefly lag the request. +set via ``set_defaults(func=...)``. ``set`` applies a partial config — a +resolution preset / ``WIDTHxHEIGHT[@FPS]`` positional and/or ``-W/-H/-F`` +flags (the flags override the spec) — and then prints the post-apply +snapshot best-effort: the engine applies the config on its own thread, so +the snapshot may briefly lag the request. """ from __future__ import annotations @@ -14,6 +16,13 @@ from resoio.cli import output +_RESOLUTION_PRESETS: dict[str, tuple[int, int]] = { + "hd": (1280, 720), + "fhd": (1920, 1080), + "qhd": (2560, 1440), + "uhd": (3840, 2160), +} + def register( subparsers: argparse._SubParsersAction[argparse.ArgumentParser], # pyright: ignore[reportPrivateUsage] @@ -52,6 +61,66 @@ def _register_get( parser.set_defaults(func=_run_get) +def _parse_spec(raw: str) -> tuple[int, int, float | None]: + """Parse a ``display set`` resolution spec into ``(width, height, fps)``. + + The spec is ``[@]`` where ```` is either a + case-insensitive preset name (``hd`` / ``fhd`` / ``qhd`` / ``uhd``) or an + explicit ``WIDTHxHEIGHT`` (e.g. ``1280x720``), and the optional ``@`` + suffix sets the background fps cap. ``fps`` is ``None`` when ``@`` is + omitted, so the caller can tell "no fps requested" from an explicit value. + The ``-W/-H/-F`` flags override the parsed fields in :func:`_run_set`. + + Examples: + ``fhd`` -> ``(1920, 1080, None)`` + ``fhd@30`` -> ``(1920, 1080, 30.0)`` + ``1280x720@60`` -> ``(1280, 720, 60.0)`` + + Raises: + argparse.ArgumentTypeError: on an unknown preset, malformed + ``WIDTHxHEIGHT``, non-positive dimensions, or a non-positive / + non-numeric ``@fps``. + """ + resolution, sep, fps_text = raw.partition("@") + + fps: float | None = None + if sep: + try: + fps = float(fps_text) + except ValueError: + raise argparse.ArgumentTypeError( + f"invalid spec {raw!r}: expected a number after '@'" + ) from None + if fps <= 0.0: + raise argparse.ArgumentTypeError( + f"invalid spec {raw!r}: fps after '@' must be positive" + ) + + key = resolution.lower() + if key in _RESOLUTION_PRESETS: + width, height = _RESOLUTION_PRESETS[key] + return width, height, fps + + width_text, x_sep, height_text = key.partition("x") + if not x_sep: + presets = "/".join(_RESOLUTION_PRESETS) + raise argparse.ArgumentTypeError( + f"invalid resolution {resolution!r}: expected a preset " + f"({presets}) or WIDTHxHEIGHT" + ) + try: + width, height = int(width_text), int(height_text) + except ValueError: + raise argparse.ArgumentTypeError( + f"invalid resolution {resolution!r}: WIDTHxHEIGHT must be integers" + ) from None + if width <= 0 or height <= 0: + raise argparse.ArgumentTypeError( + f"invalid resolution {resolution!r}: width and height must be positive" + ) + return width, height, fps + + def _register_set( subs: argparse._SubParsersAction[argparse.ArgumentParser], # pyright: ignore[reportPrivateUsage] common: argparse.ArgumentParser, @@ -62,9 +131,24 @@ def _register_set( parents=[common, fmt], help="Apply a partial display config, then print the snapshot.", description=( - "Apply a partial display config. Omitted flags are sent as the " - "proto3 default (0 / 0.0), which the server treats as 'leave " - "unchanged'. At least one flag is required." + "Apply a partial display config. Resolution can be given as a " + "positional spec (a preset hd/fhd/qhd/uhd or WIDTHxHEIGHT, with an " + "optional @FPS suffix); the -W/-H/-F flags override the matching " + "spec field. Omitted fields are sent as the proto3 default " + "(0 / 0.0), which the server treats as 'leave unchanged'. At least " + "one of the spec or a flag is required." + ), + ) + parser.add_argument( + "spec", + nargs="?", + type=_parse_spec, + default=None, + metavar="RESOLUTION", + help=( + "Resolution preset (hd/fhd/qhd/uhd) or WIDTHxHEIGHT, with an " + "optional @FPS suffix (e.g. 'fhd', '1280x720', 'fhd@30'). The " + "-W/-H/-F flags override the matching field." ), ) parser.add_argument( @@ -125,20 +209,35 @@ async def _run_set(args: argparse.Namespace) -> int: # Deferred to keep `resoio --help` and shell completion fast. from resoio.display import DisplayClient - if args.width is None and args.height is None and args.max_fps is None: + if ( + args.spec is None + and args.width is None + and args.height is None + and args.max_fps is None + ): set_parser: argparse.ArgumentParser = args._set_parser set_parser.error( - "at least one of -W/--width, -H/--height, -F/--max-fps is required" + "at least one of RESOLUTION, -W/--width, -H/--height, -F/--max-fps " + "is required" ) + # The spec supplies base values; each flag overrides its field. Fields left + # unset by both collapse to 0 / 0.0 (proto3 default = server-side "leave + # unchanged"). Explicit `--max-fps 0` is forwarded as-is. + spec_width, spec_height, spec_fps = ( + args.spec if args.spec is not None else (0, 0, None) + ) + width = args.width if args.width is not None else spec_width + height = args.height if args.height is not None else spec_height + if args.max_fps is not None: + max_fps = args.max_fps + elif spec_fps is not None: + max_fps = spec_fps + else: + max_fps = 0.0 + async with DisplayClient(args.socket) as client: - # Unset flags collapse to 0 / 0.0 (proto3 default = server-side - # "leave unchanged"). Explicit `--max-fps 0` is forwarded as-is. - await client.apply( - width=args.width if args.width is not None else 0, - height=args.height if args.height is not None else 0, - max_fps=args.max_fps if args.max_fps is not None else 0.0, - ) + await client.apply(width=width, height=height, max_fps=max_fps) info = await client.get() _emit_info(info.width, info.height, info.max_fps, args.format) return 0 diff --git a/python/tests/resoio/cli/test_display.py b/python/tests/resoio/cli/test_display.py index 236fdb2..9101198 100644 --- a/python/tests/resoio/cli/test_display.py +++ b/python/tests/resoio/cli/test_display.py @@ -1,3 +1,4 @@ +import argparse import json from pathlib import Path @@ -12,6 +13,7 @@ DisplayState, ) from resoio.cli import _amain, _build_parser +from resoio.cli.display import _parse_spec class _FakeDisplay(DisplayBase): @@ -76,6 +78,71 @@ def test_socket_flag_parses_on_both_get_and_set_leaves(tmp_path: Path): assert a.socket == sock +def test_set_positional_preset_parses_to_resolution_tuple(): + """A bare preset spec lands as ``(width, height, None)`` (no fps).""" + parser = _build_parser() + args = parser.parse_args(["display", "set", "fhd"]) + assert args.spec == (1920, 1080, None) + + +def test_set_positional_spec_with_fps_parses_full_tuple(): + parser = _build_parser() + args = parser.parse_args(["display", "set", "1280x720@60"]) + assert args.spec == (1280, 720, 60.0) + + +def test_set_invalid_spec_is_rejected_at_parse_time(): + """A malformed spec fails in the ``type`` callable -> argparse exit 2.""" + parser = _build_parser() + with pytest.raises(SystemExit) as excinfo: + parser.parse_args(["display", "set", "1280"]) + assert excinfo.value.code == 2 + + +# =========================================================================== +# _parse_spec unit tests: preset / WxH / @fps grammar and rejection. +# =========================================================================== + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("hd", (1280, 720, None)), + ("fhd", (1920, 1080, None)), + ("qhd", (2560, 1440, None)), + ("uhd", (3840, 2160, None)), + ("FHD", (1920, 1080, None)), # preset names are case-insensitive + ("1280x720", (1280, 720, None)), + ("2560X1440", (2560, 1440, None)), # the 'x' separator too + ("fhd@30", (1920, 1080, 30.0)), + ("1280x720@59.94", (1280, 720, 59.94)), + ], +) +def test_parse_spec_accepts_valid_specs( + raw: str, expected: tuple[int, int, float | None] +): + assert _parse_spec(raw) == expected + + +@pytest.mark.parametrize( + "raw", + [ + "1280", # no preset, no 'x' separator + "foo", # unknown preset + "1280x0", # non-positive dimension + "0x720", + "1280x-720", + "fhd@0", # non-positive fps + "fhd@-5", + "fhd@abc", # non-numeric fps + "fhd@", # empty fps + ], +) +def test_parse_spec_rejects_invalid_specs(raw: str): + with pytest.raises(argparse.ArgumentTypeError): + _parse_spec(raw) + + # =========================================================================== # Behavior tests: real UDS grpclib server + fake Display handler. # =========================================================================== @@ -215,6 +282,89 @@ async def test_set_explicit_max_fps_zero_counts_as_a_flag_and_forwards_zero( await server.wait_closed() +# =========================================================================== +# Positional spec behavior: presets / WxH@FPS resolve to the apply config, +# and -W/-H/-F override the spec field by field. +# =========================================================================== + + +async def test_set_preset_applies_resolution_and_leaves_fps_unchanged( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +): + """``set uhd`` sends the preset's width/height; with no @fps and no -F the + fps goes out as 0 (proto3 "leave unchanged"), so the snapshot keeps it.""" + socket_path = tmp_path / "rio-display.sock" + fake = _FakeDisplay(DisplayState(width=800, height=600, max_fps=30.0)) + server = Server([fake]) + await server.start(path=str(socket_path)) + try: + monkeypatch.setenv("RESONITE_IO_SOCKET", str(socket_path)) + args = _build_parser().parse_args(["display", "set", "uhd"]) + rc = await _amain(args) + assert rc == 0 + assert fake.last_apply is not None + assert fake.last_apply.width == 3840 + assert fake.last_apply.height == 2160 + assert fake.last_apply.max_fps == 0.0 + out = capsys.readouterr().out.strip() + assert out == "width=3840 height=2160 max_fps=30.0" + finally: + server.close() + await server.wait_closed() + + +async def test_set_preset_with_fps_suffix_forwards_fps( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +): + socket_path = tmp_path / "rio-display.sock" + fake = _FakeDisplay(DisplayState(width=800, height=600, max_fps=30.0)) + server = Server([fake]) + await server.start(path=str(socket_path)) + try: + monkeypatch.setenv("RESONITE_IO_SOCKET", str(socket_path)) + args = _build_parser().parse_args(["display", "set", "fhd@144"]) + rc = await _amain(args) + assert rc == 0 + assert fake.last_apply is not None + assert fake.last_apply.width == 1920 + assert fake.last_apply.height == 1080 + assert fake.last_apply.max_fps == 144.0 + finally: + server.close() + await server.wait_closed() + + +async def test_set_flags_override_spec_field_by_field( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +): + """``fhd@30 -W 1024 -F 144``: -W overrides the width and -F overrides the + @fps, while the height falls back to the preset (1080).""" + socket_path = tmp_path / "rio-display.sock" + fake = _FakeDisplay(DisplayState(width=800, height=600, max_fps=30.0)) + server = Server([fake]) + await server.start(path=str(socket_path)) + try: + monkeypatch.setenv("RESONITE_IO_SOCKET", str(socket_path)) + args = _build_parser().parse_args( + ["display", "set", "fhd@30", "-W", "1024", "-F", "144"] + ) + rc = await _amain(args) + assert rc == 0 + assert fake.last_apply is not None + assert fake.last_apply.width == 1024 + assert fake.last_apply.height == 1080 + assert fake.last_apply.max_fps == 144.0 + finally: + server.close() + await server.wait_closed() + + # =========================================================================== # "set without flags" rejection. The spec pins SystemExit code 2; whether # the implementation rejects at parse time or at dispatch is not part of