Kreslení na plochu v C++ – háky

V tomto článku si ukážeme, jak lze kreslit na plochu a jak můžeme překreslit tlačítko „Start“ vlastním výtvorem.
K tomuto článku mě inspiroval dotaz na diskusním fóru builder.cz „..Jak kreslit na plochu Windows..“. Samo o sobě jednorázové vykreslení grafiky nebo textu do tohoto okna není – jak za chvíli uvidíme – zase až tak složité. Jde o nalezení příslušného okna, tedy získání jeho handle, a pak je nutno podle úsloví „Dejte mi handle okna a ....“ získat kontext zařízení atd.

To si samozřejmě ukážeme, ale navíc je zde problém v případě, že chceme, aby náš výtvor v okně zůstal. Po překreslení okna (desktopu) samozřejmě náš výtvor zmizí. Řešením je použití háku zpráv, ve kterém můžeme zachytávat zprávu WM_PAINT příslušného okna a v tomto okamžiku zavolat vlastní kreslící rutinu.

Trocha teorie o hácích zpráv

Nejprve trochu teorie o hácích zpráv. Háky zpráv, resp. procedury háků, jsou funkce obdobné proceduře okna s tím, že do jedné procedury háku nám chodí zprávy od více oken různých tříd, v extrémním případě všech existujících oken v systému. Dále je třeba vědět, že pokud umístíme proceduru háku do vlastního .exe souboru aplikace, můžeme „chytat“ pouze zprávy oken patřících naší aplikaci. Chceme-li „hákovat“ všechna okna v systému, musíme tuto proceduru háku umístit do zvláštní DLL knihovny. Podrobněji se hákům můžeme věnovat v některém dalším článku, zde proto jen co nestručněji.

Typy háků

Existují různé typy háků. Jedním z nich je hák typu WH_GETMESSAGE. Příslušná hákovací funkce je volána vždy když funkce GetMessage, nebo PeekMessage vezme zprávu z fronty zpráv. Procedura háku GetMsgProc má následující strukturu:

LRESULT CALLBACK GetMsgProc(
  int code,  // kód háku
  WPARAM wParam,  // příznak vyjmutí
  LPARAM lParam  // zpráva
);

Pro nás je nyní důležitý parametr lParam, který obsahuje adresu struktury MSG:

typedef struct tagMSG {
  HWND  hwnd;
  UINT  message;
  WPARAM  wParam;
  LPARAM  lParam;
  DWORD  time;
  POINT  pt;
} MSG, *PMSG;

ve které již – jak vidíte – máme všechny informace o inkriminované zprávě včetně handle okna, jehož se týká.

Dalším typem háku, který dnes použijeme, je WM_CALLWNDPROC. Jeho hákovací funkce je volána vždy, když je poslána zpráva pomocí SendMessage. Samozřejmě že tím není myšleno pouze „uživatelské volání“ SendMessage v programu, ale i to, když systém posílá zprávu oknu některé aplikace. Takto vypadá procedura háku CallWndProc:

LRESULT CALLBACK CallWndProc(
  int  nCode,  // kód háku
  WPARAM  wParam,  // příznak aktuálního procesu
  LPARAM  lParam  // data zprávy
);

Opět nás bude zajímat parametr lParam, který je adresou struktury CWPSTRUCT:

typedef struct tagCWPSTRUCT {
  LPARAM  lParam;
  WPARAM  wParam;
  UINT  message;
  HWND  hwnd;
} CWPSTRUCT, *PCWPSTRUCT;

Ještě se stručně zmíním o háku WH_SHELL a příslušné proceduře háku ShellProc:

LRESULT CALLBACK ShellProc(
  int nCode,
  WPARAM wParam,
  LPARAM lParam
);

Tato procedura je volána v případě nějaké události týkající se shellu. Například pokud je vytvořeno nové hlavní okno (tedy většinou při startu aplikace), když je toto okno zrušeno, dále při změně aktivní aplikace apod.

Podrobněji se k hákům vrátíme, nyní si ukažme jejich použití pro to, co bylo inspirací tohoto článku, tedy kreslení na plochu, a jako „třešničku na dortu“ si ukážeme, že můžeme sami kreslit ono oblíbené tlačítko „Start“. Uděláme z něj třeba tlačítko s nápisem „Stop“.

Vytvoření 2 projektů – EXE a DLL

Pro ukázkovou aplikaci budeme tentokrát potřebovat projekty dva. Vytvoříme si proto nejlépe novou „workspace“, do které přidáme 2 nové projekty:
  • Win32 Dynamic-link library
  • MFC AppWizard (.exe)
Samozřejmě že ten druhý projekt může být třeba Win32 Application. Já jsem pro jednoduchost použil MFC aplikaci založenou na dialogu.

Klepněte pro větší obrázek

Nejdříve se podívejme na DLL knihovnu, která bude obsahovat procedury použitých háků. DLL knihovna má tzv. vstupní bod, jakousi obdobu funkce WinMain, zde nazvanou DllMain.

BOOL WINAPI DllMain(
  INSTANCE hinstDLL,  // handle to the DLL module
  WORD fdwReason,    // reason for calling function
  PVOID lpvReserved  // reserved
);

Parametr fdwReson nám určuje „důvod“, proč k volání funkce došlo. Zatím jen tolik, že v případě načtení knihovny funkcí LoadLibrary má tento parametr hodnotu DLL_PROCESS_ATTACH.

My budeme tuto funkci „potřebovat“ pro nalezení handle oken, která nás budou zajímat, tedy plochy, tlačítka „Start“ a vlastníka tlačítka „Start“, který bude dostávat zprávu WM_DRAWITEM při nutnosti překreslení tohoto tlačítka. Konkrétně tedy napíšeme DllMain třeba takto:

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved)
{
  g_hInst = hinstDLL;
  if ( fdwReason == DLL_PROCESS_ATTACH )
  {
    hwndDesktop = FindWindow("Progman", NULL);
    hwndDesktop = FindWindowEx(hwndDesktop, NULL, "SHELLDLL_DefView", NULL);
    hwndDesktop = FindWindowEx(hwndDesktop, NULL, "SysListView32", NULL);
    hwndStartParent = FindWindow("Shell_TrayWnd", NULL);
    if ( !IsWindow(hwndStartParent) )
    {
      Error("Nelze nalézt předka tlačítka Start");
      return TRUE;
    }
    hwndStart = FindWindowEx(hwndStartParent,  NULL, "Button", NULL);
    if ( hwndStart == NULL )
      Error("Nelze nalézt tlačítko Start");
  }
  return TRUE;
}

Nyní si vytvoříme proceduru háku WH_GETMESSAGE:

LRESULT CALLBACK GetMsgProc(int nCode, WPARAM wParam, LPARAM lParam)
{
  LPMSG lpmsg;
  if ( nCode < 0 )
    return CallNextHookEx(hhGetMsgProc, nCode, wParam, lParam);
  lpmsg = (LPMSG)lParam;
  switch ( lpmsg->message )
  {
    case WM_ERASEBKGND:
    case WM_PAINT:
      if ( lpmsg->hwnd == hwndDesktop )
        DrawToDesktop();
      break;
    case WM_ACTIVATE:
      if ( lpmsg->hwnd == hwndDesktop )
        DrawToDesktop();
      break;
  }
  return CallNextHookEx(hhGetMsgProc, nCode, wParam, lParam);
}

Jak je vidět, při zachycení zpráv WM_PAINT a WM_ACTIVATE musíme nejdříve otestovat, zda jde o okno desktopu, jehož handle máme uložená, a v tomto případě pak voláme naši vlastní kreslící funkci, kde musíme nejdříve získat kontext zařízení HDC desktopu a pak je prostor pro naší tvůrčí iniciativu otevřen. V ukázkovém projektu kreslím elipsu „někam vpravo dolů“ a text do středu desktopu takto:

void DrawToDesktop()
{
  RECT rect;
  GetClientRect(hwndDesktop, &rect);
  HDC hdc;
  hdc = GetDC(hwndDesktop);
  Ellipse(hdc, rect.right - 200, rect.bottom - 200,
    rect.right - 50, rect.bottom - 100);
  COLORREF oldBk = SetBkColor(hdc, 0x0080FFFF);
  COLORREF oldText = SetTextColor(hdc, 0x00000080);
  DrawText(hdc, " Střed všehomíra nachází se zde! ", -1,
    &rect, DT_SINGLELINE | DT_CENTER | DT_VCENTER);
  SetBkColor(hdc, oldBk);
  SetTextColor(hdc, oldText);
  ReleaseDC(hwndDesktop, hdc);
}

Toto všechno zatím je hezké, ale samotné procedury háků jsou mrtvým železem bez jejich aktivace. Po jejich použití je samozřejmě naopak musíme deaktivovat. Jak na to? K aktivaci háků nám slouží funkce SetWindowsHookEx:

HHOOK SetWindowsHookEx(
  int idHook,  // typ háku
  HOOKPROC  lpfn,  // procedura háku
  HINSTANCE  hMod,  // handle instance s procedurou háku
  DWORD  dwThreadId  // identifikátor vlákna
);

Konkrétní použití vidíte v následující funkci, ve které si současně zaktivujeme výše uvedené 3 háky:

BOOL SetHooks()
{
  hhCallWndProc = SetWindowsHookEx(WH_CALLWNDPROC,
    (HOOKPROC)CallWndProc, g_hInst, 0);
  if ( hhCallWndProc == NULL )
  {
    Error("Chyba při SetWindowsHookEx (WH_CALLWNDPROC)");
    return FALSE;
  }
  hhGetMsgProc = SetWindowsHookEx(WH_GETMESSAGE,
    (HOOKPROC)GetMsgProc, g_hInst, 0);
  if ( hhGetMsgProc == NULL )
  {
    Error("Chyba při SetWindowsHookEx (WH_GETMESSAGE)");
    return FALSE;
  }
  hhShellProc = SetWindowsHookEx(WH_SHELL, (HOOKPROC)ShellProc, g_hInst, 0);
  if ( hhShellProc == NULL )
  {
    Error("Chyba při SetWindowsHookEx (WH_SHELL)");
    return FALSE;
  }
  return TRUE;
}

Naopak pro ukončení „hákování“ musíme použít funkci UnhookWindowsHookEx:

BOOL UnhookWindowsHookEx(
  HHOOK  hhk  // handle porcedury háku
);

V naší DLL knihovně opět všechny háky deaktivujeme v jedné funkci:

BOOL RemoveHooks()
{
  if ( !UnhookWindowsHookEx(hhCallWndProc) )
    Error("Chyba při UnhookWindowsHookEx (WH_CALLWNPROC)");
  if ( !UnhookWindowsHookEx(hhGetMsgProc) )
    Error("Chyba při UnhookWindowsHookEx (WH_GETMESSAGE)");
  if ( !UnhookWindowsHookEx(hhShellProc) )
    Error("Chyba při UnhookWindowsHookEx (WH_SHELL)");
  return TRUE;
}

Tyto dvě funkce (SetHooks a RemoveHooks) musíme z DLL knihovny vyexportovat, abychom je mohli zavolat z naší „řídící aplikace“. To provedeme takovýmto uvedením jejich deklarace (nejlépe „někde na začátku“):

extern "C" __declspec(dllexport) BOOL SetHooks();
extern "C" __declspec(dllexport) BOOL RemoveHooks();

Použití funkcí z DLL knihovny

Než si ukážeme ono uživatelské kreslení tlačítka „Start“, odskočíme si nyní do „řídící aplikace“, kde si ukážeme, jak vyvolat tyto funkce v DLL knihovně aktivující háky. Zde si na dialog dáme 2 tlačítka pro spuštění a zastavení háků a dále check-box, jehož zaškrtnutí bude určovat, zda se má během hákování kreslit vlastní tlačítko „Start“. Za běhu bude dialog vypadat takto:

Do třídy dialogu si přidáme členskou proměnou pro uložení handle té načtené DLL knihovny:

HMODULE m_hDLL;

Pro načtení DLL knihovny použijeme funkci LoadLibrary:

HMODULE LoadLibrary(
  LPCTSTR lpFileName  // jméno souboru
);

pro její uvolnění pak použijeme funkci FreeLibrary:

BOOL FreeLibrary(
  HMODULE  hModule  // handle modulu DLL
);

Když máme platný handle DLL knihovny, můžeme funkce, které tato knihovna exportuje, použít v našem programu. Potřebujeme získat jejich adresu pomocí funkce GetProcAddress:

FARPROC GetProcAddress(
  HMODULE hModule,  // handle modulu DLL
  LPCSTR lpProcName  // jméno funkce
);

Nejdříve si tedy nadefinujeme typy a vytvoříme proměnné pro takto získané funkce:

typedef void typedef void tSetHooks MySetHooks;
tRemoveHooks MyRemoveHooks;

Nyní již si můžeme ukázat celou obsluhu příkazu „Spustit háky“:

void CRidiciDlg::OnStartHooks()
{
  if ( m_hDLL != NULL )
  {
    MessageBeep(0);
    return;
  }
  m_hDLL = LoadLibrary("hooks.dll");
  if ( m_hDLL == NULL )
  {
    MessageBox("Nelze načíst DLL", "Chyba", MB_ICONERROR);
    return;
  }
  MySetHooks = (tSetHooks)GetProcAddress(m_hDLL, "SetHooks");
  if ( MySetHooks == NULL)
  {
    MessageBox("Nenalezena adresa funkce v DLL", "Chyba", MB_ICONERROR);
    return;
  }
  MySetHooks();
}

A naopak, jejich zastavení:

void CRidiciDlg::OnStopHooks()
{
  if ( m_hDLL == NULL )
  {
    MessageBeep(0);
    return;
  }
  MyRemoveHooks = (tRemoveHooks)GetProcAddress(m_hDLL, "RemoveHooks");
  if ( MyRemoveHooks == NULL)
  {
    MessageBox("Nenalezena adresa funkce v DLL", "Chyba", MB_ICONERROR);
    return;
  }
  MyRemoveHooks();
  if ( !FreeLibrary(m_hDLL) )
  {
    MessageBox("Nepodařilo se uvolnit knihovnu DLL", "Chyba", MB_ICONERROR);
    return;
  }
  m_hDLL = NULL;
}

Nyní si v řídícím projektu vytvoříme 2 funkce pro nastavení a odebrání příznaku owner-draw tlačítku „Start“. Pouze v případě, že má button vlastnost BS_OWNERDRAW, dostává jeho vlastník zprávu WM_DRAWITEM a my jej pak můžeme (resp. musíme) sami kreslit.

BOOL CRidiciDlg::SetOwnerDraw()
{
  HWND hwnd = ::FindWindow("Shell_TrayWnd", NULL);
  if (hwnd == NULL)
    return FALSE;
  hwnd = ::FindWindowEx(hwnd, NULL, "Button", NULL);
  if ( hwnd == NULL )
    return FALSE;
  LONG_PTR style = GetWindowLongPtr(hwnd, GWL_STYLE);
  style |= BS_OWNERDRAW;
  SetWindowLongPtr(hwnd, GWL_STYLE, style);
  ::RedrawWindow(hwnd, NULL, NULL,
    RDW_ERASE | RDW_INVALIDATE | RDW_ERASENOW);
  return TRUE;
}

BOOL CRidiciDlg::RemoveOwnerDraw()
{
  HWND hwnd = ::FindWindow("Shell_TrayWnd", NULL);
  if ( hwnd == NULL )
    return FALSE;
  hwnd = ::FindWindowEx(hwnd, NULL, "Button", NULL);
  if ( hwnd == NULL )
    return FALSE;
  LONG_PTR style = GetWindowLongPtr(hwnd, GWL_STYLE);
  style &=~ BS_OWNERDRAW;
  SetWindowLongPtr(hwnd, GWL_STYLE, style);
  ::RedrawWindow(hwnd, NULL, NULL,
    RDW_ERASE | RDW_INVALIDATE | RDW_ERASENOW);
  return TRUE;
}

V obou případech je použita funkce SetWindowLongPtr, která s parametrem GWL_STYLE umožňuje změnit styl okna. Tyto funkce budeme volat po každém kliknutí na check-box podle jeho stavu zaškrtnutí:

void CRidiciDlg::OnOwnerdraw()
{
  if ( SendDlgItemMessage(IDC_OWNERDRAW,
    BM_GETCHECK, 0, 0) == BST_CHECKED )
    SetOwnerDraw();
  else
    RemoveOwnerDraw();
}

Toť vše v řídicí aplikaci a nyní zpět k DLL knihovně k realizaci kreslení tlačítka „Start“.

Zprávu WM_DRAWITEM zachytíme v háku typu WM_CALLWNDPROC, neboť tato zpráva je poslána systémem pomocí SendMessage. Vytvoříme si proto takovouto hákovací proceduru:

LRESULT CALLBACK CallWndProc(int nCode, WPARAM wParam, LPARAM lParam)
{
  LPCWPSTRUCT lpcwp;
  if ( nCode < 0 )
    return CallNextHookEx(hhCallWndProc, nCode, wParam, lParam);
  lpcwp = (LPCWPSTRUCT)lParam;
  switch ( lpcwp->message )
  {
    case WM_DRAWITEM:
      if ( hwndStartParent != lpcwp->hwnd )
        break;
      DrawStartButton((LPDRAWITEMSTRUCT)lpcwp->lParam);
  }
  return CallNextHookEx(hhCallWndProc, nCode, wParam, lParam);
}

Funkce DrawStartButton je již věcí naší kreativity. V ukázkovém projektu vypadá takto:

void DrawStartButton(LPDRAWITEMSTRUCT lpdis)
{
  if ( lpdis->hwndItem != hwndStart )
    return;
  HBRUSH hb = CreateSolidBrush(0x000000A0);
  SelectObject(lpdis->hDC, hb);
  Ellipse(lpdis->hDC,
    lpdis->rcItem.left,
    lpdis->rcItem.top,
    lpdis->rcItem.right,
    lpdis->rcItem.bottom);
  SetTextColor(lpdis->hDC, 0x0000FFFF);
  SetBkMode(lpdis->hDC, TRANSPARENT);
  DrawText(lpdis->hDC, "STOP", lstrlen(TEXT("STOP")), &lpdis->rcItem,
    DT_SINGLELINE | DT_CENTER | DT_VCENTER);
  DeleteObject(hb);
}

Kreslíme zde tedy červenou elipsu vyplňující obdélník okna tlačítka „Start“ se žlutým nápisem „STOP“ uprostřed.

Klepněte pro větší obrázek

Na závěr se ještě vrátíme k problému kreslení na desktop trochu podrobněji, protože nic není tak jednoduché, jak se zdá na první pohled. Když si projekt pustíte, zjistíte, že naše kreslení na desktop se provede například při jakémkoli pohybu okna na ploše. Což je samozřejmě v pořádku. Problém však nastane, když minimalizujeme všechna okna přes klávesnici (tedy běžně <Win>+M). Problém bude zřejmě hlavně v tom, že my zprávu WM_PAINT (a WM_ERASEBKGND) „zpracujeme příliš brzo“. Tyto zprávy totiž nejdříve procházejí řetězcem háků (pokud tedy nějaký existuje), kterých může být současně více (proto to volání CallNextHookEx – což je předání zprávy dále v řetězci háků), a pak teprve jsou zařazeny do fronty zpráv okna. Zkusil jsem proto použít ještě třetí zmíněný hák – WH_SHELL – a kreslící funkci volat i v některých jeho případech, jak vidíte ve výpisu:

LRESULT CALLBACK ShellProc(int nCode, WPARAM wParam, LPARAM lParam)
{
  switch ( nCode )
  {
    case HSHELL_GETMINRECT:
    case HSHELL_WINDOWACTIVATED:
      DrawToDesktop();
      break;
  }
  return CallNextHookEx(hhShellProc, nCode, wParam, lParam);
}

Ani toto ještě neřeší případ oné minimalizace. Dalším, asi nejlepším řešením by bylo se přímo „napíchnout“ na proceduru okna desktopu. V té by pak již neměl být problém. Bohužel, nepodařilo se a přiznávám, že zatím nevím proč. Zkoušel jsem následující kód. Ve funkci SetHooks nastavit proceduru okna desktopu na svoji vlastní a v RemoveHooks vše vrátit. Bohužel (ve Windows XP) bez úspěchu, wndprocOld je NULL.

WNDPROC wndprocOld = NULL;

LRESULT CALLBACK MyDesktopProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
  switch ( message )
  {
    case WM_ERASEBKGND:
  // tady by byl náš kód
      break;
    case WM_PAINT:
  // tady by byl náš kód
      break;
  }
  return CallWindowProc(wndprocOld, hwnd, message, wParam, lParam);
}

// ve funkci SetHook toto:
if ( IsWindow(hwndDesktop) ) // pro jistotu
{
  wndprocOld = (WNDPROC)SetClassLongPtr(hwndDesktop, GCLP_WNDPROC,
    (LONG_PTR)(WNDPROC)MyDesktopProc);
  if ( wndprocOld == NULL )
    Error("Chyba při SetWindowLongPtr");
  }

// ve funkci RemoveHooks toto:

if ( IsWindow(hwndDesktop) )
{
  if ( !SetClassLongPtr(hwndDesktop, GCLP_WNDPROC,
    (LONG_PTR)wndprocOld))
    Error("Chyba při vrácení procedury okna.");
}

Vzhledem k tomu, že tento článek byl napsán a ukázkový projekt vytvořen tak trochu „na objednávku“, dávám ho ven tak, jak je, i s mouchami jako výzvu všem k dalšímu společnému experimentování.

Vzhledem k tomu, že dotaz inspirující tento článek vzešel (zřejmě) od programátora používajícího C++ Builder, na závěr důležitá poznámka pro programátory v C++ Builderu: Když přeložíte kód DLL knihovny v C++ Builderu, kompilátor a linker vám vytvoří skutečná jména vyexportovaných funkcí tak, že před jejich jména přidá „podtržítko“. Získání adresy funkce SetHooks (a obdobně RemoveHooks) pak vypadá takto:

// ....
g_SetHooks = (TSetHooks)GetProcAddress(m_hinstDLL, "_SetHooks");
// ....

Ostatní kód (samozřejmě s výjimkou uživatelského interface řídícího projektu) zůstává stejný.

Stáhnout ukázkou aplikaci si můžete zde: paint_hooks.zip.

Diskuze (17) Další článek: Kolik lidí má u nás počítač?

Témata článku: Software, Windows, Programování, Řídicí aplikace, Reason, Aktivní aplikace, Button, Code, Error, Okno, Kreslení, Knihovna, Case, Start, Hák, Žluté tlačítko, Message, Funkce, Moucha


Určitě si přečtěte

CZ.NIC bezplatně naděluje USB/NFC klíče. Jak jej získat?
Lukáš Václavík
CZ.NICeGovernment
Šmírování kamerami Googlu: Koukněte, co šíleného se objevilo na Street View

Šmírování kamerami Googlu: Koukněte, co šíleného se objevilo na Street View

Google stále fotí celý svět do své služby Street View. A novodobou zábavou je hledat v mapách Googlu vtipné záběry. Podívejte se na výběr nejlepších!

redakce | 4

redakce
Mapy GoogleStreet View
Jak v prohlížeči vypnout oznámení zasílaná webovými stránkami

Jak v prohlížeči vypnout oznámení zasílaná webovými stránkami

** Obtěžují vás neustálé dotazy webů, zda chcete zobrazovat oznámení? ** Můžete je zakázat, a to jak kompletně, tak i pro jednotlivé stránky ** Připravili jsme návody pro Chrome, Firefox, Edge a Operu

Karel Kilián | 11

Karel Kilián
Jak na InternetTipyProhlížeče
Starlink podle betatesterů: Rychlejší a levnější než satelitní internet v Česku

Starlink podle betatesterů: Rychlejší a levnější než satelitní internet v Česku

** Reddit se začíná plnit zkušenostmi se Starlinkem ** Při přímé viditelnosti dá i 120 Mb/s ** Klasický satelitní internet už teď dalece překonává

Jakub Čížek | 48

Jakub Čížek
StarlinkPoskytovatelé internetu
WhatsApp konečně umožní smazat velké soubory z konverzací, aby nezabíraly místo
Vladislav Kluska
WhatsAppFacebookInstant Messaging
Nejlepší notebooky do 20 000 Kč. Tipy, co se dnes vyplatí koupit

Nejlepší notebooky do 20 000 Kč. Tipy, co se dnes vyplatí koupit

** S cenou do 20 tisíc lze vybrat solidní notebook na práci i hry ** Přenosné notebooky nabídnou i kovová těla a rychlý hardware ** Možná největší problém je nedostupnost, nejžádanější kusy jsou vyprodané

David Polesný | 33

David Polesný
VánoceNotebooky

Aktuální číslo časopisu Computer

Jak prodloužit výdrž notebooku

Velké testy: gamepady a inkoustové tiskárny

Důkladný test Sony Playstation 5