jueves, 26 de septiembre de 2019

TTreeView con nodos y punteros TTreeNode.Data

En la entrada anterior vimos las operaciones básicas de un TreeView solo con ítems, ahora veremos con nodos, que ya sirve de algo, con punteros a objetos, más precisamente a un registro, que es apuntado por TreeNode.Data siendo Data del tipo Pointer y, siendo Pointer un puntero sin tipo. En el programa de ejemplo se muestra como agregar un nodo raíz, un nodo hijo, el índice absoluto, modificar los datos de un registro de un nodo, borrar un nodo, mostrar los datos del registro apuntado por un nodo. Para el caso, los nodos raíz, no pueden apuntar a un objeto, solo a hojas (nodos), para el caso que se necesite que lo nodos de nivel cero tengan un registro (apunten a un registro), ya que no se puede, lo que se hace es se crea un único nodo raíz y se lo oculta, de esta forma todos los nodos parten del nivel 1 y son hijos de ese único nodo padre. En este ejemplo se parte de dos nodos raíces a los cuales el usuario le agrega nodos hijos, nietos, etc. y todos tienen el mismo tipo de registro, lo cual no es obligatorio; en este ejemplo un nodo raíz se llama "profesores" y el otro "alumnos", los nodos de ambos apuntan el mismo tipo de registro pero bien se puede crear un registro para profesores y otro para alumnos.


Como se ve en la imagen, se utilizan: un TTreeView, 5 TEdit, 4 TLabel y 5 botones.


Le agregamos 2 elementos al árbol, nodos de nivel 0 (cero), todas la operaciones que se hagan, serán sobre los nodos que "cuelguen" de estos dos.

type
PRegistro=^TRegistro;

TRegistro=record
  documento:string;
  nacionalidad:string;
  estadocivil:string;
end;


Definimos un registro y un puntero a dicho registro.

Agregar nodo:

procedure TForm1.BAgregarClick(Sender: TObject);
var
  RegPtr:PRegistro;
begin
  New(RegPtr);
  RegPtr^.documento:=edDocumento.Text;
  RegPtr^.nacionalidad:=edNacionalidad.Text;
  RegPtr^.estadocivil:=edEstadoCivil.Text;
  if (tv.Items.Count=0) or (tv.Selected=nil) then
    tv.Items.Add (nil, edNombre.Text)
  else
   tv.Items.AddChildObject(tv.Selected, edNombre.Text, RegPtr);
end;


Declaramos una variable local del tipo PRegistro (puntero al registro) y con New la instanciamos. Es decir, tenemos una nueva dirección de memoria y espacio para nuestro nuevo registro, luego le asignamos los datos. Ahora a ese registro que está "flotando" en la memoria RAM, hay que enlazarlo al árbol, y esto se hace asignando a la propiedad Data del nodo, la dirección de memoria donde se aloja el registro, eso lo hacemos mediante AddChildObject (nodopadre, textoamostrar, punteroalregistro). La parte True del if es para agregar un elemento raíz, si se agrega sin haber seleccionado un nodo, creará un ítem nivel 0.

Borrar un nodo:

procedure TForm1.BBorrarNodoClick(Sender: TObject);
begin
  if tv.Selected=nil then Exit;
  tv.Selected.Delete;
  BorroEdit; //Procedimiento que borrar los TEdit.
end;


Siempre averiguar si hay algún nodo seleccionado para evitar errores.

Modificar un nodo:

procedure TForm1.BModificarClick(Sender: TObject);
begin
  if tv.Selected=nil then Exit;
  edNombre.Text:=tv.Selected.Text;
  edDocumento.Text:=PRegistro(tv.Selected.Data)^.documento;
  edNacionalidad.Text:=PRegistro(tv.Selected.Data)^.nacionalidad;
  edEstadoCivil.Text:=PRegistro(tv.Selected.Data)^.estadocivil;
end;


En esta primera parte se carga el registro apuntado por el nodo en los TEdit. Como vemos no es necesario crear una variable local del tipo puntero a TRegsitro, se utiliza PRegistro(puntero)^.campo.

procedure TForm1.BActualizarClick(Sender: TObject);
begin
  if tv.Selected=nil then Exit;
  if tv.Selected.Level < 1 then Exit; 

  tv.Selected.Text:=edNombre.Text;      
  PRegistro(tv.Selected.Data)^.documento:=edDocumento.Text;  
  PRegistro(tv.Selected.Data)^.nacionalidad:=edNacionalidad.Text; 
  PRegistro(tv.Selected.Data)^.estadocivil:=edEstadoCivil.Text; 
end;

Cuando puslamos el botón Actualizar, lo que está en los TEdit, lo asignamos al registro; previamente hacemos las validaciones mínimas del caso, ya que solo es un ejemplo.

Ver el registro apuntado por un nodo:

procedure TForm1.BVerClick(Sender: TObject);
begin
  if tv.Items.Count < 1 then Exit;
  if tv.Selected=nil then exit;
  if (tv.Selected.Data < > nil) then
  begin
    lNombre2.Caption:=tv.Selected.Text;
    lDocumento.Caption:=PRegistro(tv.Selected.Data)^.documento;
    lNacionalidad.Caption:=PRegistro(tv.Selected.Data)^.nacionalidad;
    lEstadoCivil.Caption:=PRegistro(tv.Selected.Data)^.estadocivil;
  end;
end;


Mostramos el contenido del registro en los TLabel. Como siempre, validar antes de ejecutar la acción.

procedure TForm1.tvChange(Sender: TObject; Node: TTreeNode);
begin
  if (not(Assigned(Node)) or (Node.Level < 1)) then Exit;
  edNombre.Text:=tv.Selected.Text;
  edDocumento.Text:=PRegistro(tv.Selected.Data)^.documento;  

  edNacionalidad.Text:=PRegistro(tv.Selected.Data)^.nacionalidad;
  edEstadoCivil.Text:=PRegistro(tv.Selected.Data)^.estadocivil;
end;



En este caso cargamos los TEdit con el registro del nodo al cual se le ha hecho click y atención, no usamos el evento OnClick sino el evento OnChange que se lanza o dispara cuando cambia el nodo seleccionado. Es importante la validación previa, si el Node, que viene como parámetro, no está asignado o es de nivel 0, entonces "huímos".
Al implementar este método ya no sería necesario el botón modificar que carga los TEdit.

Ver el índice absoluto de un nodo:

procedure TForm1.BVerIndiceClick(Sender: TObject);
begin
  if tv.Selected=nil then exit;
  Edit4.Text := IntToStr(tv.Selected.AbsoluteIndex);
end;


Cada nodo tiene un índice absoluto dentro de un árbol, independientemente de su nivel (level), vendría a ser un identificador único.

Video:



Código fuente del proyecto: TTreeView_Nodos.7z

domingo, 15 de septiembre de 2019

TTreeView básico, con ítems.

La aclaración "con ítems" en el título significa, sin punteros, eso queda para la próxima, nos limitaremos a dos elementos por nodo: texto e imagen. Si bien en sí casi carece de utilidad, es por donde se debe empezar, luego vendrá lo útil, con punteros a objetos. Veremos como agregar nodos raíz, hijos; eliminar, modificar, selección simple y múltiple, ordenar el árbol, ajustar la identación, guardar a un archivo de texto, cargar desde un archivo de texto, averiguar los niveles de las hojas, buscar, expandir y contraer todo el árbol.

En un formulario, colocar un TTreeView, 12 botones, un TImageList y un TTrackBar.


Vamos a empezar con un árbol vacío para agregar, modificar y borrar hojas.

Nuevo:

procedure TForm1.BNuevoClick(Sender: TObject);
var
  nombre:String;
begin
  nombre:=InputBox('Crear un nodo','Nombre: ','');
  if nombre<>'' then
  begin
    if TreeView1.Selected<>nil then
      begin
        TreeView1.Items.AddChild(TreeView1.Selected, nombre);
        TreeView1.Selected.Expanded:=True;
      end
    else
      TreeView1.Items.Add(nil, nombre);
  end;
  TreeView1.Selected:=nil; //opcional, para que no quede el nodo seleccionado.
end;


Siempre hay que saber si hay un nodo seleccionado, para eso if TreeView1.Selected<>nil
Si hay un nodo seleccionado, entonces se agrega un nodo hijo con el evento Items.AddChild y como primer parámetro se pasa el nodo seleccionado TreeView1.Selected y el texto. Luego, opcional pero recomendable, expandir el nodo para que se vea el nuevo nodo TreeView1.Selected.Expanded:=True;
Si no hay un nodo seleccionado, entonces el nuevo nodo será raíz y usamos el evento Items.Add y como primer parámetro nil para indicar que se trata de una raíz. Desde ya si queremos que el árbol pueda tener varias raíces, hay casos en que el árbol solo tiene una raíz.
Y la última sentencia TreeView1.Selected:=nil es justamente para eso, para permitir al usuario agregar más nodos raíces, caso contrario siempre hay activa una selección y siempre se crearían nodos hijos. Otra opción sería un botón para des-seleccionar.

Modificar:

procedure TForm1.BModificarClick(Sender: TObject);
var
  nombre:String;
begin
  if TreeView1.Selected<>nil then
  begin
    nombre:=InputBox('Modificar un nodo','Nombre: ',TreeView1.Selected.Text);
    if nombre<>'' then TreeView1.Selected.Text:=nombre;
  end;
  TreeView1.Selected:=nil;
//opcional, para que no quede el nodo seleccionado.
end;

Eliminar:

procedure TForm1.BEliminarClick(Sender: TObject);
begin
  if TreeView1.Selected<>nil then TreeView1.Selected.Delete;
end;


Hasta ahora siempre trabajamos sobre un ítem seleccionado.

Borrar todo el árbol:

procedure TForm1.BBorrarArbolClick(Sender: TObject);
begin
  TreeView1.Items.Clear;
end;


Niveles:

procedure TForm1.BNivelesClick(Sender: TObject);
var
  i:Integer;
begin
  for i:=0 to TreeView1.Items.Count-1 do
    TreeView1.Items[i].Text:=TreeView1.Items[i].Text+
    'Niv.'+IntToStr(TreeView1.Items[i].Level);
end;


La propiedad items es del tipo TTreeNode y es la colección de ítems del  árbol. Este procedimiento recorre todos los ítems, averigua el nivel del ítem con con la propiedad Level (entero) y lo agrega al texto del nodo. El nodo raíz es de nivel 0 (cero), un nodo hijo es de nivel 1, etc.

Ordernar:

procedure TForm1.BOrdenarClick(Sender: TObject);
begin
  TreeView1.AlphaSort;
end;


Esta es una opción, el método AlphaSort. En el inspector de objetos podemos encontrar la opción SortType. Volviendo a AlphaSort, hay una diferencia con Delphi donde este método tiene un parámetro del tipo boolean, AlphaSort(True) en FreePascal nos devolverá un error.

El evento OnCompare:

procedure TForm1.TreeView1Compare(Sender: TObject; Node1, Node2: TTreeNode; var Compare: Integer);
begin
  Compare:=CompareText(Node1.Text,Node2.Text);
end;


Si este evento está definido y se llama a AlphaSort, AlphaSort se ignora.
Compare:=CompareText(Node1.Text,Node2.Text); donde compare es una variable pasada por referencia en OnCompare:
Compare será menor que 0 si Node1 es menor que Node2, Compare es 0 si Node1 es equivalente a Node2 y Compare será mayor que 0 si Node1 es mayor que Node2. Si no se usa OnCompare, los nodos de vista de árbol se ordenan alfabéticamente.

Elementos seleccionados:

procedure TForm1.BSeleccionadosClick(Sender: TObject);
var
  i:Integer;
  s:TStringList;
begin
  s:=TStringList.Create;
  for i:=0 to TreeView1.Items.Count-1 do
    if TreeView1.Items[i].Selected then s.Add(TreeView1.Items[i].Text);
  ShowMessage(s.Text);
  FreeAndNil(s);
end;


Como en varias de las operaciones con árboles, deben recorrerse todos sus elementos, en este caso, debemos averiguar si la propiedad Selected es True.

Cargar imágenes a los nodos:


En este caso cargué dos imágenes de 32x32 pixeles en ImageList1.
Hay que asignar ImageList1 a la propiedad Images de TreeView1, desde el IO (Inspector de Objetos) o por código.

procedure TForm1.BCargarIconosClick(Sender: TObject);
var
  i:Integer;
begin
  for i:=0 to TreeView1.Items.Count-1 do
    if TreeView1.Items[i].Level=0 then
      begin
        TreeView1.Items[i].ImageIndex:=0;
        TreeView1.Items[i].SelectedIndex:=0;
      end
    else
      begin
        TreeView1.Items[i].ImageIndex:=1;
        TreeView1.Items[i].SelectedIndex:=1;
      end;
end;


A modo de ejemplo se le asigna la imagen 0 a los nodos raíz (level=0) y la imagen 1 a las hojas de nivel 1 con TreeView1.Items[i].ImageIndex:=0 y agregamos también TreeView1.Items[i].SelectedIndex:=0 porque caso contrario al seleccionar el nodo se borra la imagen, al menos usando el widget GTK. Es decir, para evitar que desaparezca la imagen al seleccionar un ítem o nodo se debe asignar la misma imagen a la propiedad SelectedIndex.

Guardar en archivo de texto:

procedure TForm1.BguardarClick(Sender: TObject);
begin
  TreeView1.SaveToFile('arbol1.txt');
end;


Leer desde archivo de texto:

procedure TForm1.BCargarClick(Sender: TObject);
begin
  TreeView1.LoadFromFile('arbol1.txt');
end;



Como vemos es un elemento por línea y los niveles se establecen mediante tabulación.

Expandir y contraer (todo el árbol):

TreeView1.FullExpand y TreeView1.FullCollapse.

Espacio horizontal entre nodos:

procedure TForm1.TrackBar1Change(Sender: TObject);
begin
  TreeView1.Indent:=TrackBar1.Position;
end;


En este ejemplo se utiliza un TTrackBar, la posición se asigna a la propiedad Indent del árbol.

Personalizar:

procedure TForm1.TreeView1CustomDrawItem(Sender: TCustomTreeView; Node: TTreeNode; State: TCustomDrawState; var DefaultDraw: Boolean);
begin
  if Node.Level=1 then
    begin
      Sender.Canvas.Font.Color:=clBlue; //tvoThemeDraw:=False
      Sender.Canvas.Font.Style:=[fsBold];
    end
  else
    begin
      Sender.Canvas.Font.Color:=clBlack;
      Sender.Canvas.Font.Style:=[];
    end;
  if cdsFocused in State then Sender.Canvas.Font.Color:=clWhite;
end;


Mediante el evento OnCustomDraw pondremos en color azul y negrita los nodos del nivel 1 y el resto color negro sin estilo. A su vez, si el nodo está seleccionado el color de la fuente será blanco. Para que esto funcione se deberá establecer la propiedad del TTreeView.Options.tvoThemeDraw en False, ya sea mediante el IO o por código.

Luego de haber practicado como agregar, modificar y borrar un nodo, se recomienda agregar elementos al árbol.

Click derecho sobre el árbol y Editar elementos para cagar algunos datos.


Notas acerca de TTreeView:

Acepta 2 listas de imágenes, de una obtiene el estado (StateImages), ideal para check box, y la otra el icono (Images).
Para crear un nodo raíz: TreeView1.Items.Add(nil, 'texto') el nombre/texto es lo básico, sino hay que usar .Data que permite apuntar a un objeto (Clase, Registro).
Los Items del TTreeView son de la clase TTreeNode.
State: TCustomDrawState enumerados, state es un conjunto.
type TCustomDrawState = set of (
  cdsSelected,
  cdsGrayed,
  cdsDisabled,
  cdsChecked,
  cdsFocused,
  cdsDefault,
  cdsHot,
  cdsMarked,
  cdsIndeterminate
);


Código fuente del proyecto: TTreeView1.7z

Actualización: Buscar por texto:

Antes de comenzar con la actualización, quiero destacar un opinión respecto de la pobre documentación de Lazarus y Free Pascal. No es posible que tenga que estar días y días buscando documentación detalla de TTreeView, el ejemplo que viene con Lazarus es la cuarta parte de lo que dice esta entrada, y esta entrada en sí es limitadísima, basta con ver todas las propiedades y eventos que tiene la clase TTreeView como TCustomTreeView de la que deriva. ¿A quién le reclamo esto? A nadie, simplemente lo destaco, así como destaco mil cosas buenas de Lazarus y Free Pascal, destaco esta mala, de paso practico un poco de imparcialidad y me descargo. Volvamos al tema de la búsqueda.

Colocar un botón "Buscar" y un TEdit donde el usuario ingresará el texto y dicho texto es el que buscaremos que coincida con algún nodo o ítem, en caso de hallarlo, lo haremos visible y lo seleccionaremos.

procedure TForm1.BBuscarClick(Sender: TObject);
var
  n:TTreeNode;
begin
  n:=tv.Items.FindNodeWithText(edBuscar.Text);
  if not Assigned(n) then exit;
  n.MakeVisible;
  n.Selected:=True;
end;


A TreeView1 le cambié el nombre por tv por comodidad.
Usamos la función FindNodeWithText que pertence a ítems y le pasamos como parámetro el texto a buscar. Pero como dicha función devuelve un nodo, debemos tener a mano una variable del tipo TTreeNode para asignarle el resultado.
Para evitar un error SIGSEGV es que preguntamos if not Assigned(n) para el caso de que no se halla encontrado un nodo con el texto buscado. Finalmente con las dos últimas sentencias mostramos y seleccionamos el nodo.

TTreeView con nodos con punteros a registros.