Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Writing a custom property inspector - How to handle inplace editor focus when validating values?

Overview

I am trying to write my own simple property inspector but I am facing a difficult and rather confusing problem. First though let me say that my component is not meant to work with or handle component properties, instead it will allow adding custom values to it. The full source code of my component is further down the question and it should look something like this once you have installed it in a package and run it from a new empty project:

enter image description here

Problem (brief)

The issue is regarding the use of inplace editors and validating the property values. The idea is, if a property value is not valid then show a message to the user notifying them that the value cannot be accepted, then focus back to the row and inplace editor that was originally focused on.

We can actually use Delphi's very own Object Inspector to illustrate the behavior I am looking for, for example try writing a string in the Name property that cannot be accepted then click away from the Object Inspector. A message is shown and upon closing it, it will focus back to the Name row.

Source Code

The question becomes too vague without any code but due to the nature of the component I am trying to write it's also quite large. I have stripped it down as much as possible for the purpose of the question and example. I am sure there will be some comments asking me why I didn't do this or do that instead but it's important to know that I am no Delphi expert and often I make wrong decisions and choices but I am always willing to learn so all comments are welcomed, especially if it aids in finding my solution.

unit MyInspector;

interface

uses
  Winapi.Windows,
  Winapi.Messages,
  System.Classes,
  System.SysUtils,
  Vcl.Controls,
  Vcl.Dialogs,
  Vcl.StdCtrls,
  Vcl.Graphics,
  Vcl.Forms;

type
  TMyInspectorItems = class(TObject)
  private
    FPropertyNames: TStringList;
    FPropertyValues: TStringList;

    procedure AddItem(APropName, APropValue: string);
    procedure Clear;
  public
    constructor Create;
    destructor Destroy; override;
  end;

  TOnMouseMoveEvent = procedure(Sender: TObject; X, Y: Integer) of object;
  TOnSelectRowEvent = procedure(Sender: TObject; PropName, PropValue: string; RowIndex: Integer) of object;

  TMyCustomInspector = class(TGraphicControl)
  private
    FInspectorItems: TMyInspectorItems;
    FOnMouseMove: TOnMouseMoveEvent;
    FOnSelectRow: TOnSelectRowEvent;

    FRowCount: Integer;
    FNamesFont: TFont;
    FValuesFont: TFont;

    FSelectedRow: Integer;

    procedure SetNamesFont(const AValue: TFont);
    procedure SetValuesFont(const AValue: TFont);

    procedure CalculateInspectorHeight;
    function GetMousePosition: TPoint;
    function MousePositionToRowIndex: Integer;
    function RowIndexToMousePosition(ARowIndex: Integer): Integer;
    function GetRowHeight: Integer;
    function GetValueRowWidth: Integer;
    function RowExists(ARowIndex: Integer): Boolean;
    function IsRowSelected: Boolean;

  protected
    procedure Loaded; override;
    procedure Paint; override;
    procedure WMKeyDown(var Message: TMessage); message WM_KEYDOWN;
    procedure WMMouseDown(var Message: TMessage); message WM_LBUTTONDOWN;
    procedure WMMouseMove(var Message: TMessage); message WM_MOUSEMOVE;
    procedure WMMouseUp(var Message: TMessage); message WM_LBUTTONUP;
  public
    constructor Create(AOwner: TComponent); override;
    destructor Destroy; override;

    function RowCount: Integer;

    property Items: TMyInspectorItems read FInspectorItems write FInspectorItems;
    property OnMouseMove: TOnMouseMoveEvent read FOnMouseMove write FOnMouseMove;
    property OnSelectRow: TOnSelectRowEvent read FOnSelectRow write FOnSelectRow;
  published
    property Align;
  end;

  TMyPropertyInspector = class(TScrollBox)
  private
    FInspector: TMyCustomInspector;
    FInplaceStringEditor: TEdit;

    FSelectedRowName: string;
    FLastSelectedRowName: string;
    FLastSelectedRow: Integer;

    function SetPropertyValue(RevertToPreviousValueOnFail: Boolean): Boolean;

    procedure InplaceStringEditorEnter(Sender: TObject);
    procedure InplaceStringEditorExit(Sender: TObject);
    procedure InplaceStringEditorKeyPress(Sender: TObject; var Key: Char);
    procedure SelectRow(Sender: TObject; PropName, PropValue: string; RowIndex: Integer);
    function ValidateStringValue(Value: string): Boolean;
  protected
    procedure Loaded; override;
    procedure WMSize(var Message: TMessage); message WM_SIZE;
  public
    constructor Create(AOwner: TComponent); override;
    destructor Destroy; override;

    procedure AddItem(APropName, APropValue: string);
    function GetSelectedPropertyName: string;
    function GetSelectedPropertyValue: string;
    function RowCount: Integer;
  end;

var
  FCanSelect: Boolean;

implementation

{ TMyInspectorItems }

constructor TMyInspectorItems.Create;
begin
  inherited Create;
  FPropertyNames  := TStringList.Create;
  FPropertyValues := TStringList.Create;
end;

destructor TMyInspectorItems.Destroy;
begin
  FPropertyNames.Free;
  FPropertyValues.Free;
  inherited Destroy;
end;

procedure TMyInspectorItems.AddItem(APropName, APropValue: string);
begin
  FPropertyNames.Add(APropName);
  FPropertyValues.Add(APropValue);
end;

procedure TMyInspectorItems.Clear;
begin
  FPropertyNames.Clear;
  FPropertyValues.Clear;
end;

{ TMyCustomInspector }

constructor TMyCustomInspector.Create(AOwner: TComponent);
begin
  inherited Create(AOwner);

  FInspectorItems     := TMyInspectorItems.Create;

  FNamesFont          := TFont.Create;
  FNamesFont.Color    := clWindowText;
  FNamesFont.Name     := 'Segoe UI';
  FNamesFont.Size     := 9;
  FNamesFont.Style    := [];

  FValuesFont         := TFont.Create;
  FValuesFont.Color   := clNavy;
  FValuesFont.Name    := 'Segoe UI';
  FValuesFont.Size    := 9;
  FValuesFont.Style   := [];
end;

destructor TMyCustomInspector.Destroy;
begin
  FInspectorItems.Free;
  FNamesFont.Free;
  FValuesFont.Free;
  inherited Destroy;
end;

procedure TMyCustomInspector.Loaded;
begin
  inherited Loaded;
end;

procedure TMyCustomInspector.Paint;

  procedure DrawBackground;
  begin
    Canvas.Brush.Color := clWindow;
    Canvas.Brush.Style := bsSolid;
    Canvas.FillRect(Rect(0, 0, Self.Width, Self.Height));
  end;

  procedure DrawNamesBackground;
  begin
    Canvas.Brush.Color := clWindow;
    Canvas.Brush.Style := bsSolid;
    Canvas.FillRect(Rect(0, 0, Self.Width div 2, Self.Height));
  end;

  procedure DrawNamesSelection;
  begin
    if (FRowCount > -1) and (RowExists(MousePositionToRowIndex)) then
    begin
      Canvas.Brush.Color := $00E0E0E0;
      Canvas.Brush.Style := bsSolid;
      Canvas.FillRect(Rect(0, RowIndexToMousePosition(FSelectedRow),
        Self.Width div 2, RowIndexToMousePosition(FSelectedRow) + GetRowHeight));
    end;
  end;

  procedure DrawNamesText;
  var
    I: Integer;
    Y: Integer;
  begin
    FRowCount := FInspectorItems.FPropertyNames.Count;

    Canvas.Brush.Style  := bsClear;
    Canvas.Font.Color   := FNamesFont.Color;
    Canvas.Font.Name    := FNamesFont.Name;
    Canvas.Font.Size    := FNamesFont.Size;

    Y := 0;
    for I := 0 to FInspectorItems.FPropertyNames.Count -1 do
    begin
      Canvas.TextOut(2, Y, FInspectorItems.FPropertyNames.Strings[I]);
      Inc(Y, GetRowHeight);
    end;
  end;

  procedure DrawValuesBackground;
  begin
    Canvas.Brush.Color := clWindow;
    Canvas.Brush.Style := bsSolid;
    Canvas.FillRect(Rect(Self.Width div 2, 0, Self.Width, Self.Height));
  end;

  procedure DrawValuesSelection;
  begin
    if (FRowCount > -1) and (RowExists(MousePositionToRowIndex)) then
    begin
      Canvas.DrawFocusRect(Rect(Self.Width div 2, RowIndexToMousePosition(FSelectedRow),
        Self.Width, RowIndexToMousePosition(FSelectedRow) + GetRowHeight));
    end;
  end;

  procedure DrawValues;
  var
    I, Y: Integer;
  begin
    FRowCount := FInspectorItems.FPropertyValues.Count;

    Y := 0;
    for I := 0 to FInspectorItems.FPropertyValues.Count -1 do
    begin
      Canvas.Brush.Style  := bsClear;
      Canvas.Font.Color   := FValuesFont.Color;
      Canvas.Font.Name    := FValuesFont.Name;
      Canvas.Font.Size    := FValuesFont.Size;

      Canvas.TextOut(Self.Width div 2 + 2, Y + 1, FInspectorItems.FPropertyValues.Strings[I]);
      Inc(Y, GetRowHeight);
    end;
  end;

begin
  DrawNamesBackground;
  DrawNamesSelection;
  DrawNamesText;
  DrawValuesBackground;
  DrawValuesSelection;
  DrawValues;
end;

procedure TMyCustomInspector.WMKeyDown(var Message: TMessage);
begin
  inherited;

  case Message.WParam of
    VK_DOWN:
    begin

    end;
  end;
end;

procedure TMyCustomInspector.WMMouseDown(var Message: TMessage);
begin
  inherited;

  Parent.SetFocus;

  FSelectedRow := MousePositionToRowIndex;

  if FSelectedRow <> -1 then
  begin
    if Assigned(FOnSelectRow) then
    begin
      FOnSelectRow(Self, FInspectorItems.FPropertyNames.Strings[FSelectedRow],
        FInspectorItems.FPropertyValues.Strings[FSelectedRow], FSelectedRow);
    end;
  end;

  Invalidate;
end;

procedure TMyCustomInspector.WMMouseMove(var Message: TMessage);
begin
  inherited;

  if Assigned(FOnMouseMove) then
  begin
    FOnMouseMove(Self, GetMousePosition.X, GetMousePosition.Y);
  end;
end;

procedure TMyCustomInspector.WMMouseUp(var Message: TMessage);
begin
  inherited;
end;

procedure TMyCustomInspector.SetNamesFont(const AValue: TFont);
begin
  FNamesFont.Assign(AValue);
  Invalidate;
end;

procedure TMyCustomInspector.SetValuesFont(const AValue: TFont);
begin
  FValuesFont.Assign(AValue);
  Invalidate;
end;

procedure TMyCustomInspector.CalculateInspectorHeight;
var
  I, Y: Integer;
begin
  FRowCount := FInspectorItems.FPropertyNames.Count;

  Y := GetRowHeight;
  for I := 0 to FRowCount -1 do
  begin
    Inc(Y, GetRowHeight);
  end;

  if Self.Height <> Y then
    Self.Height := Y;
end;

function TMyCustomInspector.GetMousePosition: TPoint;
var
  Pt: TPoint;
begin
  Pt := Mouse.CursorPos;
  Pt := ScreenToClient(Pt);
  Result := Pt;
end;

function TMyCustomInspector.MousePositionToRowIndex: Integer;
begin
  Result := GetMousePosition.Y div GetRowHeight;
end;

function TMyCustomInspector.RowIndexToMousePosition(
  ARowIndex: Integer): Integer;
begin
  Result := ARowIndex * GetRowHeight;
end;

function TMyCustomInspector.GetRowHeight: Integer;
begin
  Result := FNamesFont.Size * 2 + 1;
end;

function TMyCustomInspector.GetValueRowWidth: Integer;
begin
  Result := Self.Width div 2;
end;

function TMyCustomInspector.RowCount: Integer;
begin
  Result := FRowCount;
end;

function TMyCustomInspector.RowExists(ARowIndex: Integer): Boolean;
begin
  Result := MousePositionToRowIndex < RowCount;
end;

function TMyCustomInspector.IsRowSelected: Boolean;
begin
  Result := FSelectedRow <> -1;
end;

{ TMyPropertyInspector }

constructor TMyPropertyInspector.Create(AOwner: TComponent);
begin
  inherited Create(AOwner);

  Self.DoubleBuffered               := True;
  Self.Height                       := 150;
  Self.HorzScrollBar.Visible        := False;
  Self.TabStop                      := True; // needed to receive focus
  Self.Width                        := 250;

  FInspector                        := TMyCustomInspector.Create(Self);
  FInspector.Parent                 := Self;
  FInspector.Align                  := alTop;
  FInspector.Height                 := 0;
  FInspector.OnSelectRow            := SelectRow;

  FInplaceStringEditor              := TEdit.Create(Self);
  FInplaceStringEditor.Parent       := Self;
  FInplaceStringEditor.BorderStyle  := bsNone;
  FInplaceStringEditor.Color        := clWindow;
  FInplaceStringEditor.Height       := 0;
  FInplaceStringEditor.Left         := 0;
  FInplaceStringEditor.Name         := 'MyPropInspectorInplaceStringEditor';
  FInplaceStringEditor.Top          := 0;
  FInplaceStringEditor.Visible      := False;
  FInplaceStringEditor.Width        := 0;
  FInplaceStringEditor.Font.Assign(FInspector.FValuesFont);

  FInplaceStringEditor.OnEnter      := InplaceStringEditorEnter;
  FInplaceStringEditor.OnExit       := InplaceStringEditorExit;
  FInplaceStringEditor.OnKeyPress   := InplaceStringEditorKeyPress;

  FCanSelect                        := True;
end;

destructor TMyPropertyInspector.Destroy;
begin
  FInspector.Free;
  FInplaceStringEditor.Free;
  inherited Destroy;
end;

procedure TMyPropertyInspector.Loaded;
begin
  inherited Loaded;
end;

procedure TMyPropertyInspector.WMSize(var Message: TMessage);
begin
  FInspector.Width := Self.Width;
  Invalidate;
end;


procedure TMyPropertyInspector.AddItem(APropName, APropValue: string);
begin
  FInspector.CalculateInspectorHeight;
  FInspector.Items.AddItem(APropName, APropValue);
  FInspector.Invalidate;
  Self.Invalidate;
end;

function TMyPropertyInspector.GetSelectedPropertyName: string;
begin
  Result := '';

  if FInspector.FSelectedRow <> -1 then
  begin
    Result := FInspector.FInspectorItems.FPropertyNames.Strings[FInspector.FSelectedRow];
  end;
end;

function TMyPropertyInspector.GetSelectedPropertyValue: string;
begin
  Result := '';

  if FInspector.FSelectedRow <> -1 then
  begin
    Result := FInspector.FInspectorItems.FPropertyValues.Strings[FInspector.FSelectedRow];
  end;
end;

function TMyPropertyInspector.RowCount: Integer;
begin
  Result := FInspector.RowCount;
end;

procedure TMyPropertyInspector.InplaceStringEditorEnter(Sender: TObject);
begin
  FCanSelect := False;
  FLastSelectedRow := FInplaceStringEditor.Tag;
end;

procedure TMyPropertyInspector.InplaceStringEditorExit(Sender: TObject);
begin
  if SetPropertyValue(True) then
  begin
    FCanSelect := True;
  end;
end;

procedure TMyPropertyInspector.InplaceStringEditorKeyPress(Sender: TObject;
  var Key: Char);
begin
  if Key = Chr(VK_RETURN) then
  begin
    Key := #0;
    FInplaceStringEditor.SelectAll;
  end;
end;

procedure TMyPropertyInspector.SelectRow(Sender: TObject; PropName, PropValue: string; RowIndex: Integer);
begin
  FSelectedRowName     := PropName;
  FLastSelectedRowName := PropName;

  FInplaceStringEditor.Height   := FInspector.GetRowHeight - 2;
  FInplaceStringEditor.Left     := Self.Width div 2;
  FInplaceStringEditor.Tag      := RowIndex;
  FInplaceStringEditor.Text     := GetSelectedPropertyValue;
  FInplaceStringEditor.Top      := FInspector.RowIndexToMousePosition(FInspector.FSelectedRow) + 1 - Self.VertScrollBar.Position;
  FInplaceStringEditor.Visible  := True;
  FInplaceStringEditor.Width    := FInspector.GetValueRowWidth - 3;
  FInplaceStringEditor.SetFocus;
  FInplaceStringEditor.SelectAll;
end;

function TMyPropertyInspector.SetPropertyValue(
  RevertToPreviousValueOnFail: Boolean): Boolean;
var
  S: string;
begin
  Result := False;

  S := FInplaceStringEditor.Text;

  if ValidateStringValue(S) then
  begin
    Result := True;
  end
  else
  begin
    ShowMessage('"' + S + '"' + 'is not a valid value.');
    Result := False;
  end;
end;

function TMyPropertyInspector.ValidateStringValue(Value: string): Boolean;
begin
  // a quick and dirty way of testing for a valid string value, here we just
  // look for strings that are not zero length.
  Result := Length(Value) > 0;
end;

end.

Problem (detailed)

The confusion I have all comes down to who receives focus first and how to handle and respond to it correctly. Because I am custom drawing my rows I determine where the mouse is when clicking on the inspector control and then I draw the selected row to show this. When handling the inplace editors however, especially the OnEnter and OnExit event I have been facing all kinds of funky problems where in some cases I have been stuck in a cycle of the validate error message repeatedly showing for example (because focus is switching from my inspector to the inplace editor and back and forth).

To populate my inspector at runtime you can do the following:

procedure TForm1.Button1Click(Sender: TObject);
begin
  MyPropertyInspector1.AddItem('A', 'Some Text');
  MyPropertyInspector1.AddItem('B', 'Hello World');
  MyPropertyInspector1.AddItem('C', 'Blah Blah');
  MyPropertyInspector1.AddItem('D', 'The Sky is Blue');
  MyPropertyInspector1.AddItem('E', 'Another String');
end;

A little something you may try:

  • Click on a row
  • Delete the contents from the inplace editor
  • Select another row
  • The validate error message box appears (don't close it yet)
  • With the message box still visible, move your mouse over another row
  • Now press Enter to close the message box
  • You will notice the selected row has now moved to where the mouse was

What I need is after the validate message box has shown and closed, I need to set the focus back to the row that was been validated in the first place. It gets confusing because it seems (or so I think) that the inplace editors OnExit is been called after the WMMouseDown(var Message: TMessage); code of my inspector.

To put it as simple as I can if the question remains unclear, the behavior of the Delphi Object Inspector is what I am trying to implement into my component. You enter a value into the inplace editors, if it fails the validation then display a messagebox and then focus back to the row that was last selected. The inplace editor validation should occur as soon as focus is switched away from the inplace editor.

like image 370
Craig Avatar asked Oct 30 '22 22:10

Craig


1 Answers

I just can't seem to figure out what is been called first and what is blocking events been fired, it becomes confusing because the way I draw my selected row is determined by where the mouse was when clicking on the inspector control.

This is your flow of events:

  • TMyCustomInspector.WMMouseDown is called
    1. Therein, Parent.SetFocus is called
      • The focus is removed from the Edit control and TMyPropertyInspector.InplaceStringEditorExit is called
      • The message dialog is shown by SetPropertyValue
    2. FSelectedRow is being reset
    3. TMyPropertyInspector.SelectRow is called (via TMyCustomInspector.FOnSelectRow) which resets the focus to the replaced Edit control.

What you need to is to prevent FSelectedRow being reset in case of validation did not succeed. All needed ingredients are already there, just add this one condition:

  if FCanSelect then
    FSelectedRow := MousePositionToRowIndex;

A few remarks:

  • Make FCanSelect a protected or private field of TMyCustomInspector,
  • You need to check for limits in TMyCustomInspector.MousePositionToRowIndex in order to return -1.
like image 68
NGLN Avatar answered Nov 15 '22 06:11

NGLN