diff --git a/scripts/build/build.sh b/scripts/build/build.sh index 3f3e787..596932a 100644 --- a/scripts/build/build.sh +++ b/scripts/build/build.sh @@ -1,10 +1,10 @@ #!/bin/bash cd "$(dirname "$0")/../.." -set -e +set -euo pipefail PY_MAJOR=$(uv run python -c "import sys; print(sys.version_info.major)") PY_MINOR=$(uv run python -c "import sys; print(sys.version_info.minor)") - +UV_VERSION=$(uv run python -c "import winrandr; print(winrandr.__version__)") echo "==> 安装构建依赖..." uv sync --dev @@ -16,9 +16,9 @@ uv run nuitka --clean-cache=all echo "==> 使用 Nuitka 编译为单文件 exe..." if [ "$PY_MAJOR" -ge 3 ] && [ "$PY_MINOR" -ge 13 ]; then echo " (Python ≥ 3.13,使用 zig 编译器)" - uv run nuitka --standalone --onefile --output-dir=dist --output-filename=winrandr.exe --assume-yes-for-downloads winrandr + uv run nuitka --standalone --onefile --output-dir=dist --output-filename=winrandr.exe --company-name=winrandr --product-name=winrandr --file-version="$UV_VERSION" --assume-yes-for-downloads winrandr else - uv run nuitka --standalone --onefile --output-dir=dist --output-filename=winrandr.exe --mingw64 --assume-yes-for-downloads winrandr + uv run nuitka --standalone --onefile --output-dir=dist --output-filename=winrandr.exe --company-name=winrandr --product-name=winrandr --file-version="$UV_VERSION" --mingw64 --assume-yes-for-downloads winrandr fi echo "==> 构建完成!exe 位于 dist/winrandr.exe" diff --git a/scripts/build/clean.sh b/scripts/build/clean.sh index 1d51961..3f926bb 100644 --- a/scripts/build/clean.sh +++ b/scripts/build/clean.sh @@ -1,12 +1,12 @@ #!/bin/bash cd "$(dirname "$0")/../.." -set -e +set -euo pipefail echo "==> 清理 Nuitka 构建缓存..." rm -rf dist/winrandr.build dist/winrandr.dist dist/winrandr.onefile-build 2>/dev/null && echo " dist/ 构建目录已清理" || echo " 部分构建目录正在使用中(跳过)" echo "==> 清理 Python 缓存..." -find . -type d -name __pycache__ -prune -exec rm -rf {} + 2>/dev/null && echo " __pycache__ 已清理" || true +find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null && echo " __pycache__ 已清理" || true find . -type f -name "*.pyc" -delete 2>/dev/null && echo " .pyc 已清理" || true echo "==> 清理测试和工具缓存..." diff --git a/tests/features/test_resolution.py b/tests/features/test_resolution.py index 6a22c28..8d9c03f 100644 --- a/tests/features/test_resolution.py +++ b/tests/features/test_resolution.py @@ -12,6 +12,29 @@ def test_enumerate_modes_empty(): assert modes == [] +def test_enumerate_modes_fractional_refresh_rates(): + """QDC 报告的 59.94 应与 EnumDisplaySettings 的 60 匹配。""" + from ctypes import sizeof + + from winrandr.features.resolution import enumerate_modes + from winrandr.win32.structures import DEVMODE + + def fake_enum(_name, _i, dm_ptr): + dm_ptr._obj.dmSize = sizeof(DEVMODE) + if _i == 0: + dm_ptr._obj.dmPelsWidth = 1920 + dm_ptr._obj.dmPelsHeight = 1080 + dm_ptr._obj.dmDisplayFrequency = 60 + return 1 + return 0 # 无更多模式 + + with patch("winrandr.features.resolution._EnumDisplaySettings", side_effect=fake_enum): + # cur_refresh=59.94 模拟 QDC 报告分数刷新率 + modes = enumerate_modes("DISPLAY1", 1920, 1080, 59.94) + assert len(modes) == 1 + assert modes[0].is_current is True + + def test_enumerate_modes_skips_invalid(): """无效尺寸(frequency=0)的 mode 应跳过(覆盖 33->44 分支)。""" from ctypes import sizeof diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index a4fb020..3f258b0 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -167,6 +167,50 @@ def test_rel_position_short_name(): assert "DISPLAY2" not in args[0] +def test_get_display_props_connector_type(): + """get_display_props 应包含 connector_type。""" + from winrandr.win32.structures import DISPLAYCONFIG_PATH_INFO + + paths = (DISPLAYCONFIG_PATH_INFO * 1)() + + def fake_enum(_name, _idx, dd_ptr, _flags): + dd_ptr._obj.DeviceID = r"MONITOR\ABC123" + dd_ptr._obj.StateFlags = 0x05 + return True + + with patch("winrandr.api._EnumDisplayDevices", side_effect=fake_enum): + with patch("winrandr.api.query_all_config", return_value=(paths, None, 1, 0)): + with patch("winrandr.api.get_gdi_name", return_value=r"\\.\DISPLAY1"): + with patch("winrandr.api.get_adapter_name", return_value=""): + with patch("winrandr.api.get_monitor_device_path", return_value=""): + with patch("winrandr.api.get_connector_type", return_value="HDMI"): + with patch("winrandr.api.get_edid", return_value=None): + props = get_display_props(r"\\.\DISPLAY1") + assert props.get("connector_type") == "HDMI" + + +def test_get_display_props_no_connector_type(): + """get_display_props 当连接类型为空时不应包含 connector_type。""" + from winrandr.win32.structures import DISPLAYCONFIG_PATH_INFO + + paths = (DISPLAYCONFIG_PATH_INFO * 1)() + + def fake_enum(_name, _idx, dd_ptr, _flags): + dd_ptr._obj.DeviceID = r"MONITOR\ABC123" + dd_ptr._obj.StateFlags = 0x05 + return True + + with patch("winrandr.api._EnumDisplayDevices", side_effect=fake_enum): + with patch("winrandr.api.query_all_config", return_value=(paths, None, 1, 0)): + with patch("winrandr.api.get_gdi_name", return_value=r"\\.\DISPLAY1"): + with patch("winrandr.api.get_adapter_name", return_value=""): + with patch("winrandr.api.get_monitor_device_path", return_value=""): + with patch("winrandr.api.get_connector_type", return_value=""): + with patch("winrandr.api.get_edid", return_value=None): + props = get_display_props(r"\\.\DISPLAY1") + assert "connector_type" not in props + + def test_list_displays_refresh_from_qdc(): """vSyncFreq.Denominator 非零时从 QDC 直接获取刷新率。""" from winrandr.win32.constants import ( @@ -286,11 +330,13 @@ def fake_enum(_name, _idx, dd_ptr, _flags): with patch("winrandr.api.get_gdi_name", return_value=r"\\.\DISPLAY1"): with patch("winrandr.api.get_adapter_name", return_value="GPU0"): with patch("winrandr.api.get_monitor_device_path", return_value=r"\\.\DEVICE123"): - with patch("winrandr.api.get_edid", return_value={"edid_name": "TestMonitor"}): - props = get_display_props(r"\\.\DISPLAY1") - assert props["device_id"] == r"MONITOR\ABC123\SomeExtra" - assert "attached" in props["state_flags"] - assert "primary" in props["state_flags"] - assert props["adapter"] == "GPU0" - assert props["monitor_path"] == r"\\.\DEVICE123" - assert props["edid_name"] == "TestMonitor" + with patch("winrandr.api.get_connector_type", return_value="HDMI"): + with patch("winrandr.api.get_edid", return_value={"edid_name": "TestMonitor"}): + props = get_display_props(r"\\.\DISPLAY1") + assert props["device_id"] == r"MONITOR\ABC123\SomeExtra" + assert "attached" in props["state_flags"] + assert "primary" in props["state_flags"] + assert props["adapter"] == "GPU0" + assert props["monitor_path"] == r"\\.\DEVICE123" + assert props.get("connector_type") == "HDMI" + assert props["edid_name"] == "TestMonitor" diff --git a/tests/unit/cli/test_cli.py b/tests/unit/cli/test_cli.py index 4311bcd..a20f96a 100644 --- a/tests/unit/cli/test_cli.py +++ b/tests/unit/cli/test_cli.py @@ -147,6 +147,38 @@ def test_apply_aliases_y_only(): assert ns.reflect == "y" +def test_apply_aliases_reflect_x_wins_over_y(): + """--reflect x 与 -y 同时使用时,--reflect 优先,-y 被忽略,记录警告。""" + ns = _ns(reflect="x", x=False, y=True) + _apply_aliases(ns) + assert ns.reflect == "x" + + +def test_apply_aliases_reflect_xy_wins_over_x(caplog): + """--reflect xy 与 -x 同时使用时,--reflect 优先,-x 被忽略,记录警告。""" + ns = _ns(reflect="xy", x=True, y=False) + _apply_aliases(ns) + assert ns.reflect == "xy" + assert len(caplog.records) == 1 + assert "被忽略" in caplog.records[0].message + + +def test_apply_aliases_reflect_y_wins_over_x(caplog): + """--reflect y 与 -x 同时使用时,--reflect 优先,-x 被忽略,记录警告。""" + ns = _ns(reflect="y", x=True, y=False) + _apply_aliases(ns) + assert ns.reflect == "y" + assert len(caplog.records) == 1 + assert "被忽略" in caplog.records[0].message + + +def test_apply_aliases_reflect_normal_with_x(): + """--reflect normal(即清除)后 -x 仍生效,因为 normal 视为「未设置」。""" + ns = _ns(reflect="normal", x=True, y=False) + _apply_aliases(ns) + assert ns.reflect == "x" + + def test_is_mod_op_true(): """修改类操作应返回 True。""" for attr in _MOD_OP_ATTRS: diff --git a/tests/unit/win32/test_connector_type.py b/tests/unit/win32/test_connector_type.py new file mode 100644 index 0000000..c103dcf --- /dev/null +++ b/tests/unit/win32/test_connector_type.py @@ -0,0 +1,74 @@ +"""Tests for win32/utils.py get_connector_type function.""" + +import ctypes +from ctypes import byref, sizeof +from unittest.mock import patch + +from winrandr.win32.constants import DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME +from winrandr.win32.structures import ( + DISPLAYCONFIG_PATH_INFO, + DISPLAYCONFIG_TARGET_DEVICE_NAME, +) +from winrandr.win32.utils import get_connector_type + + +def _make_fake_api(target_flags: int): + """创建模拟 _DisplayConfigGetDeviceInfo 的工厂函数,返回指定 targetFlags。""" + + def fake(header_ptr): + tname = DISPLAYCONFIG_TARGET_DEVICE_NAME() + tname.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME + tname.header.size = sizeof(DISPLAYCONFIG_TARGET_DEVICE_NAME) + tname.targetFlags = target_flags + ctypes.memmove(header_ptr, byref(tname), sizeof(DISPLAYCONFIG_TARGET_DEVICE_NAME)) + return 0 + + return fake + + +def _make_path(): + return (DISPLAYCONFIG_PATH_INFO * 1)()[0] + + +def test_connector_type_hdmi(): + path = _make_path() + with patch("winrandr.win32.utils._DisplayConfigGetDeviceInfo", side_effect=_make_fake_api(0x08)): + assert get_connector_type(path) == "HDMI" + + +def test_connector_type_dp(): + path = _make_path() + with patch("winrandr.win32.utils._DisplayConfigGetDeviceInfo", side_effect=_make_fake_api(0x10)): + assert get_connector_type(path) == "DisplayPort" + + +def test_connector_type_usb_c(): + path = _make_path() + with patch("winrandr.win32.utils._DisplayConfigGetDeviceInfo", side_effect=_make_fake_api(0x80)): + assert get_connector_type(path) == "USB-C" + + +def test_connector_type_vga(): + path = _make_path() + with patch("winrandr.win32.utils._DisplayConfigGetDeviceInfo", side_effect=_make_fake_api(0x04)): + assert get_connector_type(path) == "VGA" + + +def test_connector_type_dvi(): + path = _make_path() + with patch("winrandr.win32.utils._DisplayConfigGetDeviceInfo", side_effect=_make_fake_api(0x02)): + assert get_connector_type(path) == "DVI" + + +def test_connector_type_unknown(): + """未知的标志位应返回空字符串。""" + path = _make_path() + with patch("winrandr.win32.utils._DisplayConfigGetDeviceInfo", side_effect=_make_fake_api(0x40)): + assert get_connector_type(path) == "" + + +def test_connector_type_api_failure(): + """API 调用失败时返回空字符串。""" + path = _make_path() + with patch("winrandr.win32.utils._DisplayConfigGetDeviceInfo", return_value=1): + assert get_connector_type(path) == "" diff --git a/winrandr/__init__.py b/winrandr/__init__.py index 7286e0a..98323f8 100644 --- a/winrandr/__init__.py +++ b/winrandr/__init__.py @@ -3,6 +3,7 @@ __version__ = "0.7.1" __all__ = [ + "GDI_DEVICE_PREFIX", "ROTATION_FROM_NAME", "ROTATION_NAMES", "DisplayInfo", @@ -22,6 +23,7 @@ "set_auto", "set_brightness", "set_gamma", + "set_night_mode", "set_noprimary", "set_off", "set_position", @@ -42,6 +44,7 @@ set_auto, set_brightness, set_gamma, + set_night_mode, set_noprimary, set_off, set_position, @@ -62,4 +65,4 @@ preview_save, save_profile, ) -from winrandr.win32.constants import ROTATION_FROM_NAME, ROTATION_NAMES +from winrandr.win32.constants import GDI_DEVICE_PREFIX, ROTATION_FROM_NAME, ROTATION_NAMES diff --git a/winrandr/api.py b/winrandr/api.py index 7c6c89f..0937943 100644 --- a/winrandr/api.py +++ b/winrandr/api.py @@ -4,6 +4,7 @@ "get_display_props", "list_displays", "list_providers", + "set_night_mode", "set_position_relative", ] @@ -15,6 +16,7 @@ identify_display, set_brightness, set_gamma, + set_night_mode, ) from winrandr.features.layout import ( # noqa: F401 set_noprimary, @@ -50,6 +52,7 @@ from winrandr.win32.structures import DISPLAY_DEVICE from winrandr.win32.utils import ( get_adapter_name, + get_connector_type, get_friendly_name_via_enum, get_gdi_name, get_monitor_device_path, @@ -200,16 +203,7 @@ def get_display_props(device_name: str) -> dict[str, str]: config = query_all_config() if config: paths, _modes, path_count, _mode_count = config - for i in range(path_count): - gdi_name = get_gdi_name(paths[i]) - if gdi_name == device_name: - adapter = get_adapter_name(paths[i]) - if adapter: - props["adapter"] = adapter - mon_path = get_monitor_device_path(paths[i]) - if mon_path: - props["monitor_path"] = mon_path - break + props.update(_get_config_props(device_name, paths, path_count)) edid = get_edid(device_name) if edid: @@ -218,6 +212,26 @@ def get_display_props(device_name: str) -> dict[str, str]: return props +def _get_config_props(device_name: str, paths: tuple, path_count: int) -> dict[str, str]: + """从 QDC 路径列表中查找并收集指定显示器的配置属性。""" + props = {} + for i in range(path_count): + gdi_name = get_gdi_name(paths[i]) + if gdi_name != device_name: + continue + adapter = get_adapter_name(paths[i]) + if adapter: + props["adapter"] = adapter + mon_path = get_monitor_device_path(paths[i]) + if mon_path: + props["monitor_path"] = mon_path + conn_type = get_connector_type(paths[i]) + if conn_type: + props["connector_type"] = conn_type + break + return props + + def list_providers() -> list[dict[str, str | int]]: """列举 GPU 适配器。""" providers = [] diff --git a/winrandr/cli/__init__.py b/winrandr/cli/__init__.py index c312e28..09baaf2 100644 --- a/winrandr/cli/__init__.py +++ b/winrandr/cli/__init__.py @@ -23,6 +23,7 @@ _handle_identify, _handle_listmodes, _handle_mode, + _handle_night_mode, _handle_off, _handle_pos, _handle_preferred, @@ -41,6 +42,7 @@ preview_save, save_profile, ) +from winrandr.win32.constants import GDI_DEVICE_PREFIX def _handle_providers(args: Namespace) -> None: @@ -160,10 +162,12 @@ def _handle_global_ops(args: Namespace) -> None: if not targets: _fail("没有已连接的显示器") for t in targets: - short = t.name.replace("\\\\.\\", "") + short = t.name.replace(GDI_DEVICE_PREFIX, "") args.output = short if args.brightness is not None: _handle_brightness(args, t.name) + if args.night_mode is not None: + _handle_night_mode(args, t.name) if args.gamma is not None: _handle_gamma(args, t.name) @@ -186,6 +190,8 @@ def _dispatch_display_ops(args: Namespace, device_name: str) -> None: # noqa: C _handle_off(args, device_name) if args.brightness is not None: _handle_brightness(args, device_name) + if args.night_mode is not None: + _handle_night_mode(args, device_name) if args.reflect: _handle_reflect(args, device_name) if args.gamma: @@ -235,7 +241,7 @@ def main() -> None: # noqa: C901 # 调度型函数,分支复杂度本质 return # 全局操作:亮度/伽马可不带 --output,应用到所有已连接显示器 - _global_only_attrs = frozenset({"brightness", "gamma"}) + _global_only_attrs = frozenset({"brightness", "gamma", "night_mode"}) if not args.output: mod_attrs = {a for a in _MOD_OP_ATTRS if getattr(args, a, None)} if mod_attrs and mod_attrs.issubset(_global_only_attrs): diff --git a/winrandr/cli/common.py b/winrandr/cli/common.py index 1353507..5d8d0da 100644 --- a/winrandr/cli/common.py +++ b/winrandr/cli/common.py @@ -6,6 +6,9 @@ from argparse import Namespace from winrandr.api import list_displays, list_providers +from winrandr.win32.constants import GDI_DEVICE_PREFIX + +logger = logging.getLogger(__name__) def _setup_logging() -> None: @@ -27,7 +30,7 @@ def _setup_logging() -> None: def _normalize_name(name: str) -> str: n = name.strip().upper() - prefix = "\\\\.\\" + prefix = GDI_DEVICE_PREFIX if n.startswith(prefix): return n if n.startswith("DISPLAY"): @@ -49,9 +52,9 @@ def _msg(args: Namespace, text: str) -> None: def _list_available_displays() -> str: - names = {d.name.replace("\\\\.\\", "") for d in list_displays()} + names = {d.name.replace(GDI_DEVICE_PREFIX, "") for d in list_displays()} for p in list_providers(): - sn = p["name"].replace("\\\\.\\", "") + sn = p["name"].replace(GDI_DEVICE_PREFIX, "") if sn.startswith("DISPLAY"): names.add(sn) @@ -70,6 +73,7 @@ def _sort_key(n: str) -> tuple[int, int | str]: "preferred", "off", "brightness", + "night_mode", "reflect", "gamma", "left_of", @@ -97,12 +101,17 @@ def _apply_aliases(args: Namespace) -> None: args.listmonitors = True if args.reflect == "normal": args.reflect = None - if args.x and args.y: - args.reflect = "xy" - elif args.x: - args.reflect = "x" - elif args.y: - args.reflect = "y" + # --reflect 已显式设置时跳过 -x/-y 的覆盖 + if args.reflect is not None: + if args.x or args.y: + logger.warning("检测到 --reflect 与 -x/-y 同时使用,-x/-y 将被忽略") + else: + if args.x and args.y: + args.reflect = "xy" + elif args.x: + args.reflect = "x" + elif args.y: + args.reflect = "y" def _check_relative_mutex(args: Namespace) -> None: diff --git a/winrandr/cli/handlers.py b/winrandr/cli/handlers.py index 067fca0..e6f1b55 100644 --- a/winrandr/cli/handlers.py +++ b/winrandr/cli/handlers.py @@ -9,6 +9,7 @@ set_auto, set_brightness, set_gamma, + set_night_mode, set_off, set_position, set_position_relative, @@ -20,7 +21,7 @@ ) from winrandr.cli.common import _fail, _list_available_displays, _msg, _normalize_name from winrandr.formatter import fmt_modes -from winrandr.win32.constants import ROTATION_FROM_NAME +from winrandr.win32.constants import GDI_DEVICE_PREFIX, ROTATION_FROM_NAME def _handle_auto(args: Namespace, dn: str) -> None: @@ -123,6 +124,22 @@ def _handle_brightness(args: Namespace, dn: str) -> None: _msg(args, f"已将 {args.output} 亮度设为 {args.brightness}") +def _handle_night_mode(args: Namespace, dn: str) -> None: + val = args.night_mode + if val in ("light", "medium", "heavy"): + strength = {"light": 0.2, "medium": 0.5, "heavy": 0.8}[val] + else: + try: + strength = float(val) + except (ValueError, TypeError): + _fail("--night-mode 参数无效", ["使用 light(20%)/medium(50%)/heavy(80%) 或数值 0.0-1.0"]) + if not 0.0 <= strength <= 1.0: + _fail("夜间模式强度必须在 0.0-1.0 之间") + if not args.dry_run and not set_night_mode(dn, strength): + _fail("设置夜间模式失败", ["某些驱动或远程桌面环境不支持伽马校正", "使用 --verbose 查看详细日志"]) + _msg(args, f"已将 {args.output} 的蓝光强度减弱 {int(strength * 100)}%") + + def _handle_reflect(args: Namespace, dn: str) -> None: if args.reflect in ("x", "y"): _fail(f"单轴镜像翻转(-{args.reflect})在 Windows 上无标准 API 支持") @@ -164,7 +181,7 @@ def _handle_listmodes(args: Namespace, as_json: bool = False) -> None: print(json.dumps([asdict(d) for d in displays], indent=2, ensure_ascii=False)) return for d in displays: - sn = d.name.replace("\\\\.\\", "") + sn = d.name.replace(GDI_DEVICE_PREFIX, "") print(f"{sn}:") lines: list[str] = [] fmt_modes(lines, d.modes) diff --git a/winrandr/features/resolution.py b/winrandr/features/resolution.py index 138e1ab..b79114f 100644 --- a/winrandr/features/resolution.py +++ b/winrandr/features/resolution.py @@ -39,7 +39,7 @@ def enumerate_modes(gdi_name: str, cur_width: int, cur_height: int, cur_refresh: break if dm.dmPelsWidth > 0 and dm.dmPelsHeight > 0 and dm.dmDisplayFrequency > 0: rr = float(dm.dmDisplayFrequency) - is_cur = dm.dmPelsWidth == cur_width and dm.dmPelsHeight == cur_height and abs(rr - cur_refresh) < 0.01 + is_cur = dm.dmPelsWidth == cur_width and dm.dmPelsHeight == cur_height and abs(rr - cur_refresh) < 0.5 is_pref = bool(has_reg and dm.dmPelsWidth == reg_dm.dmPelsWidth and dm.dmPelsHeight == reg_dm.dmPelsHeight) modes.append( DisplayMode( diff --git a/winrandr/formatter.py b/winrandr/formatter.py index 7cecad2..dfab54c 100644 --- a/winrandr/formatter.py +++ b/winrandr/formatter.py @@ -3,13 +3,13 @@ from collections import defaultdict from winrandr.models import DisplayInfo, DisplayMode -from winrandr.win32.constants import ROTATION_MAP, ROTATION_NAMES +from winrandr.win32.constants import GDI_DEVICE_PREFIX, ROTATION_MAP, ROTATION_NAMES _ALL_ROTATIONS = "normal left inverted right" def short_name(name: str) -> str: - return name.replace("\\\\.\\", "").strip() + return name.replace(GDI_DEVICE_PREFIX, "").strip() def format_monitor_list(displays: list[DisplayInfo]) -> str: diff --git a/winrandr/profiles.py b/winrandr/profiles.py index d6ab331..3e543b8 100644 --- a/winrandr/profiles.py +++ b/winrandr/profiles.py @@ -15,6 +15,7 @@ set_resolution, set_rotation, ) +from winrandr.win32.constants import GDI_DEVICE_PREFIX logger = logging.getLogger(__name__) @@ -55,7 +56,7 @@ def preview_save() -> list[str]: return ["未检测到活动显示器"] lines = [f"将保存以下 {len(displays)} 台显示器的配置:"] for d in displays: - sn = d.name.replace("\\\\.\\", "") + sn = d.name.replace(GDI_DEVICE_PREFIX, "") primary = " (主)" if d.is_primary else "" rot = f" {d.rotation}°" if d.rotation else "" lines.append( @@ -118,7 +119,7 @@ def diff_profile(name: str) -> list[str]: continue found = True changes = [] - sn = dn.replace("\\\\.\\", "") + sn = dn.replace(GDI_DEVICE_PREFIX, "") if (cur.position_x, cur.position_y) != (dc["x"], dc["y"]): changes.append(f"位置 ({cur.position_x},{cur.position_y})→({dc['x']},{dc['y']})") if cur.rotation != dc["rotation"]: @@ -202,7 +203,7 @@ def list_profiles() -> list[dict[str, str | int | list[str]]]: displays_raw = info.get("displays", []) display_summary = [] for d in displays_raw: - sn = d.get("name", "?").replace("\\\\.\\", "") + sn = d.get("name", "?").replace(GDI_DEVICE_PREFIX, "") if "width" in d and "height" in d: display_summary.append(f"{sn}({d['width']}x{d['height']})") else: diff --git a/winrandr/win32/__init__.py b/winrandr/win32/__init__.py index a422ff6..2f53bfe 100644 --- a/winrandr/win32/__init__.py +++ b/winrandr/win32/__init__.py @@ -1,3 +1,3 @@ """Win32 底层显示 API:常量、结构体、函数绑定、工具函数。""" -# 各子模块独立导入,不在此处 re-export +from winrandr.win32.constants import GDI_DEVICE_PREFIX # noqa: F401 diff --git a/winrandr/win32/constants.py b/winrandr/win32/constants.py index 82e6b77..7024897 100644 --- a/winrandr/win32/constants.py +++ b/winrandr/win32/constants.py @@ -81,3 +81,22 @@ DISPLAY_DEVICE_DISCONNECTED = 0x00000040 DISPLAY_DEVICE_REMOTE = 0x04000000 DISPLAY_DEVICE_MODESPRUNED = 0x08000000 + +# Windows GDI 设备路径前缀(如 \\.\DISPLAY1) +GDI_DEVICE_PREFIX = "\\\\.\\" + +# DISPLAYCONFIG_TARGET_DEVICE_NAME targetFlags 连接类型位标志 +DISPLAYCONFIG_TARGET_FLAGS_DVI = 0x02 +DISPLAYCONFIG_TARGET_FLAGS_VGA = 0x04 +DISPLAYCONFIG_TARGET_FLAGS_HDMI = 0x08 +DISPLAYCONFIG_TARGET_FLAGS_DP = 0x10 +DISPLAYCONFIG_TARGET_FLAGS_USB_C = 0x80 + +# 连接类型映射表:targetFlags 低 8 位 -> 连接类型名称 +CONNECTOR_TYPE_MAP: dict[int, str] = { + 0x02: "DVI", + 0x04: "VGA", + 0x08: "HDMI", + 0x10: "DisplayPort", + 0x80: "USB-C", +} diff --git a/winrandr/win32/utils.py b/winrandr/win32/utils.py index 9b5889b..c546f96 100644 --- a/winrandr/win32/utils.py +++ b/winrandr/win32/utils.py @@ -15,6 +15,7 @@ _SetDisplayConfig, ) from winrandr.win32.constants import ( + CONNECTOR_TYPE_MAP, DISPLAYCONFIG_DEVICE_INFO_GET_ADAPTER_NAME, DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME, @@ -85,6 +86,17 @@ def get_monitor_device_path(path: DISPLAYCONFIG_PATH_INFO) -> str: return tname.monitorDevicePath if ret == 0 else "" +def get_connector_type(path: DISPLAYCONFIG_PATH_INFO) -> str: + """获取路径对应的连接器类型(HDMI/DisplayPort/USB-C 等)。""" + tname = DISPLAYCONFIG_TARGET_DEVICE_NAME() + tname.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME + tname.header.size = sizeof(DISPLAYCONFIG_TARGET_DEVICE_NAME) + tname.header.adapterId = path.targetInfo.adapterId + tname.header.id = path.targetInfo.id + ret = _DisplayConfigGetDeviceInfo(byref(tname.header)) + return "" if ret != 0 else CONNECTOR_TYPE_MAP.get(tname.targetFlags & 0xFF, "") + + def query_active_config() -> tuple | None: """查询当前活动显示配置(内部缓存,apply_config 成功后自动失效)。""" global _QDC_CACHE