CursorをCursorで遊ぶ

Last Updated on 2026年6月30日 by num46

マウスカーソルの先が常に動いている方向を向く(傾く)ようにするにはどうしたらいい? Pythonを使って記述してほしい
とAgentモードでCursorのAIに聞いたら自動で作ってくれた。
AgentモードだとCursorのIncluded Usage(プラン枠内の使用量?)がすぐ貯まる。今回の使用量が4%だった。画像も作ってもらったからかもしれない。
対して以前ソフトを作った際、Askモードで出力されたコードをコピペしながらCursorAIを使った場合にはあまり貯まらないことが分かった。(結構使ったが1~2%くらい。画像は特に作ってもらっていない。)
プランは$20/月のプロプランのヤツ。

環境はWindows 10 Pro(64bit)

"""
Windows: directional mouse cursor using Win32 API (PNG + tip hotspot).

PNG image loader version. Press Esc to exit.
"""

from __future__ import annotations

import argparse
import ctypes
import math
import sys
from ctypes import wintypes
from pathlib import Path

from PIL import Image

user32 = ctypes.windll.user32
gdi32 = ctypes.windll.gdi32
kernel32 = ctypes.windll.kernel32

SCRIPT_DIR = Path(__file__).parent
DEFAULT_CURSOR_PNG = SCRIPT_DIR / "cursor_sample.png"

SOURCE_SIZE = 48
SOURCE_ROTATION_CENTER = (24, 24)
SOURCE_TIP = (46, 24)

class ICONINFO(ctypes.Structure):
    _fields_ = [
        ("fIcon", wintypes.BOOL),
        ("xHotspot", wintypes.DWORD),
        ("yHotspot", wintypes.DWORD),
        ("hbmMask", wintypes.HBITMAP),
        ("hbmColor", wintypes.HBITMAP),
    ]


class BITMAPINFOHEADER(ctypes.Structure):
    _fields_ = [
        ("biSize", wintypes.DWORD),
        ("biWidth", wintypes.LONG),
        ("biHeight", wintypes.LONG),
        ("biPlanes", wintypes.WORD),
        ("biBitCount", wintypes.WORD),
        ("biCompression", wintypes.DWORD),
        ("biSizeImage", wintypes.DWORD),
        ("biXPelsPerMeter", wintypes.LONG),
        ("biYPelsPerMeter", wintypes.LONG),
        ("biClrUsed", wintypes.DWORD),
        ("biClrImportant", wintypes.DWORD),
    ]


class BITMAPINFO(ctypes.Structure):
    _fields_ = [("bmiHeader", BITMAPINFOHEADER), ("bmiColors", wintypes.DWORD * 3)]


class POINT(ctypes.Structure):
    _fields_ = [("x", wintypes.LONG), ("y", wintypes.LONG)]


class MSLLHOOKSTRUCT(ctypes.Structure):
    _fields_ = [
        ("pt", POINT),
        ("mouseData", wintypes.DWORD),
        ("flags", wintypes.DWORD),
        ("time", wintypes.DWORD),
        ("dwExtraInfo", ctypes.c_size_t),
    ]


WH_MOUSE_LL = 14
WM_MOUSEMOVE = 0x0200
PM_REMOVE = 0x0001

if ctypes.sizeof(ctypes.c_void_p) == 8:
    LRESULT = ctypes.c_longlong
else:
    LRESULT = ctypes.c_long

HOOKPROC = ctypes.WINFUNCTYPE(
    LRESULT,
    ctypes.c_int,
    wintypes.WPARAM,
    wintypes.LPARAM,
)


def _configure_win32_api() -> None:
    """64-bit Windows で LPARAM/WPARAM のオーバーフローを防ぐ。"""
    user32.CallNextHookEx.argtypes = [
        wintypes.HHOOK,
        ctypes.c_int,
        wintypes.WPARAM,
        wintypes.LPARAM,
    ]
    user32.CallNextHookEx.restype = LRESULT

    user32.SetWindowsHookExW.argtypes = [
        ctypes.c_int,
        HOOKPROC,
        wintypes.HINSTANCE,
        wintypes.DWORD,
    ]
    user32.SetWindowsHookExW.restype = wintypes.HHOOK

    user32.UnhookWindowsHookEx.argtypes = [wintypes.HHOOK]
    user32.UnhookWindowsHookEx.restype = wintypes.BOOL


_configure_win32_api()

OCR_NORMAL = 32512
SPI_SETCURSORS = 0x0057


def get_cursor_size() -> int:
    """DPI に合わせたカーソル画像サイズ(96 DPI 基準で 32px)。"""
    try:
        dpi = user32.GetDpiForSystem()
    except (AttributeError, OSError):
        hdc = user32.GetDC(None)
        try:
            dpi = gdi32.GetDeviceCaps(hdc, 88)  # LOGPIXELSX
        finally:
            user32.ReleaseDC(None, hdc)
    return max(32, round(32 * dpi / 96))


def normalize_angle(angle: float) -> float:
    """角度を -180〜180 度に正規化する。"""
    angle = (angle + 180) % 360 - 180
    return angle


def angle_for_cache(angle: float, step: float) -> int:
    """キャッシュ用に 0〜359 度へ量子化する。"""
    positive = angle % 360
    if positive < 0:
        positive += 360
    return int(round(positive / step) * step) % 360




def scale_point(point: tuple[int, int], from_size: int, to_size: int) -> tuple[int, int]:
    scale = to_size / from_size
    return (int(round(point[0] * scale)), int(round(point[1] * scale)))


def load_cursor_image(
    png_path: Path,
    target_size: int,
    source_size: int = SOURCE_SIZE,
    source_rotation_center: tuple[int, int] = SOURCE_ROTATION_CENTER,
    source_tip: tuple[int, int] = SOURCE_TIP,
) -> tuple[Image.Image, tuple[int, int], tuple[int, int]]:
    if not png_path.is_file():
        raise FileNotFoundError(f"Cursor PNG not found: {png_path}")
    img = Image.open(png_path).convert("RGBA")
    if img.width != img.height:
        raise ValueError(f"Cursor PNG must be square: {png_path}")
    src = source_size if source_size > 0 else img.width
    if img.width != target_size:
        img = img.resize((target_size, target_size), Image.Resampling.LANCZOS)
    rotation_center = scale_point(source_rotation_center, src, target_size)
    tip_pt = scale_point(source_tip, src, target_size)
    return img, rotation_center, tip_pt


def rotate_arrow(
    base: Image.Image, angle: float, rotation_center: tuple[int, int]
) -> Image.Image:
    """進行方向 angle(度)に矢印を向ける。expand 後に中央へ配置。"""
    size = base.size[0]
    rotated = base.rotate(
        -angle,
        resample=Image.BICUBIC,
        center=rotation_center,
        expand=True,
    )
    canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
    offset_x = (size - rotated.width) // 2
    offset_y = (size - rotated.height) // 2
    canvas.paste(rotated, (offset_x, offset_y), rotated)
    return canvas



def hotspot_for_angle(
    angle_deg: int,
    rotation_center: tuple[int, int],
    tip: tuple[int, int],
) -> tuple[int, int]:
    """回転後の矢印先端をホットスポット座標として計算する。"""
    cx, cy = rotation_center
    tx, ty = tip
    rad = math.radians(angle_deg)
    ox, oy = tx - cx, ty - cy
    hx = cx + ox * math.cos(rad) - oy * math.sin(rad)
    hy = cy + ox * math.sin(rad) + oy * math.cos(rad)
    return (int(round(hx)), int(round(hy)))



def pil_to_hcursor(pil_image: Image.Image, hotspot: tuple[int, int]) -> int:
    """Create an HCURSOR from a PIL RGBA image."""
    width, height = pil_image.size
    rgba = pil_image.convert("RGBA")

    pixels = bytearray()
    for y in range(height - 1, -1, -1):
        for x in range(width):
            r, g, b, a = rgba.getpixel((x, y))
            pixels.extend((b, g, r, a))

    bmi = BITMAPINFO()
    bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
    bmi.bmiHeader.biWidth = width
    bmi.bmiHeader.biHeight = height
    bmi.bmiHeader.biPlanes = 1
    bmi.bmiHeader.biBitCount = 32
    bmi.bmiHeader.biCompression = 0

    color_bits = ctypes.c_void_p()
    hbm_color = gdi32.CreateDIBSection(
        None,
        ctypes.byref(bmi),
        0,
        ctypes.byref(color_bits),
        None,
        0,
    )
    if not hbm_color:
        raise OSError("CreateDIBSection (color) failed")

    color_buf = (ctypes.c_ubyte * len(pixels)).from_buffer_copy(pixels)
    ctypes.memmove(color_bits, color_buf, len(pixels))

    and_row_bytes = ((width + 31) // 32) * 4
    and_mask = bytes(and_row_bytes * height)

    bmi_and = BITMAPINFO()
    bmi_and.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
    bmi_and.bmiHeader.biWidth = width
    bmi_and.bmiHeader.biHeight = height
    bmi_and.bmiHeader.biPlanes = 1
    bmi_and.bmiHeader.biBitCount = 1
    bmi_and.bmiHeader.biCompression = 0

    and_bits = ctypes.c_void_p()
    hbm_and = gdi32.CreateDIBSection(
        None,
        ctypes.byref(bmi_and),
        0,
        ctypes.byref(and_bits),
        None,
        0,
    )
    if not hbm_and:
        gdi32.DeleteObject(hbm_color)
        raise OSError("CreateDIBSection (mask) failed")

    and_buf = (ctypes.c_ubyte * len(and_mask)).from_buffer_copy(and_mask)
    ctypes.memmove(and_bits, and_buf, len(and_mask))

    icon_info = ICONINFO()
    icon_info.fIcon = False
    icon_info.xHotspot = hotspot[0]
    icon_info.yHotspot = hotspot[1]
    icon_info.hbmMask = hbm_and
    icon_info.hbmColor = hbm_color

    hcursor = user32.CreateIconIndirect(ctypes.byref(icon_info))

    gdi32.DeleteObject(hbm_color)
    gdi32.DeleteObject(hbm_and)

    if not hcursor:
        raise OSError("CreateIconIndirect failed")

    return hcursor


def destroy_hcursor(hcursor: int) -> None:
    if hcursor:
        user32.DestroyIcon(hcursor)


class CursorCache:
    """Cache HCURSOR handles per quantized angle (tip hotspot)."""

    def __init__(
        self,
        base_image: Image.Image,
        rotation_center: tuple[int, int],
        tip: tuple[int, int],
        step_degrees: float = 5.0,
    ) -> None:
        self._base = base_image
        self._rotation_center = rotation_center
        self._tip = tip
        self._step = step_degrees
        self._cache: dict[int, int] = {}

    def _quantize(self, angle: float) -> int:
        return angle_for_cache(angle, self._step)

    def get(self, angle: float) -> int:
        key = self._quantize(angle)
        if key not in self._cache:
            rotated = rotate_arrow(self._base, key, self._rotation_center)
            hotspot = hotspot_for_angle(key, self._rotation_center, self._tip)
            self._cache[key] = pil_to_hcursor(rotated, hotspot)
        return self._cache[key]

    def destroy_all(self) -> None:
        for handle in self._cache.values():
            destroy_hcursor(handle)
        self._cache.clear()


def restore_default_cursor() -> None:
    """レジストリのポインタースキームから標準カーソルを再読み込みする。"""
    user32.SystemParametersInfoW(SPI_SETCURSORS, 0, None, 0)


def apply_system_cursor(hcursor: int) -> None:
    """システム全体の標準カーソルを差し替える(全アプリで有効)。"""
    copy = user32.CopyIcon(hcursor)
    if copy:
        user32.SetSystemCursor(copy, OCR_NORMAL)


def is_escape_pressed() -> bool:
    VK_ESCAPE = 0x1B
    return bool(user32.GetAsyncKeyState(VK_ESCAPE) & 0x8000)


class DirectionalCursorApp:
    """マウス移動を監視し、進行方向に合わせてシステムカーソルを回転させる。"""

    def __init__(
        self,
        png_path: Path,
        cursor_size: int | None = None,
        source_size: int = SOURCE_SIZE,
        source_rotation_center: tuple[int, int] = SOURCE_ROTATION_CENTER,
        source_tip: tuple[int, int] = SOURCE_TIP,
        smooth: float = 0.18,
        angle_step: float = 2.0,
        move_threshold: float = 1.0,
        velocity_smooth: float = 0.35,
    ) -> None:
        size = cursor_size if cursor_size is not None else get_cursor_size()
        self.cursor_size = size
        self.png_path = png_path
        base, rotation_center, tip = load_cursor_image(
            png_path, size, source_size, source_rotation_center, source_tip
        )
        self.cache = CursorCache(
            base, rotation_center, tip, step_degrees=angle_step
        )
        self.smooth = smooth
        self.move_threshold = move_threshold
        self.velocity_smooth = velocity_smooth
        self.target_angle = 0.0
        self.display_angle = 0.0
        self.velocity_x = 0.0
        self.velocity_y = 0.0
        self.current_hcursor: int = 0
        self._applied_hcursor: int = 0
        self.prev = POINT()
        user32.GetCursorPos(ctypes.byref(self.prev))
        self.current_hcursor = self.cache.get(self.display_angle)
        self._hook = HOOKPROC(self._mouse_hook_proc)
        self._hook_handle = None

    def _apply_cursor_if_changed(self) -> None:
        if self.current_hcursor != self._applied_hcursor:
            apply_system_cursor(self.current_hcursor)
            self._applied_hcursor = self.current_hcursor

    def _smooth_toward_target(self) -> None:
        diff = normalize_angle(self.target_angle - self.display_angle)
        if abs(diff) < 0.05:
            return

        step = diff * self.smooth
        # 1 フレームあたりの最大回転量を制限してガクつきを抑える
        max_step = 12.0
        if step > max_step:
            step = max_step
        elif step < -max_step:
            step = -max_step

        self.display_angle = normalize_angle(self.display_angle + step)
        self.current_hcursor = self.cache.get(self.display_angle)

    def _update_motion(self, pt: POINT) -> None:
        dx = pt.x - self.prev.x
        dy = pt.y - self.prev.y
        distance = math.hypot(dx, dy)

        if distance >= self.move_threshold:
            alpha = self.velocity_smooth
            self.velocity_x = self.velocity_x * (1 - alpha) + dx * alpha
            self.velocity_y = self.velocity_y * (1 - alpha) + dy * alpha

            speed = math.hypot(self.velocity_x, self.velocity_y)
            if speed >= self.move_threshold * 0.5:
                self.target_angle = math.degrees(
                    math.atan2(self.velocity_y, self.velocity_x)
                )

        self.prev = pt
        self._smooth_toward_target()

    def _mouse_hook_proc(
        self,
        n_code: int,
        w_param: wintypes.WPARAM,
        l_param: wintypes.LPARAM,
    ) -> LRESULT:
        # 角度更新はメインループに一本化(二重更新による向きのブレを防ぐ)
        return user32.CallNextHookEx(self._hook_handle, n_code, w_param, l_param)

    def _install_hook(self) -> bool:
        self._hook_handle = user32.SetWindowsHookExW(
            WH_MOUSE_LL,
            self._hook,
            None,
            0,
        )
        if self._hook_handle:
            return True

        error = kernel32.GetLastError()
        print(
            f"Warning: SetWindowsHookExW failed (error {error}). "
            "Falling back to polling mode.",
            file=sys.stderr,
        )
        return False

    def _main_loop(self) -> None:
        while not is_escape_pressed():
            pt = POINT()
            user32.GetCursorPos(ctypes.byref(pt))
            self._update_motion(pt)
            self._apply_cursor_if_changed()
            kernel32.Sleep(4)

            msg = wintypes.MSG()
            while user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, PM_REMOVE):
                user32.TranslateMessage(ctypes.byref(msg))
                user32.DispatchMessageW(ctypes.byref(msg))

    def run(self) -> None:
        self._install_hook()
        self._apply_cursor_if_changed()
        print(
            f"Directional cursor (PNG) started\n"
            f"  image : {self.png_path}\n"
            f"  size  : {self.cursor_size}px\n"
            f"Press Esc to exit."
        )

        try:
            self._main_loop()
        finally:
            if self._hook_handle:
                user32.UnhookWindowsHookEx(self._hook_handle)
            restore_default_cursor()
            self.cache.destroy_all()
            print("Default cursor restored. Exiting.")


def run(
    png_path: Path | None = None,
    cursor_size: int | None = None,
    smooth: float = 0.18,
    angle_step: float = 2.0,
    move_threshold: float = 1.0,
    velocity_smooth: float = 0.35,
) -> None:
    if sys.platform != "win32":
        print("Windows only.", file=sys.stderr)
        sys.exit(1)

    DirectionalCursorApp(
        png_path=png_path or DEFAULT_CURSOR_PNG,
        cursor_size=cursor_size,
        smooth=smooth,
        angle_step=angle_step,
        move_threshold=move_threshold,
        velocity_smooth=velocity_smooth,
    ).run()


def main() -> None:
    parser = argparse.ArgumentParser(description="Directional cursor (PNG version)")
    parser.add_argument(
        "--png",
        type=Path,
        default=DEFAULT_CURSOR_PNG,
        help=f"Cursor PNG path (default: {DEFAULT_CURSOR_PNG.name})",
    )
    args = parser.parse_args()
    run(png_path=args.png)


if __name__ == "__main__":
    main()
cursor_sample.png
Cursor5.png

cursor_sample.pngはどんな感じにするか相談するでもなくAIがなんか勝手に作ってくれた。Cursor5.pngは自作。Windows風のカーソルを作って、とAIに頼んだら以下のようなカーソルが作られた。

cursor_windows.png

C:\Python\directional_cursorのようにフォルダを作る。Pythonをインストールしてcmd開いて作ったフォルダに移動して

py -m venv venv

で仮想環境作成。

venv\Scripts\activate

で仮想環境に入って

pip install pillow

でPillow(PIL)をインストール。

C:\Python\directional_cursorの中にdirectional_cursor_png.py、cursor_sample.png、Cursor5.pngを入れる。PNG画像は48×48で、右向きである必要がある。

py directional_cursor_png.py

で起動。Escキーで終了。

py directional_cursor_png.py --png cursor5.png

で別の画像(cursor5.png)をカーソルに出来る。

コンパネ→マウス→マウスのプロパティのポインター→デザイン変更して再度元に戻す。これで小さくなったポインターが元に戻った。
もしくは再起動とのこと。