TEditBox that only accepts numbers

Post date: Sep 5, 2010 2:55:20 AM

This investigation by Peter Below of TeamB, details how to create a child component and modify certain features from the inherited whilst keeping most intact.

I will edit the quote later for readability.

Question:

> I would like to start writing a few simple components, so i thought of doing

> an edit box that will only except numbers. I think there are a few around

> but i would like to know how to write one myself from scratch.

> Could someone tell me of a site where i could find out this information or

Answer:

Fairly easy. Open the IDE, from the Components menu select "New component". In

the dialog that appears select TEdit as parent for your component, enter a

class name to replace the default TEdit1 (e.g. TNumberEdit), click OK (not

Install!). The IDe creates a new unit and opens it. The unit contains the

component skeleton code. The next step is to figure out where to place the

code to do what you want. The task at hand is basically a filtering of

character input, if you were doing this on a form level you would perhaps use

the OnKeyPress event of the control. As a component writer you do not use

events, you override the *method* that fires the event. A quick browse through

the VCL source code for TEdit and TCustomedit shows, that it does not declare

or handle the event, it inherits it. So dig further down to TWinControl. Ah

yes, that introduces the event and the event is fired from a method named

KeyPress, which is dynamic, so it can be overriden. TWinControl declares this

method in its protected section, so that is where the declaration should go in

TNumberEdit as well. Enter the line

procedure KeyPress(var Key: Char); override;

at the appropriate place in the TNumberEdit class declaration and invoke class

completion to get a body for it. Now we need a management descision: should

the method call the inherited method before or after it filters the key?

Should it call inherited at all if the key is filtered out? This determines

whether a OnKeyPress handler attached to the control will ever be fired and

what keys it will see. Since the purpose of the control is to filter

characters it seems to make sense (at least for me) to only trigger the event

if the character is accepted. So the first draft of the method would look like

this:

procedure TNumberEdit.keyPress( Var Key: Char );

begin

if Key In ['0'..'9', #8] Then // #8 = backspace

inherited

Else

Key := #0;

end;

Of course this should be tested. It is a bad idea to install a component into

the IDE before it is fully tested, since a buggy component can crash the IDE.

So create a test project, add the control unit to its uses clause, add a

handler for the forms OnCreate event and create a control in code in the

handler with

with TNumberedit.create( self ) do begin

parent := self;

left := 10;

top := 10;

end;

Compile and run and play with it. Seems to work. But is it failsafe? No, you

can copy text from elsewhere to the clipboard and paste it into the control,

whether it consists of a valid number or not. Bugger. How can we trap a paste

request? The user can trigger it in numerous ways, using Ctrl-V, Shift-Ins,

the controls popup menu, some code may even call the controls

PasteFromClipboard routine! Mh, Ctrl-V does nothing but Shift-Ins does paste.

Why that? Oh, Ctrl-Letter keys do actually produce key press events, Ctrl-V

would result in ^V as key, and we filter that out. Not nice for the user,

paste should work if the clipboard contains a number, so modify the

KeyPress handler to include ^V in the set of allowed characters.

if Key In ['0'..'9', #8, ^V] Then // #8 = backspace

Now, is there a central way to trap all these ways to trigger a paste? A look

at TCustomEdit.PasteFromClipboard shows that it does this:

SendMessage(Handle, WM_PASTE, 0, 0);

Now, that looks promising. A message we can trap with a message handler, so

add a handler for WM_PASTE to the control. The declaration goes into the

private section

Procedure WMPaste( Var msg: TMessage ); message WM_PASTE;

Since no message parameters are needed we can use a plain TMessage as

parameter. The handler just logs the message for now:

procedure tNumberedit.WMPaste(var msg: TMessage);

begin

inherited;

OutputDebugString('Paste trapped');

end;

Note that i use OUtputDebugString, not ShowMessage. Popping up a message

dialog interrupts the message flow and changes timings drastically. This can

often interfere with whatever you try to debug at the moment and is especially

problematic in message and event handlers. The IDEs event log shows the

messages output via OUtputDebugString. OK, on for some more tests. And bingo!

All four ways of triggering a paste result in a WM_PASTE message! So we can

check the clipboard content in the message and see if it fits our filter. If

it does not we simply do not call the inherited handler. So, add a Uses clause

to the units Implementation section, add ClibBrd to it, modify the message

handler as

procedure tNumberedit.WMPaste(var msg: TMessage);

var

s: String;

i: Integer;

begin

S:= Clipboard.AsText;

For i:= 1 to Length(S) Do

If not (S[i] In ['0'..'9']) Then

Exit;

inherited;

end;

Compile and test, and it actually works. Now, is there another method to

change the controls content? Well, there is: assigning to the controls Text

property. Since that is code-induced one may have the opinion that this is the

programmers responsibility, not the controls. But lets be thorough and at

least check the text before it is assigned and raise an assertion if it is not

fitting out filter. How to go about this? Again, lets look at the VCL source

(i hope you see that it is absolutely indispensible to have that if you want

to write components). The Text property is actually inherited from TControl

and it has a setter method, SetText. Which is not virtual. Oh, it calls

SetTextBuf internally. Which is not virtual. But it uses a message to load the

text into the control: WM_SETTEXT. Bingo, another message to handle. So lets

add another message handler, this time we need a parameter and so use one of

the predefined message records (see Messages unit):

Procedure WMSetText( Var msg: TWMSettext ); message WM_SETTEXT;

procedure tNumberedit.WMSetText(var msg: TWMSettext);

var

S: String;

i: Integer;

begin

S:= msg.Text;

For i:= 1 to Length(S) Do

If not (S[i] In ['0'..'9']) Then

Exit;

inherited;

end;

To test that we have to have access to the test control, so add a field

FNumedit: TNumberEdit;

to the test forms private section, add a button, a normal TEdit, and modify

the OnCreate event

procedure TForm1.FormCreate(Sender: TObject);

begin

FNumedit:= TNumberedit.create( self );

with FNumEdit do begin

parent := self;

left := 10;

top := 10;

end;

end;

procedure TForm1.Button1Click(Sender: TObject);

begin

FNumEdit.text := edit1.text;

end;

Compile, run, type something in the edit, hit button, works as intended. Now,

is this all? No, unfortunately not. There is a further way to modify the

content of an edit control: by assigning to the SelText property. <Groan>,

will this never end? No rest for the wicked... Seltext is introduced in

TCustomEdit, it has a set method SetSelText, which is not virtual but calls

SetSelTextBuf, which is not virtual but sends a message to the control:

EM_REPLACESEL. Does this sound familiar? Yes, another message handler! This

time we have a slight problem since there is no predefined message record for

this message. A comparison between SetSeltextBuf and SettextBuf shows,

however, that both messages use the exact same set of message parameters,

passing the text PChar in lparam. So we can reuse TWMSettext here.

type

TEMreplaceSel = TWMSettext;

private

Procedure EMReplaceSel( Var msg: TEMReplaceSel ); message EM_REPLACESEL;

and the handler code is exactly identical to the one for WM_SETTEXT:

procedure tNumberedit.EMReplaceSel(var msg: TEMReplaceSel);

var

S: String;

i: Integer;

begin

S:= msg.Text;

For i:= 1 to Length(S) Do

If not (S[i] In ['0'..'9']) Then

Exit;

inherited;

end;

This now seems to cover all the bases but the code stinks to high heaven: it

is full of code duplication. And it is not very flexible. What if we want to

make and edit control for real numbers, or one that only allows hexadecimal

numbers? The answer is to create a new class inserted between TEdit and out

TNumberedit and move all the code that would be common for these modified

edits to that class. And the rest is isolated into virtual methods we can

override as needed to change the behaviour. The variable part is basically the

set of allowed characters, so a virtual IsCharValid function will do nicely.

The final (for now) product looks like this:

unit testNumber;

interface

uses

Windows, Messages, SysUtils, Classes, Graphics, Controls, Forms, Dialogs,

StdCtrls;

type

TEMreplaceSel = TWMSettext;

TFilteredEdit = class( TEdit )

private

Procedure WMPaste( Var msg: TMessage ); message WM_PASTE;

Procedure WMSetText( Var msg: TWMSettext ); message WM_SETTEXT;

Procedure EMReplaceSel( Var msg: TEMReplaceSel ); message EM_REPLACESEL;

protected

procedure KeyPress(var Key: Char); override;

function IsCharValid( ch: Char ): Boolean; virtual; abstract;

function IsStringValid( const S: String ): Boolean;

end;

tNumberedit = class(TFilteredEdit)

protected

function IsCharValid( ch: Char ): Boolean; override;

end;

procedure Register;

implementation

uses clipbrd;

procedure Register;

begin

RegisterComponents('PBGoodies', [tNumberedit]);

end;

{ tNumber }

procedure TFilteredEdit.EMReplaceSel(var msg: TEMReplaceSel);

begin

If IsStringValid( msg.Text ) Then

inherited;

end;

function TFilteredEdit.IsStringValid(const S: String): Boolean;

var

i: Integer;

begin

Result := True;

For i:= 1 to Length(S) Do

If not IsCharValid(S[i]) Then Begin

Result := False;

Break;

End;

end;

procedure TFilteredEdit.KeyPress(var Key: Char);

begin

if IsCharValid(Key) or (Key In [#8, ^V]) Then

// #8 = backspace, ^V = Ctrl-V

inherited

Else

Key := #0;

end;

procedure TFilteredEdit.WMPaste(var msg: TMessage);

begin

If IsStringValid( Clipboard.AsText ) Then

inherited;

end;

procedure TFilteredEdit.WMSetText(var msg: TWMSettext);

begin

If IsStringValid( msg.Text ) Then

inherited;

end;

{ tNumberedit }

function tNumberedit.IsCharValid(ch: Char): Boolean;

begin

result := ch In ['0'..'9'];

end;

end.

Now, is this *really* the final version? Well, the TFileredEdit class should

perhaps go into its own unit, so it is easier reused for other descendents.

And while the code is sufficient for input of positive integer numbers there

are a few more problems if you want to extend this to negative numbers, or

real numbers. There it is possible to compose invalid input using valid

characters only! So more checks are needed, which are applied every time the

controls contents changes. Changes? Oh, there is an OnChange event, what

method is used to fire that? Can we override it (yes)? How do we reject

invalid input after the fact? Undo it? Would that interfere with the build-in

Undo capability of the control? Questions, questions, but i'm not gonna answer

them today.

Peter Below