WinBounce: Poruszaj oknami na ekranie Windowsa

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, że w jakiś "Dzień Programisty", "Prima Aprilis", czy inny podobny "szajs" spróbuję poruszyć ten temat. Zainspirowany tym pomysłem udało mi się wczorajszego 1-szego kwietnia nadziergać trochę kodu.

Tak powstał prosty programik (WinBounce) wprawiający okna w ruch po ekranie. Jest jakaś namiastka 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

lub sobie stworzyć zwykły projekt w VS lub innym IDE/kompilatorze, pamiętając 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 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. W istocie program zamiast na pojedynczym HWND operuje na wektorze z listą okien.

Window win;
PrepareWindow(hwnd, win);
 
while (process) {
 
	MoveWindow(win, screen);
	::Sleep(50);
}
 
RestoreWindow(win);

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, to one wykonują zasadniczą "czarna robotę". Stan "używanych" okien trzymany jest w strukturze Window i na tych obiektach funkcje bezpośrednio operują.

Na początku przygotowane jest okno i wypełniana instancję 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 mi dane o oknie. Jeśli okno 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.

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ący parametry 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 zmienić 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 wymagane 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ę:

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 komentarz w kodzie. To jedyna funkcja, która potrafi sama wydedukować system liczbowy, w jakim została zapisana liczba (przy base = 0). Inne, lepsze 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:

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

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

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

Jako, że 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, procesu i wszystkie, 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:

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

Można zauważyć że tryb all także nie działa do końca poprawnie według wstępnych założeń.

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 ;)

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *