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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 6 additions & 3 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand Down
129 changes: 114 additions & 15 deletions python/src/resoio/cli/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]
Expand Down Expand Up @@ -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 ``<resolution>[@<fps>]`` where ``<resolution>`` is either a
case-insensitive preset name (``hd`` / ``fhd`` / ``qhd`` / ``uhd``) or an
explicit ``WIDTHxHEIGHT`` (e.g. ``1280x720``), and the optional ``@<fps>``
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,
Expand All @@ -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(
Expand Down Expand Up @@ -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
150 changes: 150 additions & 0 deletions python/tests/resoio/cli/test_display.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import argparse
import json
from pathlib import Path

Expand All @@ -12,6 +13,7 @@
DisplayState,
)
from resoio.cli import _amain, _build_parser
from resoio.cli.display import _parse_spec


class _FakeDisplay(DisplayBase):
Expand Down Expand Up @@ -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.
# ===========================================================================
Expand Down Expand Up @@ -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
Expand Down
Loading