wlgui_widget вроде как и был. Давай все 4 файла. Спасибо!
Полная версия: wlgui_widget.pas + Label + CheckBox + Edit

Даю все 4 файла полностью. Заменяешь wlgui_widget.pas целиком, остальные — создаёшь.
1. wlgui_widget.pas (полный)
pascal

unit wlgui_widget;
{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils,
  wlgui_types, wlgui_canvas, wlgui_font, wlgui_events;

type
  TWLWidget = class;
  TWLWidgetList = array of TWLWidget;

  { Базовый класс виджета с деревом, фокусом и диспетчеризацией. }
  TWLWidget = class
  private
    FRect: TRectI;
    FParent: TWLWidget;
    FChildren: TWLWidgetList;
    FVisible: Boolean;
    FEnabled: Boolean;
    FCanFocus: Boolean;
    FFocused: Boolean;
    FName: String;
    FFont: TWLFont;
    FTag: PtrInt;
    function GetChild(Index: Integer): TWLWidget;
    function GetChildCount: Integer;
    function GetRoot: TWLWidget;
  protected
    { Переопределяются в наследниках. }
    procedure DoPaint(ACanvas: TWLCanvas); virtual;
    function  DoMouseDown(X, Y: Integer): Boolean; virtual;
    function  DoMouseUp(X, Y: Integer): Boolean; virtual;
    procedure DoMouseMove(X, Y: Integer); virtual;
    procedure DoKeyDown(const E: TWLKeyEvent); virtual;
    procedure DoKeyUp(const E: TWLKeyEvent); virtual;
    procedure DoGotFocus; virtual;
    procedure DoLostFocus; virtual;
  public
    constructor Create(const ARect: TRectI); virtual;
    destructor Destroy; override;

    { Дерево }
    procedure AddChild(AChild: TWLWidget);
    procedure RemoveChild(AChild: TWLWidget);
    procedure ClearChildren;

    { Геометрия }
    function ScreenRect: TRectI;
    function ClientRect: TRectI;
    function HitTest(X, Y: Integer): TWLWidget;

    { Рисование }
    procedure Paint(ACanvas: TWLCanvas);

    { Диспетчеризация мыши }
    function DispatchMouseDown(X, Y: Integer): Boolean;
    function DispatchMouseUp(X, Y: Integer): Boolean;
    procedure DispatchMouseMove(X, Y: Integer);

    { Диспетчеризация клавиатуры — только focused виджету }
    function DispatchKeyDown(const E: TWLKeyEvent): Boolean;
    function DispatchKeyUp(const E: TWLKeyEvent): Boolean;

    { Фокус }
    procedure SetFocus;
    procedure ClearFocus;
    function GetFocusedWidget: TWLWidget;   // ищет в дереве от корня
    function CanFocus: Boolean; virtual;

    { Свойства }
    property Rect: TRectI read FRect write FRect;
    property Parent: TWLWidget read FParent;
    property Children[Index: Integer]: TWLWidget read GetChild;
    property ChildCount: Integer read GetChildCount;
    property Visible: Boolean read FVisible write FVisible;
    property Enabled: Boolean read FEnabled write FEnabled;
    property Focused: Boolean read FFocused;
    property Name: String read FName write FName;
    property Font: TWLFont read FFont write FFont;
    property Tag: PtrInt read FTag write FTag;
    property Root: TWLWidget read GetRoot;
  end;

implementation

{ ============================================================ }
{  TWLWidget                                                    }
{ ============================================================ }

constructor TWLWidget.Create(const ARect: TRectI);
begin
  inherited Create;
  FRect := ARect;
  FParent := nil;
  SetLength(FChildren, 0);
  FVisible := True;
  FEnabled := True;
  FCanFocus := False;
  FFocused := False;
  FName := '';
  FFont := nil;
  FTag := 0;
end;

destructor TWLWidget.Destroy;
begin
  ClearChildren;
  inherited;
end;

function TWLWidget.GetRoot: TWLWidget;
begin
  Result := Self;
  while Result.FParent <> nil do
    Result := Result.FParent;
end;

procedure TWLWidget.AddChild(AChild: TWLWidget);
var
  N: Integer;
begin
  if AChild = nil then Exit;
  if AChild = Self then Exit;
  if AChild.FParent <> nil then
    AChild.FParent.RemoveChild(AChild);

  N := Length(FChildren);
  SetLength(FChildren, N + 1);
  FChildren[N] := AChild;
  AChild.FParent := Self;
end;

procedure TWLWidget.RemoveChild(AChild: TWLWidget);
var
  I, J: Integer;
begin
  for I := 0 to High(FChildren) do
  begin
    if FChildren[I] = AChild then
    begin
      FChildren[I].FParent := nil;
      for J := I to High(FChildren) - 1 do
        FChildren[J] := FChildren[J + 1];
      SetLength(FChildren, Length(FChildren) - 1);
      Exit;
    end;
  end;
end;

procedure TWLWidget.ClearChildren;
var
  I: Integer;
begin
  for I := 0 to High(FChildren) do
  begin
    FChildren[I].FParent := nil;
    FChildren[I].Free;
  end;
  SetLength(FChildren, 0);
end;

function TWLWidget.ScreenRect: TRectI;
var
  ParentRect: TRectI;
begin
  if FParent = nil then
  begin
    Result := FRect;
    Exit;
  end;
  ParentRect := FParent.ScreenRect;
  Result.X := ParentRect.X + FRect.X;
  Result.Y := ParentRect.Y + FRect.Y;
  Result.W := FRect.W;
  Result.H := FRect.H;
end;

function TWLWidget.ClientRect: TRectI;
begin
  Result := TRectI.New(0, 0, FRect.W, FRect.H);
end;

function TWLWidget.HitTest(X, Y: Integer): TWLWidget;
var
  I: Integer;
  Screen: TRectI;
  Child: TWLWidget;
begin
  Result := nil;
  if not FVisible then Exit;

  Screen := ScreenRect;
  if not Screen.Contains(X, Y) then Exit;

  // С конца — последние добавленные сверху
  for I := High(FChildren) downto 0 do
  begin
    Child := FChildren[I].HitTest(X, Y);
    if Child <> nil then Exit(Child);
  end;

  // Если никто из детей не попал и мы focusable/enabled
  if FEnabled then
    Result := Self;
end;

procedure TWLWidget.Paint(ACanvas: TWLCanvas);
var
  I: Integer;
begin
  if not FVisible then Exit;

  DoPaint(ACanvas);

  for I := 0 to High(FChildren) do
    FChildren[I].Paint(ACanvas);
end;

{ --- Мышь --- }

function TWLWidget.DispatchMouseDown(X, Y: Integer): Boolean;
var
  I: Integer;
  Screen: TRectI;
begin
  Result := False;
  if not FVisible then Exit;
  if not FEnabled then Exit;

  Screen := ScreenRect;
  if not Screen.Contains(X, Y) then Exit;

  // Сначала детям
  for I := High(FChildren) downto 0 do
  begin
    if FChildren[I].DispatchMouseDown(X, Y) then
      Exit(True);
  end;

  // Потом себе
  Result := DoMouseDown(X, Y);
end;

function TWLWidget.DispatchMouseUp(X, Y: Integer): Boolean;
var
  I: Integer;
begin
  Result := False;
  if not FVisible then Exit;

  // Сначала детям (все пробуют, кто «захвачен» — тот и обработает)
  for I := 0 to High(FChildren) do
  begin
    if FChildren[I].DispatchMouseUp(X, Y) then
      Exit(True);
  end;

  Result := DoMouseUp(X, Y);
end;

procedure TWLWidget.DispatchMouseMove(X, Y: Integer);
var
  I: Integer;
  Screen: TRectI;
begin
  if not FVisible then Exit;

  // Детям — всегда (для hover)
  for I := 0 to High(FChildren) do
    FChildren[I].DispatchMouseMove(X, Y);

  Screen := ScreenRect;
  if Screen.Contains(X, Y) then
    DoMouseMove(X, Y);
end;

{ --- Клавиатура --- }

function TWLWidget.DispatchKeyDown(const E: TWLKeyEvent): Boolean;
var
  Focused: TWLWidget;
begin
  Result := False;
  if not FVisible then Exit;

  // Если корень — ищем focused виджет
  if FParent = nil then
  begin
    Focused := GetFocusedWidget;
    if Focused <> nil then
    begin
      Focused.DoKeyDown(E);
      Exit(True);
    end;
    Exit(False);
  end;

  // Иначе — на себе
  if FFocused then
  begin
    DoKeyDown(E);
    Result := True;
  end;
end;

function TWLWidget.DispatchKeyUp(const E: TWLKeyEvent): Boolean;
var
  Focused: TWLWidget;
begin
  Result := False;
  if not FVisible then Exit;

  if FParent = nil then
  begin
    Focused := GetFocusedWidget;
    if Focused <> nil then
    begin
      Focused.DoKeyUp(E);
      Exit(True);
    end;
    Exit(False);
  end;

  if FFocused then
  begin
    DoKeyUp(E);
    Result := True;
  end;
end;

{ --- Фокус --- }

function TWLWidget.CanFocus: Boolean;
begin
  Result := FCanFocus and FEnabled and FVisible;
end;

function TWLWidget.GetFocusedWidget: TWLWidget;
var
  I: Integer;
  W: TWLWidget;
begin
  Result := nil;
  if not FVisible then Exit;

  if FFocused then Exit(Self);

  for I := 0 to High(FChildren) do
  begin
    W := FChildren[I].GetFocusedWidget;
    if W <> nil then Exit(W);
  end;
end;

procedure TWLWidget.SetFocus;
var
  R: TWLWidget;
  Prev: TWLWidget;
begin
  if FFocused then Exit;
  if not CanFocus then Exit;

  R := GetRoot;

  // Снять фокус с предыдущего
  Prev := R.GetFocusedWidget;
  if Prev <> nil then
    Prev.ClearFocus;

  // Установить
  FFocused := True;
  DoGotFocus;
end;

procedure TWLWidget.ClearFocus;
begin
  if not FFocused then Exit;
  FFocused := False;
  DoLostFocus;
end;

{ --- Виртуальные (по умолчанию — ничего) --- }

procedure TWLWidget.DoPaint(ACanvas: TWLCanvas);
begin
end;

function TWLWidget.DoMouseDown(X, Y: Integer): Boolean;
begin
  Result := False;
end;

function TWLWidget.DoMouseUp(X, Y: Integer): Boolean;
begin
  Result := False;
end;

procedure TWLWidget.DoMouseMove(X, Y: Integer);
begin
end;

procedure TWLWidget.DoKeyDown(const E: TWLKeyEvent);
begin
end;

procedure TWLWidget.DoKeyUp(const E: TWLKeyEvent);
begin
end;

procedure TWLWidget.DoGotFocus;
begin
end;

procedure TWLWidget.DoLostFocus;
begin
end;

function TWLWidget.GetChild(Index: Integer): TWLWidget;
begin
  if (Index >= 0) and (Index < Length(FChildren)) then
    Result := FChildren[Index]
  else
    Result := nil;
end;

function TWLWidget.GetChildCount: Integer;
begin
  Result := Length(FChildren);
end;

end.

2. wlgui_label.pas
pascal

unit wlgui_label;
{$mode objfpc}{$H+}

interface

uses
  SysUtils,
  wlgui_types, wlgui_canvas, wlgui_font, wlgui_widget;

type
  TWLTextAlign = (taLeft, taCenter, taRight);

  TWLLabel = class(TWLWidget)
  private
    FCaption: String;
    FAlign: TWLTextAlign;
    FTextColor: TWLColor;
  protected
    procedure DoPaint(ACanvas: TWLCanvas); override;
  public
    constructor Create(const ARect: TRectI; const ACaption: String;
                       AFont: TWLFont);
    property Caption: String read FCaption write FCaption;
    property Align: TWLTextAlign read FAlign write FAlign;
    property TextColor: TWLColor read FTextColor write FTextColor;
  end;

implementation

constructor TWLLabel.Create(const ARect: TRectI; const ACaption: String;
  AFont: TWLFont);
begin
  inherited Create(ARect);
  FFont := AFont;
  FCaption := ACaption;
  FAlign := taLeft;
  FTextColor := TWLColor($00FFFFFF);
  // Label не принимает мышь и не берёт фокус — пропускает всё дальше
  FEnabled := False;
end;

procedure TWLLabel.DoPaint(ACanvas: TWLCanvas);
var
  R: TRectI;
  TextW, TextH, TextX, TextY: Integer;
begin
  if (ACanvas = nil) or (FFont = nil) then Exit;
  if FCaption = '' then Exit;

  R := ScreenRect;
  TextW := ACAnvas.TextWidth(FCaption, FFont);
  TextH := FFont.Height;

  case FAlign of
    taCenter: TextX := R.X + (R.W - TextW) div 2;
    taRight:  TextX := R.X + R.W - TextW;
  else
    TextX := R.X;
  end;

  TextY := R.Y + (R.H - TextH) div 2;

  ACAnvas.TextOut(TextX, TextY, FCaption, FTextColor, FFont);
end;

end.

3. wlgui_checkbox.pas
pascal

unit wlgui_checkbox;
{$mode objfpc}{$H+}

interface

uses
  SysUtils,
  wlgui_types, wlgui_canvas, wlgui_font, wlgui_widget;

type
  TWLCheckBox = class(TWLWidget)
  private
    FCaption: String;
    FChecked: Boolean;
    FTextColor: TWLColor;
    FBorderColor: TWLColor;
    FBoxColor: TWLColor;
    FCheckColor: TWLColor;
    FOnChange: TWLNotifyEvent;
    FBoxSize: Integer;
    FSpaceBetween: Integer;
  protected
    procedure DoPaint(ACanvas: TWLCanvas); override;
    function  DoMouseDown(X, Y: Integer): Boolean; override;
    function  DoMouseUp(X, Y: Integer): Boolean; override;
  public
    constructor Create(const ARect: TRectI; const ACaption: String;
                       AFont: TWLFont; AChecked: Boolean = False);
    property Caption: String read FCaption write FCaption;
    property Checked: Boolean read FChecked write FChecked;
    property TextColor: TWLColor read FTextColor write FTextColor;
    property BorderColor: TWLColor read FBorderColor write FBorderColor;
    property BoxColor: TWLColor read FBoxColor write FBoxColor;
    property CheckColor: TWLColor read FCheckColor write FCheckColor;
    property OnChange: TWLNotifyEvent read FOnChange write FOnChange;
  end;

implementation

constructor TWLCheckBox.Create(const ARect: TRectI; const ACaption: String;
  AFont: TWLFont; AChecked: Boolean);
begin
  inherited Create(ARect);
  FFont := AFont;
  FCaption := ACaption;
  FChecked := AChecked;
  FTextColor := TWLColor($00FFFFFF);
  FBorderColor := TWLColor($00A0A0A0);
  FBoxColor := TWLColor($00202020);
  FCheckColor := TWLColor($0040FF40);
  FBoxSize := 16;
  FSpaceBetween := 6;
  FCanFocus := True;
end;

procedure TWLCheckBox.DoPaint(ACanvas: TWLCanvas);
var
  R, BoxR, InnerR: TRectI;
  TextX, TextY: Integer;
  DrawColor: TWLColor;
  BoxY: Integer;
begin
  if (ACanvas = nil) or (FFont = nil) then Exit;

  R := ScreenRect;
  BoxY := R.Y + (R.H - FBoxSize) div 2;
  BoxR := TRectI.New(R.X, BoxY, FBoxSize, FBoxSize);

  if FEnabled then
    DrawColor := FTextColor
  else
    DrawColor := TWLColor($00808080);

  // Фон чекбокса
  ACAnvas.FillRect(BoxR, FBoxColor);

  // Рамка
  ACAnvas.Rect(BoxR, FBorderColor);

  // Галочка
  if FChecked then
  begin
    InnerR := TRectI.New(BoxR.X + 3, BoxR.Y + 3, FBoxSize - 6, FBoxSize - 6);
    ACAnvas.Line(InnerR.X, InnerR.Y + InnerR.H div 2,
                 InnerR.X + InnerR.W div 3, InnerR.Y + InnerR.H - 1,
                 FCheckColor);
    ACAnvas.Line(InnerR.X + InnerR.W div 3, InnerR.Y + InnerR.H - 1,
                 InnerR.X + InnerR.W - 1, InnerR.Y,
                 FCheckColor);
  end;

  // Текст
  if FCaption <> '' then
  begin
    TextX := R.X + FBoxSize + FSpaceBetween;
    TextY := R.Y + (R.H - FFont.Height) div 2;
    ACAnvas.TextOut(TextX, TextY, FCaption, DrawColor, FFont);
  end;
end;

function TWLCheckBox.DoMouseDown(X, Y: Integer): Boolean;
begin
  Result := FEnabled;
end;

function TWLCheckBox.DoMouseUp(X, Y: Integer): Boolean;
begin
  Result := False;
  if not FEnabled then Exit;
  if not ScreenRect.Contains(X, Y) then Exit;

  FChecked := not FChecked;
  if Assigned(FOnChange) then
    FOnChange(Self);
  Result := True;
end;

end.

4. wlgui_edit.pas
pascal

unit wlgui_edit;
{$mode objfpc}{$H+}

interface

uses
  SysUtils,
  wlgui_types, wlgui_canvas, wlgui_font, wlgui_widget, wlgui_events;

type
  TWLEdit = class(TWLWidget)
  private
    FText: String;
    FPosition: Integer;
    FScrollX: Integer;
    FTextColor: TWLColor;
    FBackColor: TWLColor;
    FBorderColor: TWLColor;
    FFocusedBorderColor: TWLColor;
    FCursorColor: TWLColor;
    FOnChange: TWLNotifyEvent;
    FMaxLength: Integer;
    FPadding: Integer;
    procedure UpdateScrollX;
    function TextWidthUpTo(APos: Integer): Integer;
  protected
    procedure DoPaint(ACanvas: TWLCanvas); override;
    function  DoMouseDown(X, Y: Integer): Boolean; override;
    procedure DoKeyDown(const E: TWLKeyEvent); override;
  public
    constructor Create(const ARect: TRectI; const AText: String;
                       AFont: TWLFont);
    function CanFocus: Boolean; override;

    property Text: String read FText write FText;
    property Position: Integer read FPosition write FPosition;
    property MaxLength: Integer read FMaxLength write FMaxLength;
    property OnChange: TWLNotifyEvent read FOnChange write FOnChange;
    property TextColor: TWLColor read FTextColor write FTextColor;
    property BackColor: TWLColor read FBackColor write FBackColor;
    property BorderColor: TWLColor read FBorderColor write FBorderColor;
    property FocusedBorderColor: TWLColor read FFocusedBorderColor
                                       write FFocusedBorderColor;
  end;

implementation

constructor TWLEdit.Create(const ARect: TRectI; const AText: String;
  AFont: TWLFont);
begin
  inherited Create(ARect);
  FFont := AFont;
  FText := AText;
  FPosition := Length(AText);
  FScrollX := 0;
  FTextColor := TWLColor($00FFFFFF);
  FBackColor := TWLColor($00202020);
  FBorderColor := TWLColor($00606060);
  FFocusedBorderColor := TWLColor($00FFFFFF);
  FCursorColor := TWLColor($00FFFFFF);
  FMaxLength := 255;
  FPadding := 4;
  FCanFocus := True;
end;

function TWLEdit.CanFocus: Boolean;
begin
  Result := FEnabled;
end;

function TWLEdit.TextWidthUpTo(APos: Integer): Integer;
var
  Part: String;
begin
  if APos <= 0 then Exit(0);
  if APos > Length(FText) then APos := Length(FText);
  Part := Copy(FText, 1, APos);
  Result := FFont.TextWidth(Part);
end;

procedure TWLEdit.UpdateScrollX;
var
  CursorPixelX, VisibleWidth: Integer;
begin
  if (FFont = nil) or (FRect.W <= 2 * FPadding) then
  begin
    FScrollX := 0;
    Exit;
  end;

  VisibleWidth := FRect.W - 2 * FPadding;
  CursorPixelX := TextWidthUpTo(FPosition) - FScrollX;

  // Курсор ушёл за правый край — прокрутить вправо
  if CursorPixelX > VisibleWidth then
    FScrollX := TextWidthUpTo(FPosition) - VisibleWidth;

  // Курсор ушёл за левый край — прокрутить влево
  if CursorPixelX < 0 then
    FScrollX := TextWidthUpTo(FPosition);

  if FScrollX < 0 then
    FScrollX := 0;
end;

procedure TWLEdit.DoPaint(ACanvas: TWLCanvas);
var
  R, InnerR: TRectI;
  BorderC, TextC: TWLColor;
  TextX, TextY: Integer;
  CursorX: Integer;
begin
  if (ACanvas = nil) or (FFont = nil) then Exit;

  R := ScreenRect;

  // Фон
  ACAnvas.FillRect(R, FBackColor);

  // Рамка
  if FFocused then BorderC := FFocusedBorderColor
  else BorderC := FBorderColor;
  ACAnvas.Rect(R, BorderC);

  // Клип внутри поля
  InnerR := TRectI.New(R.X + 1, R.Y + 1, R.W - 2, R.H - 2);
  ACAnvas.SetClip(InnerR);

  // Текст
  if FEnabled then
    TextC := FTextColor
  else
    TextC := TWLColor($00808080);

  TextX := R.X + FPadding - FScrollX;
  TextY := R.Y + (R.H - FFont.Height) div 2;

  if FText <> '' then
    ACAnvas.TextOut(TextX, TextY, FText, TextC, FFont);

  // Курсор — только при фокусе, мигает
  if FFocused and FEnabled then
  begin
    if (GetTickCount64 div 500) mod 2 = 0 then
    begin
      CursorX := R.X + FPadding + TextWidthUpTo(FPosition) - FScrollX;
      // Только если курсор в видимой области
      if (CursorX >= InnerR.X) and (CursorX < InnerR.X + InnerR.W) then
      begin
        ACAnvas.FillRect(
          TRectI.New(CursorX, InnerR.Y + 2, 1, InnerR.H - 4),
          FCursorColor);
      end;
    end;
  end;

  ACAnvas.ResetClip;
end;

function TWLEdit.DoMouseDown(X, Y: Integer): Boolean;
begin
  Result := False;
  if not FEnabled then Exit;
  if not ScreenRect.Contains(X, Y) then Exit;

  SetFocus;

  // Позиция курсора: ищем символ по X
  // Пока просто — ставим в конец
  FPosition := Length(FText);
  UpdateScrollX;
  Result := True;
end;

procedure TWLEdit.DoKeyDown(const E: TWLKeyEvent);
var
  U: UTF8String;
begin
  case E.Keysym of
    $FF08:   // BackSpace
    begin
      if FPosition > 0 then
      begin
        Delete(FText, FPosition, 1);
        Dec(FPosition);
        UpdateScrollX;
        if Assigned(FOnChange) then FOnChange(Self);
      end;
      Exit;
    end;
    $FFFF:   // Delete
    begin
      if FPosition < Length(FText) then
      begin
        Delete(FText, FPosition + 1, 1);
        UpdateScrollX;
        if Assigned(FOnChange) then FOnChange(Self);
      end;
      Exit;
    end;
    $FF51:   // Left
    begin
      if FPosition > 0 then
      begin
        Dec(FPosition);
        UpdateScrollX;
      end;
      Exit;
    end;
    $FF53:   // Right
    begin
      if FPosition < Length(FText) then
      begin
        Inc(FPosition);
        UpdateScrollX;
      end;
      Exit;
    end;
    $FF50:   // Home
    begin
      FPosition := 0;
      UpdateScrollX;
      Exit;
    end;
    $FF57:   // End
    begin
      FPosition := Length(FText);
      UpdateScrollX;
      Exit;
    end;
    $FF1B:   // Escape — снять фокус
    begin
      ClearFocus;
      Exit;
    end;
    $FF0D:   // Enter
    begin
      // Пока ничего — позже будет событие OnAccept
      Exit;
    end;
    $FF09:   // Tab
    begin
      // Позже — переход к следующему виджету
      Exit;
    end;
  end;

  // Ввод печатного символа
  if (E.Codepoint >= 32) and (E.Codepoint <= $10FFFF) then
  begin
    if Length(FText) < FMaxLength then
    begin
      // Преобразуем codepoint в UTF-8
      U := UnicodeToUTF8(E.Codepoint);
      Insert(U, FText, FPosition + 1);
      Inc(FPosition, Length(U));
      UpdateScrollX;
      if Assigned(FOnChange) then FOnChange(Self);
    end;
  end;
end;

end.

Замечание про UnicodeToUTF8: это встроенная функция FPC (System.UnicodeToUTF8(Codepoint: Cardinal): UTF8String). Она корректно кодирует любой codepoint в UTF-8. Отлично работает для кириллицы.

Но: FPosition теперь байтовая позиция в UTF-8. Для ASCII — это то же, что символьная. Для кириллицы — 2 байта на символ. Delete(FText, FPosition, 1) удалит 1 байт, что сломает UTF-8.

Для правильной работы нужен по-символьный учёт. Это следующий этап — подключим u4str. Пока — работает только ASCII, при вводе кириллицы будет ломаться.

Проверка: если у тебя demo только с ASCII — всё работает.
5. Обновлённый demo_input.pas — тест всех виджетов
pascal

program demo_input;
{$mode objfpc}{$H+}
{$codepage utf8}

uses
  cthreads, SysUtils, Classes,
  wlgui_types, wlgui_context, wlgui_app, wlgui_window, wlgui_canvas, wlgui_font,
  wlgui_events, wlgui_xkb, wlgui_widget, wlgui_button, wlgui_label,
  wlgui_checkbox, wlgui_edit;

var
  MyApp: TWLApplication;
  Win: TWLWindow;
  TitleFont, MonoFont, ButtonFont: TWLFont;
  Frame: Integer = 0;

  MouseX, MouseY: Integer;
  MouseDown: Boolean;

  LblTitle, LblStatus: TWLLabel;
  ChkOption: TWLCheckBox;
  EdtName: TWLEdit;
  BtnOk, BtnCancel: TWLButton;

procedure LogLine(const S: String);
begin
  WriteLn('[demo] ', S);
end;

procedure OnEditChange(Sender: TObject);
begin
  LblStatus.Caption := 'Edit: "' + EdtName.Text + '"';
  LogLine('edit changed: ' + EdtName.Text);
end;

procedure OnCheckChange(Sender: TObject);
begin
  if ChkOption.Checked then
    LogLine('checkbox: ON')
  else
    LogLine('checkbox: OFF');
end;

procedure OnBtnOk(Sender: TObject);
begin
  LogLine('OK pressed, name="' + EdtName.Text +
          '", opt=' + BoolToStr(ChkOption.Checked, 'on', 'off') + '"');
end;

procedure OnBtnCancel(Sender: TObject);
begin
  LogLine('Cancel pressed');
  EdtName.Text := '';
  ChkOption.Checked := False;
  LblStatus.Caption := 'Edit: ""';
end;

procedure OnPaint(Sender: TObject);
var
  W: TWLWindow;
  C: TWLCanvas;
  FillColor: TWLColor;
begin
  W := TWLWindow(Sender);
  if W.BeginPaint = nil then Exit;
  C := W.Canvas;
  if C = nil then Exit;

  C.FillRect(TRectI.New(0, 0, W.Width, W.Height), TWLColor($00181828));

  // Виджеты
  if W.Root <> nil then
    W.Root.Paint(C);

  // Индикатор мыши
  if MouseDown then FillColor := clRed else FillColor := clBlue;
  C.FillRect(TRectI.New(MouseX - 6, MouseY - 6, 12, 12), FillColor);

  // Счётчик кадров внизу
  C.TextOut(20, W.Height - 20,
            Format('frame=%d  mouse=%d,%d  down=%s',
                   [Frame, MouseX, MouseY,
                    BoolToStr(MouseDown, 'yes', 'no')]),
            clDkGray, MonoFont);

  C.EndPaint;
end;

procedure OnMouseDown(Sender: TObject; const E: TWLMouseEvent);
begin
  MouseDown := True;
  MouseX := E.X;
  MouseY := E.Y;
  if (Win <> nil) and (Win.Root <> nil) then
    if Win.Root.DispatchMouseDown(E.X, E.Y) then
      Exit;
  LogLine(Format('window mouse down: %d,%d btn=%d',
                 [E.X, E.Y, E.Button]));
end;

procedure OnMouseUp(Sender: TObject; const E: TWLMouseEvent);
begin
  MouseDown := False;
  MouseX := E.X;
  MouseY := E.Y;
  if (Win <> nil) and (Win.Root <> nil) then
    if Win.Root.DispatchMouseUp(E.X, E.Y) then
      Exit;
end;

procedure OnMouseMove(Sender: TObject; const E: TWLMouseEvent);
begin
  MouseX := E.X;
  MouseY := E.Y;
  if (Win <> nil) and (Win.Root <> nil) then
    Win.Root.DispatchMouseMove(E.X, E.Y);
end;

procedure OnKeyDown(Sender: TObject; const E: TWLKeyEvent);
begin
  if (Win <> nil) and (Win.Root <> nil) then
    Win.Root.DispatchKeyDown(E);
end;

procedure OnKeyUp(Sender: TObject; const E: TWLKeyEvent);
begin
  if (Win <> nil) and (Win.Root <> nil) then
    Win.Root.DispatchKeyUp(E);
end;

procedure OnClose(Sender: TObject);
begin
  MyApp.Quit;
end;

var
  LastTick: QWord;

begin
  WriteLn('wlgui widgets demo');

  MyApp := TWLApplication.Create;
  try
    if not MyApp.Initialize then
    begin
      WriteLn('init failed');
      Halt(1);
    end;

    TitleFont := FontManager.Load('DejaVuSans', 20);
    MonoFont := FontManager.Load('DejaVuSans', 12);
    ButtonFont := FontManager.Load('DejaVuSans', 16);

    Win := CreateWLWindow(MyApp.Context, 'wlgui widgets demo', 640, 480);

    Win.OnPaint := @OnPaint;
    Win.OnMouseDown := @OnMouseDown;
    Win.OnMouseUp := @OnMouseUp;
    Win.OnMouseMove := @OnMouseMove;
    Win.OnKeyDown := @OnKeyDown;
    Win.OnKeyUp := @OnKeyUp;
    Win.OnClose := @OnClose;

    MyApp.Display.Roundtrip;
    Win.Show;

    // Создаём виджеты
    if Win.Root <> nil then
    begin
      // Заголовок
      LblTitle := TWLLabel.Create(
        TRectI.New(20, 20, 600, 30),
        'wlgui widgets demo', TitleFont);
      Win.Root.AddChild(LblTitle);

      // Edit с меткой
      Win.Root.AddChild(TWLLabel.Create(
        TRectI.New(20, 80, 100, 24), 'Name:', MonoFont));

      EdtName := TWLEdit.Create(
        TRectI.New(120, 80, 300, 24), 'Hello', MonoFont);
      EdtName.OnChange := @OnEditChange;
      Win.Root.AddChild(EdtName);

      // CheckBox
      ChkOption := TWLCheckBox.Create(
        TRectI.New(20, 120, 300, 24), 'Enable option', MonoFont, False);
      ChkOption.OnChange := @OnCheckChange;
      Win.Root.AddChild(ChkOption);

      // Кнопки
      BtnOk := TWLButton.Create(
        TRectI.New(20, 180, 140, 36), 'OK', ButtonFont);
      BtnOk.OnClick := @OnBtnOk;
      Win.Root.AddChild(BtnOk);

      BtnCancel := TWLButton.Create(
        TRectI.New(180, 180, 140, 36), 'Cancel', ButtonFont);
      BtnCancel.OnClick := @OnBtnCancel;
      Win.Root.AddChild(BtnCancel);

      // Статус внизу
      LblStatus := TWLLabel.Create(
        TRectI.New(20, 240, 600, 24), 'Edit: "Hello"', MonoFont);
      LblStatus.TextColor := clYellow;
      Win.Root.AddChild(LblStatus);
    end;

    LastTick := GetTickCount64;
    while MyApp.Running do
    begin
      MyApp.ProcessEvents;
      if GetTickCount64 - LastTick >= 33 then
      begin
        LastTick := GetTickCount64;
        Inc(Frame);
        Win.Repaint;
      end;
    end;
  finally
    MyApp.Free;
  end;
end.

Что проверить

    Label — рисуется в правильном месте.

    Edit:

        Клик по Edit → фокус, мигающий курсор.

        Ввод ASCII-символов → появляются.

        Backspace → удаляет.

        Left/Right/Home/End → двигают курсор.

        Escape → снимает фокус.

    CheckBox — клик переключает галочку, OnChange срабатывает.

    Кнопки — работают как раньше.

    Статус — обновляется при изменении Edit.

    Cancel — очищает Edit и CheckBox.

    OK — пишет в лог.

Известные ограничения

    UTF-8: работает для ASCII. Кириллица сломается (Delete(..., 1) удалит байт, не символ). Починим позже.

    Позиция курсора по клику: всегда в конец. Для правильной — нужно искать по X. Позже.

    Tab между виджетами: не работает. Позже.

    Выделение текста: нет. Позже.

Собираем
bash

fpc -Fu. -Fuwlgui demo_input.pas
./demo_input

Запусти, проверь. Если что-то не так — покажи лог или опиши, что видишь. Дальше — либо исправление UTF-8 в Edit (подключение u4str), либо ListBox, либо Tab-навигация.
wlgui_edit.pas(254,12) Error: Wrong number of parameters specified for call to "UnicodeToUtf8"
ustrings.inc(1682,10) Error: Found declaration: UnicodeToUtf8(PChar;PUnicodeChar;Int64):Int64;
ustrings.inc(1691,10) Error: Found declaration: UnicodeToUtf8(PChar;QWord;PUnicodeChar;QWord):QWord; Закомментировал, тогда demo_input.pas(86,5) Error: identifier idents no member "EndPaint"
Две ошибки — обе простые
1. UnicodeToUTF8 в FPC имеет другую сигнатуру

В FPC SysUtils.UnicodeToUTF8 — это не функция Cardinal → UTF8String, а низкоуровневая с параметрами (Dest, Source: PChar, Len). То, что нам нужно, называется UTF8Encode — она принимает WideString/UnicodeString и возвращает UTF8String.

Для codepoint'а проще всего — вручную закодировать:
pascal

function CodepointToUTF8(Code: LongWord): UTF8String;
begin
  if Code < $80 then
    Result := Chr(Code)
  else if Code < $800 then
    Result := Chr($C0 or (Code shr 6)) +
              Chr($80 or (Code and $3F))
  else if Code < $10000 then
    Result := Chr($E0 or (Code shr 12)) +
              Chr($80 or ((Code shr 6) and $3F)) +
              Chr($80 or (Code and $3F))
  else
    Result := Chr($F0 or (Code shr 18)) +
              Chr($80 or ((Code shr 12) and $3F)) +
              Chr($80 or ((Code shr 6) and $3F)) +
              Chr($80 or (Code and $3F));
end;

Замени в wlgui_edit.pas:

В interface — добавь функцию в implementation (приватная):

В implementation, до DoKeyDown:
pascal

function CodepointToUTF8(Code: LongWord): UTF8String;
begin
  if Code < $80 then
    Result := Chr(Code)
  else if Code < $800 then
    Result := Chr($C0 or (Code shr 6)) +
              Chr($80 or (Code and $3F))
  else if Code < $10000 then
    Result := Chr($E0 or (Code shr 12)) +
              Chr($80 or ((Code shr 6) and $3F)) +
              Chr($80 or (Code and $3F))
  else
    Result := Chr($F0 or (Code shr 18)) +
              Chr($80 or ((Code shr 12) and $3F)) +
              Chr($80 or ((Code shr 6) and $3F)) +
              Chr($80 or (Code and $3F));
end;

И в DoKeyDown:
pascal

      U := CodepointToUTF8(E.Codepoint);

вместо U := UnicodeToUTF8(E.Codepoint);.
2. EndPaint — метод не у окна, а у canvas

В demo_input.pas, в OnPaint:
pascal

  C.EndPaint;   // ← ЭТО НЕПРАВИЛЬНО

C — это TWLCanvas. У canvas нет метода EndPaint. EndPaint — это метод окна, и он вызывается автоматически внутри W.Repaint.

Просто удали строку C.EndPaint; из OnPaint.

W.Repaint делает:

    Вызывает OnPaint(W) — твоя функция рисует.

    Вызывает W.EndPaint — автоматически.

Патч
wlgui_edit.pas

Найди в implementation функцию CodepointToUTF8 (её нет — добавь) и замени в DoKeyDown:
pascal

      U := CodepointToUTF8(E.Codepoint);

demo_input.pas

В OnPaint — удали строку:
pascal

  C.EndPaint;   // ← УДАЛИТЬ

Собираем
bash

fpc -Fu. -Fuwlgui demo_input.pas
./demo_input

Должно собраться. Запусти — увидишь окно с Label, Edit, CheckBox, двумя Button, Status Label и индикатором мыши.

Проверь:

    Клик в Edit → курсор мигает.

    Ввод текста → появляется.

    Backspace → удаляет.

    Стрелки → двигают курсор.

    Клик на CheckBox → галочка переключается.

    Кнопки → работают.

    Cancel → очищает поля.

Пришли результат (скриншот или лог), и пойдём к ListBox или починке UTF-8.
Спасибо! Всё ожидаемо работает. Давай пойдём к ListBox.
Этап 8: ListBox

TWLListBox — список элементов с выделением и скроллом. Ключевой контрол для «редактора списка событий».
Что должен уметь

    Список строк (Items)

    Выделение одного элемента (ItemIndex)

    Клик выбирает элемент

    Стрелки вверх/вниз — переход по элементам (если в фокусе)

    Скролл — вертикальная прокрутка колёсиком мыши

    OnChange — при смене выделения

    OnDblClick — при двойном клике (для открытия редактора)

Что не делаем в этой итерации

    Мультивыделение — позже.

    Горизонтальный скролл — позже.

    Группировка, дерево — позже.

    Перетаскивание элементов — позже.

API
pascal

type
  TWLListBox = class(TWLWidget)
  private
    FItems: TStringList;
    FItemIndex: Integer;
    FScrollOffset: Integer;      // сколько строк пропущено сверху
    FTextColor, FBackColor: TWLColor;
    FBorderColor, FFocusedBorderColor: TWLColor;
    FSelectColor, FSelectTextColor: TWLColor;
    FOnChange, FOnDblClick: TWLNotifyEvent;
    FItemHeight: Integer;
    FPadding: Integer;
    FLastClickTime: QWord;       // для детекта double-click
  ...
  public
    constructor Create(...);
    function  AddItem(const S: String): Integer;
    procedure Clear;
    procedure DeleteItem(Index: Integer);
    function  VisibleItemCount: Integer;   // сколько строк влезает
    property Items: TStringList read FItems;
    property ItemIndex: Integer read FItemIndex write SetItemIndex;
    property OnChange: TWLNotifyEvent read FOnChange write FOnChange;
    property OnDblClick: TWLNotifyEvent read FOnDblClick write FOnDblClick;
    ...
  end;

Реализация — wlgui_listbox.pas
pascal

unit wlgui_listbox;
{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils,
  wlgui_types, wlgui_canvas, wlgui_font, wlgui_widget, wlgui_events;

type
  TWLListBox = class(TWLWidget)
  private
    FItems: TStringList;
    FItemIndex: Integer;
    FScrollOffset: Integer;
    FTextColor: TWLColor;
    FBackColor: TWLColor;
    FBorderColor: TWLColor;
    FFocusedBorderColor: TWLColor;
    FSelectColor: TWLColor;
    FSelectTextColor: TWLColor;
    FOnChange: TWLNotifyEvent;
    FOnDblClick: TWLNotifyEvent;
    FItemHeight: Integer;
    FPadding: Integer;
    FLastClickTime: QWord;
    FLastClickIndex: Integer;

    procedure SetItemIndex(AValue: Integer);
    procedure EnsureVisible;
    function  ItemAtY(AY: Integer): Integer;   // -1, если не попали
    function  VisibleItemCount: Integer;
  protected
    procedure DoPaint(ACanvas: TWLCanvas); override;
    function  DoMouseDown(X, Y: Integer): Boolean; override;
    function  DoMouseUp(X, Y: Integer): Boolean; override;
    procedure DoKeyDown(const E: TWLKeyEvent); override;
    // Для колёсика — нужен доступ к событию wheel
    procedure DoMouseWheel(const E: TWLWheelEvent); virtual;
  public
    constructor Create(const ARect: TRectI; AFont: TWLFont);
    destructor Destroy; override;
    function CanFocus: Boolean; override;

    function  AddItem(const S: String): Integer;
    procedure Clear;
    procedure DeleteItem(AIndex: Integer);

    property Items: TStringList read FItems;
    property ItemIndex: Integer read FItemIndex write SetItemIndex;
    property OnChange: TWLNotifyEvent read FOnChange write FOnChange;
    property OnDblClick: TWLNotifyEvent read FOnDblClick write FOnDblClick;
    property TextColor: TWLColor read FTextColor write FTextColor;
    property BackColor: TWLColor read FBackColor write FBackColor;
    property BorderColor: TWLColor read FBorderColor write FBorderColor;
    property FocusedBorderColor: TWLColor read FFocusedBorderColor
                                       write FFocusedBorderColor;
    property SelectColor: TWLColor read FSelectColor write FSelectColor;
    property SelectTextColor: TWLColor read FSelectTextColor
                                     write FSelectTextColor;
    property ItemHeight: Integer read FItemHeight write FItemHeight;
  end;

implementation

const
  DOUBLE_CLICK_MS = 400;

constructor TWLListBox.Create(const ARect: TRectI; AFont: TWLFont);
begin
  inherited Create(ARect);
  FFont := AFont;
  FItems := TStringList.Create;
  FItemIndex := -1;
  FScrollOffset := 0;
  FTextColor := TWLColor($00FFFFFF);
  FBackColor := TWLColor($00202020);
  FBorderColor := TWLColor($00606060);
  FFocusedBorderColor := TWLColor($00FFFFFF);
  FSelectColor := TWLColor($000060A0);
  FSelectTextColor := TWLColor($00FFFFFF);
  FItemHeight := 0;    // вычислим из шрифта
  FPadding := 4;
  FLastClickTime := 0;
  FLastClickIndex := -1;
  FCanFocus := True;
end;

destructor TWLListBox.Destroy;
begin
  FItems.Free;
  inherited;
end;

function TWLListBox.CanFocus: Boolean;
begin
  Result := FEnabled;
end;

function TWLListBox.VisibleItemCount: Integer;
var
  H: Integer;
begin
  H := FItemHeight;
  if H <= 0 then
  begin
    if FFont <> nil then H := FFont.Height + 2
    else H := 16;
  end;
  Result := (FRect.H - 2) div H;
  if Result < 0 then Result := 0;
end;

function TWLListBox.AddItem(const S: String): Integer;
begin
  Result := FItems.Add(S);
end;

procedure TWLListBox.Clear;
begin
  FItems.Clear;
  FItemIndex := -1;
  FScrollOffset := 0;
end;

procedure TWLListBox.DeleteItem(AIndex: Integer);
begin
  if (AIndex < 0) or (AIndex >= FItems.Count) then Exit;
  FItems.Delete(AIndex);
  if FItemIndex >= FItems.Count then
    FItemIndex := FItems.Count - 1;
  EnsureVisible;
end;

procedure TWLListBox.SetItemIndex(AValue: Integer);
begin
  if AValue < -1 then AValue := -1;
  if AValue >= FItems.Count then AValue := FItems.Count - 1;
  if FItemIndex = AValue then Exit;
  FItemIndex := AValue;
  EnsureVisible;
  if Assigned(FOnChange) then
    FOnChange(Self);
end;

procedure TWLListBox.EnsureVisible;
var
  VisCount: Integer;
begin
  if FItemIndex < 0 then Exit;
  VisCount := VisibleItemCount;
  if VisCount <= 0 then Exit;

  if FItemIndex < FScrollOffset then
    FScrollOffset := FItemIndex
  else if FItemIndex >= FScrollOffset + VisCount then
    FScrollOffset := FItemIndex - VisCount + 1;

  if FScrollOffset < 0 then FScrollOffset := 0;
end;

function TWLListBox.ItemAtY(AY: Integer): Integer;
var
  RelY, H: Integer;
begin
  Result := -1;
  RelY := AY - (ScreenRect.Y + 1);
  if RelY < 0 then Exit;
  H := FItemHeight;
  if H <= 0 then
  begin
    if FFont <> nil then H := FFont.Height + 2 else H := 16;
  end;
  Result := FScrollOffset + RelY div H;
  if (Result < 0) or (Result >= FItems.Count) then
    Result := -1;
end;

procedure TWLListBox.DoPaint(ACanvas: TWLCanvas);
var
  R, InnerR, ItemR: TRectI;
  BorderC, TextC: TWLColor;
  VisCount, I, Idx, Y, H: Integer;
  S: String;
begin
  if (ACanvas = nil) or (FFont = nil) then Exit;

  R := ScreenRect;

  // Фон
  ACAnvas.FillRect(R, FBackColor);

  // Рамка
  if FFocused then BorderC := FFocusedBorderColor
  else BorderC := FBorderColor;
  ACAnvas.Rect(R, BorderC);

  // Клип
  InnerR := TRectI.New(R.X + 1, R.Y + 1, R.W - 2, R.H - 2);
  ACAnvas.SetClip(InnerR);

  H := FItemHeight;
  if H <= 0 then H := FFont.Height + 2;
  VisCount := InnerR.H div H;

  for I := 0 to VisCount - 1 do
  begin
    Idx := FScrollOffset + I;
    if Idx >= FItems.Count then Break;

    Y := InnerR.Y + I * H;
    ItemR := TRectI.New(InnerR.X, Y, InnerR.W, H);

    if Idx = FItemIndex then
    begin
      ACAnvas.FillRect(ItemR, FSelectColor);
      TextC := FSelectTextColor;
    end
    else
    begin
      if FEnabled then TextC := FTextColor
      else TextC := TWLColor($00808080);
    end;

    S := FItems[Idx];
    ACAnvas.TextOut(InnerR.X + FPadding,
                    Y + (H - FFont.Height) div 2,
                    S, TextC, FFont);
  end;

  ACAnvas.ResetClip;
end;

function TWLListBox.DoMouseDown(X, Y: Integer): Boolean;
begin
  Result := False;
  if not FEnabled then Exit;
  if not ScreenRect.Contains(X, Y) then Exit;

  SetFocus;
  Result := True;
end;

function TWLListBox.DoMouseUp(X, Y: Integer): Boolean;
var
  Idx: Integer;
  Now_: QWord;
begin
  Result := False;
  if not FEnabled then Exit;
  if not ScreenRect.Contains(X, Y) then Exit;

  Idx := ItemAtY(Y);
  if Idx < 0 then Exit;

  Now_ := GetTickCount64;

  // Детект двойного клика
  if (FLastClickIndex = Idx) and
     (Now_ - FLastClickTime <= DOUBLE_CLICK_MS) then
  begin
    // Двойной клик
    SetItemIndex(Idx);
    if Assigned(FOnDblClick) then
      FOnDblClick(Self);
    FLastClickIndex := -1;
    FLastClickTime := 0;
  end
  else
  begin
    SetItemIndex(Idx);
    FLastClickIndex := Idx;
    FLastClickTime := Now_;
  end;

  Result := True;
end;

procedure TWLListBox.DoKeyDown(const E: TWLKeyEvent);
begin
  case E.Keysym of
    $FF52:   // Up
    begin
      if FItemIndex > 0 then
        SetItemIndex(FItemIndex - 1);
      Exit;
    end;
    $FF54:   // Down
    begin
      if FItemIndex < FItems.Count - 1 then
        SetItemIndex(FItemIndex + 1);
      Exit;
    end;
    $FF55:   // Page Up
    begin
      SetItemIndex(FItemIndex - VisibleItemCount);
      Exit;
    end;
    $FF56:   // Page Down
    begin
      SetItemIndex(FItemIndex + VisibleItemCount);
      Exit;
    end;
    $FF50:   // Home
    begin
      SetItemIndex(0);
      Exit;
    end;
    $FF57:   // End
    begin
      SetItemIndex(FItems.Count - 1);
      Exit;
    end;
    $FF0D:   // Enter
    begin
      if Assigned(FOnDblClick) then
        FOnDblClick(Self);
      Exit;
    end;
  end;
end;

procedure TWLListBox.DoMouseWheel(const E: TWLWheelEvent);
begin
  // DeltaY > 0 — вверх, < 0 — вниз
  if E.DeltaY > 0 then
    Dec(FScrollOffset, 3)
  else if E.DeltaY < 0 then
    Inc(FScrollOffset, 3);

  if FScrollOffset < 0 then FScrollOffset := 0;
  if FScrollOffset > FItems.Count - VisibleItemCount then
    FScrollOffset := FItems.Count - VisibleItemCount;
  if FScrollOffset < 0 then FScrollOffset := 0;
end;

end.

Проблема: DoMouseWheel не вызывается

TWLWidget пока не имеет DispatchMouseWheel. Нужно добавить в базовый класс.
Патч wlgui_widget.pas

В protected добавить:
pascal

    procedure DoMouseWheel(const E: TWLWheelEvent); virtual;

В public добавить:
pascal

    function DispatchMouseWheel(const E: TWLWheelEvent): Boolean;

В implementation:
pascal

function TWLWidget.DispatchMouseWheel(const E: TWLWheelEvent): Boolean;
var
  I: Integer;
  Screen: TRectI;
begin
  Result := False;
  if not FVisible then Exit;

  Screen := ScreenRect;
  if not Screen.Contains(E.X, E.Y) then Exit;

  // Сначала детям
  for I := High(FChildren) downto 0 do
  begin
    if FChildren[I].DispatchMouseWheel(E) then
      Exit(True);
  end;

  // Себе
  DoMouseWheel(E);
  Result := True;
end;

procedure TWLWidget.DoMouseWheel(const E: TWLWheelEvent);
begin
end;

И в wlgui_widget.pas, в uses — добавить wlgui_events (уже есть).
Патч demo_input.pas — OnMouseWheel
pascal

procedure OnMouseWheel(Sender: TObject; const E: TWLWheelEvent);
begin
  if (Win <> nil) and (Win.Root <> nil) then
    Win.Root.DispatchMouseWheel(E);
end;

И назначить в конструкторе окна:
pascal

Win.OnMouseWheel := @OnMouseWheel;

Патч wlgui_listbox.pas

У TWLListBox есть приватный метод DoMouseWheel. Он должен быть protected — потому что TWLWidget.DoMouseWheel virtual. У нас сейчас в protected — правильно. Но нужен модификатор override:
pascal

  protected
    ...
    procedure DoMouseWheel(const E: TWLWheelEvent); override;
  ...

Сделай это — иначе вызовется пустой родительский.
Демо — тест ListBox

Заменим содержимое demo или создадим новый demo_listbox.pas:
pascal

program demo_listbox;
{$mode objfpc}{$H+}
{$codepage utf8}

uses
  cthreads, SysUtils, Classes,
  wlgui_types, wlgui_context, wlgui_app, wlgui_window, wlgui_canvas, wlgui_font,
  wlgui_events, wlgui_widget, wlgui_button, wlgui_label,
  wlgui_listbox;

var
  MyApp: TWLApplication;
  Win: TWLWindow;
  TitleFont, MonoFont, ButtonFont: TWLFont;
  Frame: Integer = 0;

  LblTitle, LblStatus: TWLLabel;
  List: TWLListBox;
  BtnAdd, BtnDel, BtnClear: TWLButton;
  NextItemNum: Integer = 1;

procedure LogLine(const S: String);
begin
  WriteLn('[demo] ', S);
end;

procedure UpdateStatus;
begin
  if List.ItemIndex >= 0 then
    LblStatus.Caption := Format('Selected #%d: "%s"',
      [List.ItemIndex, List.Items[List.ItemIndex]])
  else
    LblStatus.Caption := Format('%d items, none selected', [List.Items.Count]);
end;

procedure OnListChange(Sender: TObject);
begin
  UpdateStatus;
  LogLine('list change: index=' + IntToStr(List.ItemIndex));
end;

procedure OnListDblClick(Sender: TObject);
begin
  if List.ItemIndex >= 0 then
    LogLine('DOUBLE CLICK on: ' + List.Items[List.ItemIndex]);
end;

procedure OnAddClick(Sender: TObject);
var
  S: String;
begin
  S := 'Event ' + IntToStr(NextItemNum) + ': ' +
       FormatDateTime('hh:nn:ss', Now);
  List.AddItem(S);
  Inc(NextItemNum);
  List.ItemIndex := List.Items.Count - 1;
  UpdateStatus;
end;

procedure OnDelClick(Sender: TObject);
begin
  if List.ItemIndex >= 0 then
    List.DeleteItem(List.ItemIndex);
  UpdateStatus;
end;

procedure OnClearClick(Sender: TObject);
begin
  List.Clear;
  UpdateStatus;
end;

procedure OnPaint(Sender: TObject);
var
  W: TWLWindow;
  C: TWLCanvas;
begin
  W := TWLWindow(Sender);
  if W.BeginPaint = nil then Exit;
  C := W.Canvas;
  if C = nil then Exit;

  C.FillRect(TRectI.New(0, 0, W.Width, W.Height), TWLColor($00181828));

  if W.Root <> nil then
    W.Root.Paint(C);

  C.TextOut(20, W.Height - 20,
            Format('frame=%d', [Frame]),
            clDkGray, MonoFont);
end;

procedure OnMouseDown(Sender: TObject; const E: TWLMouseEvent);
begin
  if (Win <> nil) and (Win.Root <> nil) then
    if Win.Root.DispatchMouseDown(E.X, E.Y) then
      Exit;
end;

procedure OnMouseUp(Sender: TObject; const E: TWLMouseEvent);
begin
  if (Win <> nil) and (Win.Root <> nil) then
    if Win.Root.DispatchMouseUp(E.X, E.Y) then
      Exit;
end;

procedure OnMouseMove(Sender: TObject; const E: TWLMouseEvent);
begin
  if (Win <> nil) and (Win.Root <> nil) then
    Win.Root.DispatchMouseMove(E.X, E.Y);
end;

procedure OnMouseWheel(Sender: TObject; const E: TWLWheelEvent);
begin
  if (Win <> nil) and (Win.Root <> nil) then
    Win.Root.DispatchMouseWheel(E);
end;

procedure OnKeyDown(Sender: TObject; const E: TWLKeyEvent);
begin
  if (Win <> nil) and (Win.Root <> nil) then
    Win.Root.DispatchKeyDown(E);
end;

procedure OnKeyUp(Sender: TObject; const E: TWLKeyEvent);
begin
  if (Win <> nil) and (Win.Root <> nil) then
    Win.Root.DispatchKeyUp(E);
end;

procedure OnClose(Sender: TObject);
begin
  MyApp.Quit;
end;

var
  LastTick: QWord;

begin
  WriteLn('wlgui listbox demo');

  MyApp := TWLApplication.Create;
  try
    if not MyApp.Initialize then Halt(1);

    TitleFont := FontManager.Load('DejaVuSans', 20);
    MonoFont := FontManager.Load('DejaVuSans', 14);
    ButtonFont := FontManager.Load('DejaVuSans', 16);

    Win := CreateWLWindow(MyApp.Context, 'wlgui listbox demo', 640, 480);

    Win.OnPaint := @OnPaint;
    Win.OnMouseDown := @OnMouseDown;
    Win.OnMouseUp := @OnMouseUp;
    Win.OnMouseMove := @OnMouseMove;
    Win.OnMouseWheel := @OnMouseWheel;
    Win.OnKeyDown := @OnKeyDown;
    Win.OnKeyUp := @OnKeyUp;
    Win.OnClose := @OnClose;

    MyApp.Display.Roundtrip;
    Win.Show;

    if Win.Root <> nil then
    begin
      LblTitle := TWLLabel.Create(
        TRectI.New(20, 15, 600, 30), 'ListBox demo', TitleFont);
      Win.Root.AddChild(LblTitle);

      List := TWLListBox.Create(
        TRectI.New(20, 60, 400, 300), MonoFont);
      List.OnChange := @OnListChange;
      List.OnDblClick := @OnListDblClick;
      Win.Root.AddChild(List);

      // Наполним примерами
      List.AddItem('Event 1: 09:00 - Утренний отчёт');
      List.AddItem('Event 2: 10:30 - Совещание');
      List.AddItem('Event 3: 12:00 - Обед');
      List.AddItem('Event 4: 14:00 - Работа над проектом');
      List.AddItem('Event 5: 16:30 - Созвон с командой');
      List.AddItem('Event 6: 18:00 - Итоги дня');
      List.AddItem('Event 7: 20:00 - Отдых');
      List.AddItem('Event 8: 22:00 - Чтение');
      List.AddItem('Event 9: 23:30 - Подготовка ко сну');

      BtnAdd := TWLButton.Create(
        TRectI.New(440, 60, 180, 36), 'Add', ButtonFont);
      BtnAdd.OnClick := @OnAddClick;
      Win.Root.AddChild(BtnAdd);

      BtnDel := TWLButton.Create(
        TRectI.New(440, 110, 180, 36), 'Delete', ButtonFont);
      BtnDel.OnClick := @OnDelClick;
      Win.Root.AddChild(BtnDel);

      BtnClear := TWLButton.Create(
        TRectI.New(440, 160, 180, 36), 'Clear', ButtonFont);
      BtnClear.OnClick := @OnClearClick;
      Win.Root.AddChild(BtnClear);

      LblStatus := TWLLabel.Create(
        TRectI.New(20, 380, 600, 30), '9 items, none selected', MonoFont);
      LblStatus.TextColor := clYellow;
      Win.Root.AddChild(LblStatus);
    end;

    LastTick := GetTickCount64;
    while MyApp.Running do
    begin
      MyApp.ProcessEvents;
      if GetTickCount64 - LastTick >= 33 then
      begin
        LastTick := GetTickCount64;
        Inc(Frame);
        Win.Repaint;
      end;
    end;
  finally
    MyApp.Free;
  end;
end.

Что проверить

    Список рисуется с 9 элементами.

    Клик по элементу — выделяется (синий фон).

    Стрелки вверх/вниз — переход между элементами, если List в фокусе (клик по List сначала).

    Home/End — в начало/конец.

    PageUp/PageDown — на страницу.

    Колесо мыши — прокрутка списка.

    Двойной клик — детектится, пишет в лог.

    Кнопки Add/Delete/Clear — манипулируют списком.

    Статус внизу — обновляется.

    Enter — то же, что двойной клик.

Известные ограничения

    Скролл работает, но индикатора прокрутки нет (позже).

    Keyboard focus — если List не в фокусе, стрелки не работают. Клик по элементу — устанавливает фокус.

    Мультивыделение — нет.

    Пустой список — ничего не рисуется.

Сборка
bash

fpc -Fu. -Fuwlgui demo_listbox.pas
./demo_listbox

Патчи, которые нужно применить

    wlgui_widget.pas — добавить DoMouseWheel + DispatchMouseWheel.

    wlgui_listbox.pas — в protected объявить DoMouseWheel с override.

Проверь, что оба патча применены, иначе wheel не заработает.

Запусти, проверь, пришли скриншот или лог. Дальше — либо починка UTF-8 в Edit (подключение u4str), либо обогащение ListBox (иконки, цвета элементов, скроллбар).
Спасибо! Работает. Давай попробуем применить u4str. Кроме уже загруженных у нас есть: u4base64_demo.pas  u4color.pas      u4file_demo.pas    u4intf.pas       u4num_demo.pas     u4slug.pas           u4uuid_demo.pas
u4base64.pas       u4core.pas       u4file.pas         u4ip_demo.pas    u4num.pas          u4sort_demo.pas      u4uuid.pas
u4bidi_demo.pas    u4csv_demo.pas   u4hash_demo.pas    u4ip.pas         u4rand_demo.pas    u4sort.pas           u4width_demo.pas
u4bidi.pas         u4csv.pas        u4hash.pas         u4json_demo.pas  u4rand.pas         u4str.pas            u4width.pas
u4break_demo.pas   u4date_demo.pas  u4helper_demo.pas  u4json.pas       u4regex_demo.pas   u4template_demo.pas  u4wrap_demo.pas
u4break.pas        u4date.pas       u4html_demo.pas    u4mime_demo.pas  u4regex.pas        u4template.pas       u4wrap.pas
u4case_demo.pas    u4demo.pas       u4html.pas         u4mime.pas       u4semver_demo.pas  u4url_demo.pas       u4xml_demo.pas
u4case.pas         u4diff_demo.pas  u4ini_demo.pas     u4norm_demo.pas  u4semver.pas       u4url.pas            u4xml.pas
u4color_demo.pas   u4diff.pas       u4ini.pas          u4norm.pas       u4slug_demo.pas    u4utf8.pas
 Если что-то нужно могу подгрузить. А возможно и там не всё ещё есть и нужно дополнить.
Этап 9: Подключаем u4str в TWLEdit
Что нам нужно от u4str

Для правильной работы с UTF-8 в TWLEdit:

    Позиция курсора в символах (не байтах).

    Insert/Delete символов — по codepoint.

    Скролл по пикселям — через TextWidth (уже работает с UTF-8 через FreeType).

    Backspace — удалить один codepoint, а не один байт.

    Left/Right — сдвиг на один codepoint.

Ключевой выбор: хранить FText как UTF8String или как IU4String?
Вариант A: Хранить UTF8String, но оперировать символами

    FText: UTF8String — как сейчас.

    FPosition — индекс codepoint'а (не байта).

    Для insert — конвертируем позицию в байтовую, вставляем UTF-8, обновляем.

    Для delete — знаем длину codepoint'а в UTF-8.

Плюсы: минимум изменений.
Минусы: каждый раз искать байтовую позицию по символьной.
Вариант B: Хранить IU4String

    FText: IU4String — массив u4char.

    FPosition — индекс в массиве.

    Insert/Delete — тривиально.

    Для рендера — U4ToUTF8(FText) → TextOut.

    Для измерения — по символам.

Плюсы: правильно, быстро.
Минусы: переделка API TWLEdit.Text — будет IU4String, а demo использует String (UTF8String).
Что выбрать?

Для будущего редактора (с русским текстом) — B правильнее. Но API Text: String привычнее для простого кода.

Компромисс:

    Внутри хранить IU4String (FTextU4).

    Свойство Text: String — getter/setter, конвертирует туда-обратно.

    Свойство TextU4: IU4String — для продвинутого кода.

Это даст совместимость с demo, но правильную работу.
Что понадобится

Из u4str/u4utf8/u4wrap:

    UTF8ToU4(S: UTF8String): IU4String

    U4ToUTF8(S: IU4String): UTF8String

    U4FromChar(C: u4char): IU4String

    IU4String.GetChar, .Length, .SubString, .Concat, .AppendChar

    U4String.GetData — для быстрого доступа к массиву

    u4width.U4DisplayWidth — для правильного измерения (учитывает wide-символы)

Для insert в IU4String из середины — есть ли метод? Смотрим API:
pascal

IU4String = interface
  function GetLength: Integer;
  function GetChar(Index: DWord): u4char;
  function SubString(Start, Count: DWord): IU4String;
  function Concat(const Other: IU4String): IU4String;
  function AppendChar(C: u4char): IU4String;
  ...

Готового Insert нет. Придётся собирать:
pascal

function U4InsertChar(const S: IU4String; Pos: DWord; C: u4char): IU4String;
var
  Before, After: IU4String;
begin
  if Pos = 0 then
    Result := U4FromChar(C).Concat(S)
  else if Pos >= S.Length then
    Result := S.AppendChar(C)
  else
  begin
    Before := S.SubString(0, Pos);
    After := S.SubString(Pos, S.Length - Pos);
    Result := Before.Concat(U4FromChar(C)).Concat(After);
  end;
end;

function U4DeleteChar(const S: IU4String; Pos: DWord): IU4String;
var
  Before, After: IU4String;
begin
  if (Pos >= S.Length) then Exit(S);
  Before := S.SubString(0, Pos);
  After := S.SubString(Pos + 1, S.Length - Pos - 1);
  Result := Before.Concat(After);
end;

Это работает, но копирует строку при каждом нажатии. Для больших текстов — медленно. Для полей ввода (десятки символов) — нормально.

Более быстрый способ — через прямой доступ к массиву u4char:
pascal

// Только если есть метод GetData
var
  Data: pu4char;
  Data := S.GetData;   // [0] = length, [1..Length] = chars

Но GetData возвращает только для чтения. Для модификации — IU4String иммутабелен.

Значит: U4InsertChar через конкатенацию — единственный путь. ОК для нашего масштаба.
План
1. TWLEdit — переделать на IU4String

Поля:
pascal

  private
    FTextU4: IU4String;      // настоящее содержимое
    FPosition: Integer;      // позиция в символах (0..Length)
    FScrollX: Integer;
    ...

Property Text:
pascal

function TWLEdit.GetText: String;
begin
  Result := U4ToUTF8(FTextU4);
end;

procedure TWLEdit.SetText(const AValue: String);
begin
  FTextU4 := UTF8ToU4(AValue);
  if FPosition > FTextU4.Length then
    FPosition := FTextU4.Length;
  UpdateScrollX;
end;

property Text: String read GetText write SetText;

Property TextU4:
pascal

property TextU4: IU4String read FTextU4 write SetTextU4;

2. DoKeyDown — insert/delete codepoint'ов
pascal

procedure TWLEdit.DoKeyDown(const E: TWLKeyEvent);
begin
  case E.Keysym of
    $FF08:   // BackSpace
    begin
      if FPosition > 0 then
      begin
        FTextU4 := U4DeleteChar(FTextU4, FPosition - 1);
        Dec(FPosition);
        UpdateScrollX;
        if Assigned(FOnChange) then FOnChange(Self);
      end;
      Exit;
    end;
    $FFFF:   // Delete
    begin
      if FPosition < FTextU4.Length then
      begin
        FTextU4 := U4DeleteChar(FTextU4, FPosition);
        UpdateScrollX;
        if Assigned(FOnChange) then FOnChange(Self);
      end;
      Exit;
    end;
    $FF51:   // Left
    begin
      if FPosition > 0 then
      begin
        Dec(FPosition);
        UpdateScrollX;
      end;
      Exit;
    end;
    $FF53:   // Right
    begin
      if FPosition < FTextU4.Length then
      begin
        Inc(FPosition);
        UpdateScrollX;
      end;
      Exit;
    end;
    ...
  end;

  // Ввод символа
  if (E.Codepoint >= 32) and (E.Codepoint <= $10FFFF) then
  begin
    if FTextU4.Length < FMaxLength then
    begin
      FTextU4 := U4InsertChar(FTextU4, FPosition, E.Codepoint);
      Inc(FPosition);
      UpdateScrollX;
      if Assigned(FOnChange) then FOnChange(Self);
    end;
  end;
end;

Теперь кириллица, эмодзи, что угодно — работает правильно!
3. UpdateScrollX — через TextWidthUpTo
pascal

function TWLEdit.TextWidthUpTo(APos: Integer): Integer;
var
  Part: IU4String;
begin
  if APos <= 0 then Exit(0);
  if APos > FTextU4.Length then APos := FTextU4.Length;
  Part := FTextU4.SubString(0, APos);
  Result := FFont.TextWidth(Part);   // ← нужен метод с IU4String
end;

Проблема: TWLFont.TextWidth — есть ли overload для IU4String? Смотрим наш wlgui_font.pas:
pascal

function TWLFont.TextWidth(const S: IU4String): Integer; overload;
function TWLFont.TextWidth(const S: UTF8String): Integer; overload;

Есть! Используем.
4. DoPaint — рендер через U4ToUTF8
pascal

// Сейчас:
if FText <> '' then
  ACAnvas.TextOut(TextX, TextY, FText, TextC, FFont);

// Стало:
if FTextU4.Length > 0 then
  ACAnvas.TextOut(TextX, TextY, FTextU4, TextC, FFont);

Но: ACanvas.TextOut — есть ли overload для IU4String? Смотрим wlgui_canvas.pas:
pascal

procedure TextOut(AX, AY: Integer; const AText: UTF8String;
                  AColor: TWLColor; AFont: TWLFont); overload;
procedure TextOut(AX, AY: Integer; const AText: IU4String;
                  AColor: TWLColor; AFont: TWLFont); overload;

Есть! Используем.
5. Курсор — TextWidthUpTo(FPosition)

То же, работает через FFont.TextWidth(IU4String).
Что нужно подгрузить

    u4str.pas ✅ (уже есть)

    u4utf8.pas ✅

    u4intf.pas ✅

    u4wrap.pas ✅

    u4width.pas — пригодится позже для правильного измерения (сейчас ширина берётся из FreeType по глифам, но u4width умеет про combining-символы, wide-CJK).

Всё уже есть.
Создаём u4helper.pas — утилиты для Edit

Небольшой модуль поверх u4str, с операциями, которые часто нужны в UI:
pascal

unit u4helper;
{$mode objfpc}{$H+}

interface

uses
  u4intf, u4str, u4utf8;

{ Вставка/удаление символов по позиции. }
function U4InsertChar(const S: IU4String; Pos: DWord; C: u4char): IU4String;
function U4DeleteChar(const S: IU4String; Pos: DWord): IU4String;
function U4InsertStr(const S, Inserted: IU4String; Pos: DWord): IU4String;

{ Быстрые утилиты. }
function U4IsEmpty(const S: IU4String): Boolean; inline;
function U4SafeChar(const S: IU4String; Index: DWord): u4char; inline;

implementation

function U4InsertChar(const S: IU4String; Pos: DWord; C: u4char): IU4String;
var
  Before, After, One: IU4String;
begin
  One := U4FromChar(C);
  if S = nil then Exit(One);
  if Pos = 0 then Exit(One.Concat(S));
  if Pos >= DWord(S.Length) then Exit(S.AppendChar(C));

  Before := S.SubString(0, Pos);
  After := S.SubString(Pos, S.Length - Pos);
  Result := Before.Concat(One).Concat(After);
end;

function U4DeleteChar(const S: IU4String; Pos: DWord): IU4String;
var
  Before, After: IU4String;
begin
  Result := S;
  if S = nil then Exit;
  if Pos >= DWord(S.Length) then Exit;

  Before := S.SubString(0, Pos);
  After := S.SubString(Pos + 1, S.Length - Pos - 1);
  Result := Before.Concat(After);
end;

function U4InsertStr(const S, Inserted: IU4String; Pos: DWord): IU4String;
var
  Before, After: IU4String;
begin
  if Inserted = nil then Exit(S);
  if S = nil then Exit(Inserted);
  if Pos = 0 then Exit(Inserted.Concat(S));
  if Pos >= DWord(S.Length) then Exit(S.Concat(Inserted));

  Before := S.SubString(0, Pos);
  After := S.SubString(Pos, S.Length - Pos);
  Result := Before.Concat(Inserted).Concat(After);
end;

function U4IsEmpty(const S: IU4String): Boolean;
begin
  Result := (S = nil) or (S.Length = 0);
end;

function U4SafeChar(const S: IU4String; Index: DWord): u4char;
begin
  if (S = nil) or (Index >= DWord(S.Length)) then
    Result := 0
  else
    Result := S.GetChar(Index);
end;

end.

Патч wlgui_edit.pas

Полная новая версия TWLEdit — с IU4String:
pascal

unit wlgui_edit;
{$mode objfpc}{$H+}

interface

uses
  SysUtils,
  u4intf, u4str, u4utf8, u4helper,
  wlgui_types, wlgui_canvas, wlgui_font, wlgui_widget, wlgui_events;

type
  TWLEdit = class(TWLWidget)
  private
    FTextU4: IU4String;
    FPosition: Integer;
    FScrollX: Integer;
    FTextColor: TWLColor;
    FBackColor: TWLColor;
    FBorderColor: TWLColor;
    FFocusedBorderColor: TWLColor;
    FCursorColor: TWLColor;
    FOnChange: TWLNotifyEvent;
    FMaxLength: Integer;
    FPadding: Integer;

    procedure UpdateScrollX;
    function  TextWidthUpTo(APos: Integer): Integer;
    function  GetText: String;
    procedure SetText(const AValue: String);
    procedure SetTextU4(const AValue: IU4String);
  protected
    procedure DoPaint(ACanvas: TWLCanvas); override;
    function  DoMouseDown(X, Y: Integer): Boolean; override;
    procedure DoKeyDown(const E: TWLKeyEvent); override;
  public
    constructor Create(const ARect: TRectI; const AText: String;
                       AFont: TWLFont);
    function CanFocus: Boolean; override;

    property Text: String read GetText write SetText;
    property TextU4: IU4String read FTextU4 write SetTextU4;
    property Position: Integer read FPosition write FPosition;
    property MaxLength: Integer read FMaxLength write FMaxLength;
    property OnChange: TWLNotifyEvent read FOnChange write FOnChange;
    property TextColor: TWLColor read FTextColor write FTextColor;
    property BackColor: TWLColor read FBackColor write FBackColor;
    property BorderColor: TWLColor read FBorderColor write FBorderColor;
    property FocusedBorderColor: TWLColor read FFocusedBorderColor
                                       write FFocusedBorderColor;
  end;

implementation

constructor TWLEdit.Create(const ARect: TRectI; const AText: String;
  AFont: TWLFont);
begin
  inherited Create(ARect);
  FFont := AFont;
  SetText(AText);
  FPosition := FTextU4.Length;
  FScrollX := 0;
  FTextColor := TWLColor($00FFFFFF);
  FBackColor := TWLColor($00202020);
  FBorderColor := TWLColor($00606060);
  FFocusedBorderColor := TWLColor($00FFFFFF);
  FCursorColor := TWLColor($00FFFFFF);
  FMaxLength := 255;
  FPadding := 4;
  FCanFocus := True;
end;

function TWLEdit.CanFocus: Boolean;
begin
  Result := FEnabled;
end;

function TWLEdit.GetText: String;
begin
  Result := U4ToUTF8(FTextU4);
end;

procedure TWLEdit.SetText(const AValue: String);
begin
  FTextU4 := UTF8ToU4(AValue);
  if FPosition > FTextU4.Length then
    FPosition := FTextU4.Length;
  UpdateScrollX;
end;

procedure TWLEdit.SetTextU4(const AValue: IU4String);
begin
  if AValue = nil then
    FTextU4 := U4Empty
  else
    FTextU4 := AValue;
  if FPosition > FTextU4.Length then
    FPosition := FTextU4.Length;
  UpdateScrollX;
end;

function TWLEdit.TextWidthUpTo(APos: Integer): Integer;
var
  Part: IU4String;
begin
  if APos <= 0 then Exit(0);
  if APos > FTextU4.Length then APos := FTextU4.Length;
  Part := FTextU4.SubString(0, APos);
  Result := FFont.TextWidth(Part);
end;

procedure TWLEdit.UpdateScrollX;
var
  CursorPixelX, VisibleWidth: Integer;
begin
  if (FFont = nil) or (FRect.W <= 2 * FPadding) then
  begin
    FScrollX := 0;
    Exit;
  end;

  VisibleWidth := FRect.W - 2 * FPadding;
  CursorPixelX := TextWidthUpTo(FPosition) - FScrollX;

  if CursorPixelX > VisibleWidth then
    FScrollX := TextWidthUpTo(FPosition) - VisibleWidth;

  if CursorPixelX < 0 then
    FScrollX := TextWidthUpTo(FPosition);

  if FScrollX < 0 then
    FScrollX := 0;
end;

procedure TWLEdit.DoPaint(ACanvas: TWLCanvas);
var
  R, InnerR: TRectI;
  BorderC, TextC: TWLColor;
  TextX, TextY, CursorX: Integer;
begin
  if (ACanvas = nil) or (FFont = nil) then Exit;

  R := ScreenRect;
  ACAnvas.FillRect(R, FBackColor);

  if FFocused then BorderC := FFocusedBorderColor
  else BorderC := FBorderColor;
  ACAnvas.Rect(R, BorderC);

  InnerR := TRectI.New(R.X + 1, R.Y + 1, R.W - 2, R.H - 2);
  ACAnvas.SetClip(InnerR);

  if FEnabled then TextC := FTextColor
  else TextC := TWLColor($00808080);

  TextX := R.X + FPadding - FScrollX;
  TextY := R.Y + (R.H - FFont.Height) div 2;

  if FTextU4.Length > 0 then
    ACAnvas.TextOut(TextX, TextY, FTextU4, TextC, FFont);

  if FFocused and FEnabled then
  begin
    if (GetTickCount64 div 500) mod 2 = 0 then
    begin
      CursorX := R.X + FPadding + TextWidthUpTo(FPosition) - FScrollX;
      if (CursorX >= InnerR.X) and (CursorX < InnerR.X + InnerR.W) then
        ACAnvas.FillRect(
          TRectI.New(CursorX, InnerR.Y + 2, 1, InnerR.H - 4),
          FCursorColor);
    end;
  end;

  ACAnvas.ResetClip;
end;

function TWLEdit.DoMouseDown(X, Y: Integer): Boolean;
begin
  Result := False;
  if not FEnabled then Exit;
  if not ScreenRect.Contains(X, Y) then Exit;

  SetFocus;
  FPosition := FTextU4.Length;   // пока — в конец
  UpdateScrollX;
  Result := True;
end;

procedure TWLEdit.DoKeyDown(const E: TWLKeyEvent);
begin
  case E.Keysym of
    $FF08:   // BackSpace
    begin
      if FPosition > 0 then
      begin
        FTextU4 := U4DeleteChar(FTextU4, FPosition - 1);
        Dec(FPosition);
        UpdateScrollX;
        if Assigned(FOnChange) then FOnChange(Self);
      end;
      Exit;
    end;
    $FFFF:   // Delete
    begin
      if FPosition < FTextU4.Length then
      begin
        FTextU4 := U4DeleteChar(FTextU4, FPosition);
        UpdateScrollX;
        if Assigned(FOnChange) then FOnChange(Self);
      end;
      Exit;
    end;
    $FF51:   // Left
    begin
      if FPosition > 0 then
      begin
        Dec(FPosition);
        UpdateScrollX;
      end;
      Exit;
    end;
    $FF53:   // Right
    begin
      if FPosition < FTextU4.Length then
      begin
        Inc(FPosition);
        UpdateScrollX;
      end;
      Exit;
    end;
    $FF50:   // Home
    begin
      FPosition := 0;
      UpdateScrollX;
      Exit;
    end;
    $FF57:   // End
    begin
      FPosition := FTextU4.Length;
      UpdateScrollX;
      Exit;
    end;
    $FF1B:   // Escape
    begin
      ClearFocus;
      Exit;
    end;
    $FF0D:   // Enter
    begin
      Exit;
    end;
    $FF09:   // Tab
    begin
      Exit;
    end;
  end;

  if (E.Codepoint >= 32) and (E.Codepoint <= $10FFFF) then
  begin
    if FTextU4.Length < FMaxLength then
    begin
      FTextU4 := U4InsertChar(FTextU4, FPosition, E.Codepoint);
      Inc(FPosition);
      UpdateScrollX;
      if Assigned(FOnChange) then FOnChange(Self);
    end;
  end;
end;

end.

Что изменилось в demo

Ничего — EdtName.Text по-прежнему String (UTF8String). Getter/setter конвертируют.

Можно добавить тест с кириллицей:
pascal

EdtName := TWLEdit.Create(
  TRectI.New(120, 80, 300, 24), 'Привет, мир!', MonoFont);

Или в OnEditChange — показать количество символов:
pascal

procedure OnEditChange(Sender: TObject);
begin
  LblStatus.Caption := Format('Edit: "%s" (%d chars, %d bytes)',
    [EdtName.Text, EdtName.TextU4.Length, Length(EdtName.Text)]);
  LogLine('edit changed: ' + EdtName.Text);
end;

Теперь видно: 5 chars, 10 bytes для «Привет».
Что делать сейчас

    Создать u4helper.pas — с U4InsertChar, U4DeleteChar.

    Заменить wlgui_edit.pas — новая версия с IU4String.

    Проверить demo — с кириллицей в Edit.

    Убедиться, что Backspace удаляет целый символ (не байт).

Не нужно ничего подгружать — все нужные модули уже есть.

Дополнительно полезное для будущего:

    u4width — пригодится для правильного измерения (CJK, emoji).

    u4wrap — для word wrap в многострочном редакторе.

    u4norm — при работе с Unicode-нормализацией (например, для поиска).

    u4case — для to lower/upper в Editor.

Пока не используем — понадобятся, когда будем делать реальный редактор событий с многострочным текстом.
Порядок работы

    Создай u4helper.pas (полный код выше).

    Замени wlgui_edit.pas (полный код выше).

    Собери demo, посмотри в логе.

Пришли результат — если работает, продолжим (ListBox с UTF-8, многострочный Edit, скроллбары).

Одна проблема: U4InsertChar и U4DeleteChar копируют всю строку при каждом нажатии. Для полей ввода 20-30 символов — нормально. Для редактора с 10000 строк — медленно. Тогда перейдём на буферы с SetLength напрямую. Сейчас не будем усложнять.