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