前往
大廳
主題

【程式】GUI元件——按鈕類 (Windows API)

Shark | 2023-07-28 09:49:20 | 巴幣 2004 | 人氣 406

之前介紹頂層視窗,這篇開始介紹各種GUI元件的建立方法和事件處理。Gtk把GUI元件稱為widget,但Windows API稱為control(控制項)。

各種GUI系統有很多共通部分,每個GUI系統在製作時都會參考現有的系統,做成大家已經習慣的方式比較容易讓人接受。像是check box是多個是非題,radio button是多選一,在每個系統上都是如此。
這是一些GUI系統的元件一覽,即使不是用這些系統寫程式也可以參考。
Gtk 4  Java Swing  HTML <input> tag

按鈕雖然是比較簡單的control,但是本篇要介紹一些寫GUI程式必要的知識,篇幅比較長。

前篇:如何建一個視窗—Windows API篇
按鈕類Gtk篇
Shark的程式教學目錄



buttoncontrol.c
#define UNICODE
#include<windows.h>
#include<commctrl.h>
#include<windowsx.h> //使用Button_開頭的macro
#include<stdio.h>
#include<stdint.h>


const int BUTTON_W=120;
const int BUTTON_H=30;
const int BUTTON_OFSX=130;
const int BUTTON_OFSY=40;

LRESULT CALLBACK wndProc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam){
  switch(message){
  case WM_COMMAND:{
    uint32_t notifyCode=HIWORD(wparam);
    if(notifyCode==BN_CLICKED){
      uint32_t buttonID=LOWORD(wparam);
      uint32_t checked=Button_GetCheck((HWND)lparam);
      printf("button %u clicked. checked %u\n",buttonID, checked);
    }
    }return 0;
  case WM_NOTIFY:{
    NMHDR* nmhdr=(NMHDR*)lparam;
    if(nmhdr->code==BCN_DROPDOWN){
      NMBCDROPDOWN* nmhdr2=(NMBCDROPDOWN*)lparam;
      printf("split button dropdown id=%llu\n", nmhdr2->hdr.idFrom);
    }
    }return 0;
  case WM_DESTROY:
    PostQuitMessage(0);
    return 0;
  }
  return DefWindowProc(hwnd,message,wparam,lparam);
}

static HWND createButton(int id, WCHAR* text, DWORD style,
    int x,int y,int w,int h, HWND parent){
  HWND hwnd=CreateWindow(WC_BUTTON, text, style|WS_CHILD|WS_VISIBLE,x,y,w,h,
    parent,NULL,NULL,NULL);
  SetWindowLongPtr(hwnd, GWLP_ID, id);
  return hwnd;
}

int main(){
  INITCOMMONCONTROLSEX icc={sizeof(INITCOMMONCONTROLSEX), ICC_STANDARD_CLASSES};
  InitCommonControlsEx(&icc);

  WNDCLASS wndclass;
  ZeroMemory(&wndclass, sizeof(WNDCLASS));
  wndclass.style=CS_HREDRAW|CS_VREDRAW;
  wndclass.lpfnWndProc=wndProc;
  wndclass.lpszClassName=L"window";
  wndclass.hbrBackground=(HBRUSH)(COLOR_BTNFACE+1);
  wndclass.hCursor=LoadCursor(NULL,IDC_ARROW);
  RegisterClass(&wndclass);

  RECT rect={0,0, BUTTON_W*3+40, 400};
  AdjustWindowRect(&rect, WS_CAPTION|WS_SYSMENU|WS_VISIBLE, 0);
  HWND window=CreateWindow(L"window", L"button control",
    WS_OVERLAPPED|WS_SYSMENU|WS_VISIBLE, CW_USEDEFAULT,CW_USEDEFAULT,
    rect.right-rect.left, rect.bottom-rect.top,
    NULL,NULL,NULL,NULL);

  //產生按鈕
  HWND pushButton1= createButton(1, L"(1) push button", 0,
    10,10,BUTTON_W,BUTTON_H, window);

  HWND checkbox1= createButton(2, L"(2) check box", BS_AUTOCHECKBOX,
    10, 10+BUTTON_OFSY,BUTTON_W,BUTTON_H, window);
  HWND checkbox2= createButton(3, L"(3) check box", BS_AUTOCHECKBOX,
    10, 10+BUTTON_OFSY*2,BUTTON_W,BUTTON_H, window);
  HWND checkbox3= createButton(4, L"(4) check box", BS_AUTOCHECKBOX|BS_PUSHLIKE,
    10, 10+BUTTON_OFSY*3,BUTTON_W,BUTTON_H, window);

  HWND radio1= createButton(5, L"(5) radioA", BS_AUTORADIOBUTTON|WS_GROUP,
    10+BUTTON_OFSX, 10+BUTTON_OFSY,BUTTON_W,BUTTON_H, window);
  HWND radio2= createButton(6, L"(6) radioA", BS_AUTORADIOBUTTON,
    10+BUTTON_OFSX, 10+BUTTON_OFSY*2,BUTTON_W,BUTTON_H, window);
  HWND radio3= createButton(7, L"(7) radioA", BS_AUTORADIOBUTTON|BS_PUSHLIKE,
    10+BUTTON_OFSX, 10+BUTTON_OFSY*3,BUTTON_W,BUTTON_H, window);

  HWND radio4= createButton(8, L"(8) radioB", BS_AUTORADIOBUTTON|WS_GROUP,
    10+BUTTON_OFSX*2, 10+BUTTON_OFSY,BUTTON_W,BUTTON_H, window);
  HWND radio5= createButton(9, L"(9) radioB", BS_AUTORADIOBUTTON,
    10+BUTTON_OFSX*2, 10+BUTTON_OFSY*2,BUTTON_W,BUTTON_H, window);
  HWND radio6= createButton(10, L"(10) radioB", BS_AUTORADIOBUTTON|BS_PUSHLIKE,
    10+BUTTON_OFSX*2, 10+BUTTON_OFSY*3,BUTTON_W,BUTTON_H, window);

  HWND threeStateCheckbox= createButton(11, L"(11) 3-state", BS_AUTO3STATE,
    10, 10+BUTTON_OFSY*5,BUTTON_W,BUTTON_H, window);

  HWND pushButton2= createButton(12, L"(12) defbutton", BS_DEFPUSHBUTTON,
    10+BUTTON_OFSX, 10+BUTTON_OFSY*5,BUTTON_W,BUTTON_H, window);
  HWND pushButton3= createButton(13, L"(13) pushbox", BS_PUSHBOX,
    10+BUTTON_OFSX*2, 10+BUTTON_OFSY*5,BUTTON_W,BUTTON_H, window);

  HWND commandLink= createButton(14, L"(14) command link", BS_COMMANDLINK,
    10, 10+BUTTON_OFSY*6,BUTTON_W*2,BUTTON_H*2, window);
  Button_SetNote(commandLink, L"note");
  HWND splitButton= createButton(15, L"(15) split button", BS_SPLITBUTTON,
    10, 10+BUTTON_OFSY*8,BUTTON_W*2,BUTTON_H, window);

  MSG msg;
  while(GetMessage(&msg,NULL,0,0)>0){
    DispatchMessage(&msg);
  }
  return 0;
}
增加一個header:commctrl.h,linker參數增加一個library:comctl32.lib。這兩個檔名是common controls的縮寫。
還有windowsx.h,它裡面定義一些方便的macro。

首先按照「如何建一個視窗—Windows API篇」建好頂層視窗。在建立控制項之前呼叫InitCommonControlsEx()將comctl32.dll初始化。
InitCommonControlsEx()說明  struct INITCOMMONCONTROLSEX說明
參數是宣告一個struct再把它的指標傳入。struct第一個成員是struct的大小,這是Windows API常見的用法,因為新版的struct往往會追加項目,函式裡可以根據struct大小判斷版本做不同處理。第二個成員是要初始化哪些控制項,本篇只用到ICC_STANDARD_CLASSES。

之後的部分暫不解釋,先編譯出來看看,用這個指令
cl buttoncontrol.c /Febuttoncontrol.exe /O2 /MD /link user32.lib comctl32.lib

執行的樣子




可以看到三個問題
  1. 外觀是Windows 95的風格,跟其他程式的外觀不合。
  2. (14)command link和(15)split button沒出現,因為這兩個是Windows Vista以後新增的。
  3. 字型也跟其他程式不一樣。
前兩個問題我是參考這一篇:Microsoft Learn: Enabling Visual Styles,然後另外找一些資料找到解法。
準備下面兩個純文字檔放在同一資料夾。
manifest.xml
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0"
processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
</assembly>
manifest.rc
#include<winuser.h>
CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST "manifest.xml"

打這個指令編譯。
rc manifest.rc
這是「Visual C++的命令列工具」提到的resource compiler。resource是Windows exe檔的特有功能,可以把資料嵌入exe檔。
會產生manifest.res,把它放在跟.c同一資料夾。

文件裡有提到其他方法,但我認為做成resource嵌入可執行檔最省事。

問題3要修改四個地方
//1.增加一個全域變數
HFONT systemFont;

//2.createButton()裡增加一行SendMessage()設定字型
static HWND createButton(int id, WCHAR* text, DWORD style,
    int x,int y,int w,int h, HWND parent){
  HWND hwnd=CreateWindow(WC_BUTTON, text, style|WS_CHILD|WS_VISIBLE, x,y,w,h,
    parent, NULL,NULL,NULL);
  SetWindowLongPtr(hwnd, GWLP_ID, id);
  SendMessage(hwnd, WM_SETFONT, (WPARAM)systemFont, FALSE); //增加這行
  return hwnd;
}

int main(){

  ……

  //3.在產生第一個button之前,用這幾行載入系統字型
  NONCLIENTMETRICS ncm;
  ncm.cbSize=sizeof(NONCLIENTMETRICS);
  SystemParametersInfo(SPI_GETNONCLIENTMETRICS, 0, &ncm, 0);
  systemFont=CreateFontIndirect(&ncm.lfMessageFont);

  ……

  MSG msg;
  while(GetMessage(&msg,NULL,0,0)>0){
    DispatchMessage(&msg);
  }
  //4.程式結束前刪除字型物件
  DeleteObject(systemFont);
  return 0;
}

linker參數增加gdi32.lib和manifest.res
cl buttoncontrol.c /Febuttoncontrol.exe /O2 /MD /link user32.lib comctl32.lib \
  gdi32.lib manifest.res
這樣執行後,外觀跟現在的Windows版本一致了。

因為要用命令列輸出一些訊息,程式進入點用「int main()」讓執行時出現命令列視窗。



開始進入正題:按鈕控制項。微軟的官方文件中與本篇有關的:
控制項一覽  按鈕類control的圖和說明

由於有一些程式碼是所有按鈕共通的,寫成一個函式createButton()。
static HWND createButton(int id, WCHAR* text, DWORD style,
    int x,int y,int w,int h, HWND parent){
  HWND hwnd=CreateWindow(WC_BUTTON, text, style|WS_CHILD|WS_VISIBLE, x,y,w,h,
    parent, NULL,NULL,NULL);
  SetWindowLongPtr(hwnd, GWLP_ID, id);
  SendMessage(hwnd, WM_SETFONT, (WPARAM)systemFont, FALSE);
  return hwnd;
}
產生按鈕的方法跟產生頂層視窗一樣是CreateWindow(),傳回值也是HWND型態,參數如下:
1:WC_BUTTON macro。查commctrl.h會發現它被代換成一個字串L"Button",直接填L"Button"也可以(大小寫皆可)。
2:顯示在按鈕上的字。
3:window style,一些bit flag。因為這不是頂層視窗,必須有WS_CHILD,其他flag會決定這是哪一種按鈕。因為每個按鈕都需要WS_CHILD|WS_VISIBLE所以寫在createButton()裡面。
4~7:在視窗工作區裡的位置。
8:上層視窗的HWND。
9~11:此處沒用到,填NULL。

先不繼續講程式碼,先看看本篇程式裡的按鈕,把程式build出來操作看看。
CreateWindow()第三參數會決定按鈕種類,以下會說明style怎麼填。
Button Styles
  1. push button,圖中的(1)。官方文件裡寫要填BS_PUSHBUTTON,但查winuser.h會發現它的值是0,填0也可以。
    最一般的按鈕,按滑鼠按鈕時外觀變成被按下,放開滑鼠按鈕就跳起。
  2. check box(核取方塊),圖中的(2)~(4)。style=BS_AUTOCHECKBOX。
    多個是或否的選擇。每個check box各自獨立。
  3. radio button(單選按鈕) (5)~(10)。style=BS_AUTORADIOBUTTON。
    用在多個選項裡選一個。點選一個之後,群組裡其他radio button自動取消選取。
    設定群組的方式是,群組裡第一個radio button有WS_GROUP,之後建立的radio button會視為同一群組,直到下一個WS_GROUP才開始新的群組。

    (4)、(7)、(10)帶有BS_PUSHLIKE,這是讓它們的外觀像push button,但行為還是check box和radio button,按了會保持狀態而不是立刻跳起。
    查Button Styles的文件會發現也有名稱沒有AUTO的BS_CHECKBOX和BS_RADIOBUTTON,這些是滑鼠點了也不會改變狀態,需要在後述的事件處理寫程式控制。

    以上三種是基本,以下是比較不常用或特殊用途的。
  4. 3-state check box (11)。style=BS_AUTO3STATE。
    有三種狀態的check box:空白、打勾、未決定。未決定的外觀有的GUI系統是方塊變灰色,有的是一個正方形填滿方塊。
    三種狀態各代表什麼是由作者寫程式決定,但要注意不能讓使用者混淆。
    跟一般的check box一樣,可以用BS_PUSHLIKE改變外觀,也有不自動改變狀態的BS_3STATE。
  5. default push button (12),style=BS_DEFPUSHBUTTON。
    邊框比較粗,用在一種特殊種類的視窗:對話框(dialog box),按鍵盤的Enter就等同按這個鈕,但本程式不是對話框所以看不出效果。
  6. push box (13),style=BS_PUSHBOX。
    平常外觀看不出是按鈕,但滑鼠點會有反應。
  7. command link (14),style=BS_COMMANDLINK。
    一個箭頭加兩行字如圖,行為類似push button。除了用CreateWindow()第二參數設定主要顯示的字以外,可以用Button_SetNote()加一行小字。
  8. split button (15),style=BS_SPLITBUTTON。
    按鈕右邊有個小箭頭,通常按了箭頭就會跳出一個選單。不過「跳出選單」的動作不是建立按鈕就自動有,要寫程式控制。
文件裡還有一個BS_GROUPBOX,這是用來顯示下圖的方框,算是靜態元件而不會產生事件。

其他button style就請查官方文件,大多是跟其他flag做or運算產生效果。

第二行SetWindowLongPtr()是設定一些整數屬性。GWLP_ID是設定ID,之後處理事件會有用。本篇在按鈕上把ID標示出來以方便辨認。
SetWindowLongPtrW()說明
(說明文件裡的函式名稱是SetWindowLongPtrW。之前說過,只要程式開頭有#define UNICODE,Windows API裡跟字串有關的函式和struct都會用Unicode的版本,反之會用ansi的版本。
LONG_PTR是跟該平台指標一樣大小的整數,等同intptr_t。)

至於x,y,w,h參數,Windows底層API沒有排版的功能,只能自己指定坐標。本篇用一些常數:BUTTON_W、BUTTON_H、BUTTON_OFSX、BUTTON_OFSY方便計算。
Gtk就有排版功能了,可以根據文字長短、視窗大小自動調整元件的位置大小,以後寫Gtk的教學可能會介紹。



接下來解說Windows處理GUI事件的方式。
GUI是個樹狀結構,不只Windows,其他GUI函式庫也是這樣做。

頂層視窗處理事件的函式:WndProc()是我們自己寫的,按鈕也有各自的WndProc(),是Windows內建。

當滑鼠點按鈕的時候,按鈕本身收到滑鼠的message (就是「讀取鍵盤與滑鼠輸入(Windows)」裡介紹的東西),上層視窗則收到notification(通知)。

上層視窗的WndProc()要怎麼處理通知呢?在官方文件左邊的選單找到「Button Control Notifications」:

點開後按「BN_CLICKED」看說明:BN_CLICKED notification code
有這一段:
  The parent window of the button receives this notification code through the WM_COMMAND message.
表示上層收到的是WM_COMMAND訊息。不過很多事件都會送出WM_COMMAND訊息,要根據notification code判斷是哪一種事件。
下面寫wparam的低位2 bytes是控制項ID,第3~4 bytes是notification code,可以用LOWORD()、HIWORD()這兩個macro取出。在WndProc()裡檢查message==WM_COMMAND以及HIWORD(wparam)==BN_CLICKED就可得知按鈕按下。
控制項ID是先前用SetWindowLongPtr()設定的值,可以藉此知道是哪個按鈕被按下。lparam是按鈕的hwnd。
再查看WM_COMMAND訊息的說明,裡面有寫「If an application processes this message, it should return zero.」,所以我們自己寫的WndProc()如果有處理這個事件要傳回0。

check box、radio button、3-state check box還需要知道目前狀態才能處理事件,但狀態沒有從wparam和lparam傳過來,要用Button_GetCheck()取得。它的說明要在左邊選單點開「Button Control Macros」找。
Button_GetCheck macro
裡面寫return None是錯的,實際上會傳回BST_CHECKED、BST_UNCHECKED、BST_INDETERMINATE的其中一個。

按split button右邊的箭頭則會送BCN_DROPDOWN通知,查文件可得知上層視窗收到WM_NOTIFY訊息,這個訊息的額外資訊不一樣,lparam是指向一個NMHDR struct的指標,事件資訊存在裡面。
BCN_DROPDOWN notification code  WM_NOTIFY message  NMHDR structure
一樣很多事件都會送WM_NOTIFY訊息,要根據nmhdr->code判斷是哪一種事件,nmhdr->idFrom判斷是哪個控制項送出,然後把lparam轉成其他型態取出事件相關資訊(本篇的例子是struct NMBCDROPDOWN)。
「按箭頭之後跳出選單」要寫在這個事件處理裡面。



最後介紹SendMessage()函式。
本篇用到兩個Button_開頭的macro。查Button_GetCheck()的說明,會發現裡面有提到BM_GETCHECK message
打開windowsx.h找Button_GetCheck()的定義會發現它被代換成SendMessage()。
Button_GetCheck((HWND)lparam);
//代換成這樣
SendMessage((HWND)lparam, BM_GETCHECK, 0,0);
Button_SetNote()也是被代換成BCM_SETNOTE message。此外設定字型時也是用SendMessage()送出WM_SETFONT message

Windows API裡取得控制項的資訊或是修改控制項屬性,很多時候是用SendMessage()。第一參數是控制項的hwnd,第二參數是一個整數代表要做哪種操作,第三和四參數是額外資訊,隨操作種類而異。
SendMessage()說明
例如BM_SETCHECK訊息可以改變check box或radio button的狀態,有興趣可以試試。

至於設定字型用的幾個函式,在此不詳細解釋。SystemParametersInfo()可以取得或修改一些系統資訊,CreateFontIndirect()和DeleteObject()跟Windows的一個系統:GDI有關。
SystemParametersInfoW()說明  CreateFontIndirectW()說明  DeleteObject()說明

創作回應

更多創作