VictorJAM.zapto.org
Programación 

2025-02-15

Creando un editor de texto.

Los siguientes tutoriales del curso de win32 enseñan a como realizar un pequeño editor de texto que voy a resumir en un solo documento.

Aplicación Parte 1: Crear Controles en Tiempo de Ejecución.

Pienso que dar un ejemplo sobre crear controles en ejecución, si bien es muy usado, podría ser en vano a menos que la aplicación realmente haga algo útil, por lo tanto en esta sección voy a comenzar con el desarrolo de un editor de texto y lo iremos desarrollando hasta que alcanzemos un programa útil que soporte abrir, editar y guardar archivos de textos.

El primer paso, el cual es cubierto por esta sección, será simplemente crear la ventana y el control EDIT que servirá como centro de nuestro programa.

Comenzaremos con el esquelto del código de la aplicación Simple Window, agregaremos un #define para el ID de nuestro control y los siguientes dos manejadores de mensajes en nuestro window procedure:

#define IDC_MAIN_EDIT  101
case WM_CREATE:
  {
    HFONT hfDefault;
    HWND hEdit;
    hEdit = CreateWindowEx(WS_EX_CLIENTEDGE, "EDIT", "", 
                           WS_CHILD | WS_VISIBLE | WS_VSCROLL | WS_HSCROLL | ES_MULTILINE | ES_AUTOVSCROLL | ES_AUTOHSCROLL, 
                           0, 0, 100, 100, hwnd, (HMENU)IDC_MAIN_EDIT, GetModuleHandle(NULL), NULL);
    if(hEdit == NULL)
      MessageBox(hwnd, "Could not create edit box.", "Error", MB_OK | MB_ICONERROR);
    hfDefault = GetStockObject(DEFAULT_GUI_FONT);
    SendMessage(hEdit, WM_SETFONT, (WPARAM)hfDefault, MAKELPARAM(FALSE, 0));
  }
  break;
case WM_SIZE:
  {
    HWND hEdit;
    RECT rcClient;
    GetClientRect(hwnd, &rcClient);
    hEdit = GetDlgItem(hwnd, IDC_MAIN_EDIT);
    SetWindowPos(hEdit, NULL, 0, 0, rcClient.right, rcClient.bottom, SWP_NOZORDER);
  }
  break;

Crear los controles

Para crear los controles, al igual que lo hacemos para cualquier otra ventana, utilizamos la API CreateWindowEx( ). Pasamos una clase pre-registrada, en este caso la clase del control "EDIT", y obtenemos un control edit estándar. Cuando usamos diálogos para crear nuestros controles, básicamente estamos escribiendo una lista de controles a crear, tal que, cuando llamamos a DialogBox( ) o CreateDialog( ) el sistema lee la lista de controles en el recurso diálogo y por cada uno llama a CreateWindowEx( ), con la posición y estilo con que fueron definidos.

hEdit = CreateWindowEx(WS_EX_CLIENTEDGE, "EDIT", "", 
                       WS_CHILD | WS_VISIBLE | WS_VSCROLL | WS_HSCROLL | ES_MULTILINE | ES_AUTOVSCROLL | ES_AUTOHSCROLL, 
                       0, 0, 100, 100, hwnd, (HMENU)IDC_MAIN_EDIT, GetModuleHandle(NULL), NULL);
if(hEdit == NULL)
  MessageBox(hwnd, "Could not create edit box.", "Error", MB_OK | MB_ICONERROR);

Puedes ver que esta llamada a CreateWindowEx() especifica cierta cantidad de estilos y no es extraño tener alguno mas, especialmente para los controles comunes los cuales tienen una larga lista de opciones. Los primeros cuatro estilos WS_ deberian ser obvios, estamos creando el control como un hijo de nuestra ventana, queremos que sea visible y tenga barras de desplazamiento vertical y horizontal. Los tres estilos que son específicos de los controles EDIT (ES_MULTILINE | ES_AUTOVSCROLL | ES_AUTOHSCROLL) especifican que el control edit debe poseer múltiples lineas de texto y desplazarse automáticamente cuando tipeamos mas alla del límite inferior de la ventana y del limite derecho de la misma.

Los estilos regulares (WS_*) puedes encontrarlos aquí.

Los estilos extendidos (WS_EX_*) son explicados en: CreateWindowEx() estas referencias son de MSDN donde también puedes encontrar links a los estilos específicos de cada control (ES_* en el caso del control EDIT).

Hemos especificado nuestro window handle como padre del control y le asignamos un ID de IDC_MAIN_EDIT, el cual usaremos luego para referirnos al control, de la misma manera que lo hariamos si el control hubiera sido creado en un diálogo. Los parámetros de posición y tamaño no importan mucho por el momento, debido a que cambiaremos el tamaño del control dinámicamente con el mensaje WM_SIZE para que éste siempre entre en nuestra ventana.

Cambio del tamaño de controles creados dinámicamente

Generalmente, si el tamaño de nuestra ventana puede ser cambiado, tendremos algún código para reposicionar o ajustar el tamaño de los controles que hemos creado dentro de ella, para que siempre sean mostrados apropiadamente.

GetClientRect(hwnd, &rcClient);
hEdit = GetDlgItem(hwnd, IDC_MAIN_EDIT);
SetWindowPos(hEdit, NULL, 0, 0, rcClient.right, rcClient.bottom, SWP_NOZORDER);

Debido a que por ahora solo tenemos un control, la tarea es relativamente simple. Usamos GetClientRect() para obtener las dimensiones del Area Cliente de la ventana, la gran área vacía (por ahora) que no incluye los bordes, menú o título. Esto llenará nuestra estructura RECT con valores. Los valores left y top siempre serán 0, por lo tanto puedes ignorarlos. Los valores right and bottom indican el ancho y el alto del area cliente.

A Continuación, simplemente obtenemos el handle a nuestro control EDIT usando GetDlgItem(), el cual funciona bien en las ventanas regulares como en los diálogos y la llamada SetWindowPos() para mover y ajustar el tamaño para llenar todo el área cliente. Por su puesto, puedes cambiar los valores pasados en SetWindowPos() para hacer algo como sólo llenar la mitad del alto de la ventana, dejando libre la parte inferior para ubicar otros controles.

Crear otros controles en tiempo de ejecución

No voy a dar ejemplos de como crear dinámicamente los otros controles, como listas, botones, etc... debido a que es básicamente lo mismo. Si visitas los links anteriores en MSDN o buscas en tu referencia de Win32 API, encontrarás toda la información necesaria para crear cualquiera de los otros controles estándar.

Veremos mas sobre esto con los controles comunes, en la siguientes secciones, donde obtendrás mas práctica.

Aplicación Parte 2: Uso de archivos y Diálogos Comunes

Diálogos Comunes para archivos

El primer paso para abrir o guardar archivos es obtener el nombre del archivo a usar... aunque, simplemente podríamos poner el nombre del archivo en el código de nuestro programa, pero honestamente esto no es muy útil en la mayoría de los programas.

Debido a que esto es una tarea bastante común, existen diálogos predefinidos por el sistema que pueden ser usados para permitir al usuario seleccionar el nombre de un archivo. Los diálogos mas comunes, para abrir y para guardar archivos, son accedidos a través de GetOpenFileName() and GetSaveFileName() respectivamente, ambos de los cuales utilizan una estructura OPENFILENAME.

OPENFILENAME ofn;
char szFileName[MAX_PATH] = "";
ZeroMemory(&ofn, sizeof(ofn));
ofn.lStructSize = sizeof(ofn); // Mira la nota a continuación
ofn.hwndOwner = hwnd;
ofn.lpstrFilter = "Text Files (*.txt)\0*.txt\0All Files (*.*)\0*.*\0";
ofn.lpstrFile = szFileName;
ofn.nMaxFile = MAX_PATH;
ofn.Flags = OFN_EXPLORER | OFN_FILEMUSTEXIST | OFN_HIDEREADONLY;
ofn.lpstrDefExt = "txt";
if(GetOpenFileName(&ofn)) {
  // Do something usefull with the filename stored in szFileName 
}

Observa que en la estructura llamamos a ZeroMemory( ) para inicializarla a 0. Esto es generalmente una práctica prudente, dado que muchas APIs suelen asignar los campos que no utilizamos con NULL y de esta forma no necesitamos asignar cada miebro que no utilizamos.

Puedes encontrar fácilmente el significado de cada uno de los campos de la estructura en la documentación. El campo lpstrFilter apunta a un string doblemente terminado en NULL, puedes ver en el ejemplo que hay varios "\0" a través de éste, incluyendo uno al final... el compilador agregará el segundo al final, como siempre lo hace con los strings constantes (esto es por lo que generalmente no necesitas ponerlos tu mismo). Los NULLs en este string lo dividen en filtros, donde cada uno tiene dos partes. El primer filtro tiene la descripción "Text Files (*.txt)", la parte con la especificación (*.txt) no es necesaria aquí. La segunda parte es la especificación real del primer filtro , "*.txt". Hacemos lo mismo con el segundo filtro, excepto que es un filtro genérico para todos los archivos. Podemos agregar tantos filtros como deseemos.

El campo lpstrFile apunta al búffer que hemos asignado para almacenar el nombre del archivo, debido a que la longitud del nombre del archivo no puede ser mayor que MAXPATH, éste es el valor que escogeremos para el tamaño de dicho búffer.

Los flags indican que el diálogo sólo debe permitir al usuario ingresar nombres de archivos que ya existen (esto es porque queremos abrirlos, no crearlos) y para ocultar la opción de abrirlos en modo de sólo lectura, opción que no queremos proveer. Finalmente proveemos una extensión por default, por lo tanto si el usuario tipea "foo" y el archivo no es encontrado, intentaremos abrir "foo.txt".

Cuando seleccionamos un archivo para guardar en lugar de abrir, el código es bastante parecido, excepto que llamamos a GetSaveFile() y además necesitamos cambiar los flags para poner opciones mas apropiadas al guardar archivos.

ofn.Flags = OFN_EXPLORER | OFN_PATHMUSTEXIST | OFN_HIDEREADONLY | OFN_OVERWRITEPROMPT;

En este caso no necesitamos que el archivo exista, pero si necesitamos que el directorio exista debido a que no vamos a crearlo si no existe. Además, si el archivo existe, preguntamos al usuario si está seguro que desea sobreescribirlo.

MSDN establece que el campo lStructSize especifica la longitud de la estructura en bytes, pero en windows 2000 ha agregado algunos miembros a esta estructura y su tamaño ha cambiado. Si el código no funciona, posiblemente sea porque no coincidan en tamaño que el compilador usar. Si ocurre intante usar OPENFILENAME_SIZE_VERSION_400 en lugar de sizeof(ofn).

Lectura y Escritura de Archivos

En windows tenemos algunas opcciones para especificar de que manera queremos acceder a los archivos. Podemos usar la vieja librería io.h open()/read()/write(), o podemos usar la librería stdio.h fopen()/fread()/fwrite(), y por último si usamos C++ podemos usar iostreams.

Sin embargo, en Windows, todos éstos métodos finalmente llaman a las funciones de la API Win32, las cuales usaremos aquí. Si estás utilizando otro método para la entrada/salida de archivos, entonces te resultará fácil entender el método que vamos a ver.

Para abrir archivos, podemos usar OpenFile( ) o CreateFile( ). MS recomienda usar solamente CreateFile( ) debido a que OpenFile( ) es "obsoleta". CreateFile( ) es una funcióm mucho más versátil y provee un gran control sobre la forma en que abrimos nuestros archivos.

Lectura

Digamos, por ejemplo, que le has permitido al usuario seleccionar un archivo usando GetOpenFileName( )...

BOOL LoadTextFileToEdit(HWND hEdit, LPCTSTR pszFileName) {
  HANDLE hFile;
  BOOL bSuccess = FALSE;

  hFile = CreateFile(pszFileName, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
  if(hFile != INVALID_HANDLE_VALUE) {
    DWORD dwFileSize;
    dwFileSize = GetFileSize(hFile, NULL);
    if(dwFileSize != 0xFFFFFFFF) {
      LPSTR pszFileText;
      pszFileText = GlobalAlloc(GPTR, dwFileSize + 1);
      if(pszFileText != NULL) {
        DWORD dwRead;
        if(ReadFile(hFile, pszFileText, dwFileSize, &dwRead, NULL)) {
          pszFileText[dwFileSize] = 0; // Add null terminator
          if(SetWindowText(hEdit, pszFileText)) 
            bSuccess = TRUE; // funcionó!
        }
        GlobalFree(pszFileText);
      }
    }
    CloseHandle(hFile);
  }
  return bSuccess;
}

Hay una función completa para leer un archivo de texto dentro de un control edit. Esta función, toma como parámetro el handle al control edit y el nombre del archivo a leer. Además esta particular función tiene un buen chequeo de errores, debido a que la entrada/salida de archivos está expuesta a una gran cantidad de ellos y por lo tanto, necesitamos chequearlos.

No usaremos la variable dwRead, excepto como prámetro en ReadFile( ). Este parámetro DEBE ser provisto, de no ser así la llamada fallará.

En la llamada a CreateFile( ), GENERIC_READ significa que queremos acceso de sólo lectura. FILE_SHARE_READ significa que otros programas pueden abrir el archivo al mismo tiempo que nosotros lo hacemos, pero sólo si quieren leerlo, no queremos que escriban en el archivo mientras estamos leyendo. Por último, OPEN_EXISTING significa que sólo abriremos el archivo si existe, no queremos crearlo.

Una vez que hemos abierto el archivo y hemos chequeado si CreateFile() se ejecutó con éxito, chequeamos el tamaño del archivo para averiguar cuanta memoria necesitamos reservar para poder leer el archivo entero. Cuando reservamos dicha memoria, chequeamos para asegurarnos que se hizo con éxito, y luego llamamos a ReadFile() para cargar el contenido del disco dentro del búfer en memoria. Las funciones de la API para archivos no entienden sobre Archivos de Texto, por lo tanto no proveen mecanismos para leer una linea de texto, o agregar terminadores NULL al final de nuestros strings. Esta es la razón por la que hemos asignado un byte extra, después de leer el archivo agregamos el terminador NULL para que luego podamos pasar el búfer de memoria como si fuera un string a SetWindowsText().

Una vez que todo esto se ha ejecutado con éxito, ponemos en nuestra variable booleana bSucces (que indica si la operación se ha ejecutado con éxito) el valor TRUE. Por último, liberamos el búffer de memoria y cerramos el archivo.

Escritura

BOOL SaveTextFileFromEdit(HWND hEdit, LPCTSTR pszFileName) {
  HANDLE hFile;
  BOOL bSuccess = FALSE;
  hFile = CreateFile(pszFileName, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
  if(hFile != INVALID_HANDLE_VALUE) {
    DWORD dwTextLength;
    dwTextLength = GetWindowTextLength(hEdit);
    // No need to bother if there's no text.
    if(dwTextLength > 0) {
      LPSTR pszText;
      DWORD dwBufferSize = dwTextLength + 1;
      pszText = GlobalAlloc(GPTR, dwBufferSize);
      if(pszText != NULL) {
        if(GetWindowText(hEdit, pszText, dwBufferSize)) {
          DWORD dwWritten;
          if(WriteFile(hFile, pszText, dwTextLength, &dwWritten, NULL))
            bSuccess = TRUE;
        }
        GlobalFree(pszText);
      }
    }
    CloseHandle(hFile);
  }
  return bSuccess;
}

Es muy similar a leer archivos, pero con algunos cambios. Primero que todo, cuando llamamos a CreateFile() especificamos que queremos acceso de lectura (Read), que el archivo debería simpre ser creado nuevamente (y si existe debería ser borrado cuando es abierto) y si no existe que sea creado con los atributos de los archivos normales.

A continuación obtenemos del control edit, la longitud necesaria del búffer de memoria, debido a que éste es la fuente de los datos. Una vez que hemos asignado la memoria, solicitamos el string al control edit usando GetWindowText() y luego lo escribimos al achivo con WriteFile(). Nuevamente, al igual que con ReadFile(), es necesario el parámetro que retorna cuanto fué escrito, aún cuando no lo usemos.

Aplicación Parte 3: Barras de Herramientas y Barras de Estado

Una Palabra IMPORTANTE en los Controles Comunes

Al igual que con todos los controles comunes, debemos llamar a InitCommonControl ANTES de intentar usarlos. Necesitaremos "#include <commctrl.h> para poder usar esta función y para obtener las funciones y declaraciones necesarias para el uso de los controles comunes. También necesitaremos agregar comctl32.lib en la configuración de nuestro enlazador (linker), si no se encuentra configurado de esa manera. Observa que InitCommonControls() es una vieja API, para obtener mas control podemos usar InitCommonControlsEx() la cual es requerida por los más recientes controles comunes. Sin embargo, debido a que no voy a usar ninguna de las características avanzadas, InitCommonControls() será adecuado y mas simple.

Barras de Herramientas

Podemos crear una barra de herramientas usando CreateToolbarEx() pero no es la idea... Lo que necesitamos hacer es crear la barra de herramientas...

hTool = CreateWindowEx(0, TOOLBARCLASSNAME, NULL, WS_CHILD | WS_VISIBLE, 0, 0, 0, 0,
                       hwnd, (HMENU)IDC_MAIN_TOOL, GetModuleHandle(NULL), NULL);

Esto es bastante simple, TOOLBARCLASSNAME es una constante definida por los encabezados (headers) de los controles comunes. hwnd es la ventana padre, la ventana en la que aparecerá la barra de herramientas. IDC_MAIN_TOOL es un identificador que podemos usar posteriormente para obtener el HWND de la barra de herramientas por medio de GetDlgItem().

// Send the TB_BUTTONSTRUCTSIZE message, which is required for
// backward compatibility.
SendMessage(hTool, TB_BUTTONSTRUCTSIZE, (WPARAM)sizeof(TBBUTTON), 0);

Este mensaje es necesario para permitirle al sistema darse cuenta que versión de la librería de controles comunes estamos usando. Debido a que nuevas versiones agregan código a la estructura, dando el tamaño de la misma el sistema puede darse cuenta que comportamiento estamos esperando.

Botones de la barra de Herramientas

Los botones con bitmaps para las barras de herramientas vienen en dos variedades, los botones estándar que son provistos por comctl32 y los botones definidos por el usuario, que son creados por él mismo.

Los botones y bitmaps son agregados a las barras de herramientas en forma separada... primero agregamos una lista de imágenes a usar y luego agregamos la lista de botones. Por último especificamos que imagen usar en cada botón.

Agregado de Botones Estándar

Ahora que hemos creado una barra de herramientas, necesitamos agregarle algunos botones. Los bitmaps mas comunes están disponibles dentro de la librería de los controles comunes, por lo tanto no necesitamos crearlos o agregarlos en cada archivo .exe que los usa.

Primero declaramos TBBUTTON y TBADDBITMAP

TBBUTTON tbb[3];
TBADDBITMAP tbab;

y luego agregamos los los bitmaps estándar a la barra de herramientas usando la lista de imágenes en la libreria de los controles comunes...

tbab.hInst = HINST_COMMCTRL;
tbab.nID = IDB_STD_SMALL_COLOR;
SendMessage(hTool, TB_ADDBITMAP, 0, (LPARAM)&tbab);

Ahora que hemos cargado nuestras imágenes, podemos agregar algunos botones que las utilicen...

ZeroMemory(tbb, sizeof(tbb));
tbb[0].iBitmap = STD_FILENEW;
tbb[0].fsState = TBSTATE_ENABLED;
tbb[0].fsStyle = TBSTYLE_BUTTON;
tbb[0].idCommand = ID_FILE_NEW;
tbb[1].iBitmap = STD_FILEOPEN;
tbb[1].fsState = TBSTATE_ENABLED;
tbb[1].fsStyle = TBSTYLE_BUTTON;
tbb[1].idCommand = ID_FILE_OPEN;
tbb[2].iBitmap = STD_FILESAVE;
tbb[2].fsState = TBSTATE_ENABLED;
tbb[2].fsStyle = TBSTYLE_BUTTON;
tbb[2].idCommand = ID_FILE_SAVEAS;
SendMessage(hTool, TB_ADDBUTTONS, sizeof(tbb)/sizeof(TBBUTTON), (LPARAM)&tbb);

Aquí hemos agregado los botones New, Open y Save As usando las imágenes estándar, lo cual es una buena idea debido a que las personas suelen verlas y ya saben que significan.

Los índices de las lista de imágenes están definidos en los encabezados de los controles comunes y también son listados en MSDN.

Hemos asignado a cada botón un ID (ID_FILE_NEW, etc...) lo cual es idéntico a los IDs de los items equivalentes en el Menú. Estos botones generarán mensajes WM_COMMAND identicos a los del Menú, por lo tanto no se requiere algún procesamiento extra! Si fueramos a agregar un botón para un comando que no aparece en el Menú, simplemente definimos un nuevo ID y agregamos un manejador a WM_COMMAND.

Si te estás preguntando por qué he pasado wParam a TB_ADDBUTTONS, bueno, está realizando un cálculo del número de botones en el arreglo tbb para que no necesitemos especificar un valor. Si en lugar de esto, lo que hacemos es poner un 3, bien, estaría correcto pero que sucede si queremos agregar otros botones... deberíamos cambiar este numero por 4 y en programación eso no es muy bueno... queremos que los cambios provoquen la menor cantidad de cambios. Por ejemplo, si el tamaño de TBBUTON tiene 16 bits, debido a que tenemos 3 botones el tamaño del tbb debería ser de 16*3 = 48. Del mismo modo, 48/16 da el número de botones, 3 en este caso.

Barras de Estado

Algo que se suele encontrar a menudo en las aplicaciones son las barras de estado, la pequeña barra en la parte inferior de la ventana que muestra información. Son muy fácil de usar, solo hay que crearlas...

hStatus = CreateWindowEx(0, STATUSCLASSNAME, NULL,
                         WS_CHILD | WS_VISIBLE | SBARS_SIZEGRIP, 0, 0, 0, 0,
                         hwnd, (HMENU)IDC_MAIN_STATUS, GetModuleHandle(NULL), NULL);

Y luego (opcionalmente) especificamos el numero de secciones que deseamos. Si no especificamos ninguna, simplemente tendrá una que usa el ancho entero de la barra. Podemos especificar o recuperar el texto usando SetWindowText( ) como con lo hacíamos con muchos de los otros controles. Si especificamos más de una sección, necesitamos especificar el ancho de cada una y luego usar SB_SETTEXT para especificar el texto de cada sección.

Para definir los anchos declaramos un arreglo de enteros donde cada valor es el ancho en pixeles de una sección. Si queremos que una sección use el espacio restante fijamos como valor -1.

El wParam nuevamente es para calcular cuantos elementos hay en el arreglo. Una vez que hemos terminado de agregar secciones, fijamos el primer valor (de índice 0) para verla en acción.

Tamaño Apropiado

A diferencia de los Menúes, las barras de estado y de herramientas son controles separados que viven dentro del área cliente de la ventana padre. Por lo tanto si solo dejamos nuestro código WM_SIZE anterior, se van a solapar con el control edit que hemos agregado en los ejemplos anteriores. En WM_SIZE, ponemos las barras de estado y de herramientas en posición y luego restamos el alto del area cliente para que podamos mover nuestro control edit para rellenar el espacio restante...

HWND hTool;
RECT rcTool;
int iToolHeight;

HWND hStatus;
RECT rcStatus;
int iStatusHeight;

HWND hEdit;
int iEditHeight;
RECT rcClient;

// Size toolbar and get height

hTool = GetDlgItem(hwnd, IDC_MAIN_TOOL);
SendMessage(hTool, TB_AUTOSIZE, 0, 0);

GetWindowRect(hTool, &rcTool);
iToolHeight = rcTool.bottom - rcTool.top;

// Size status bar and get height

hStatus = GetDlgItem(hwnd, IDC_MAIN_STATUS);
SendMessage(hStatus, WM_SIZE, 0, 0);

GetWindowRect(hStatus, &rcStatus);
iStatusHeight = rcStatus.bottom - rcStatus.top;

// Calculate remaining height and size edit
GetClientRect(hwnd, &rcClient);
iEditHeight = rcClient.bottom - iToolHeight - iStatusHeight;
hEdit = GetDlgItem(hwnd, IDC_MAIN_EDIT);
SetWindowPos(hEdit, NULL, 0, iToolHeight, rcClient.right, iEditHeight, SWP_NOZORDER);

Desafortunadamente esta es una porción de código bastante larga, pero es simple... las barras de herramiemntas se autoposicionarán cuando envíemos el mensaje TB_AUTOSIZE y las barras de estado harán lo mismo cuando enviemos WM_SIZE (las librerías de los controles comunes no entienden nada acerca de consistencia).

Aplicación Parte 4: Interfaces con Múltiples Documentos

Introducción a IMD

Primero un poco de repaso... Todas las ventanas tienen un Area Cliente, aquí es donde la mayoría de los programas dibujan imágenes, ubican los controles, etc... el Area Cliente no está separada de la ventana, simplemente es una pequeña región especializada de la ventana. A veces una ventana puede ser todo el área cliente, y veces nada, a veces el área cliente puede hacerse mas pequeña para caber en menus, títulos, barras de desplazamiento, etc...

En términos de IMD (en inglés, MDI - Multiple Document Interface), nuestra ventana principal es llamada Frame y ésta es probablemente la única ventana que podríamos tener en un programa IUD (Interface con un Unico Documento). En IMD hay una ventana adicional llamada Ventana Cliente IMD, la cual es hija de nuestro Frame y a diferencia del Area Cliente, es una ventana completa y separada de las demás que tiene un área cliente y probablemente algunos pixeles para un borde. Nunca procesaremos mensajes directamente del Cliente IMD, esto es hecho por la clase ventana pre-definida "MDI_CLIENT". Podemos comunicarnos y manipular el área cliente IMD, como así también las ventanas que ésta contiene, a través de mensajes.

Cuando entramos a la ventana que muestra nuestro documento, o lo que sea que muestre el programa, envíamos un mensaje al Cliente IMD para decirle que cree una nueva ventana del tipo que hemos especificado. La nueva ventana es creada como una ventana hija del Cliente IMD, no de nuestro Frame Window. Esta nueva ventana es una hija IMD (IMD Child). La hija IMD Child es hija del Cliente IMD, el cual a su vez, es hijo del Frame IMD... La Hija IMD probablemente tenga sus propias ventanas hijas, por ejemplo el control edit en el programa del ejemplo de esta sección.

Tenemos que escribir dos (o mas) Window Procedures. Uno, como siempre, para nuestra ventana principal (el Frame) y uno más para la Hija IMD. Podemos tener también mas de un tipo de hija, en cuyo caso escribiremos window procedures separados para cada tipo.

Si te he confundido hablando de Clientes IMD y todo eso, quizás este diagrama puede ayudarte a aclarar un poco las cosas.

Ahora si, IMD

IMD requiere algunos cambios astutos a lo largo de un programa, por lo tanto lee esta sección cuidadosamente... si tu programa no funciona o tiene un comportamiento extraño es porque erraste alguna de las alteraciones que vamos a hacer al programa regular.

Ventana Cliente IMD

Antes de que creemos nuestra ventana IMD necesitamos hacer un cambio al procesamiento por default que utilizamos en nuestro Window Procedure... debido a que estamos creando un Frame que residirá en un Cliente IMD, necesitamos cambiar la llamada a DefWindowProc( ) a DefFrameProc( ) la cual agrega un procesamiento especializado de mensajes para Frames.

default:
  return DefFrameProc(hwnd, g_hMDIClient, msg, wParam, lParam);

El próximo paso es crear la ventana Cliente IMD, como una hija de nuestro Frame. Esto lo hacemos en el WM_CREATE, como antes...

CLIENTCREATESTRUCT ccs;

ccs.hWindowMenu  = GetSubMenu(GetMenu(hwnd), 2);
ccs.idFirstChild = ID_MDI_FIRSTCHILD;
g_hMDIClient = CreateWindowEx(WS_EX_CLIENTEDGE, "mdiclient", NULL,
                              WS_CHILD | WS_CLIPCHILDREN | WS_VSCROLL | WS_HSCROLL | WS_VISIBLE,
                              CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT,
                              hwnd, (HMENU)IDC_MAIN_MDI, GetModuleHandle(NULL), (LPVOID)&ccs);

El menu handle es el handle al menú desplegable en el que el Cliente IMD agregará items para representar cada ventana que es creada, permitiéndole al usuario elegir desde el menú la ventana que quiere activar. Por lo tanto, agregaremos funcionalidad para manejar este caso. En este ejemplo, es la tercera (de índice 2) debido a que la he agregado al Menú después de File, Edit y Window.

ccs.idFirstChild es el número para usar como el primer ID para los items que el Cliente agrega a la Ventana menú... queremos que esto sea fácilmente distinguible de nuestros identificadores de menúes para que podamos procesar los comandos del menú y pasarlos desde la ventana a DefFrameProc( ) para que los procese. En el ejemplo he especificado un identificador definido como 50000, que es lo suficientemente grande para que ninguno de los identificadores de los comandos del menú sean mayor que éste.

Ahora para que este menú funcione apropiadamente, necesitamos agregar algún procesamiento especial a nuestro manejador del mensaje WM_COMMAND:

case WM_COMMAND:
  switch(LOWORD(wParam))
    {
      case ID_FILE_EXIT:
        PostMessage(hwnd, WM_CLOSE, 0, 0);
        break;
      // ... handle other regular IDs ...
      // Handle MDI Window commands
      default:
        {
          if(LOWORD(wParam) >= ID_MDI_FIRSTCHILD) {
            DefFrameProc(hwnd, g_hMDIClient, msg, wParam, lParam);
          }
          else {
            HWND hChild = (HWND)SendMessage(g_hMDIClient, WM_MDIGETACTIVE,0,0);
            if(hChild) {
              SendMessage(hChild, WM_COMMAND, wParam, lParam);
            }
          }
        }
    }
    break;

He agregado en el case un caso más, el default:, el cual atrapará todos los comandos que no procesemos directamente y realizará un chequeo para ver si el valor es mayor o igual a ID_MDI_FIRSTCHILD. Si lo es, entonces el usuario ha clickeado en uno de los items del menú de la ventana y envíamos el mensaje a DefFrameProc( ) para que lo procese.

Si no es un alguno de los IDs de la ventana, entoces obtenemos el handle de la ventana hijo que está activa y le enviamos el mensaje para que lo procese. Esto nos permite delegar la responsabilidad de realizar ciertas acciones a la ventana Hija y nos permite que diferentes ventanas Hijas procesen comandos de diferentes formas, si así se desea. En el ejemplo, en el Frame window procedure solo proceso los comandos que son globales al programa y envío los comandos que afectan a cierto documento o una ventana Hija hacia dicha ventana Hija para que los procese.

También necesitamos modificar un poco nuestro Loop de Mensajes...

while(GetMessage(&Msg, NULL, 0, 0)) {
  if (!TranslateMDISysAccel(g_hMDIClient, &Msg)) {
    TranslateMessage(&Msg);
    DispatchMessage(&Msg);
  }
}

Hemos agregado un paso extra (TranslateMDISysAccel()), que chequea por el acelerador de teclas pre-definido, Ctrl+F6 cambia a la siguiente ventana, Ctrl+F4 cierra la ventana Hijo, etc... Si no quieres agregar este chequeo impedirás a los usuarios de usar el comportamiento estándar que están acostumbrados a usar, o tendrás que implementarlo manualmente.

Clase Ventana Hija

Además de la ventana principal de un programa (el Frame), necesitamos crear una nueva clase ventana para cada tipo de ventana hija que necesitamos. Por ejemplo, podemos tener una que muestre texto y otra para mostrar gráficos o fotos. En este ejemplo solo crearemos un tipo de hijo, el cual será como el editor de los ejemplos anteriores.

BOOL SetUpMDIChildWindowClass(HINSTANCE hInstance)
{
    WNDCLASSEX wc;

    wc.cbSize        = sizeof(WNDCLASSEX);
    wc.style         = CS_HREDRAW | CS_VREDRAW;
    wc.lpfnWndProc   = MDIChildWndProc;
    wc.cbClsExtra    = 0;
    wc.cbWndExtra    = 0;
    wc.hInstance     = hInstance;
    wc.hIcon         = LoadIcon(NULL, IDI_APPLICATION);
    wc.hCursor       = LoadCursor(NULL, IDC_ARROW);
    wc.hbrBackground = (HBRUSH)(COLOR_3DFACE+1);
    wc.lpszMenuName  = NULL;
    wc.lpszClassName = g_szChildClassName;
    wc.hIconSm       = LoadIcon(NULL, IDI_APPLICATION);

    if(!RegisterClassEx(&wc))
    {
        MessageBox(0, "Could Not Register Child Window", "Oh Oh...",
            MB_ICONEXCLAMATION | MB_OK);
        return FALSE;
    }
    else
        return TRUE;
}

Esto es básicamente idéntico a registrar nuestro Frame, no hay flags particularmente especiales para usar con IMD. Debemos poner nuestro menú en NULL y el window procedure apuntando al window procedure de la ventana hija que escribiremos a continuación.

IMD Procedure Hijo

El window procedure para una IMD Hija es parecido a cualquier otro pero con algunas excepciones. Primero de todo, los mensajes por default, en lugar de ser pasados a DefWindowProc( ), son pasados a DefMDIChildProc( ).

En este caso particular, también queremos deshabilitar los menúes Edit y Window cuando no se necesitan (sólo porque es bueno hacerlo), por lo tanto procesando el mensaje WM_MDIACTIVATE los habilitamos o deshabilitamos dependiendo si la ventana está activa o no. Si tenemos múltiples tipos de ventana hijo, aquí es donde podemos poner el código para cambiar completamente el menú, la barra de herramientas o hacer alteraciones a otros aspectos del programa para reflejar las acciones y comandos que son específicos del tipo de ventana que está siendo activada.

Para ser aún mas completos, podemos deshabilitar los items Close y Open del menú, debido a que no serán de utilidad cuando las ventanas no estén activas. He deshabilitado todos estos items por default en el recurso, por lo tanto no necesitamos agregar código extra para hacer esto cuando la aplicación se ejecuta por primera vez.

LRESULT CALLBACK MDIChildWndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    switch(msg)
    {
        case WM_CREATE:
        {
            HFONT hfDefault;
            HWND hEdit;

            // Creamos el control Edit

            hEdit = CreateWindowEx(WS_EX_CLIENTEDGE, "EDIT", "", 
                WS_CHILD | WS_VISIBLE | WS_VSCROLL | WS_HSCROLL | ES_MULTILINE | ES_AUTOVSCROLL | ES_AUTOHSCROLL, 
                0, 0, 100, 100, hwnd, (HMENU)IDC_CHILD_EDIT, GetModuleHandle(NULL), NULL);
            if(hEdit == NULL)
                MessageBox(hwnd, "Could not create edit box.", "Error", MB_OK | MB_ICONERROR);

            hfDefault = GetStockObject(DEFAULT_GUI_FONT);
            SendMessage(hEdit, WM_SETFONT, (WPARAM)hfDefault, MAKELPARAM(FALSE, 0));
        }
        break;
        case WM_MDIACTIVATE:
        {
            HMENU hMenu, hFileMenu;
            UINT EnableFlag;

            hMenu = GetMenu(g_hMainWindow);
            if(hwnd == (HWND)lParam)
            {      //si está activa, activamos los menués
                EnableFlag = MF_ENABLED;
            }
            else
            {                          //cuando se desactiva, desactivamos los menués
                EnableFlag = MF_GRAYED;
            }

            EnableMenuItem(hMenu, 1, MF_BYPOSITION | EnableFlag);
            EnableMenuItem(hMenu, 2, MF_BYPOSITION | EnableFlag);

            hFileMenu = GetSubMenu(hMenu, 0);
            EnableMenuItem(hFileMenu, ID_FILE_SAVEAS, MF_BYCOMMAND | EnableFlag);

            EnableMenuItem(hFileMenu, ID_FILE_CLOSE, MF_BYCOMMAND | EnableFlag);
            EnableMenuItem(hFileMenu, ID_FILE_CLOSEALL, MF_BYCOMMAND | EnableFlag);

            DrawMenuBar(g_hMainWindow);
        }
        break;
        case WM_COMMAND:
            switch(LOWORD(wParam))
            {
                case ID_FILE_OPEN:
                    DoFileOpen(hwnd);
                break;
                case ID_FILE_SAVEAS:
                    DoFileSave(hwnd);
                break;
                case ID_EDIT_CUT:
                    SendDlgItemMessage(hwnd, IDC_CHILD_EDIT, WM_CUT, 0, 0);
                break;
                case ID_EDIT_COPY:
                    SendDlgItemMessage(hwnd, IDC_CHILD_EDIT, WM_COPY, 0, 0);
                break;
                case ID_EDIT_PASTE:
                    SendDlgItemMessage(hwnd, IDC_CHILD_EDIT, WM_PASTE, 0, 0);
                break;
            }
        break;
        case WM_SIZE:
        {
            HWND hEdit;
            RECT rcClient;


            GetClientRect(hwnd, &rcClient);

            hEdit = GetDlgItem(hwnd, IDC_CHILD_EDIT);
            SetWindowPos(hEdit, NULL, 0, 0, rcClient.right, rcClient.bottom, SWP_NOZORDER);
        }
        return DefMDIChildProc(hwnd, msg, wParam, lParam);
        default:
            return DefMDIChildProc(hwnd, msg, wParam, lParam);
    
    }
    return 0;
}

He implementado como comandos File Open y Save, DoFileOpen() y DoFileSave() son igual que en los ejemplos anteriores con el ID del control edit cambiado y adicionalmente he puesto como título de la Ventana Hija IMD el nombre del archivo.

Los comandos edit son fáciles, debido a que el control edit está desarrollado para soportarlos, sólo le decimos que hacer.

¿Recuerdas que he mencionado que hay algunas cosas que necesitas recordar o tu aplicación se comportará de manera extraña? Observa que he llamado DefMDIChildProc( ) al final del WM_SIZE, esto es importante porque de no ser así, el sistema no tendrá la chance de hacer su propio procesamiento sobre el mensaje. Puedes buscar sobre DefMDIChildProc( ) en MSDN para encontrar una lista sobre los mensajes que procesa y siempre asegurarte de pasárselos.

Crear y Destruir Ventanas

Las ventanas Hijas IMD nos son creadas directamente, al contrario, le enviamos a la ventana cliente el mensaje WM_MDICREATE diciéndole que tipo de ventana queremos crear rellenando los miembros de una estructura MDICREATESTRUCT. Puedes ver los distintos miembros de ésta estructura en la documentación. El valor de retorno del mensaje WM_MDICREATE es el handle a la nueva ventana.

HWND CreateNewMDIChild(HWND hMDIClient)
{ 
    MDICREATESTRUCT mcs;
    HWND hChild;

    mcs.szTitle = "[Untitled]";
    mcs.szClass = g_szChildClassName;
    mcs.hOwner  = GetModuleHandle(NULL);
    mcs.x = mcs.cx = CW_USEDEFAULT;
    mcs.y = mcs.cy = CW_USEDEFAULT;
    mcs.style = MDIS_ALLCHILDSTYLES;

    hChild = (HWND)SendMessage(hMDIClient, WM_MDICREATE, 0, (LONG)&mcs);
    if(!hChild)
    {
        MessageBox(hMDIClient, "MDI Child creation failed.", "Oh Oh...",
            MB_ICONEXCLAMATION | MB_OK);
    }
    return hChild;
}

Un miembro de la estructura MDICREATESTRUCT que no usamos pero puede ser muy útil es el lParm. Este puede ser usado para enviar cualquier valor de 32 bits (un puntero) a la ventana hija que estás creando con el propósito de proveerle cualquier información. En el manejador WM_CREATE de nuestra ventana hija, el valor lParam para el mensaje WM_CREATE apuntará a la estructura MDICREATESTRUCT. El miembro lpCreateParams de dicha estructura apuntará a MDICREATESTRUCT que enviamos junto con WM_MDICREATE. Por lo tanto para acceder al valor lparam de la ventana hija necesitamos hacer algo como esto en el window procedure de dicha ventana...

    case WM_CREATE:
    {
        CREATESTRUCT* pCreateStruct;
        MDICREATESTRUCT* pMDICreateStruct;

        pCreateStruct = (CREATESTRUCT*)lParam;
        pMDICreateStruct = (MDICREATESTRUCT*)pCreateStruct->lpCreateParams;

        /*
        pMDICreateStruct now points to the same MDICREATESTRUCT that you
        sent along with the WM_MDICREATE message and you can use it
        to access the lParam.
        */
    }
    break;

Si no quieres complicarte con esos dos punteros extras, puedes acceder a lParam en un solo paso de la siguiente manera:

((MDICREATESTRUCT*)((CREATESTRUCT*)lParam)->lpCreateParams)->lParam

Ahora podemos implementar los comandos File del menú en nuestro Frame window procedure:

    case ID_FILE_NEW:
        CreateNewMDIChild(g_hMDIClient);
    break;
    case ID_FILE_OPEN:
    {
        HWND hChild = CreateNewMDIChild(g_hMDIClient);
        if(hChild)
        {
            DoFileOpen(hChild); 
        }
    }
    break;
    case ID_FILE_CLOSE:
    {
        HWND hChild = (HWND)SendMessage(g_hMDIClient, WM_MDIGETACTIVE,0,0);
        if(hChild)
        {
            SendMessage(hChild, WM_CLOSE, 0, 0);
        }
    }
    break;

También podemos proveer algún procesamiento IMD por default al ordenamiento de la ventana para nuestro Window Menú, debido a que IMD soporta esto, no es mucho trabajo.

    case ID_WINDOW_TILE:
        SendMessage(g_hMDIClient, WM_MDITILE, 0, 0);
    break;
    case ID_WINDOW_CASCADE:
        SendMessage(g_hMDIClient, WM_MDICASCADE, 0, 0);
    break;

Referencias

Código fuente de la primera parte.

Código fuente de la segunda parte.

Código fuente de la tercera parte.

Código fuente de la cuarta parte.