WinBounce: Poruszaj oknami na ekranie Windowsa

tech • 1279 słów • 7 minut czytania

Gdy szlajając się po Internecie przypadkiem natrafiłem na wpis Juli “Challenge: Write a bouncy window manager” zaświtała mi w głowie myśl, że w sumie to dla zabawy mógłbym zrobić coś podobnego na Windowsa. Postanowiłem wtedy, że w jakiś “Dzień Programisty”, “Prima Aprilis”, czy inny podobny “szajs” spróbuję poruszyć ten temat. Zainspirowany tym pomysłem wczorajszego 1-szego kwietnia udało mi się nadziergać trochę kodu.

twitter.com/malcompl/status/1245406565139730433

Tak powstał prosty programik (WinBounce) wprawiający w ruch okna na ekranie. Jest jakaś uproszczona możliwość specyfikowania, które to okna mają zostać ożywione, co widać na poniższym “usage” programu:

Bounces your windows on the screen!

Usage: winbounce <param>
Available param value:
 <hwnd>   like w<hwnd>
 w<hwnd>  only the specified window
 p<pid>   all windows in the specified process
 s<id>    all windows in the specified screen / not supported yet /
 all      all windows / only on default screen now /

Copyrigth (c) 2020 by Marcin 'Malcom' Malich <me@malcom.pl>
Made for fun on April Fools' Day 2020 ;)

Pełne źródła programu dostępne są w moim śmietnikowym mal-code-repo. Tam też wrzuciłem tymczasowo skompilowanego exeka, gdyby ktoś chciał się pobawić, a nie miał pod ręką jakiegoś kompilatora.

Kod można zbudować z palca z konsoli (mając kompilator od MS/VC++):

cl /EHsc /MT /GL /O2 /W4 /std:c++17 winbounce.cpp user32.lib

Ewentualnie stworzyć sobie zwykły projekt w VS, innym IDE lub użyć ulubionego kompilatora, pamiętając tylko o ustawieniu używanego standardu języka C++ na C++17.

Jednym z moich założeń było tak skonstruować program, aby po jego zakończeniu dotknięte przez niego okna wróciły na swoje miejsce - pozycja i stan min/max. Niestety nie jest zachowany z-order okien, co chyba i tak nie ma większego znaczenia. Co prawda dałoby się to zrobić, ale wymagałoby za dużo komplikacji.

Działanie aplikacji można zapisać w uproszczonym pseudokodzie, poniżej przykład dla jednego okna.

Window win;
PrepareWindow(hwnd, win);

while (process) {

	MoveWindow(win, screen);
	::Sleep(50);
}

RestoreWindow(win);

W istocie program zamiast na pojedynczym HWND operuje na wektorze z listą okien.

W Windowsie do manipulowania oknem wystarczy znać jego uchwyt (HWND). Nie trzeba być twórcą okna, można dobrać się do dowolnego okna w systemie. Większość funkcji po prostu wysyła odpowiednie komunikaty do okna/procesu (procedury obsługi okna). To z kolei ułatwia zabawę z zewnętrznego procesu, bo w innym przypadku takie zapętlenie przesuwania okna nie dałoby spodziewanego efektu - zablokowanie wątku obsługującego GUI (pętlę obsługi komunikatów).

Przechodząc do krótkiego omówienia mojej implementacji, warto zacząć od tych funkcji przedstawionych w powyższym pseudokodzie, bo to one wykonują “czarną robotę”. Stan “używanych” okien trzymany jest w strukturze Window i na tych obiektach funkcje bezpośrednio operują.

Na początku przygotowywane jest okno i inicjalizowana instancja Window dla podanego uchwytu okna:

void PrepareWindow(HWND hWnd, Window& win) {

	WINDOWPLACEMENT wndpl;
	wndpl.length = sizeof(WINDOWPLACEMENT);
	::GetWindowPlacement(hWnd, &wndpl);

	win.hWnd = hWnd;
	win.OldPosition.x = wndpl.rcNormalPosition.left;
	win.OldPosition.y = wndpl.rcNormalPosition.top;
	win.OldShowState = wndpl.showCmd;
	win.Rect = wndpl.rcNormalPosition;
	win.Vel.x = GenRandom();
	win.Vel.y = GenRandom();

	if (win.OldShowState != SW_NORMAL && win.OldShowState != SW_RESTORE)
		::ShowWindow(hWnd, SW_RESTORE);
}

Użyta funkcja WinAPI GetWindowPlacement pozwala za jednym zamachem pobrać potrzebne dane o oknie. Jeśli było zminimalizowane lub zmaksymalizowane to zostaje przywrócone do “normalnych” wymiarów. Pole Vel określa szybkość poruszania się okna po ekranie.

Wartość ta jest używana do aktualizacji aktualnego położenia okna (Rect), co dokonuje funkcja MoveWindow odpalana w stałych odstępach czasu w głównej pętli.

void MoveWindow(Window& win, const RECT& screen) {

	win.Rect.left   += win.Vel.x;
	win.Rect.right  += win.Vel.x;
	win.Rect.top    += win.Vel.y;
	win.Rect.bottom += win.Vel.y;

	if (win.Rect.left <= screen.left || win.Rect.right  >= screen.right)
		win.Vel.x *= -1;
	if (win.Rect.top  <= screen.top  || win.Rect.bottom >= screen.bottom)
		win.Vel.y *= -1;

	SetWindowPos(win.hWnd, win.Rect.left, win.Rect.top);
}

Funkcja ta dostaje także rozmiar ekranu (lub dowolnego pola roboczego), po którym to może poruszać się dane okno. Jeśli okno dotrze do krawędzi obszaru to “odbija” się i “leci” w przeciwnym kierunku.

Na koniec przywracana jest początkowa pozycja i stan okna w RestoreWindow.

void RestoreWindow(Window& win) {

	SetWindowPos(win.hWnd, win.OldPosition.x, win.OldPosition.y);

	if (win.OldShowState != SW_NORMAL && win.OldShowState != SW_RESTORE)
		::ShowWindow(win.hWnd, win.OldShowState);
}

Widoczna funkcja SetWindowPos używana do zmiany położenia okien to prosty “alias” na systemowe SetWindowPos, redukujące ilość parametrów do tych niezbędnych.

inline void SetWindowPos(HWND hWnd, LONG cx, LONG cy) {

	::SetWindowPos(
		hWnd,
		HWND_TOP,
		cx,
		cy,
		0,
		0,
		SWP_NOZORDER | SWP_NOSIZE
	);
}

Czemu akurat ona a nie prostszy MoveWindow czy coś innego? Z wygody, bo taki “move” o dziwo wymaga także podania wymiarów, a ja chcę tylko zmieniać położenie…

Mała ciekawostka, na Aero z flagą SWP_NOREDRAW okno i tak się “odświeżało”, pewnie przez te wszystkie transparency na elementach “non-client” okna, bo przy testowej maszynce bez włączonego Aero potrzebne było odmalowywanie.

Działanie głównej części kodu jest jasne, tylko jak się dorwać do uchwytów okien? Za to odpowiada funkcja GrabWindows szukająca i zwracająca listę dostępnych okien na podstawie sparsowanego argumentu przekazanego do programu.

using Hwnds = std::vector<HWND>;
Hwnds GrabWindows(const Param& param);

Program przyjmuje jeden argument, który po sparsowaniu reprezentowany jest przez strukturkę Param:

struct Param {

	enum Types {
		Unknown = 0,
		Win,
		Proc,
		Screen,
		All,
	};

	Types Type = Unknown;
	unsigned int Value = 0;
};

Samym parsowaniem nie ma sensu się zajmować. Zostało ono ręcznie napisane na kilku “ifach” (ParseParam). Zastanawiającą kwestią może być użycie strtoul do parsowania int-a, które ma paskudną metodę zwracania błędu. Tutaj akurat to bez znaczenia, bo 0 lub ULONG_MAX i tak zostanie odrzucony w dalszej części programu przy walidacji uchwytu okna…

// only strto* funcs support auto-detection of the numeric base
param.Value = std::strtoul(arg.data(), nullptr, 0);

Przewagę tej funkcji jasno tłumaczy dołączony do kodu komentarz. To jedyna funkcja, która potrafi sama wydedukować system liczbowy w jakim została zapisana liczba (przy base = 0). Alternatywy wymagają już jawnego podania base-a. A mi zależało, aby w parametrach programu obsługiwane były przynajmniej wartości decymalne i szesnastkowe.

Wracając do pobierania listy okien, wspomniana GrabWindows zależnie od argumentu enumeruje okna za pomocą dostępnej w WinAPI funkcji EnumWindows. Całość logiki związanej z walidacją i filtrowaniem okien znajduje się w callback-u EnumWindowsProc odpalanym przez enumeratora.

Do przekazywania większej ilości danych miedzy tymi funkcjami wykorzystana jest prosta struktura “contextu”:

struct EnumWindowsProcContext {
	const Param& Param;
	Hwnds& Hwnds;
};

Przez co enumerowanie okien sprowadza się do prostego wywołania systemowej funkcji:

Hwnds hwnds;
EnumWindowsProcContext ctx{ param, hwnds };
// [...]
::EnumWindows(EnumWindowsProc, reinterpret_cast<LPARAM>(&ctx));

W pierwszych liniach EnumWindowsProc znajduje się walidacja okien w postaci:

// omit invalid and invisible windows
if (!::IsWindow(hwnd) || !::IsWindowVisible(hwnd))
	return TRUE;

Dla trybu w (ze specyfikowanym uchwytem okna) ta procedura jest także wywoływana bezpośrednio w GrabWindows, aby przekazana od użytkownika wartość przeleciała przez powyższy kod walidujący (bez jego powtarzania w innych miejscach) i finalnie wylądowała w wektorze poprawnych uchwytów.

// push the hwnd by EnumProc to validate
EnumWindowsProc(reinterpret_cast<HWND>(param.Value), reinterpret_cast<LPARAM>(&ctx));

Obecna implementacja jest nieco ograniczona i wspiera tylko kilka trybów - pojedyncze okno, okna procesu oraz wszystkie okna aktualnej sesji, więc sama logika jest prosta:

BOOL CALLBACK EnumWindowsProc(HWND hwnd, LPARAM lParam) {

	[...]

	auto ctx = reinterpret_cast<EnumWindowsProcContext*>(lParam);

	if (ctx->Param.Type == Param::Proc) {

		DWORD pid;
		::GetWindowThreadProcessId(hwnd, &pid);

		if (pid == ctx->Param.Value)
			ctx->Hwnds.push_back(hwnd);

	} else {
		// screen or all
		ctx->Hwnds.push_back(hwnd);
	}

	return TRUE;
}

A to z kolei świadczy o kilku niedoróbkach. Po analizie powyższego kodu i zerknięciu do funkcji pobierającej wymiary ekranu (GetDesktopRect) można zauważyć że tryb all także nie działa do końca poprawnie według wstępnych założeń.

inline RECT GetDesktopRect() {
	RECT rect;
	::GetWindowRect(::GetDesktopWindow(), &rect);
	return rect;
}

Zwracane są wymiary domyślnego ekranu. I to one używane są do ograniczania pola roboczego, po którym poruszane będą wszystkie okna. Także w przypadku wielo-monitorowego stanowiska, zależnie od konfiguracji okna mogą poruszać się w ograniczonym obszarze definiowanym przez wymiary domyślnego monitora. Niestety nie mam teraz pod ręką dostępu do dodatkowego monitora do testów.

Zapewne wymaga to poprawki, która pewnie, o ile to w ogóle nastąpi, pojawi się wraz z wsparciem trybu s. Ale biorąc pod uwagę, że cały program powstał głównie dla zabawy… niczego nie można wykluczyć. Najważniejsze, że wisi w todo. Podobnie jak dodanie jakieś sensownej obsługi błędów, którą tutaj totalnie olałem ;)

Komentarze (0)

Dodaj komentarz

/dozwolony markdown/

/nie zostanie opublikowany/