YOUSEFUL / DELPHI

Atlas VCL scheduling, printing and PDF: FAQ, examples and tutorial

Learning center · Atlas VCL guide

This page covers the planning and document classes of Atlas VCL: TAtlasPrintDocument (unit Atlas.Printing) paginates a TAtlasTable for preview and printing; TAtlasSchedule and TAtlasScheduler (Atlas.Schedule, Atlas.Scheduler) are an appointment model and its day/week view; TAtlasTaskPlan and TAtlasGantt (Atlas.Gantt) are a task model with dependencies and its Gantt chart; TAtlasPdfDocument and TAtlasPdfViewer (Atlas.Pdf) load and render PDF files through the Windows PDF engine. Add the units you use to uses, plus Atlas.Graph when you catch EAtlasGraphError or read task dependencies. Apart from the VCL, they depend only on Atlas.Data (the table the print document reads) and Atlas.Graph (the dependency graph behind the task plan); Atlas VCL needs no other product. Every model is meant for the UI thread and reports bad input with an exception while leaving its data unchanged.

FAQ

What does TAtlasPrintDocument print, and what does it not? The rows a TAtlasTable currently shows (after filter and sort), laid out as a table with a bold title, a shaded header row on every page, single-line cells cut off with an ellipsis and Page n of m at the bottom. Prepare takes a snapshot, so later table changes are not printed until you prepare again. It is not a report designer: there are no groups, bands, images or rich text. To print a summary, pass the table that TAtlasPivot.Summarize returns.

Which units does the page geometry use? Abstract units that Preview maps one to one to pixels. The defaults of Prepare (794 × 1123, margin 48, row height 28) are A4 portrait at 96 dpi and give 33 data rows per page: (1123 - 2 * 48 - 3 * 28) div 28. Print scales the same layout to the printer page, so the proportions stay the same at any printer resolution. Prepare(Table, 1123, 794) gives landscape pages with 21 rows each.

Do I need a printer to use it? Only for Print, which sends every page to the default Windows printer through Vcl.Printers. Preview, RenderPage, PageCount, FirstRow and RowsOnPage work in any process, including a console program without a window; the PrintAndPdf sample does exactly that. The product's validation record lists real printer output as not yet tested by hand.

How does TAtlasSchedule decide that two appointments conflict? They must belong to the same resource (exact, case-sensitive string match) and their half-open intervals [StartsAt, EndsAt) must overlap. An appointment ending at 10:00 and another starting at 10:00 do not conflict. With AllowOverlap at its default False, Add, Update and LoadJSON refuse a conflict with EAtlasScheduleError and change nothing. CanPlace asks the same question without changing anything, whatever AllowOverlap is set to.

Are appointment and task IDs stable? Yes. TAtlasSchedule.Add and TAtlasTaskPlan.AddTask return sequential IDs from 1 that are never reused within the component, even after Delete or Clear. ToJSON stores them and LoadJSON restores them and moves the counter past the largest one, so IDs you keep in your own data stay valid across a save and reload.

Does the scheduler have recurrence, time zones or a month view? No. TAtlasScheduler.DaysShown accepts 1 (day) or 7 (week) and nothing else. Dates are plain TDateTime values without time zone conversion, so keep your time zone policy outside the model. The application has to expand recurring appointments into individual ones. There is no drag and drop or resizing with the mouse. The control moves the selected appointment with the arrow keys (15 minutes, one day), but inside a form it only receives those keys through a small descendant; see Troubleshooting. OnEdit (double-click) is where you open your own dialog and call Update.

How do I know what the user selected? TAtlasScheduler.OnSelect fires on every left click with the appointment ID, or 0 for empty space, and SelectedID holds it afterwards. The scheduler's selection cannot be set from code. TAtlasGantt is a grid with row selection: its inherited Row property is the selected row (row 1 is the first task), you can set it from code, and MoveSelected acts on it.

What does AutoSchedule do exactly? It walks the tasks in dependency order and starts each one at the project start or at the latest end of its predecessors, whichever is later, keeping every task's duration and progress. Tasks without predecessors therefore move to the project start. It handles finish-to-start links only, counts calendar days, and has no weekends, working hours, critical path or resource levelling. The new dates are computed on a copy and swapped in only if every task stays inside the valid date range; otherwise EAtlasGraphError is raised and nothing moves.

How are dependencies shown in the Gantt chart? As the predecessor IDs in brackets after the caption (Build [1]), not as arrows. Each row has a bar from start to end within the plan's overall date range, with the completed share filled in a second colour and the progress percentage printed on it. The bar colours are fixed data colours and are not changed by TAtlasThemer.

What does the PDF viewer need on the target machine? The Windows PDF runtime (Windows.Data.Pdf), combase.dll and shcore.dll, which ship with Windows 10 and 11. Atlas VCL does not parse PDF itself; Windows does, and TAtlasPdfDocument adds file access, waiting (30 seconds at most), validation and the TPngImage result. Use each document on the thread that created it; any other thread gets EAtlasPdfError. The product has been validated only on its development machine.

Which size does PageSize return? The value the Windows engine reports, which is in device-independent pixels at 96 dpi, not in PDF points: a page with a 300 × 400 point MediaBox reports 400 × 533. TAtlasPdfViewer at 100 % renders the page at that width in pixels. RenderPage(Index, Width) keeps the ratio, so a 600 pixel render of that page is 600 × 800.

Can I save and load schedules and plans? ToJSON and LoadJSON on TAtlasSchedule and TAtlasTaskPlan save everything including IDs, in a JSON layout specific to Atlas VCL (not iCalendar or a project-file format). Both imports are all or nothing: the whole text is validated on a staged copy, and the current data stays untouched when anything is wrong.

Examples

Preview the first page of a table report as a PNG file. With 80 rows and the default geometry the last line prints 3 pages, 33 rows on the first.

pascal
uses System.SysUtils, Vcl.Graphics, Vcl.Imaging.pngimage, Atlas.Data, Atlas.Printing;

procedure SaveFirstPage(Table: TAtlasTable; const FileName: string);
var
  Document: TAtlasPrintDocument;
  Page: TBitmap;
  Png: TPngImage;
begin
  Document := TAtlasPrintDocument.Create(nil);
  try
    Document.Title := 'Invoices / September';
    Document.Prepare(Table);            // snapshot of the current view, A4 at 96 dpi
    Page := Document.Preview(0);        // caller owns the bitmap
    try
      Png := TPngImage.Create;
      try
        Png.Assign(Page);
        Png.SaveToFile(FileName);
      finally
        Png.Free;
      end;
    finally
      Page.Free;
    end;
    Writeln(Format('%d pages, %d rows on the first', [Document.PageCount, Document.RowsOnPage(0)]));
  finally
    Document.Free;
  end;
end;

Book a room and check a slot. The first two appointments touch and are both accepted. The slot 10:00-11:00 overlaps Review in Room A but is free in Room B. The query for [09:00, 10:00) returns both appointments, because Review starts at 09:30.

pascal
uses System.SysUtils, Atlas.Schedule;

procedure BookRoom;
var
  Schedule: TAtlasSchedule;
  Day: TDateTime;
  Item: TAtlasAppointment;
begin
  Schedule := TAtlasSchedule.Create(nil);
  try
    Day := EncodeDate(2026, 9, 21);
    Schedule.Add('Stand-up', 'Room A', Day + 9/24, Day + 9.5/24);
    Schedule.Add('Review', 'Room A', Day + 9.5/24, Day + 10.5/24);      // touching is allowed
    Writeln(Schedule.CanPlace(Day + 10/24, Day + 11/24, 'Room A'));      // FALSE: overlaps Review
    Writeln(Schedule.CanPlace(Day + 10/24, Day + 11/24, 'Room B'));      // TRUE: another resource
    for Item in Schedule.Query(Day + 9/24, Day + 10/24) do                // half-open [09:00, 10:00)
      Writeln(Item.ID, ' ', Item.Caption);                                // 1 Stand-up, 2 Review
  finally
    Schedule.Free;
  end;
end;

Output:

Example
FALSE
TRUE
1 Stand-up
2 Review

Save a schedule and reject a bad file. LoadJSON is transactional: after the failed second call Target still holds the two appointments from the first, with their IDs.

pascal
uses System.SysUtils, Atlas.Schedule;

procedure SaveAndReload;
var
  Source, Target: TAtlasSchedule;
  Day: TDateTime;
begin
  Source := TAtlasSchedule.Create(nil);
  Target := TAtlasSchedule.Create(nil);
  try
    Day := EncodeDate(2026, 9, 21);
    Source.Add('Stand-up', 'Room A', Day + 9/24, Day + 9.5/24);
    Source.Add('Review', 'Room A', Day + 9.5/24, Day + 10.5/24);
    Writeln(Source.ToJSON);
    Target.LoadJSON(Source.ToJSON);          // all or nothing; IDs are kept
    try
      Target.LoadJSON('[{"bad": 1}]');
    except
      on E: EAtlasScheduleError do
        Writeln('Rejected: ', E.Message);
    end;
    Writeln(Target.Count, ' appointments; the first is ', Target.Appointment(1).Caption);
  finally
    Target.Free;
    Source.Free;
  end;
end;

Output (dates are TDateTime numbers; 46286.375 is 21 September 2026 09:00, and 12161078 is the default colour $00B99036):

Example
[{"id":1,"caption":"Stand-up","resource":"Room A","start":46286.375,"end":46286.3958333333,"color":12161078},{"id":2,"caption":"Review","resource":"Room A","start":46286.3958333333,"end":46286.4375,"color":12161078}]
Rejected: Invalid appointment object
2 appointments; the first is Stand-up

Render a week off screen. TAtlasScheduler draws into any canvas without a parent window and records where it drew each appointment, so HitTest works after Render. With the two Room A appointments above and WeekStart set to 21 September 2026, the point (100, 272) lies on Stand-up (Monday 09:00-09:30) and the program prints 1; (100, 290) lies on Review and gives 2.

pascal
uses System.Types, Vcl.Graphics, Atlas.Schedule, Atlas.Scheduler;

procedure RenderWeek(Schedule: TAtlasSchedule; WeekStart: TDateTime; const FileName: string);
var
  View: TAtlasScheduler;
  Bitmap: TBitmap;
begin
  View := TAtlasScheduler.Create(nil);
  Bitmap := TBitmap.Create;
  try
    View.Schedule := Schedule;
    View.Date := WeekStart;
    View.DaysShown := 7;
    Bitmap.SetSize(980, 660);
    View.Render(Bitmap.Canvas, Rect(0, 0, 980, 660));   // also records the hit rectangles
    Writeln('Appointment at (100, 272): ', View.HitTest(100, 272));
    Bitmap.SaveToFile(FileName);
  finally
    Bitmap.Free;
    View.Free;
  end;
end;

Schedule a three-task chain. Every task is created at the project start; AutoSchedule pushes Build behind Design and Verify behind Build, keeping durations and progress.

pascal
uses System.SysUtils, Atlas.Gantt;

procedure PlanRelease;
var
  Plan: TAtlasTaskPlan;
  Start: TDateTime;
  Design, Build, Verify: Integer;
  Task: TAtlasTask;
begin
  Plan := TAtlasTaskPlan.Create(nil);
  try
    Start := EncodeDate(2026, 9, 21);
    Design := Plan.AddTask('Design', Start, Start + 2, 100);
    Build := Plan.AddTask('Build', Start, Start + 5, 40);
    Verify := Plan.AddTask('Verify', Start, Start + 2);
    Plan.AddDependency(Design, Build);
    Plan.AddDependency(Build, Verify);
    Plan.AutoSchedule(Start);
    for Task in Plan.Tasks do
      Writeln(Format('%-7s %s - %s  %3.0f%%', [Task.Caption,
        FormatDateTime('dd mmm', Task.StartsAt), FormatDateTime('dd mmm', Task.EndsAt), Task.Progress]));
  finally
    Plan.Free;
  end;
end;

Output (month names follow the Windows locale):

Example
Design  21 Sep - 23 Sep  100%
Build   23 Sep - 28 Sep   40%
Verify  28 Sep - 30 Sep    0%

Refuse a cyclic dependency. EAtlasGraphError and TAtlasGraphEdge are declared in Atlas.Graph. The dependency that would close the loop is removed again, and the plan keeps its single dependency. The program prints Rejected: Graph contains a cycle and then Design -> Build.

pascal
uses System.SysUtils, Atlas.Graph, Atlas.Gantt;

procedure RejectCycle;
var
  Plan: TAtlasTaskPlan;
  Design, Build: Integer;
  Edge: TAtlasGraphEdge;
begin
  Plan := TAtlasTaskPlan.Create(nil);
  try
    Design := Plan.AddTask('Design', EncodeDate(2026, 9, 21), EncodeDate(2026, 9, 23));
    Build := Plan.AddTask('Build', EncodeDate(2026, 9, 23), EncodeDate(2026, 9, 28));
    Plan.AddDependency(Design, Build);
    try
      Plan.AddDependency(Build, Design);   // would close a cycle
    except
      on E: EAtlasGraphError do
        Writeln('Rejected: ', E.Message);
    end;
    for Edge in Plan.Dependencies do
      Writeln(Plan.Task(Edge.SourceID).Caption, ' -> ', Plan.Task(Edge.TargetID).Caption);
  finally
    Plan.Free;
  end;
end;

Render the first page of a PDF file to PNG. For the two-page PDF that the PrintAndPdf sample writes, this prints 2 pages; first page 400 x 533 and saves a 1200 × 1600 image.

pascal
uses System.SysUtils, Vcl.Imaging.pngimage, Atlas.Pdf;

procedure PdfToPng(const PdfFile, PngFile: string);
var
  Document: TAtlasPdfDocument;
  Image: TPngImage;
  Size: TAtlasPdfSize;
begin
  Document := TAtlasPdfDocument.Create(nil);   // initializes the Windows runtime for this thread
  try
    Document.LoadFile(PdfFile);                 // raises EAtlasPdfError when the file is missing
    Size := Document.PageSize(0);
    Writeln(Format('%d pages; first page %.0f x %.0f', [Document.PageCount, Size.Width, Size.Height]));
    Image := Document.RenderPage(0, 1200);      // 1200 pixels wide, height follows the page shape
    try
      Image.SaveToFile(PngFile);
    finally
      Image.Free;
    end;
  finally
    Document.Free;
  end;
end;

Show a PDF in a form. The viewer brings its own toolbar and status text; the code below opens the second page at 150 %. Set ZoomPercent and PageIndex after LoadFile: without a document both are ignored, and LoadFile resets them.

pascal
uses Vcl.Controls, Vcl.Forms, Atlas.Pdf;

procedure ShowPdf(Form: TForm; const FileName: string);
var
  Viewer: TAtlasPdfViewer;
begin
  Viewer := TAtlasPdfViewer.Create(Form);
  Viewer.Parent := Form;
  Viewer.Align := alClient;
  Viewer.LoadFile(FileName);   // page 1 at 100 %
  Viewer.ZoomPercent := 150;   // 25..300
  Viewer.PageIndex := 1;       // zero-based; raises EAtlasPdfError past the last page
end;

Tutorial

Sample project: SchedulerAndGantt. The deeper document sample is PrintAndPdf.

Goal: build a window that shows this week's appointments in a TAtlasScheduler week view over a TAtlasSchedule, lets the user move and rename them, and shows an auto-scheduled three-task plan in a TAtlasGantt below it.

  1. Create a new VCL application in Delphi 13, remove the default form and its unit, and add the Atlas VCL src folder to the project's search path. Also add the shared folder of the sample download if you want the optional snapshot feature of step 9. Add a new unit and save it as PlanningForm.pas. The whole window is created in code, so there is no .dfm.

  2. Declare the form, plus a four-line descendant of the scheduler. TAtlasScheduler moves the selected appointment with the arrow keys, but it does not ask Windows for them (WM_GETDLGCODE). Inside a form, the VCL then uses the arrows to move the focus to the next control, and the appointment stays put. TKeyboardScheduler asks for them and changes nothing else. The form owns the two models and the two views, and every button handler is a method:

    pascal
    unit PlanningForm;
    
    interface
    
    uses
      System.SysUtils, System.Classes, Winapi.Messages, Vcl.Forms, Vcl.Controls,
      Vcl.StdCtrls, Vcl.ExtCtrls, Atlas.Schedule, Atlas.Scheduler, Atlas.Gantt;
    
    type
      TKeyboardScheduler = class(TAtlasScheduler)
      protected
        procedure WMGetDlgCode(var Message: TWMGetDlgCode); message WM_GETDLGCODE;
      end;
    
      TPlanningForm = class(TForm)
      private
        FWeekStart: TDateTime;
        FSchedule: TAtlasSchedule;
        FScheduler: TAtlasScheduler;
        FPlan: TAtlasTaskPlan;
        FGantt: TAtlasGantt;
        FStatus: TLabel;
        FPlanStatus: TLabel;
        procedure AddButton(Bar: TWinControl; const Caption: string; Left: Integer;
          Handler: TNotifyEvent; Tag: Integer = 0);
        procedure BuildSchedule;
        procedure BuildPlan;
        procedure DayClick(Sender: TObject);
        procedure WeekClick(Sender: TObject);
        procedure EarlierClick(Sender: TObject);
        procedure LaterClick(Sender: TObject);
        procedure AppointmentSelected(Sender: TObject; ID: Integer);
        procedure AppointmentEdit(Sender: TObject; ID: Integer);
        procedure ScheduleChanged(Sender: TObject);
        procedure ShiftTaskClick(Sender: TObject);
        procedure RescheduleClick(Sender: TObject);
        procedure PlanChanged(Sender: TObject);
      public
        constructor Create(AOwner: TComponent); override;
      end;
    
    implementation
    
    uses
      System.DateUtils, Winapi.Windows, Vcl.Dialogs;
    
    procedure TKeyboardScheduler.WMGetDlgCode(var Message: TWMGetDlgCode);
    begin
      inherited;
      Message.Result := Message.Result or DLGC_WANTARROWS;
    end;
  3. Write the constructor. It calls CreateNew because there is no form resource, remembers the Monday of the current week, and builds a toolbar and then the two halves. The bottom panel is built first, so that the scheduler, aligned to the client area, fills the space that is left.

    pascal
    constructor TPlanningForm.Create(AOwner: TComponent);
    var
      Bar: TPanel;
    begin
      inherited CreateNew(AOwner);
      Caption := 'Atlas VCL: week schedule and task plan';
      Position := poScreenCenter;
      Width := 1100;
      Height := 760;
      FWeekStart := StartOfTheWeek(Date); // the Monday of the current week
    
      Bar := TPanel.Create(Self);
      Bar.Parent := Self;
      Bar.Align := alTop;
      Bar.Height := 44;
      Bar.BevelOuter := bvNone;
      AddButton(Bar, 'Day', 8, DayClick);
      AddButton(Bar, 'Week', 96, WeekClick);
      AddButton(Bar, '< Earlier', 184, EarlierClick);
      AddButton(Bar, 'Later >', 272, LaterClick);
      FStatus := TLabel.Create(Self);
      FStatus.Parent := Bar;
      FStatus.SetBounds(372, 14, 700, 20);
      FStatus.Caption := 'Click an appointment; arrow keys move it; double-click renames it.';
    
      BuildPlan; // the bottom panel first, so the scheduler fills what is left
      BuildSchedule;
    end;
    
    procedure TPlanningForm.AddButton(Bar: TWinControl; const Caption: string;
      Left: Integer; Handler: TNotifyEvent; Tag: Integer);
    var
      Button: TButton;
    begin
      Button := TButton.Create(Self);
      Button.Parent := Bar;
      Button.SetBounds(Left, 7, 80, 30);
      Button.Caption := Caption;
      Button.Tag := Tag;
      Button.OnClick := Handler;
    end;
  4. Fill the schedule and attach the week view. Times are TDateTime values, so FWeekStart + 1 + 14/24 is Tuesday 14:00. Two of the four appointments have their own colours; the other two use the default. The view is created as the descendant from step 2 but kept in a TAtlasScheduler field. Assign Schedule after Date and DaysShown, and hook OnChange last.

    pascal
    procedure TPlanningForm.BuildSchedule;
    begin
      FSchedule := TAtlasSchedule.Create(Self);
      FSchedule.Add('Sprint planning', 'Team', FWeekStart + 9/24, FWeekStart + 10.5/24);
      FSchedule.Add('Design review', 'Design', FWeekStart + 1 + 14/24, FWeekStart + 1 + 15/24, $00C8A060);
      FSchedule.Add('Customer call', 'Team', FWeekStart + 2 + 11/24, FWeekStart + 2 + 12/24, $0060A0C8);
      FSchedule.Add('Release check', 'Team', FWeekStart + 4 + 15/24, FWeekStart + 4 + 16/24);
    
      FScheduler := TKeyboardScheduler.Create(Self);
      FScheduler.Parent := Self;
      FScheduler.Align := alClient;
      FScheduler.Date := FWeekStart;
      FScheduler.DaysShown := 7;
      FScheduler.Schedule := FSchedule;
      FScheduler.OnSelect := AppointmentSelected;
      FScheduler.OnEdit := AppointmentEdit;
      FSchedule.OnChange := ScheduleChanged;
    end;

    The upper part of the window now shows hour rows from 00:00 to 23:00, seven day columns headed Mon 21, Tue 22 and so on (for the week of the day you run it), and four coloured blocks.

  5. Handle the toolbar. DaysShown accepts only 1 or 7. The earlier and later buttons move the view by its own length: one day in day view, one week in week view.

    pascal
    procedure TPlanningForm.DayClick(Sender: TObject);
    begin
      FScheduler.DaysShown := 1;
    end;
    
    procedure TPlanningForm.WeekClick(Sender: TObject);
    begin
      FScheduler.DaysShown := 7;
    end;
    
    procedure TPlanningForm.EarlierClick(Sender: TObject);
    begin
      FScheduler.Date := FScheduler.Date - FScheduler.DaysShown;
    end;
    
    procedure TPlanningForm.LaterClick(Sender: TObject);
    begin
      FScheduler.Date := FScheduler.Date + FScheduler.DaysShown;
    end;
  6. React to the user. OnSelect arrives with the appointment ID or 0, and Appointment returns a copy of the record for display. OnEdit fires on a double-click and is the place for your own dialog; here an InputQuery renames the appointment through Update, which validates and notifies. ScheduleChanged refreshes the status line after every change, including the arrow-key moves the control makes itself. The scheduler's own listener has already run by then, so a deleted selection reads as 0.

    pascal
    procedure TPlanningForm.AppointmentSelected(Sender: TObject; ID: Integer);
    var
      Item: TAtlasAppointment;
    begin
      if ID = 0 then
        FStatus.Caption := 'Nothing selected.'
      else
      begin
        Item := FSchedule.Appointment(ID);
        FStatus.Caption := Format('%s (%s): %s to %s', [Item.Caption, Item.Resource,
          FormatDateTime('ddd hh:nn', Item.StartsAt), FormatDateTime('hh:nn', Item.EndsAt)]);
      end;
    end;
    
    procedure TPlanningForm.AppointmentEdit(Sender: TObject; ID: Integer);
    var
      Item: TAtlasAppointment;
      Text: string;
    begin
      Item := FSchedule.Appointment(ID);
      Text := Item.Caption;
      if InputQuery('Rename appointment', 'Caption', Text) then
        FSchedule.Update(ID, Text, Item.Resource, Item.StartsAt, Item.EndsAt, Item.Color);
    end;
    
    procedure TPlanningForm.ScheduleChanged(Sender: TObject);
    begin
      // The scheduler's own listener runs first, so a deleted selection is already 0.
      AppointmentSelected(Sender, FScheduler.SelectedID);
    end;
  7. Build the plan and the Gantt chart. Three tasks are created at the week start. Two finish-to-start dependencies and AutoSchedule then put them in a chain: Design from Monday to Wednesday, Build from Wednesday to the next Monday, and Verify for the two days after that. The chart sits in a bottom panel with its own small toolbar; the two shift buttons share one handler and differ only by Tag.

    pascal
    procedure TPlanningForm.BuildPlan;
    var
      Box, Tools: TPanel;
      Design, Build, Verify: Integer;
    begin
      FPlan := TAtlasTaskPlan.Create(Self);
      Design := FPlan.AddTask('Design', FWeekStart, FWeekStart + 2, 100);
      Build := FPlan.AddTask('Build', FWeekStart, FWeekStart + 5, 40);
      Verify := FPlan.AddTask('Verify', FWeekStart, FWeekStart + 2);
      FPlan.AddDependency(Design, Build); // Build may start when Design ends
      FPlan.AddDependency(Build, Verify);
      FPlan.AutoSchedule(FWeekStart); // Design Mon-Wed, Build Wed-Mon, Verify Mon-Wed
    
      Box := TPanel.Create(Self);
      Box.Parent := Self;
      Box.Align := alBottom;
      Box.Height := 236;
      Box.BevelOuter := bvNone;
      Tools := TPanel.Create(Self);
      Tools.Parent := Box;
      Tools.Align := alTop;
      Tools.Height := 44;
      Tools.BevelOuter := bvNone;
      AddButton(Tools, 'Task +1 day', 8, ShiftTaskClick, 1);
      AddButton(Tools, 'Task -1 day', 96, ShiftTaskClick, -1);
      AddButton(Tools, 'Reschedule', 184, RescheduleClick);
      FPlanStatus := TLabel.Create(Self);
      FPlanStatus.Parent := Tools;
      FPlanStatus.SetBounds(372, 14, 700, 20);
    
      FGantt := TAtlasGantt.Create(Self);
      FGantt.Parent := Box;
      FGantt.Align := alClient;
      FGantt.Plan := FPlan;
      FPlan.OnChange := PlanChanged;
      PlanChanged(FPlan);
    end;

    The chart shows a header row, Task / predecessors, with the plan's date range, then Design (100 %), Build [1] (40 %) and Verify [2] (0 %). The numbers in brackets are predecessor IDs.

  8. Handle the plan buttons. MoveSelected shifts the task in the selected Gantt row (the first task until the user clicks another row) and leaves its dependencies alone. AutoSchedule recomputes the chain from the week start. PlanChanged lists every task's dates from Tasks, which returns copies in creation order.

    pascal
    procedure TPlanningForm.ShiftTaskClick(Sender: TObject);
    begin
      FGantt.MoveSelected(TComponent(Sender).Tag);
    end;
    
    procedure TPlanningForm.RescheduleClick(Sender: TObject);
    begin
      FPlan.AutoSchedule(FWeekStart);
    end;
    
    procedure TPlanningForm.PlanChanged(Sender: TObject);
    var
      Task: TAtlasTask;
      Text: string;
    begin
      Text := '';
      for Task in FPlan.Tasks do
        Text := Text + Format('%s %s-%s   ', [Task.Caption,
          FormatDateTime('dd mmm', Task.StartsAt), FormatDateTime('dd mmm', Task.EndsAt)]);
      FPlanStatus.Caption := Text;
    end;
    
    end.
  9. Write the program file. Application.CreateForm calls the virtual Create, which calls CreateNew. The if is optional. With a file name on the command line, the program saves a picture of the window through SaveFormImage from Atlas.TestVcl (in the shared folder) instead of showing it, which lets you check a layout without clicking. Leave out the unit and the if if you do not want that.

    pascal
    program SchedulerAndGantt;
    
    uses
      Vcl.Forms,
      System.SysUtils,
      Atlas.TestVcl,
      PlanningForm in 'PlanningForm.pas';
    
    var
      Form: TPlanningForm;
    
    begin
      Application.Initialize;
      Application.MainFormOnTaskbar := True;
      Application.CreateForm(TPlanningForm, Form);
      if ParamCount > 0 then
        SaveFormImage(Form, ParamStr(1))
      else
        Application.Run;
    end.
  10. Run it. Click Sprint planning and the status line reads Sprint planning (Team): Mon 09:00 to 10:30. Press Down: the block moves to 09:15 and the line follows. Select Customer call and press Left twice to bring it to Monday, then press Up until it reaches Sprint planning. At that point the move is refused with Appointment overlaps this resource, because both belong to Team, while Design review (resource Design) may share any slot with them. Double-click an appointment to rename it. Below the chart, Task +1 day moves Design one day later, so that it now overlaps Build. Reschedule runs AutoSchedule from Monday again, which puts Design back at the start and the chain back in order.

    To adapt it, load the appointments from a file with FSchedule.LoadJSON at start-up and write FSchedule.ToJSON back in OnClose. Both models save their IDs, so references you keep elsewhere stay valid. Set FScheduler.Resource := 'Team' to show a single resource, or add a second scheduler on the same FSchedule for a day view next to the week.

Troubleshooting

The arrow keys do not move the selected appointment; the focus jumps to another control instead. TAtlasScheduler handles the arrow keys in KeyDown, but it does not answer WM_GETDLGCODE with DLGC_WANTARROWS. The VCL therefore treats the arrows as dialog keys and moves the focus (or, when the scheduler is the only focusable control, swallows the key). Use the four-line TKeyboardScheduler descendant from step 2 of the tutorial, or call MoveSelected from your own buttons or menu commands. The product's tests call MoveSelected directly, and keyboard handling in a visible window is listed as not yet tested by hand.

EAtlasScheduleError: Appointment overlaps this resource when adding, updating or pressing an arrow key. Another appointment of the same resource covers part of the interval; intervals are half-open, so only a real overlap counts. Check the slot first with CanPlace(StartsAt, EndsAt, Resource, ID) (pass the appointment's own ID when moving it), use another resource name, or set AllowOverlap := True if double booking is intended. The model is unchanged after the exception.

EAtlasScheduleError: Choose day (1) or week (7) view. DaysShown accepts only 1 and 7. There is no month or work-week view. If you need a five-day view, place five day-view schedulers side by side on the same schedule.

The scheduler shows the grid but no appointments. There are three usual causes. Schedule may not be assigned. The appointments may lie outside the DaysShown days that start at Date; the view does not scroll in time, so move Date. Or Resource is set to a name that does not match the appointments exactly (the comparison is case-sensitive). Very short appointments are still drawn at least 4 pixels tall, so an appointment inside the range is never invisible.

EAtlasPrintError: Page has no space for a data row or Invalid page geometry. The geometry passed to Prepare leaves no room. The page height must be at least 2 * Margin + 4 * RowHeight units, PageWidth and PageHeight must be at least 100, Margin may be at most a third of either dimension, and RowHeight must be at least 8. A table with columns is required means the table is nil or has no columns yet, so define the columns before preparing.

PageCount stays at the old value after the table changed. Prepare takes a snapshot by design: filters, sorts and new rows are only picked up when you call Prepare again. If you print a pivot summary, summarize again as well, and remember that you own and must free the table Summarize returns.

EAtlasGraphError: Task and graph captions disagree or Every dependency node requires a task from TAtlasTaskPlan.LoadJSON. The text was edited by hand. Each task's caption must equal the caption of the graph node with the same ID, and every node in graph needs an entry in tasks. Write the file with ToJSON and do not edit it. Nothing is loaded from a rejected file.

TAtlasGantt.MoveSelected does nothing. Either no plan is attached, or the plan has no tasks (the grid then shows one empty row). Otherwise it moves the task in the selected row, the grid's Row, which starts at the first task. Dependencies are not re-applied after a move; call AutoSchedule to restore finish-to-start order.

EAtlasPdfError: PDF file not found although the file exists. LoadFile resolves relative names against the process's current directory, which in a GUI application is often not the folder of the executable. Build the path with ExtractFilePath(ParamStr(0)) and TPath.Combine. If the name is right and the call raises EOleSysError instead, Windows could not read the file. It may be damaged, need a password, or not be a PDF at all (a text file renamed to .pdf gives OLE error 80048014). The previous document stays loaded either way.

EAtlasPdfError: Use the PDF document on its creating thread. TAtlasPdfDocument, and the document inside TAtlasPdfViewer, belong to the thread that created them, normally the UI thread. To render in a worker thread, create the document in that thread, and hand the finished TPngImage back to the UI thread.

The rendered PDF page is blurry, or the viewer image is huge. RenderPage rasterizes at exactly the width you pass (32..4096 pixels), and the viewer uses the reported page width times ZoomPercent. A page reported as 400 × 533 is only 400 pixels wide at 100 %. Render at the width of the control, or twice that on high-DPI screens, and scale the image down; in the viewer, raise ZoomPercent (up to 300).