Perhaps the most important principle for the good algorithm designer is to refuse to be content.

Alfred V. Aho



Home

Introduction

In this article we are going to use the Delphi SynEdit component to mimic an input/output terminal.

Start a new "VCL Forms Application - Delphi" project in the Delphi IDE. SynEdit is at least for the moment a Windows only Delphi component, so we must use VCL.

Drag a SynEdit component into the application main form, change its "Align" property to "alClient" so we will have the editor occupying all the form area. Save the application, I used "uMain.pas" as the unit name, I kept the main form name as "Form1"

NewVcl

Now, we must include the System.Generics.Collections unit to the uses clause in the main form. We will use a TList object to keep track of the "commands" entered at the terminal command prompt.

unit uMain;

interface

uses
  Winapi.Windows, Winapi.Messages,
  System.SysUtils, System.Variants, System.Classes, System.Generics.Collections,
  Vcl.Graphics, Vcl.Controls, Vcl.Forms, Vcl.Dialogs, SynEdit;
...
...

Now, we are going to add some new private variables to the TForm1 class.

...
...
TForm1 = class(TForm)
  SynEdit1: TSynEdit;
private
  { Private declarations }
  CurrCommand: string;
  CmdHist: TList<string>;
  CmdIdx, CaretLen, CmdCount: integer;
  tCaret: string;
public
  { Public declarations }
end;
...
...

We will initialize these variables during the form creation. We don't need to wait in order to complete this task, select the Form1 on the Delphi IDE and go to the Events tab, double click the OnCreate event and add the following code into the new form method.

procedure TForm1.FormCreate(Sender: TObject);
begin
  CurrCommand := '';
  CmdHist := TList<string>.Create;
  CmdIdx := 0;
  CmdCount := 1;
  tCaret := IntToStr(CmdCount)+'> ';
  CaretLen := Length(tCaret)+1;
end;

Keep the Form1 component selected and double click the OnShow event, add the following code into the new method.

procedure TForm1.FormShow(Sender: TObject);
begin
  SynEdit1.Lines.Add('SynEdit command terminal');
  SynEdit1.Lines.Add('+-----------------------------------------------------+');
  SynEdit1.Lines.Add('|<CTRL>+<DEL> -> clean the prompt line.               |');
  SynEdit1.Lines.Add('|exit -> quit terminal.                               |');
  SynEdit1.Lines.Add('+-----------------------------------------------------+');
  SynEdit1.Lines.Add('');
  SynEdit1.Lines.Add(tCaret);
  SynEdit1.CaretX := CaretLen;
  SynEdit1.CaretY := SynEdit1.Lines.Count;
end;

Now, select the SynEdit1 component and clean its default text by clicking on the Properties tab in Delphi IDE, double click the Lines property and clean all the lines at the pop up editor.

At this point, the project main form should be similar to the image below.

NewVcl

The main purpose for a command terminal, is to enter textual commands that could be interpreted to perform some sort of task after its validation. There are situations these commands could be made by more than one line of text, for example, if the terminal will be used to parse commands which are enclosed by " or ' chars, or even to parse math expressions that could use ( and ) to nest expressions. It's important the terminal is able to check for that sort of closure and finish the command input only when certain validations are concluded.

Lets add a method to the terminal application to perform a very rudimentary checking for commands closure. Add the method CheckEndCommand detailed below as a private method to the main form class.

...
...
TForm1 = class(TForm)
  SynEdit1: TSynEdit;
private
  { Private declarations }
  CurrCommand: string;
  CmdHist: TList<string>;
  CmdIdx, CaretLen, CmdCount: integer;
  tCaret: string;

  function CheckEndCommand(cmd: string): integer;
public
  { Public declarations }
end;
...
...
function TForm1.CheckEndCommand(cmd: string): integer;
var
  i: integer;
  cbcnt, {curly brackets counter}
  bcknt, {brackets counter}
  parencnt: integer; {parenthesis counter}
  dqok: Boolean; {double quote closed}
begin
  Result := 0;
  cbcnt := 0; bcknt := 0; parencnt := 0;
  dqok := True;
  i := 1;
  while i <= cmd.Length do
  begin
    if (cmd[i] = '"') then dqok := not dqok
    else if (cmd[i] = '{') and (dqok) then Inc(cbcnt)
    else if (cmd[i] = '}') and (dqok) then Dec(cbcnt)
    else if (cmd[i] = '[') and (dqok) then Inc(bcknt)
    else if (cmd[i] = ']') and (dqok) then Dec(bcknt)
    else if (cmd[i] = '(') and (dqok) then Inc(parencnt)
    else if (cmd[i] = ')') and (dqok) then Dec(parencnt);

    Inc(i);
  end;
  if (cmd[cmd.Length - Length(System.sLineBreak)] = '\') then Exit(1);
  if not dqok then Exit(1);

  if parencnt < 0 then Exit(-1);
  if parencnt > 0 then Exit(1);
  if cbcnt < 0 then Exit(-1);
  if cbcnt > 0 then Exit(1);
  if bcknt < 0 then Exit(-1);
  if bcknt > 0 then Exit(1);
end;

This method basically checks for closures when entering text using the { } [ ] ( ) and " chars.

The text typed at the prompt is considered a complete input just after any of the opening chars above have a correspondent closing match.

The function returns 0 (zero) if the parameter string is a closed command, -1 (minus one) if there is an underflow (more closing chars than equivalent opening chars), and 1 (one) if there is an overflow (more opening chars than equivalent closing chars).

A command overflow makes the terminal keep accepting inputs until the balance between all the open/close chars is found. A command underflow is considered an error.

When a double quote is present, the command is considered closed when another double quote is found and matches the anterior. Inside double quotes, the balance checking for the ( ) [ ] and { } chars is not executed.

Now, let's take a look in what happens when input is typed in the commands terminal.

Back in the application main form, select the SynEdit1 editor and double click its OnKeyDown event. This will create a new empty method to handle what happens when the user presses any key while the terminal has focus.

Enter the following code in this method.

procedure TForm1.SynEdit1KeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
var
  CmdRes: integer;
  ExceptFound: boolean;
begin
  if (key = VK_DELETE) and (Shift = [ssCtrl]) then
  begin
    CurrCommand := '';
    SynEdit1.Lines.Add('Command canceled.');
    SynEdit1.Lines.Add(tCaret);
    SynEdit1.CaretY := SynEdit1.Lines.Count;
  end
  else if Key = VK_BACK then
  begin
    if (SynEdit1.CaretX <= CaretLen) or (SynEdit1.CaretY <> SynEdit1.Lines.Count) then
      Key := 0;
  end
  else if Key = VK_DELETE then
  begin
    if (SynEdit1.CaretY <> SynEdit1.Lines.Count) or (SynEdit1.CaretX < CaretLen) then
      Key := 0;
  end
  else if Key = VK_UP then
  begin
    Key := 0;
    if CmdHist.Count > 0 then
    begin
      if CmdIdx > 0 then Dec(CmdIdx);
      SynEdit1.Lines[SynEdit1.Lines.Count-1] := tCaret+Trim(CmdHist.Items[CmdIdx]);
    end;
    SynEdit1.CaretY := SynEdit1.Lines.Count;
    SynEdit1.CaretX := Length(SynEdit1.Lines[SynEdit1.Lines.Count-1])+1;
  end
  else if Key = VK_DOWN then
  begin
    Key := 0;
    if CmdHist.Count > 0 then
    begin
      if CmdIdx < CmdHist.Count-1 then Inc(CmdIdx);
      SynEdit1.Lines[SynEdit1.Lines.Count-1] := tCaret+Trim(CmdHist.Items[CmdIdx]);
    end;
    SynEdit1.CaretY := SynEdit1.Lines.Count;
    SynEdit1.CaretX := Length(SynEdit1.Lines[SynEdit1.Lines.Count-1])+1;
  end
  else if Key = VK_LEFT then
  begin
    if SynEdit1.CaretX <= CaretLen then
    begin
      Key := 0;
      SynEdit1.CaretX := CaretLen;
    end;
  end
  else if Key = VK_RIGHT then
  begin
    if SynEdit1.CaretX < CaretLen then
    begin
      Key := 0;
      SynEdit1.CaretX := CaretLen;
    end;
  end
  else if (Key = VK_HOME) then
  begin
    Key := 0;
    SynEdit1.CaretX := CaretLen;
  end
  else if (Key = VK_PRIOR) then
  begin
    Key := 0;
    if CmdHist.Count > 0 then
    begin
      if CmdIdx > 0 then CmdIdx := 0;
      SynEdit1.Lines[SynEdit1.Lines.Count-1] := tCaret+Trim(CmdHist.Items[CmdIdx]);
    end;
    SynEdit1.CaretY := SynEdit1.Lines.Count;
    SynEdit1.CaretX := Length(SynEdit1.Lines[SynEdit1.Lines.Count-1])+1;
  end
  else if (Key = VK_END) then
  begin
    Key := 0;
    SynEdit1.CaretX := Length(SynEdit1.Lines[SynEdit1.Lines.Count-1])+1;
  end
  else if (Key = VK_NEXT) then
  begin
    Key := 0;
    if CmdHist.Count > 0 then
    begin
      if CmdIdx < CmdHist.Count-1 then CmdIdx := CmdHist.Count-1;
      SynEdit1.Lines[SynEdit1.Lines.Count-1] := tCaret+Trim(CmdHist.Items[CmdIdx]);
    end;
    SynEdit1.CaretY := SynEdit1.Lines.Count;
    SynEdit1.CaretX := Length(SynEdit1.Lines[SynEdit1.Lines.Count-1])+1;
  end
  else if Key = VK_ESCAPE then
  begin
    SynEdit1.Lines[SynEdit1.Lines.Count-1] := tCaret;
    SynEdit1.CaretY := SynEdit1.Lines.Count-1;
    SynEdit1.CaretX := CaretLen;
  end
  else if Key = VK_RETURN then
  begin
    Key := 0;
    CurrCommand := CurrCommand + Copy(SynEdit1.Lines[SynEdit1.Lines.Count-1], CaretLen, Length(SynEdit1.Lines[SynEdit1.Lines.Count-1]));
    CurrCommand := CurrCommand + System.sLineBreak;

    CmdRes := CheckEndCommand(CurrCommand);
    if CmdRes = 0 then
    begin
      if Trim(CurrCommand) <> '' then
      begin
        ExceptFound := False;
        try
          //The line below should be changed by a method to parse and execute
          //some sort of command set, entered using the terminal
          SynEdit1.Lines.Add('ECHO: '+CurrCommand);
        except
          on E:Exception do //This never happens for the moment
          begin
            SynEdit1.Lines.Add('*** ERROR ***');
            SynEdit1.Lines.Add('Exception raised:');
            SynEdit1.Lines.Add(E.Message);
            CurrCommand := '';
            ExceptFound := True;
          end;
        end;

        if (not ExceptFound) then
        begin
          CmdHist.Add(CurrCommand);
          Inc(CmdCount);
          tCaret := IntToStr(CmdCount)+'> ';
          CaretLen := Length(tCaret)+1;
        end;
        CurrCommand := '';
      end;
    end
    else if CmdRes < 0 then
    begin
      SynEdit1.Lines.Add('*** ERROR ***');
      SynEdit1.Lines.Add('Unbalanced command.');
      CurrCommand := '';
    end;

    SynEdit1.Lines.Add(tCaret);
    SynEdit1.CaretY := SynEdit1.Lines.Count;
    SynEdit1.CaretX := CaretLen;
  end;
end;

So, any key that's pressed when the terminal is active is intercepted and tested. Let's check all possibilities treated in the method's body.

Ctrl+Delete

If this combination is pressed, the terminal writes a message informing that the command is canceled. The cursor is positioned in the last active line in the editor, just after the prompt. The variable that holds the command being typed (CurrCommand) is reset to an empty string.

Backspace

When the backspace key is pressed, the method checks if the cursor is in the input line, that means the last line in the terminal. If that's not the case, the key is canceled and nothing happens. Remember that we can only enter (and delete) text that is located in the terminal input line.

Delete

Has a similar behaviour to the backspace key. The terminal will only perform the delete if the cursor is located in the valid input line.

Up

Each valid command typed in the terminal will be recorded in a list. The "up" key will automatically reproduce the valid commands already stored in the input line, from the newest to the oldest.

Down

Each valid command typed in the terminal will be recorded in a list. The "down" key will automatically reproduce the valid commands already stored in the input line, from the oldest to the newest.

Left

Move the cursor one character to the left if it is positioned in the valid input line.

Right

Move the cursor one character to the right if it is positioned in the valid input line.

Home

Move the cursor to the beginning of the command being typed.

PageUp

Copy the oldest valid command stored in the command history to the input line.

End

Move the cursor to the end of the command being typed.

PageDown

Copy the newest valid command stored in the command history to the input line.

Esc

Erases all the text present in the input line.

Return

When the return key is pressed, all text typed at the input line is submited to the CheckEndCommand method. If this method returns 0 (zero), it means we have a closed string, since we are just dealing with an example application here, the typed text is just echoed in the terminal, this same typed text is saved in the commands history and the prompt command counter is increased by 1. If there is a "command underflow" (more closing chars than opening chars), an error message is printed and the typed text is discarded. In case we have a "command overflow" situation (more opening chars than closing chars), the input line will keep accepting text until there is a closure.

In order to conclude the keyboard input management, we have to add code to the OnKeyPress event of the SynEdit1 component.


procedure TForm1.SynEdit1KeyPress(Sender: TObject; var Key: Char);
begin
  if SynEdit1.Lines.Count > 0 then
    if not (SynEdit1.CaretY = SynEdit1.Lines.Count) then
      Key := #0;

  SynEdit1.CaretY := SynEdit1.Lines.Count;
  if SynEdit1.CaretX < CaretLen then
    SynEdit1.CaretX := CaretLen;
end;

This method is necessary to check if any typed text in the terminal is being entered at the correct input line, that's always the last line in the editor. The editor's cursor is automatically moved to the input line if it's not on it.

Now, get back to the SynEdit1 control on the application's main form and double click the OnMouseUp event. Add the code below as the event method.

procedure TForm1.SynEdit1MouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer);
begin
  Clipboard.AsText := SynEdit1.SelText;
  SynEdit1.CaretY := SynEdit1.Lines.Count;
  if SynEdit1.CaretX < CaretLen then
    SynEdit1.CaretX := CaretLen;
end;

Now, any text select in the terminal using the mouse is automatically copied to clipboard, when this operation is completed the cursor is positioned back in the input line.

The terminal gives support to text that is past from the clipboard into the input line. In order to have this feature available we have to add some code to the OnProcessCommand

procedure TForm1.SynEdit1ProcessCommand(Sender: TObject; var Command: TSynEditorCommand; var AChar: Char; Data: Pointer);
var
  i: integer;
  clipcmd, finalcmd: string;
  cbcnt, bcknt, parencnt: integer;
  dqok: boolean;
begin
  case Command of
    ecPaste: //replaces paste command functionality
    begin
      Command := 0; //cancel original paste
      cbcnt := 0; bcknt := 0; parencnt := 0;
      dqok := True;
      i := 1;

      if Clipboard.HasFormat(CF_TEXT) then
      begin
        clipcmd := Clipboard.AsText;
        finalcmd := '';

        while i <= clipcmd.Length do
        begin
          if clipcmd[i] = '"' then dqok := not dqok
          else if (clipcmd[i] = '{') and (dqok)then Inc(cbcnt)
          else if (clipcmd[i] = '}') and (dqok)then Dec(cbcnt)
          else if (clipcmd[i] = '[') and (dqok)then Inc(bcknt)
          else if (clipcmd[i] = ']') and (dqok) then Dec(bcknt)
          else if (clipcmd[i] = '(') and (dqok) then Inc(parencnt)
          else if (clipcmd[i] = ')') and (dqok) then Dec(parencnt);

          if dqok and (cbcnt = 0) and (bcknt = 0) and (parencnt = 0) then
            finalcmd := finalcmd + clipcmd[i]
          else
          begin
            if (clipcmd[i] <> #13) and (clipcmd[i] <> #10) then
              finalcmd := finalcmd + clipcmd[i];
          end;

          Inc(i);
        end;

        Clipboard.AsText := finalcmd;
        SynEdit1.PasteFromClipboard;
      end;
    end;
  end;
end;

We basically intercept any "paste from clipboard" command. The incoming text is processed before be available at the input line, we have to execute the check for balanced command strings by counting the number of opening and closing parens, brackets and curly brackets, the double quoted string test is also performed as we do for any text that's typed at the input line. I had to deal with CR and LF chars by removing them when a multiple line string is paste from the clipboard, feel free to change this behaviour if necessary, it was not really a problem to me when developing this tool.

I had to do some other configurations to the SynEdit1 component in order to make it work better as a console.

Move back to the Delphi IDE and make sure to have the SynEdit1 component selected, for personal reasons I prefer to have it without a border but this is just personal taste, a more important setup must be done under the Options property, select it and find the eoDragDropEditing row and turn it to False. Also, the eoScrollPasteEol row must be changed to False. Now outside the Options property, look for the ScrollBars property, change it's value to ssVertical. Now look for the WordWrap property anf change it to True. That's it, now our console application is ready.

NewVcl

This is the link for the source code of the terminal project. Have a nice programming.