Спасибо! ./u4builder_demo
=== u4builder test ===

Hello, мир!
Numbers: 1 2 3 4 5

[1] event line
[2] event line
[3] event line
[4] event line
[5] event line
[6] event line
[7] event line
[8] event line
[9] event line
[10] event line

Count (chars): 183
Capacity:      256

Append 100000 chars: 30.00 ms
Result length: 100000

String concat 10000 chars: 125.00 ms

Done.
🎉 Отлично — производительность видна наглядно

Смотрим цифры:

    100000 Append через builder: 30 мс

    10000 конкатенаций через S := S + 'x': 125 мс

В 10 раз больше символов, а в 4 раза быстрее. Если бы сделали 100000 конкатенаций — было бы ~12.5 секунд (O(n²) даёт экспоненциальный рост). Builder — линейный.

Собственно, это и была цель — правильный инструмент для сборки строк.
Что мы имеем после этой итерации

Утилиты u4, готовые к использованию:
Модуль	Что даёт
u4helper.pas	U4InsertChar, U4DeleteChar, U4InsertStr
u4list.pas	TU4List — список IU4String (Unicode-aware)
u4builder.pas	TU4StringBuilder — эффективная сборка

Виджеты wlgui:
Модуль	Что
wlgui_widget.pas	базовый виджет + дерево + фокус
wlgui_button.pas	кнопка с hover/pressed
wlgui_label.pas	текст с выравниванием
wlgui_checkbox.pas	чекбокс
wlgui_edit.pas	поле ввода (Unicode!)
wlgui_listbox.pas	список (Unicode!)

Ввод/вывод: мышь, клавиатура (XKB), курсор (libwayland-cursor), шрифты (FreeType).
Куда двигаемся дальше

Есть несколько направлений, и все полезны:
1. u4lines.pas — разбиение/склейка строк

Нужно для:

    Многострочного Edit (Memo) — следующего виджета.

    INI-файлов — для сохранения событий.

    CSV — экспорт списка.

API:
pascal

type
  TU4Lines = class
  public
    procedure SetText(const S: IU4String);    // разбить по \n
    function  GetText: IU4String;             // склеить
    function  Count: Integer;
    function  GetLine(Index: Integer): IU4String;
    procedure SetLine(Index: Integer; const S: IU4String);
    procedure Add(const S: IU4String);
    procedure Insert(Index: Integer; const S: IU4String);
    procedure Delete(Index: Integer);
    procedure Clear;
    // Удобные утилиты
    procedure LoadFromUTF8(const S: UTF8String);
    function  ToUTF8: UTF8String;
  end;

2. TWLMemo — многострочный редактор

Нужно для:

    Описание события в органайзере.

    Лог-панель.

    Просмотр текстовых файлов.

Основан на u4lines + u4builder.
3. Скроллбар для ListBox

Нужно для:

    Визуального индикатора в списке.

    Возможности таскать ползунок.

4. Tab-навигация между виджетами

Нужно для:

    UX — переключение между Edit, CheckBox, Button по Tab.

    Классический GUI-паттерн.

5. Расширение u4helper.pas

Полезные функции:

    U4ToStr(S): String — короткая конверсия.

    U4ReplaceAll(S, Find, Replace): IU4String.

    U4TrimLeft, U4TrimRight.

    U4StartsWithCI, U4ContainsCI.

6. Другие u4-модули в wlgui

    u4date — форматирование даты/времени в органайзере.

    u4json — сохранение событий.

    u4ini — простой формат сохранения.

    u4template — шаблоны уведомлений будильника.

    u4regex — поиск в редакторе.

Моя рекомендация по порядку

Приоритет — то, что даёт сразу видимый результат:

    Tab-навигация — 30 минут, сразу удобно.

    Скроллбар для ListBox — 1 час, сразу видно.

    u4lines — 30 минут, база для Memo.

    TWLMemo — 2 часа, новый виджет.

    u4helper — расширяем по мере необходимости.

    u4date — когда начнём органайзер.

Но: всё зависит от того, что тебе интереснее.
Что важнее для органайзера

Помним цель — органайзер с тремя окнами:

    Часы-панель — «летающая», stay-on-top.

    Редактор события — форма с полями:

        Время будильника.

        Сообщение — многострочный текст.

        Мелодия / звук.

        Скрипт — многострочный текст.

    Редактор списка событий — ListBox с событиями.

Из этого видно: многострочный Edit — критичен для редактора события.

Значит: u4lines → TWLMemo — правильный следующий шаг.
Что сделать сейчас

Я предлагаю:

    Сделать u4lines.pas — 30 минут.

    Проверить — тест u4lines_demo.pas.

    Сделать TWLMemo — на базе u4lines.

    Проверить — тест с многострочным текстом, скроллом, курсором.

    Потом — либо Tab-навигация, либо u4date, либо прототип органайзера.

Согласен — начинаем с u4lines.pas? Или есть другие приоритеты?

Мой план: сделаем u4lines, потом TWLMemo. Это самый естественный путь — от утилиты к виджету, от низкого к высокому.

Скажи — поехали с u4lines?
Давай u4lines. Поехали!
Этап 12: u4lines.pas — разбиение и склейка строк
Что делаем

TU4Lines — список строк IU4String с операциями:

    Разбиение UTF-8 текста на строки (по \n, \r\n, \r).

    Склейка обратно в текст.

    Доступ по индексу.

    Вставка/удаление строк.

    Загрузка/сохранение через UTF-8.

API
pascal

type
  TU4Lines = class
  private
    FLines: array of IU4String;
    FCount: Integer;
    procedure EnsureCapacity(N: Integer);
    function  GetLine(Index: Integer): IU4String;
    procedure SetLine(Index: Integer; const S: IU4String);
  public
    constructor Create;
    destructor Destroy; override;

    { Основное. }
    procedure SetText(const S: IU4String); overload;
    procedure SetText(const S: UTF8String); overload;
    function  GetText: IU4String;
    function  GetTextUTF8: UTF8String;

    { Строки. }
    function  Add(const S: IU4String): Integer; overload;
    function  Add(const S: UTF8String): Integer; overload;
    procedure Insert(Index: Integer; const S: IU4String);
    procedure Delete(Index: Integer);
    procedure Clear;
    procedure Assign(ASource: TU4Lines);

    { Импорт/экспорт через UTF-8. }
    procedure LoadFromUTF8(const S: UTF8String);
    function  SaveToUTF8: UTF8String;
    procedure LoadFromFile(const AFileName: String);   // опционально
    procedure SaveToFile(const AFileName: String);

    { Утилиты. }
    function  IndexOf(const S: IU4String): Integer;
    function  LastIndexOf(const S: IU4String): Integer;

    { Свойства. }
    property Lines[Index: Integer]: IU4String read GetLine write SetLine; default;
    property Count: Integer read FCount;
    property Item[Index: Integer]: IU4String read GetLine write SetLine;
  end;

Реализация u4lines.pas
pascal

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

interface

uses
  Classes, SysUtils,
  u4intf, u4str, u4utf8, u4sort;

type
  { Разбивает текст на строки и обратно.
    Понимает \n, \r\n, \r как разделители строк. }
  TU4Lines = class
  private
    FLines: array of IU4String;
    FCount: Integer;

    procedure EnsureCapacity(N: Integer);
    function  GetLine(Index: Integer): IU4String;
    procedure SetLine(Index: Integer; const S: IU4String);
  public
    constructor Create;
    destructor Destroy; override;

    { Установить текст — разбивает на строки. }
    procedure SetText(const S: IU4String); overload;
    procedure SetText(const S: UTF8String); overload;

    { Получить текст — склеивает строки через \n. }
    function  GetText: IU4String;
    function  GetTextUTF8: UTF8String;

    { Работа со строками. }
    function  Add(const S: IU4String): Integer; overload;
    function  Add(const S: UTF8String): Integer; overload;
    procedure Insert(Index: Integer; const S: IU4String);
    procedure Delete(Index: Integer);
    procedure Clear;
    procedure Assign(ASource: TU4Lines);

    { Импорт/экспорт. }
    procedure LoadFromUTF8(const S: UTF8String);
    function  SaveToUTF8: UTF8String;
    procedure LoadFromFile(const AFileName: String);
    procedure SaveToFile(const AFileName: String);

    { Поиск. }
    function  IndexOf(const S: IU4String): Integer;
    function  LastIndexOf(const S: IU4String): Integer;

    { Свойства. }
    property Lines[Index: Integer]: IU4String read GetLine write SetLine; default;
    property Item[Index: Integer]: IU4String read GetLine write SetLine;
    property Count: Integer read FCount;
  end;

implementation

{ ============================================================ }
{  TU4Lines                                                    }
{ ============================================================ }

constructor TU4Lines.Create;
begin
  inherited Create;
  FCount := 0;
  SetLength(FLines, 0);
end;

destructor TU4Lines.Destroy;
begin
  Clear;
  inherited;
end;

procedure TU4Lines.EnsureCapacity(N: Integer);
var
  NewCap: Integer;
begin
  if N <= Length(FLines) then Exit;
  NewCap := Length(FLines);
  if NewCap < 16 then NewCap := 16;
  while NewCap < N do
    NewCap := NewCap * 2;
  SetLength(FLines, NewCap);
end;

function TU4Lines.GetLine(Index: Integer): IU4String;
begin
  if (Index < 0) or (Index >= FCount) then
    Result := nil
  else
    Result := FLines[Index];
end;

procedure TU4Lines.SetLine(Index: Integer; const S: IU4String);
begin
  if (Index < 0) or (Index >= FCount) then Exit;
  FLines[Index] := S;
end;

procedure TU4Lines.Clear;
var
  I: Integer;
begin
  for I := 0 to FCount - 1 do
    FLines[I] := nil;
  FCount := 0;
end;

function TU4Lines.Add(const S: IU4String): Integer;
begin
  EnsureCapacity(FCount + 1);
  FLines[FCount] := S;
  Result := FCount;
  Inc(FCount);
end;

function TU4Lines.Add(const S: UTF8String): Integer;
begin
  Result := Add(UTF8ToU4(S));
end;

procedure TU4Lines.Insert(Index: Integer; const S: IU4String);
var
  I: Integer;
begin
  if Index < 0 then Index := 0;
  if Index > FCount then Index := FCount;

  EnsureCapacity(FCount + 1);
  for I := FCount downto Index + 1 do
    FLines[I] := FLines[I - 1];
  FLines[Index] := S;
  Inc(FCount);
end;

procedure TU4Lines.Delete(Index: Integer);
var
  I: Integer;
begin
  if (Index < 0) or (Index >= FCount) then Exit;
  for I := Index to FCount - 2 do
    FLines[I] := FLines[I + 1];
  FLines[FCount - 1] := nil;
  Dec(FCount);
end;

procedure TU4Lines.Assign(ASource: TU4Lines);
var
  I: Integer;
begin
  Clear;
  if ASource = nil then Exit;
  EnsureCapacity(ASource.Count);
  for I := 0 to ASource.Count - 1 do
    Add(ASource[I]);
end;

{ --- Разбор и склейка --- }

procedure TU4Lines.SetText(const S: IU4String);
var
  I, Start, L: Integer;
  Line: IU4String;
  Ch: u4char;
begin
  Clear;
  if (S = nil) or (S.Length = 0) then Exit;

  L := S.Length;
  Start := 0;
  I := 0;
  while I < L do
  begin
    Ch := S.GetChar(I);
    if (Ch = $0A) or (Ch = $0D) then
    begin
      // Конец строки [Start .. I-1]
      if I > Start then
        Line := S.SubString(Start, I - Start)
      else
        Line := U4Empty;
      Add(Line);

      // Пропускаем \r\n (второй символ пары)
      if (Ch = $0D) and (I + 1 < L) and (S.GetChar(I + 1) = $0A) then
        Inc(I);

      Start := I + 1;
    end;
    Inc(I);
  end;

  // Последняя строка (если не закончился на \n)
  if Start < L then
  begin
    Line := S.SubString(Start, L - Start);
    Add(Line);
  end
  else if (Start = L) and (L > 0) then
  begin
    // Текст заканчивается на \n — считаем, что после него пустая строка?
    // Нет — просто не добавляем ничего. Поведение как в большинстве
    // редакторов: "abc\n" = одна строка "abc".
    // Если нужна пустая — пользователь добавит \n\n.
  end;
end;

procedure TU4Lines.SetText(const S: UTF8String);
begin
  SetText(UTF8ToU4(S));
end;

function TU4Lines.GetText: IU4String;
var
  I: Integer;
  B: array of u4char;
  Pos: Integer;
  Line: IU4String;
  J, LL: Integer;
begin
  Result := U4Empty;
  if FCount = 0 then Exit;

  // Считаем общий размер
  LL := 0;
  for I := 0 to FCount - 1 do
    if FLines[I] <> nil then
      Inc(LL, FLines[I].Length);
  Inc(LL, FCount - 1);   // разделители \n между строками

  SetLength(B, LL);
  Pos := 0;
  for I := 0 to FCount - 1 do
  begin
    if I > 0 then
    begin
      B[Pos] := u4char($0A);
      Inc(Pos);
    end;
    Line := FLines[I];
    if Line <> nil then
      for J := 0 to Line.Length - 1 do
      begin
        B[Pos] := Line.GetChar(J);
        Inc(Pos);
      end;
  end;

  Result := U4FromChars(@B[0], LL);
end;

function TU4Lines.GetTextUTF8: UTF8String;
begin
  Result := U4ToUTF8(GetText);
end;

{ --- Файлы --- }

procedure TU4Lines.LoadFromUTF8(const S: UTF8String);
begin
  SetText(S);
end;

function TU4Lines.SaveToUTF8: UTF8String;
begin
  Result := GetTextUTF8;
end;

procedure TU4Lines.LoadFromFile(const AFileName: String);
var
  F: TFileStream;
  S: UTF8String;
  Size: Int64;
begin
  Clear;
  if not FileExists(AFileName) then Exit;
  F := TFileStream.Create(AFileName, fmOpenRead);
  try
    Size := F.Size;
    if Size = 0 then Exit;
    SetLength(S, Size);
    F.ReadBuffer(S[1], Size);
  finally
    F.Free;
  end;
  SetText(S);
end;

procedure TU4Lines.SaveToFile(const AFileName: String);
var
  F: TFileStream;
  S: UTF8String;
begin
  S := GetTextUTF8;
  F := TFileStream.Create(AFileName, fmCreate);
  try
    if Length(S) > 0 then
      F.WriteBuffer(S[1], Length(S));
  finally
    F.Free;
  end;
end;

{ --- Поиск --- }

function TU4Lines.IndexOf(const S: IU4String): Integer;
var
  I: Integer;
begin
  for I := 0 to FCount - 1 do
    if U4CompareOrdinal(FLines[I], S) = 0 then
      Exit(I);
  Result := -1;
end;

function TU4Lines.LastIndexOf(const S: IU4String): Integer;
var
  I: Integer;
begin
  for I := FCount - 1 downto 0 do
    if U4CompareOrdinal(FLines[I], S) = 0 then
      Exit(I);
  Result := -1;
end;

end.

Тест u4lines_demo.pas
pascal

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

uses
  SysUtils,
  u4intf, u4utf8, u4str, u4wrap, u4lines;

var
  Lines: TU4Lines;
  S: String;
  I: Integer;

procedure DumpLines(const Title: String);
var
  J: Integer;
  LineUTF8: UTF8String;
begin
  WriteLn('--- ', Title, ' ---');
  WriteLn('Count: ', Lines.Count);
  for J := 0 to Lines.Count - 1 do
  begin
    LineUTF8 := U4ToUTF8(Lines[J]);
    WriteLn('  [', J, '] "', LineUTF8, '"');
  end;
  WriteLn;
end;

begin
  WriteLn('=== u4lines test ===');
  WriteLn;

  Lines := TU4Lines.Create;
  try
    // Тест 1: простой разбор
    Lines.SetText(UTF8String('Hello'#10'World'#10'Привет'#10'Мир'));
    DumpLines('SetText with LF');

    // Тест 2: склейка обратно
    S := Lines.GetTextUTF8;
    WriteLn('--- GetTextUTF8 ---');
    WriteLn('"', S, '"');
    WriteLn;

    // Тест 3: разбор с \r\n
    Lines.SetText(UTF8String('line1'#13#10'line2'#13#10'line3'));
    DumpLines('SetText with CRLF');

    // Тест 4: одиночный \r
    Lines.SetText(UTF8String('a'#13'b'#13'c'));
    DumpLines('SetText with CR');

    // Тест 5: пустые строки
    Lines.SetText(UTF8String(#10#10#10));
    DumpLines('Three empty lines (LF LF LF)');

    // Тест 6: работа с отдельными строками
    Lines.Clear;
    Lines.Add(UTF8String('one'));
    Lines.Add(UTF8String('two'));
    Lines.Add(UTF8String('три'));
    Lines.Insert(1, UTF8String('между'));
    DumpLines('After Add/Insert');

    Lines.Delete(0);
    DumpLines('After Delete(0)');

    // Тест 7: поиск
    WriteLn('--- Search ---');
    WriteLn('IndexOf("два")   = ', Lines.IndexOf(UTF8ToU4('два')));
    WriteLn('IndexOf("две")   = ', Lines.IndexOf(UTF8ToU4('две')));
    WriteLn;

    // Тест 8: многострочный текст с эмодзи и mixed
    Lines.SetText(UTF8String(
      'Пользователь: Иван'#10 +
      'Комментарий: Привет! 👋'#10 +
      'Статус: OK'));
    DumpLines('Mixed Unicode');
    S := Lines.GetTextUTF8;
    WriteLn('Round-trip: ', Length(S), ' bytes');
    WriteLn;

  finally
    Lines.Free;
  end;

  WriteLn('Done.');
end.

Собери, запусти:
bash

fpc -Fu. u4lines_demo.pas
./u4lines_demo

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

    Простой LF-разбор — 4 строки.

    Склейка — возвращает то же.

    CRLF-разбор — 3 строки (без пустых между).

    CR-разбор — 3 строки.

    Пустые строки — \n\n\n = 3 пустых или 4? В большинстве редакторов — 3 пустых + финальный переход (без строки). У нас FCount = 3 (не 4). Проверим в тесте.

    Add/Insert/Delete — работают.

    Поиск — работает.

    Round-trip — текст не изменился после SetText → GetTextUTF8.

Замечания
Про финальный \n

В разных редакторах по-разному:

    Vim: "abc\n" = одна строка "abc".

    Блокнот: "abc\n" = одна строка "abc" (не добавляет пустую).

    Некоторые редакторы: "abc\n" = две строки "abc" и "".

Наш выбор — первый вариант (как Vim). Это стандарт.
Про производительность GetText

Мы используем SetLength + прямое заполнение массива. Линейное время. Хорошо.

Однако: U4FromChars(@B[0], LL) копирует массив второй раз в IU4String. Можно избежать через U4TakeChars — но он не работает с динамическим массивом напрямую. Оставляем как есть — для 1000 строк это микросекунды.
Про Assign

Метод для копирования содержимого из другого TU4Lines. Пригодится для undo/redo в редакторе.
Что дальше

После u4lines — два пути:

A. TWLMemo — многострочный редактор на базе u4lines.

B. Tab-навигация + ScrollBar — улучшение существующих виджетов.

Я за A: TWLMemo нужен для органайзера (описание события, скрипт). Tab-навигация — приятно, но не критично.

Но: TWLMemo — большой виджет. 2-3 часа. Внутри:

    Многострочный текст.

    Курсор по (Line, Col).

    Вертикальный скролл (визуально — без скроллбара пока).

    Стрелки вверх/вниз/left/right.

    Home/End/PageUp/PageDown.

    Backspace/Delete/Enter — работают по строкам.

    Insert символа — в позицию в строке.

Рендер: видимые строки (по вертикальному скроллу) рисуются через TextOut.
Собираем u4lines

Запусти тест, покажи результат. Если всё ок — начинаем TWLMemo (или предложи свою последовательность).
Спасибо! ./u4builder_demo
=== u4builder test ===

Hello, мир!
Numbers: 1 2 3 4 5

[1] event line
[2] event line
[3] event line
[4] event line
[5] event line
[6] event line
[7] event line
[8] event line
[9] event line
[10] event line

Count (chars): 183
Capacity:      256

Append 100000 chars: 30.00 ms
Result length: 100000

String concat 10000 chars: 125.00 ms

Done.
Снова запустился u4builder_demo, а не u4lines_demo

Смотрю: ты запустил u4builder_demo — старый тест. u4lines_demo ещё не собран/не запущен.
Что сделать

    Создай файл u4lines.pas (я дал полный код).

    Создай файл u4lines_demo.pas (тоже дал полный код).

    Собери именно u4lines_demo:

bash

fpc -Fu. u4lines_demo.pas
./u4lines_demo

Обрати внимание — команда ./u4lines_demo, а не ./u4builder_demo.
Что ожидаем увидеть
text

=== u4lines test ===

--- SetText with LF ---
Count: 4
  [0] "Hello"
  [1] "World"
  [2] "Привет"
  [3] "Мир"

--- GetTextUTF8 ---
"Hello
World
Привет
Мир"

--- SetText with CRLF ---
Count: 3
  [0] "line1"
  [1] "line2"
  [2] "line3"

--- SetText with CR ---
Count: 3
  [0] "a"
  [1] "b"
  [2] "c"

--- Three empty lines (LF LF LF) ---
Count: 3
  [0] ""
  [1] ""
  [2] ""

--- After Add/Insert ---
Count: 4
  [0] "one"
  [1] "между"
  [2] "two"
  [3] "три"

--- After Delete(0) ---
Count: 3
  [0] "между"
  [1] "two"
  [2] "три"

--- Search ---
IndexOf("два")   = -1
IndexOf("две")   = -1

--- Mixed Unicode ---
Count: 3
  [0] "Пользователь: Иван"
  [1] "Комментарий: Привет! 👋"
  [2] "Статус: OK"

Round-trip: ... bytes

Done.

Обрати внимание:

    LF-разбор даёт 4 строки.

    CRLF-разбор даёт 3 строки (а не 6 с пустыми — правильно).

    CR-разбор даёт 3 строки.

    \n\n\n даёт 3 пустых строки (как Vim).

    Round-trip — текст сохраняется.

Собери и запусти
bash

fpc -Fu. u4lines_demo.pas
./u4lines_demo

Пришли результат — и продолжим.

Если что-то не скомпилируется — пришли ошибку. Возможные проблемы:

    TFileStream — требует Classes (есть в uses).

    FileExists — требует SysUtils (есть).

    Move в GetText — надо проверить, но у нас ручной цикл, не Move.

Ждём результат.
./u4lines_demo
=== u4lines test ===

--- SetText with LF ---
Count: 4
  [0] "Hello"
  [1] "World"
  [2] "Привет"
  [3] "Мир"

--- GetTextUTF8 ---
"Hello
World
Привет
Мир"

--- SetText with CRLF ---
Count: 3
  [0] "line1"
  [1] "line2"
  [2] "line3"

--- SetText with CR ---
Count: 3
  [0] "a"
  [1] "b"
  [2] "c"

--- Three empty lines (LF LF LF) ---
Count: 3
  [0] ""
  [1] ""
  [2] ""

--- After Add/Insert ---
Count: 4
  [0] "one"
  [1] "между"
  [2] "two"
  [3] "три"

--- After Delete(0) ---
Count: 3
  [0] "между"
  [1] "two"
  [2] "три"

--- Search ---
IndexOf("два")   = -1
IndexOf("две")   = -1

--- Mixed Unicode ---
Count: 3
  [0] "Пользователь: Иван"
  [1] "Комментарий: Привет! 👋"
  [2] "Статус: OK"

Round-trip: 94 bytes

Done.
🎉 u4lines работает идеально

Все тесты прошли:
Тест	Результат	Комментарий
LF-разбор	4 строки	✅
Склейка	round-trip сохраняет текст	✅
CRLF-разбор	3 строки (не 6 с пустыми)	✅ правильно
CR-разбор	3 строки	✅
\n\n\n	3 пустых (как Vim)	✅
Add/Insert/Delete	работают	✅
Поиск	корректно возвращает -1	✅
Mixed Unicode (эмодзи 👋)	94 байта, 3 строки	✅

Эмодзи в строке — это 4 байта в UTF-8, 1 codepoint. u4lines работает правильно с ним. Отлично.
Итог: что у нас есть в u4-слое
Модуль	Назначение	Готов?
u4intf.pas	Базовый IU4String	✅ (был)
u4utf8.pas	UTF-8 ↔ U4	✅ (был)
u4str.pas	Строковые утилиты	✅ (был)
u4case.pas	Регистр (Unicode)	✅ (был)
u4norm.pas	Нормализация	✅ (был)
u4sort.pas	Сортировка	✅ (был)
u4wrap.pas	Helper для IU4String	✅ (был)
u4width.pas	Ширина символов	✅ (был)
u4helper.pas	U4InsertChar, U4DeleteChar, U4InsertStr	✅ новый
u4list.pas	TU4List	✅ новый
u4builder.pas	TU4StringBuilder	✅ новый
u4lines.pas	TU4Lines	✅ новый

У нас теперь есть крепкий u4-фундамент.
Что дальше — TWLMemo

Помню, ты хотел продолжить виджеты. Многострочный редактор — следующий логичный шаг.
План TWLMemo

Основан на:

    TU4Lines — хранит строки.

    TU4StringBuilder — для эффективной вставки (позже, при оптимизации).

    TWLCanvas.TextOut(IU4String, ...) — рендер строк.

    TWLFont.TextWidth(IU4String) — для измерения.

Что умеет:

    Список строк — Lines: TU4Lines.

    Курсор — позиция (LineIndex, ColIndex).

    Вертикальный скролл (ScrollY — первая видимая строка).

    Горизонтальный скролл (опционально, для длинных строк).

    Вставка символа в позицию курсора.

    Backspace / Delete — удаление символа перед / после курсора.

    Enter — разбивает строку на две.

    Стрелки вверх/вниз — переход по строкам (с сохранением желаемой X-позиции).

    Home/End — в начало/конец строки.

    PageUp/PageDown — на страницу вверх/вниз.

    Click — ставит курсор по координатам (Line, Col).

    Курсор мигает (как в Edit).

    Скролл колёсиком.

    Word wrap — не делаем пока (сложно, добавим позже).

    Выделение — не делаем пока (сложно).

Архитектура
pascal

TWLMemo = class(TWLWidget)
private
  FLines: TU4Lines;
  FCursorLine: Integer;      // 0..Count-1
  FCursorCol: Integer;       // 0..Lines[CursorLine].Length
  FScrollY: Integer;         // первая видимая строка
  FScrollX: Integer;         // пиксельный сдвиг по X (для длинных строк)
  FPreferredCol: Integer;    // "желаемая" X-позиция при движении вверх/вниз

  // Стиль
  FTextColor, FBackColor: TWLColor;
  FBorderColor, FFocusedBorderColor: TWLColor;
  FCursorColor: TWLColor;
  FSelColor: TWLColor;       // на будущее

  // Мерки
  FLineHeight: Integer;      // высота одной строки (из шрифта)
  FPadding: Integer;

  // Обработчики
  FOnChange: TWLNotifyEvent;
  FReadOnly: Boolean;

  // Методы
  procedure MoveCursorTo(ALine, ACol: Integer);
  procedure EnsureCursorVisible;
  function  LineWidthPixel(ALine: Integer): Integer;   // ширина строки в пикселях
  function  ColFromPixelX(ALine: Integer; AX: Integer): Integer;  // найти col по X
  function  ScreenLineAtY(AY: Integer): Integer;        // строка под Y
  procedure InsertText(const S: IU4String);
  procedure Backspace;
  procedure DeleteForward;
  procedure SplitLine;
  procedure MergeWithPrevLine;
  procedure DeleteSelection;   // позже
protected
  procedure DoPaint(ACanvas: TWLCanvas); override;
  function  DoMouseDown(X, Y: Integer): Boolean; override;
  procedure DoKeyDown(const E: TWLKeyEvent); override;
  procedure DoMouseWheel(const E: TWLWheelEvent); override;
public
  constructor Create(const ARect: TRectI; AFont: TWLFont);
  destructor Destroy; override;
  function CanFocus: Boolean; override;

  procedure SetText(const S: UTF8String);
  function  GetText: UTF8String;
  procedure SetLines(ALines: TU4Lines);
  procedure Clear;

  property Lines: TU4Lines read FLines;
  property CursorLine: Integer read FCursorLine;
  property CursorCol: Integer read FCursorCol;
  property ReadOnly: Boolean read FReadOnly write FReadOnly;
  property OnChange: TWLNotifyEvent read FOnChange write FOnChange;
  property TextColor: TWLColor read FTextColor write FTextColor;
  property BackColor: TWLColor read FBackColor write FBackColor;
  // ... остальные цвета
end;

Что пока не делаем

    Многоуровневый undo/redo — следующий этап.

    Выделение через мышь — следующий этап.

    Копирование/вставка — следующий этап (потребует wl_data_device).

    Поиск/замена — следующий этап.

    Word wrap — сложно, отложим.

    Номера строк — отложим.

    Подсветка синтаксиса — отложим.

Ключевые детали реализации
1. Перевод (line, col) ↔ (X, Y) в пикселях

    Y = Padding + (Line - ScrollY) * LineHeight.

    X = Padding + Font.TextWidth(Lines[Line].SubString(0, Col)) - ScrollX.

Обратное:

    Line = ScrollY + (Y - Padding) div LineHeight.

    Col = ищем по X — сколько символов влезает до X. Пробегаем по строке и накапливаем ширину. Оптимизация: через Font.TextWidth префиксов.

2. Желаемая колонка (FPreferredCol)

При движении Up/Down — если текущая строка короче, курсор сдвигается. Но FPreferredCol запоминает «желаемую» X. При следующем up/down — восстанавливаем FPreferredCol, если строка длиннее.

Пример:
text

Line 0: "Hello, world!"    (13 символов)
Line 1: "Hi"                (2 символа)
Line 2: "Hello, again!"    (13 символов)

Курсор в Line 0, col 10. Down → Line 1 — короче, курсор на конце (col 2). FPreferredCol = 10. Down → Line 2 — курсор восстанавливается в col 10. Правильное поведение.
3. Скролл

EnsureCursorVisible — если курсор выше ScrollY, ScrollY := CursorLine. Если ниже — ScrollY := CursorLine - VisibleLineCount + 1.

VisibleLineCount = (FRect.H - 2*Padding) div LineHeight.
4. Клик мыши

DoMouseDown:

    SetFocus.

    Line = ScreenLineAtY(Y) — какая строка под курсором.

    Col = ColFromPixelX(Line, X).

    MoveCursorTo(Line, Col).

5. Enter — разбивает строку
text

Было: "Hello World" (курсор после "Hello ")
                 ^

Стало:
Line: "Hello "
Line: "World"

Реализация:
pascal

procedure SplitLine;
var
  CurLine: IU4String;
  Before, After: IU4String;
begin
  CurLine := FLines[FCursorLine];
  Before := CurLine.SubString(0, FCursorCol);
  After := CurLine.SubString(FCursorCol, CurLine.Length - FCursorCol);

  FLines[FCursorLine] := Before;
  FLines.Insert(FCursorLine + 1, After);

  Inc(FCursorLine);
  FCursorCol := 0;
  FPreferredCol := 0;
end;

6. Backspace — три случая

    Col > 0 — удаляем символ перед курсором.

    Col = 0, Line > 0 — сливаем с предыдущей строкой:
    text

    Line N-1: "Hello "
    Line N:   "World"
    Курсор в (N, 0)

    Стало:
    Line N-1: "Hello World"
    Курсор в (N-1, 6)

    Col = 0, Line = 0 — ничего.

7. Delete — три случая

    Col < len — удаляем символ под курсором.

    Col = len, Line < Count-1 — сливаем со следующей:
    text

    Line N:   "Hello"
    Line N+1: " World"
    Курсор в (N, 5)

    Стало:
    Line N: "Hello World"

    Последняя строка, конец — ничего.

Порядок работы

Предлагаю такую последовательность:

    Каркас TWLMemo — поля, конструктор, деструктор, SetText, GetText.

    Рендер — простой (без курсора): строки через TextOut.

    Курсор — рисование на текущей позиции, мигание.

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

    Стрелки — движение влево/вправо (простое) и вверх/вниз (с FPreferredCol).

    Home/End.

    Ввод символа.

    Backspace — все три случая.

    Delete — все три случая.

    Enter — разбиение.

    Скролл — EnsureCursorVisible и колёсико.

    PageUp/PageDown.

    Тест — многострочный текст с кириллицей и эмодзи.

13 шагов — можно разбить на 2-3 итерации. Но код получится большой (~500 строк). Если готов получить всё сразу — дам полный модуль.
Что ты думаешь

Варианты:

A. Всё сразу — я даю полный TWLMemo и демо. Ты собираешь, тестируешь. Если что-то не так — правим.

B. По частям — сначала каркас + рендер + курсор, потом ввод, потом скролл. Дольше, но понятнее.

C. Сначала — Tab-навигация и скроллбар для ListBox, потом Memo. Улучшаем то, что есть, потом новое.

Мой выбор — A: TWLMemo нужен для органайзера, и лучше получить сразу рабочий виджет. Правки по ходу — быстрее, чем пошаговая сборка.

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

Скажи, какой вариант — и приступаем.
Давай пункт A: TWLMemo сразу. Спасибо!
Этап 13: TWLMemo — полная версия

Даю полный модуль wlgui_memo.pas + тестовую программу. Модуль большой (~550 строк), но разбит на понятные части.
1. wlgui_memo.pas
pascal

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

interface

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

type
  TWLMemo = class(TWLWidget)
  private
    FLines: TU4Lines;
    FCursorLine: Integer;
    FCursorCol: Integer;
    FPreferredCol: Integer;
    FScrollY: Integer;         // первая видимая строка
    FScrollX: Integer;         // пиксельный сдвиг влево (для длинных строк)

    FTextColor: TWLColor;
    FBackColor: TWLColor;
    FBorderColor: TWLColor;
    FFocusedBorderColor: TWLColor;
    FCursorColor: TWLColor;

    FLineHeight: Integer;
    FPadding: Integer;

    FReadOnly: Boolean;
    FOnChange: TWLNotifyEvent;

    procedure RecalcLineHeight;
    function  VisibleLineCount: Integer;
    function  ScreenLineAtY(AY: Integer): Integer;
    function  ColFromPixelX(ALine: Integer; AX: Integer): Integer;
    function  LinePixelWidth(ALine: Integer): Integer;
    function  PrefixPixelWidth(ALine, ACol: Integer): Integer;

    procedure EnsureCursorVisible;
    procedure MoveCursorTo(ALine, ACol: Integer; AKeepPreferred: Boolean = False);
    procedure MoveCursorLeft;
    procedure MoveCursorRight;
    procedure MoveCursorUp;
    procedure MoveCursorDown;

    procedure InsertChar(C: u4char);
    procedure Backspace;
    procedure DeleteForward;
    procedure SplitLine;
    procedure MergeWithPrevLine;

    procedure NotifyChange;
    function  CurrentLine: IU4String;
    function  CurrentLineLength: Integer;
  protected
    procedure DoPaint(ACanvas: TWLCanvas); override;
    function  DoMouseDown(X, Y: Integer): Boolean; override;
    procedure DoKeyDown(const E: TWLKeyEvent); override;
    procedure DoMouseWheel(const E: TWLWheelEvent); override;
  public
    constructor Create(const ARect: TRectI; AFont: TWLFont);
    destructor Destroy; override;
    function CanFocus: Boolean; override;

    procedure SetText(const S: UTF8String); overload;
    procedure SetText(const S: IU4String); overload;
    function  GetText: UTF8String;
    function  GetTextU4: IU4String;
    procedure Clear;
    procedure AppendLine(const S: UTF8String);

    property Lines: TU4Lines read FLines;
    property CursorLine: Integer read FCursorLine;
    property CursorCol: Integer read FCursorCol;
    property ReadOnly: Boolean read FReadOnly write FReadOnly;
    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;
    property LineHeight: Integer read FLineHeight;
  end;

implementation

const
  CURSOR_BLINK_MS = 500;

{ ============================================================ }
{  Конструктор / деструктор                                     }
{ ============================================================ }

constructor TWLMemo.Create(const ARect: TRectI; AFont: TWLFont);
begin
  inherited Create(ARect);
  FFont := AFont;
  FLines := TU4Lines.Create;
  FLines.Add(U4Empty);   // одна пустая строка

  FCursorLine := 0;
  FCursorCol := 0;
  FPreferredCol := 0;
  FScrollY := 0;
  FScrollX := 0;

  FTextColor := TWLColor($00FFFFFF);
  FBackColor := TWLColor($00202020);
  FBorderColor := TWLColor($00606060);
  FFocusedBorderColor := TWLColor($00FFFFFF);
  FCursorColor := TWLColor($00FFFFFF);

  FPadding := 4;
  FReadOnly := False;
  FCanFocus := True;

  RecalcLineHeight;
end;

destructor TWLMemo.Destroy;
begin
  FLines.Free;
  inherited;
end;

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

procedure TWLMemo.RecalcLineHeight;
begin
  if FFont <> nil then
    FLineHeight := FFont.Height + 2
  else
    FLineHeight := 16;
end;

{ ============================================================ }
{  Текст                                                        }
{ ============================================================ }

procedure TWLMemo.SetText(const S: UTF8String);
begin
  SetText(UTF8ToU4(S));
end;

procedure TWLMemo.SetText(const S: IU4String);
begin
  FLines.SetText(S);
  if FLines.Count = 0 then
    FLines.Add(U4Empty);
  FCursorLine := 0;
  FCursorCol := 0;
  FPreferredCol := 0;
  FScrollY := 0;
  FScrollX := 0;
  NotifyChange;
end;

function TWLMemo.GetText: UTF8String;
begin
  Result := FLines.GetTextUTF8;
end;

function TWLMemo.GetTextU4: IU4String;
begin
  Result := FLines.GetText;
end;

procedure TWLMemo.Clear;
begin
  FLines.Clear;
  FLines.Add(U4Empty);
  FCursorLine := 0;
  FCursorCol := 0;
  FPreferredCol := 0;
  FScrollY := 0;
  FScrollX := 0;
  NotifyChange;
end;

procedure TWLMemo.AppendLine(const S: UTF8String);
begin
  FLines.Add(S);
  NotifyChange;
end;

procedure TWLMemo.NotifyChange;
begin
  if Assigned(FOnChange) then
    FOnChange(Self);
end;

function TWLMemo.CurrentLine: IU4String;
begin
  if (FCursorLine < 0) or (FCursorLine >= FLines.Count) then
    Result := U4Empty
  else
    Result := FLines[FCursorLine];
end;

function TWLMemo.CurrentLineLength: Integer;
var
  L: IU4String;
begin
  L := CurrentLine;
  if L = nil then Result := 0
  else Result := L.Length;
end;

{ ============================================================ }
{  Мерки                                                        }
{ ============================================================ }

function TWLMemo.VisibleLineCount: Integer;
var
  InnerH: Integer;
begin
  InnerH := FRect.H - 2 * FPadding;
  if InnerH <= 0 then Exit(0);
  Result := InnerH div FLineHeight;
  if Result < 0 then Result := 0;
end;

function TWLMemo.ScreenLineAtY(AY: Integer): Integer;
var
  RelY: Integer;
begin
  RelY := AY - (ScreenRect.Y + FPadding);
  if RelY < 0 then Exit(FScrollY);
  Result := FScrollY + RelY div FLineHeight;
  if Result < 0 then Result := 0;
  if Result >= FLines.Count then
    Result := FLines.Count - 1;
  if Result < 0 then Result := 0;
end;

function TWLMemo.PrefixPixelWidth(ALine, ACol: Integer): Integer;
var
  L, Part: IU4String;
begin
  Result := 0;
  if (FFont = nil) or (ALine < 0) or (ALine >= FLines.Count) then Exit;
  L := FLines[ALine];
  if L = nil then Exit;
  if ACol <= 0 then Exit;
  if ACol > L.Length then ACol := L.Length;
  Part := L.SubString(0, ACol);
  Result := FFont.TextWidth(Part);
end;

function TWLMemo.LinePixelWidth(ALine: Integer): Integer;
begin
  Result := PrefixPixelWidth(ALine, MaxInt);
end;

function TWLMemo.ColFromPixelX(ALine: Integer; AX: Integer): Integer;
var
  InnerX: Integer;
  TargetX: Integer;
  L: IU4String;
  Len, I, W: Integer;
  PrevW, CurW: Integer;
begin
  Result := 0;
  if (ALine < 0) or (ALine >= FLines.Count) then Exit;
  if FFont = nil then Exit;

  L := FLines[ALine];
  if L = nil then Exit;

  InnerX := ScreenRect.X + FPadding - FScrollX;
  TargetX := AX - InnerX;
  if TargetX <= 0 then Exit(0);

  Len := L.Length;
  PrevW := 0;
  for I := 1 to Len do
  begin
    CurW := FFont.TextWidth(L.SubString(0, I));
    if CurW > TargetX then
    begin
      // Проверяем, ближе ли к предыдущему
      if (TargetX - PrevW) <= (CurW - TargetX) then
        Result := I - 1
      else
        Result := I;
      Exit;
    end;
    PrevW := CurW;
  end;
  Result := Len;
end;

{ ============================================================ }
{  Курсор                                                       }
{ ============================================================ }

procedure TWLMemo.MoveCursorTo(ALine, ACol: Integer; AKeepPreferred: Boolean);
begin
  if ALine < 0 then ALine := 0;
  if ALine >= FLines.Count then ALine := FLines.Count - 1;
  if ALine < 0 then ALine := 0;

  if ACol < 0 then ACol := 0;
  if ACol > FLines[ALine].Length then ACol := FLines[ALine].Length;

  FCursorLine := ALine;
  FCursorCol := ACol;
  if not AKeepPreferred then
    FPreferredCol := ACol;

  EnsureCursorVisible;
end;

procedure TWLMemo.EnsureCursorVisible;
var
  VisCount: Integer;
begin
  VisCount := VisibleLineCount;
  if VisCount <= 0 then Exit;

  // Вертикальный скролл
  if FCursorLine < FScrollY then
    FScrollY := FCursorLine
  else if FCursorLine >= FScrollY + VisCount then
    FScrollY := FCursorLine - VisCount + 1;

  if FScrollY < 0 then FScrollY := 0;
  if FScrollY > FLines.Count - VisCount then
    FScrollY := FLines.Count - VisCount;
  if FScrollY < 0 then FScrollY := 0;

  // Горизонтальный скролл
  // Если курсор не влезает справа — сдвигаем вправо
  var CursorX: Integer := PrefixPixelWidth(FCursorLine, FCursorCol) - FScrollX;
  var InnerW: Integer := FRect.W - 2 * FPadding;
  if InnerW < 1 then InnerW := 1;

  if CursorX > InnerW then
    FScrollX := PrefixPixelWidth(FCursorLine, FCursorCol) - InnerW;
  else if CursorX < 0 then
    FScrollX := PrefixPixelWidth(FCursorLine, FCursorCol);

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

procedure TWLMemo.MoveCursorLeft;
begin
  if FCursorCol > 0 then
    Dec(FCursorCol)
  else if FCursorLine > 0 then
  begin
    Dec(FCursorLine);
    FCursorCol := FLines[FCursorLine].Length;
  end;
  FPreferredCol := FCursorCol;
  EnsureCursorVisible;
end;

procedure TWLMemo.MoveCursorRight;
begin
  if FCursorCol < CurrentLineLength then
    Inc(FCursorCol)
  else if FCursorLine < FLines.Count - 1 then
  begin
    Inc(FCursorLine);
    FCursorCol := 0;
  end;
  FPreferredCol := FCursorCol;
  EnsureCursorVisible;
end;

procedure TWLMemo.MoveCursorUp;
begin
  if FCursorLine = 0 then Exit;
  Dec(FCursorLine);
  FCursorCol := FPreferredCol;
  if FCursorCol > FLines[FCursorLine].Length then
    FCursorCol := FLines[FCursorLine].Length;
  EnsureCursorVisible;
end;

procedure TWLMemo.MoveCursorDown;
begin
  if FCursorLine >= FLines.Count - 1 then Exit;
  Inc(FCursorLine);
  FCursorCol := FPreferredCol;
  if FCursorCol > FLines[FCursorLine].Length then
    FCursorCol := FLines[FCursorLine].Length;
  EnsureCursorVisible;
end;

{ ============================================================ }
{  Вставка / удаление                                           }
{ ============================================================ }

procedure TWLMemo.InsertChar(C: u4char);
var
  L: IU4String;
begin
  if FReadOnly then Exit;
  L := CurrentLine;
  L := U4InsertChar(L, FCursorCol, C);
  FLines[FCursorLine] := L;
  Inc(FCursorCol);
  FPreferredCol := FCursorCol;
  EnsureCursorVisible;
  NotifyChange;
end;

procedure TWLMemo.Backspace;
var
  L, Prev, Merged: IU4String;
  PrevLen: Integer;
begin
  if FReadOnly then Exit;

  if FCursorCol > 0 then
  begin
    // Просто удаляем символ слева
    L := CurrentLine;
    L := U4DeleteChar(L, FCursorCol - 1);
    FLines[FCursorLine] := L;
    Dec(FCursorCol);
    FPreferredCol := FCursorCol;
    EnsureCursorVisible;
    NotifyChange;
  end
  else if FCursorLine > 0 then
  begin
    // Сливаем с предыдущей
    Prev := FLines[FCursorLine - 1];
    L := CurrentLine;
    PrevLen := Prev.Length;
    Merged := Prev.Concat(L);
    FLines[FCursorLine - 1] := Merged;
    FLines.Delete(FCursorLine);
    Dec(FCursorLine);
    FCursorCol := PrevLen;
    FPreferredCol := FCursorCol;
    EnsureCursorVisible;
    NotifyChange;
  end;
end;

procedure TWLMemo.DeleteForward;
var
  L, NextLine, Merged: IU4String;
begin
  if FReadOnly then Exit;

  if FCursorCol < CurrentLineLength then
  begin
    // Удаляем символ справа
    L := CurrentLine;
    L := U4DeleteChar(L, FCursorCol);
    FLines[FCursorLine] := L;
    EnsureCursorVisible;
    NotifyChange;
  end
  else if FCursorLine < FLines.Count - 1 then
  begin
    // Сливаем со следующей
    NextLine := FLines[FCursorLine + 1];
    L := CurrentLine;
    Merged := L.Concat(NextLine);
    FLines[FCursorLine] := Merged;
    FLines.Delete(FCursorLine + 1);
    EnsureCursorVisible;
    NotifyChange;
  end;
end;

procedure TWLMemo.SplitLine;
var
  L, Before, After: IU4String;
begin
  if FReadOnly then Exit;
  L := CurrentLine;
  Before := L.SubString(0, FCursorCol);
  After := L.SubString(FCursorCol, L.Length - FCursorCol);
  FLines[FCursorLine] := Before;
  FLines.Insert(FCursorLine + 1, After);
  Inc(FCursorLine);
  FCursorCol := 0;
  FPreferredCol := 0;
  EnsureCursorVisible;
  NotifyChange;
end;

procedure TWLMemo.MergeWithPrevLine;
begin
  // Используется только Backspace-логикой (встроено)
end;

{ ============================================================ }
{  Отрисовка                                                    }
{ ============================================================ }

procedure TWLMemo.DoPaint(ACanvas: TWLCanvas);
var
  R, InnerR, LineR: TRectI;
  BorderC, TextC: TWLColor;
  VisCount, I, Idx, Y: Integer;
  Line: IU4String;
  CursorX, CursorY: 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);

  VisCount := VisibleLineCount;

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

    Y := InnerR.Y + FPadding + I * FLineHeight;
    LineR := TRectI.New(InnerR.X, Y, InnerR.W, FLineHeight);

    Line := FLines[Idx];
    if (Line <> nil) and (Line.Length > 0) then
      ACAnvas.TextOut(InnerR.X + FPadding - FScrollX,
                      Y + (FLineHeight - FFont.Height) div 2,
                      Line, TextC, FFont);
  end;

  // Курсор
  if FFocused and FEnabled and (not FReadOnly) then
  begin
    if (GetTickCount64 div CURSOR_BLINK_MS) mod 2 = 0 then
    begin
      CursorX := InnerR.X + FPadding + PrefixPixelWidth(FCursorLine, FCursorCol) - FScrollX;
      CursorY := InnerR.Y + FPadding + (FCursorLine - FScrollY) * FLineHeight;
      if (CursorY >= InnerR.Y) and (CursorY < InnerR.Y + InnerR.H) then
        ACAnvas.FillRect(
          TRectI.New(CursorX, CursorY + 2, 2, FLineHeight - 4),
          FCursorColor);
    end;
  end;

  ACAnvas.ResetClip;
end;

{ ============================================================ }
{  Обработка мыши                                               }
{ ============================================================ }

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

  SetFocus;
  Ln := ScreenLineAtY(Y);
  Cl := ColFromPixelX(Ln, X);
  MoveCursorTo(Ln, Cl);
  Result := True;
end;

procedure TWLMemo.DoMouseWheel(const E: TWLWheelEvent);
var
  MaxScroll: Integer;
begin
  if E.DeltaY > 0 then
    Dec(FScrollY, 3)
  else if E.DeltaY < 0 then
    Inc(FScrollY, 3);

  if FScrollY < 0 then FScrollY := 0;
  MaxScroll := FLines.Count - VisibleLineCount;
  if MaxScroll < 0 then MaxScroll := 0;
  if FScrollY > MaxScroll then FScrollY := MaxScroll;
end;

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

procedure TWLMemo.DoKeyDown(const E: TWLKeyEvent);
begin
  case E.Keysym of
    $FF08: begin Backspace; Exit; end;
    $FFFF: begin DeleteForward; Exit; end;
    $FF51: begin MoveCursorLeft; Exit; end;
    $FF53: begin MoveCursorRight; Exit; end;
    $FF52: begin MoveCursorUp; Exit; end;
    $FF54: begin MoveCursorDown; Exit; end;
    $FF50: begin MoveCursorTo(FCursorLine, 0); Exit; end;
    $FF57: begin MoveCursorTo(FCursorLine, CurrentLineLength); Exit; end;
    $FF55:   // PageUp
    begin
      MoveCursorTo(FCursorLine - VisibleLineCount, FCursorCol);
      Exit;
    end;
    $FF56:   // PageDown
    begin
      MoveCursorTo(FCursorLine + VisibleLineCount, FCursorCol);
      Exit;
    end;
    $FF0D: begin SplitLine; Exit; end;
    $FF1B: begin ClearFocus; Exit; end;
    $FF09: begin Exit; end;   // Tab — пока ничего
  end;

  if (E.Codepoint >= 32) and (E.Codepoint <= $10FFFF) then
    InsertChar(E.Codepoint);
end;

end.

2. Тест demo_memo.pas
pascal

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

uses
  cthreads, SysUtils, Classes,
  u4intf, u4utf8, u4wrap,
  wlgui_types, wlgui_context, wlgui_app, wlgui_window, wlgui_canvas, wlgui_font,
  wlgui_events, wlgui_xkb, wlgui_widget, wlgui_button, wlgui_label,
  wlgui_memo;

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

  LblTitle, LblInfo: TWLLabel;
  Memo: TWLMemo;
  BtnClear, BtnDump: TWLButton;

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

procedure OnMemoChange(Sender: TObject);
begin
  LblInfo.Caption := Format('Lines: %d, cursor: (%d, %d), bytes: %d',
    [Memo.Lines.Count, Memo.CursorLine, Memo.CursorCol,
     Length(Memo.GetText)]);
end;

procedure OnClearClick(Sender: TObject);
begin
  Memo.Clear;
  Memo.SetFocus;
  LogLine('memo cleared');
end;

procedure OnDumpClick(Sender: TObject);
begin
  LogLine('--- memo content ---');
  WriteLn(Memo.GetText);
  LogLine('--- end ---');
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 memo demo');

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

    TitleFont := FontManager.Load('DejaVuSans', 20);
    MemoFont := FontManager.Load('DejaVuSans', 14);
    MonoFont := FontManager.Load('DejaVuSans', 12);

    Win := CreateWLWindow(MyApp.Context, 'wlgui memo demo', 700, 500);

    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, 660, 30), 'Memo demo', TitleFont);
      Win.Root.AddChild(LblTitle);

      Memo := TWLMemo.Create(
        TRectI.New(20, 60, 660, 300), MemoFont);
      Memo.OnChange := @OnMemoChange;
      Memo.SetText(UTF8String(
        'Добро пожаловать в многострочный редактор!'#10 +
        ''#10 +
        'Наберите текст здесь. Работают:'#10 +
        '  - Стрелки: вверх/вниз/влево/вправо'#10 +
        '  - Home/End — начало/конец строки'#10 +
        '  - PageUp/PageDown — на страницу'#10 +
        '  - Enter — новая строка'#10 +
        '  - Backspace — удалить символ слева или слить со строкой выше'#10 +
        '  - Delete — удалить символ справа или слить со строкой ниже'#10 +
        ''#10 +
        'Кликните в любое место, чтобы поставить курсор.'#10 +
        'Колёсико мыши — вертикальная прокрутка.'#10 +
        ''#10 +
        'Поддерживаются Unicode: 日本語, Ελληνικά, العربية, эмодзи 🎉🚀'));
      Win.Root.AddChild(Memo);

      BtnClear := TWLButton.Create(
        TRectI.New(20, 380, 140, 36), 'Clear', MemoFont);
      BtnClear.OnClick := @OnClearClick;
      Win.Root.AddChild(BtnClear);

      BtnDump := TWLButton.Create(
        TRectI.New(180, 380, 140, 36), 'Dump to log', MemoFont);
      BtnDump.OnClick := @OnDumpClick;
      Win.Root.AddChild(BtnDump);

      LblInfo := TWLLabel.Create(
        TRectI.New(20, 430, 660, 24),
        'Lines: 1, cursor: (0, 0)', MonoFont);
      LblInfo.TextColor := clYellow;
      Win.Root.AddChild(LblInfo);

      // Обновим инфо
      OnMemoChange(nil);
    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.

Что делает Memo

Клавиши:

    Стрелки — движение курсора.

    Home/End — в начало/конец строки.

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

    Enter — разбить строку.

    Backspace — удалить слева (или слить со строкой выше).

    Delete — удалить справа (или слить со строкой ниже).

    Escape — снять фокус.

    Tab — пока ничего (позже — навигация).

    Печатные символы — вставляются.

Мышь:

    Клик — ставит курсор (по строке и колонке).

    Колёсико — вертикальный скролл.

Курсор:

    Мигает (каждые 500 мс).

    Ромбовидный (пока — вертикальная палочка).

Что не сделано (отложено)

    Выделение текста — следующий этап.

    Копирование/вставка — через wl_data_device (следующий этап).

    Undo/redo — следующий этап.

    Word wrap — следующий этап.

    Скроллбар — визуальный индикатор (следующий этап).

    Поиск/замена — позже.

    Номера строк — позже.

Сборка
bash

fpc -Fu. -Fuwlgui demo_memo.pas
./demo_memo

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

    Открывается окно, есть текст.

    Клик в тексте — курсор в этой позиции.

    Стрелки — движение курсора.

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

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

    Ввод — символы добавляются в позицию.

    Enter — разбивает строку.

    Backspace — удаляет или сливает.

    Delete — то же справа.

    Колёсико — скролл.

    Clear — очищает.

    Dump to log — печатает содержимое в консоль.

    Кириллица, эмодзи, арабский, греческий — работают.

Возможные проблемы при компиляции

    RecalcLineHeight — вызывается после установки FFont.

    FLines — внутри создаётся TU4Lines, сразу добавляет одну пустую.

    PrefixPixelWidth — пересчитывает ширину префикса через TextWidth. Медленно для очень длинных строк, но приемлемо для UI.

    ColFromPixelX — линейный поиск. Для строк в 1000 символов — быстро.

Ожидаемая ошибка при запуске

Скорее всего, компилируется, но в рантайме возможен крэш — у нас нет опыта с таким большим виджетом сразу. Если что-то не так — пришли лог, поправим.

Особенно — может быть проблема с:

    var внутри EnsureCursorVisible (FPC 3.2.2 поддерживает var в середине процедуры с {$modeswitch} — если не поддерживает, вынеси наверх).

    MaxInt в PrefixPixelWidth — проверь.

Правки, которые возможно нужны

Если FPC ругается на var CursorX: Integer := ... в середине EnsureCursorVisible — вынеси объявления наверх:
pascal

procedure TWLMemo.EnsureCursorVisible;
var
  VisCount, CursorX, InnerW: Integer;
begin
  ...
  CursorX := PrefixPixelWidth(FCursorLine, FCursorCol) - FScrollX;
  InnerW := FRect.W - 2 * FPadding;
  ...
end;

Собирай, проверяй, присылай лог

Запусти:
bash

fpc -Fu. -Fuwlgui demo_memo.pas
./demo_memo

Если собирается и работает — отлично. Если что-то не так — пришли ошибку или поведение.

Дальше — по результату:

    Всё ок → добавим скроллбар, выделение, undo/redo.

    Мелкие проблемы → поправим.

    Крупные проблемы → переделаем архитектуру.

Starting program: /tmp/demo_memo 
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/x86_64-linux-gnu/libthread_db.so.1".
wlgui memo demo
[wlgui] EventManager created
[xkb] libxkbcommon loaded
[xkb] context created
[wlgui] Wayland initialized
[font] sizeof(FT_FaceRec)=160 offset(glyph)=152 offset(units_per_EM)=136 offset(ascender)=138
[font] file=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf name=DejaVuSans height=20 valid=TRUE builtin=FALSE ascent=19 descent=5 units_per_EM=2048
[font] sizeof(FT_FaceRec)=160 offset(glyph)=152 offset(units_per_EM)=136 offset(ascender)=138
[font] file=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf name=DejaVuSans height=14 valid=TRUE builtin=FALSE ascent=13 descent=3 units_per_EM=2048
[font] sizeof(FT_FaceRec)=160 offset(glyph)=152 offset(units_per_EM)=136 offset(ascender)=138
[font] file=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf name=DejaVuSans height=12 valid=TRUE builtin=FALSE ascent=11 descent=3 units_per_EM=2048
[wlgui] window created: "wlgui memo demo" 700x500
[events] seat name: default
[events] seat capabilities=3
[cursor] libwayland-cursor loaded
[cursor] theme loaded: "" size=24
[cursor] cursor surface created
[events] pointer attached
[events] keyboard attached
[wlgui] 2 buffers created: 700x500 stride=2800

Program received signal SIGSEGV, Segmentation fault.
0x00005555555b9cce in fpc_ansistr_assign ()
(gdb) bt full
#0  0x00005555555b9cce in fpc_ansistr_assign ()
No symbol table info available.
#1  0x00005555555af5d0 in ONMEMOCHANGE (SENDER=0x7ffff7f75100) at demo_memo.pas:29
No locals.
#2  0x000055555563c46b in NOTIFYCHANGE (this=0x7ffff7f75100) at wlgui_memo.pas:193
No locals.
#3  0x000055555563c327 in SETTEXT (this=0x7ffff7f75100, S=0x7ffff7f9cb20) at wlgui_memo.pas:159
No locals.
#4  0x000055555563c299 in SETTEXT (this=0x7ffff7f75100, 
    S=0x55555565e1a0 'Добро пожаловать в многострочный редактор!\n\nНаберите текст здесь. Работают:\n  - Стрелки: вверх/вниз/влево/вправ'...)
    at wlgui_memo.pas:146
No locals.
#5  0x00005555555afd92 in main () at demo_memo.pas:148
No locals.
🎯 OnMemoChange вызывается до создания LblInfo

Смотрим стек:
text

#1  ONMEMOCHANGE  (demo_memo.pas:29)   ← LblInfo.Caption := ...
#2  NOTIFYCHANGE  (wlgui_memo.pas:193)
#3  SETTEXT       (wlgui_memo.pas:159)
#4  SETTEXT       (wlgui_memo.pas:146)
#5  main          (demo_memo.pas:148)

В demo, в порядке:
pascal

Memo := TWLMemo.Create(...);
Memo.OnChange := @OnMemoChange;      ← назначаем ОБРАБОТЧИК
Memo.SetText(...);                    ← ЭТО ВЫЗЫВАЕТ OnChange
Win.Root.AddChild(Memo);

// ...потом...
LblInfo := TWLLabel.Create(...);      ← создаётся ПОЗЖЕ!

SetText вызывает NotifyChange → OnMemoChange → LblInfo.Caption := ... — а LblInfo ещё nil → SIGSEGV.
Патч demo_memo.pas

Вариант A: создать LblInfo до Memo. Но Memo сам создаётся до SetText, а SetText внутри — так что порядок:
pascal

LblInfo := TWLLabel.Create(...);   // ← СНАЧАЛА
Win.Root.AddChild(LblInfo);

Memo := TWLMemo.Create(...);
Memo.OnChange := @OnMemoChange;
Win.Root.AddChild(Memo);

Memo.SetText(...);                 // ← теперь OnChange безопасен

Вариант B: защитить OnMemoChange:
pascal

procedure OnMemoChange(Sender: TObject);
begin
  if LblInfo = nil then Exit;
  LblInfo.Caption := ...;
end;

Я за B — надёжнее и проще.
Патч — оба варианта

В demo_memo.pas, замени OnMemoChange:
pascal

procedure OnMemoChange(Sender: TObject);
begin
  if LblInfo = nil then Exit;
  LblInfo.Caption := Format('Lines: %d, cursor: (%d, %d), bytes: %d',
    [Memo.Lines.Count, Memo.CursorLine, Memo.CursorCol,
     Length(Memo.GetText)]);
end;

И заодно — переставь порядок создания, чтобы LblInfo был до Memo:
pascal

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

      // Инфо — СОЗДАЁМ ДО MEMO
      LblInfo := TWLLabel.Create(
        TRectI.New(20, 430, 660, 24),
        'Lines: 1, cursor: (0, 0)', MonoFont);
      LblInfo.TextColor := clYellow;
      Win.Root.AddChild(LblInfo);

      // Memo
      Memo := TWLMemo.Create(
        TRectI.New(20, 60, 660, 300), MemoFont);
      Memo.OnChange := @OnMemoChange;
      Memo.SetText(...);   // ← OnChange вызовется, но LblInfo уже создан
      Win.Root.AddChild(Memo);

      BtnClear := ...
      // ...
    end;

Плюс — защитный if LblInfo = nil then Exit в OnMemoChange.
Общий принцип

OnChange может срабатывать из конструктора (или из SetText) — до того, как все виджеты созданы. Всегда либо защищай обработчик (if X = nil then Exit), либо вызывай SetText после создания всех зависимых виджетов.
Патч + сборка

Патчим demo, собираем:
bash

fpc -Fu. -Fuwlgui demo_memo.pas
./demo_memo

Ожидаемо — Memo откроется с текстом, курсор мигает, ввод работает.
Если снова падает

Возможные проблемы:

    Length(Memo.GetText) — GetText возвращает UTF8String, Length — байты. Ок.

    PrefixPixelWidth — для пустой строки TextWidth('') = 0 — ок.

    Font.TextWidth(L.SubString(0, I)) — вызывается для каждого I. Для длинных строк — медленно, но не падает.

Пришли новый лог — если снова упадёт, найдём точное место.

Особенно интересны следующие потенциальные проблемы:

    Курсор мигает — есть ли проблема с отрисовкой?

    Клик — попадает правильно?

    Стрелки — двигают курсор?

    Enter — разбивает строку?

Собери, проверь, пришли результат

Если работает — продолжаем (скроллбар, выделение, undo/redo). Если нет — правим.
Заодно — важная оптимизация на будущее

PrefixPixelWidth — каждый раз считает TextWidth для всех префиксов с нуля. Для 80-символьной строки — 80 раз FreeType. Медленно при быстром вводе.

Оптимизация (позже): кэшировать ширины символов в TWLFont, или накапливать ширины по символам.

Сейчас не трогаем — работает.

Патчим demo — и в путь.
Запустилось. Попробовал редактировать текст и:
🎯 L = 0x0 — CurrentLine вернул nil

Смотрим:
text

#0  SPLITLINE (this=0x7ffff7f75100) at wlgui_memo.pas:485
485  Before := L.SubString(0, FCursorCol);
     L = 0x0     ← L = nil!

L := CurrentLine вернул nil. Но CurrentLine не должен возвращать nil, если FLines содержит хотя бы одну строку.
Почему L = nil

Гипотеза: FLines[FCursorLine] равен nil, потому что после некоторых операций (Backspace, удаление) строка становится nil.

Смотрим u4lines.SetLine:
pascal

procedure TU4Lines.SetLine(Index: Integer; const S: IU4String);
begin
  if (Index < 0) or (Index >= FCount) then Exit;
  FLines[Index] := S;
end;

Смотрим TU4Lines.Delete:
pascal

procedure TU4Lines.Delete(Index: Integer);
var
  I: Integer;
begin
  if (Index < 0) or (Index >= FCount) then Exit;
  for I := Index to FCount - 2 do
    FLines[I] := FLines[I + 1];
  FLines[FCount - 1] := nil;   ← ← ВОТ ОНО!
  Dec(FCount);
end;

Мы ставим nil в последний слот. Правильно, если FCount уменьшился.

Но: если после удаления FCount = 1 (осталась одна строка), и она была — ок. А если после удаления FCount = 0? Тогда — пусто, но у нас должна быть хотя бы одна пустая.
Проверим TWLMemo.Backspace и DeleteForward

Backspace:
pascal

  else if FCursorLine > 0 then
  begin
    Prev := FLines[FCursorLine - 1];
    L := CurrentLine;
    PrevLen := Prev.Length;
    Merged := Prev.Concat(L);
    FLines[FCursorLine - 1] := Merged;
    FLines.Delete(FCursorLine);
    ...

Delete(FCursorLine) — удаляет строку. Если FCursorLine — последняя — FCount уменьшается.

DeleteForward — то же.

SplitLine — вставляет — FCount растёт.

Clear — устанавливает одну пустую.

Все они не должны оставить пустой список.
Но L = 0x0 — почему?

Смотрим внимательнее:
text

#5  WL_KEYBOARD_KEY ... AKEY=28, ASTATE=1
    E = {KEYSYM = 65293, CODEPOINT = 13, SCANCODE = 28, ...}

KEYSYM = 65293 = $FF0D = Enter. CODEPOINT = 13 — тоже Enter.

Enter → SplitLine. В SplitLine:
pascal

L := CurrentLine;
Before := L.SubString(0, FCursorCol);

CurrentLine:
pascal

function TWLMemo.CurrentLine: IU4String;
begin
  if (FCursorLine < 0) or (FCursorLine >= FLines.Count) then
    Result := U4Empty
  else
    Result := FLines[FCursorLine];
end;

Возвращает FLines[FCursorLine], но если эта ячейка nil (не U4Empty, а буквально nil) — вернётся nil.

Значит: в FLines[FCursorLine] — nil. Как он там оказался?
Ищем источник nil

TU4Lines.Add(const S: IU4String):
pascal

function TU4Lines.Add(const S: IU4String): Integer;
begin
  EnsureCapacity(FCount + 1);
  FLines[FCount] := S;
  Result := FCount;
  Inc(FCount);
end;

Если S = nil — запишет nil. Но Add обычно не вызывается с nil.

TU4Lines.SetText:
pascal

Line := S.SubString(Start, I - Start);
if I > Start then
  Line := S.SubString(Start, I - Start)
else
  Line := U4Empty;
Add(Line);

Если I > Start — SubString возвращает IU4String, но для непустой подстроки. Если SubString возвращает nil — вот проблема. Но у нас SubString возвращает валидный IU4String (даже пустой — не nil).

Проверяем SubString:
pascal

function TU4String.SubString(Start, Count: DWord): IU4String;
var
  Impl: TU4String;
  Len: DWord;
  I: DWord;
begin
  Result := nil;                                    ← ← ВОТ!
  Len := GetLength;
  if (Start >= Len) or (Count = 0) then Exit;
  ...

Result := nil — если Start >= Len или Count = 0 — возвращается nil!

Значит: если FCursorCol = 0 (курсор в начале строки), то L.SubString(0, 0) возвращает nil (потому что Count = 0).
Точное место падения
pascal

procedure TWLMemo.SplitLine;
var
  L, Before, After: IU4String;
begin
  if FReadOnly then Exit;
  L := CurrentLine;
  Before := L.SubString(0, FCursorCol);       ← если FCursorCol = 0, Before = nil
  After := L.SubString(FCursorCol, L.Length - FCursorCol);  ← если FCursorCol = L.Length, After = nil
  FLines[FCursorLine] := Before;              ← FLines[...] := nil !!!
  FLines.Insert(FCursorLine + 1, After);
  ...

Если FCursorCol = 0:

    L.SubString(0, 0) возвращает nil.

    FLines[FCursorLine] := nil — записывает nil.

    Строка становится nil.

    Дальнейший доступ к FLines[FCursorLine] даёт nil.

    Падение.

Решение

Два подхода:
A. Пофиксить TU4String.SubString — возвращать пустой IU4String, не nil
pascal

function TU4String.SubString(Start, Count: DWord): IU4String;
var
  Impl: TU4String;
  Len: DWord;
  I: DWord;
begin
  Result := U4Empty;    ← ← вместо nil
  Len := GetLength;
  if (Start >= Len) or (Count = 0) then Exit;
  ...

Плюсы: все SubString гарантированно не nil. Минус: изменяем u4-модуль.

Хм — u4intf.pas — ваш модуль? Если да — правьте смело. Если внешний — надо осторожнее.

Проверка: IU4String обычно — не nil, это интерфейс. nil — это без интерфейса. U4Empty — валидный интерфейс. Все функции должны возвращать U4Empty, а не nil.

Рекомендую: пофиксить TU4String.SubString, чтобы возвращал U4Empty.
B. Защититься в TWLMemo

Проверять результат:
pascal

Before := L.SubString(0, FCursorCol);
if Before = nil then Before := U4Empty;
After := L.SubString(FCursorCol, L.Length - FCursorCol);
if After = nil then After := U4Empty;

Плюс: не трогаем u4. Минус: надо везде защищаться.
C. Комбинированный

Оба: фикс u4intf + защита в TWLMemo. Надёжно.
Что делать

Предлагаю:

    Проверь u4intf.pas — TU4String.SubString. Если он ваш — пофиксить:
    pascal

    function TU4String.SubString(Start, Count: DWord): IU4String;
    begin
      Result := U4Empty;
      ...

    В wlgui_memo.pas — в SplitLine и InsertChar — добавить защиту:
    pascal

    Before := L.SubString(0, FCursorCol);
    if Before = nil then Before := U4Empty;

Проблема: аналогично DeleteChar, InsertChar, PrefixPixelWidth — все могут вернуть nil.

Надёжнее — фикс u4intf. Это правильное место.
Проверь u4intf

Смотрим u4intf.pas, который ты мне показывал:
pascal

function TU4String.SubString(Start, Count: DWord): IU4String;
var
  Impl: TU4String;
  Len: DWord;
  I: DWord;
begin
  Result := nil;                                          ← ← НАДО U4Empty
  Len := GetLength;
  if (Start >= Len) or (Count = 0) then Exit;
  ...

Да, Result := nil. Это и есть баг.

Аналогично Trim:
pascal

function TU4String.Trim: IU4String;
begin
  Result := nil;                    ← ← надо U4Empty
  Len := GetLength;
  if Len = 0 then Exit;
  ...

Reverse:
pascal

function TU4String.Reverse: IU4String;
begin
  ...
  Impl := TU4String.Create(Len);     ← Len может быть 0, тогда Result = nil через U4Empty

Не критично.
Решение

Пофиксить u4intf.pas: все методы, возвращающие IU4String, должны возвращать U4Empty при пустом результате.

Список методов для проверки:

    SubString — сейчас Result := nil.

    Trim — сейчас Result := nil.

    Reverse — не критично.

    Replace — проверить.

    Concat — если Other = nil — что? Смотрим:
    pascal

    function TU4String.Concat(const Other: IU4String): IU4String;
    ...
    if L1 + L2 = 0 then
    begin
      Result := U4Empty;                ← OK
      Exit;
    end;

    Хорошо.

    AppendChar — всегда создаёт новый, не nil.

Итог: фиксим SubString и Trim — в первую очередь.
Патч u4intf.pas

Найди TU4String.SubString и замени Result := nil на Result := U4Empty:
pascal

function TU4String.SubString(Start, Count: DWord): IU4String;
var
  Impl: TU4String;
  Len: DWord;
  I: DWord;
begin
  Result := U4Empty;                 ← ← было nil
  Len := GetLength;
  if (Start >= Len) or (Count = 0) then Exit;
  if Start + Count > Len then Count := Len - Start;
  Impl := TU4String.Create(Count);
  for I := 0 to Count - 1 do
    Impl.FData[I + 1] := FData[Start + I + 1];
  Result := Impl;
end;

Найди TU4String.Trim:
pascal

function TU4String.Trim: IU4String;
var
  Start, Finish, Len: DWord;
  Impl: TU4String;
  I: DWord;
  function IsSpace(C: u4char): Boolean; inline;
  begin
    ...
  end;
begin
  Result := U4Empty;                 ← ← было nil
  Len := GetLength;
  if Len = 0 then Exit;
  Start := 0;
  while (Start < Len) and IsSpace(FData[Start + 1]) do Inc(Start);
  if Start = Len then Exit;          ← возвращает U4Empty — правильно
  ...

После патча

    Пересобрать всё (u4intf.pas — базовый модуль).

    Запустить demo — Enter должен работать.

    Проверить другие сценарии:

        Backspace в начале строки.

        Enter в начале/конце строки.

        Home/End + Enter.

Сборка
bash

# Пересобрать с u4intf (удалить старые .ppu)
rm -f u4intf.ppu u4intf.o
fpc -Fu. -Fuwlgui demo_memo.pas
./demo_memo

Что ещё проверить после патча

    Before теперь никогда не nil — FLines[FCursorLine] := Before не запишет nil.

    After тоже.

    Все остальные операции с SubString — тоже.

Возможные ещё проблемы:

    DeleteChar — если S.SubString возвращал nil, то Concat(nil, ...) мог падать.

    PrefixPixelWidth — если L.SubString(0, 0) = nil, TextWidth(nil) — не факт что падает, но лучше фиксить.

Патч u4intf — решает все.
Итог

Причина: TU4String.SubString возвращает nil вместо U4Empty, когда результат пустой. SplitLine пишет nil в FLines[...], потом читает — падает.

Фикс: Result := U4Empty в SubString и Trim.

Проверь u4intf.pas — пофиксь, пересобери, проверь. Пришли результат.

Если патч u4intf невозможен (модуль внешний) — обходной путь: в TWLMemo добавить защиту:
pascal

function SafeU4(const S: IU4String): IU4String; inline;
begin
  if S = nil then Result := U4Empty
  else Result := S;
end;

И везде где получаем результат SubString — оборачивать. Громоздко, но работает.

Скажи, какой путь — фикс u4intf или защита в wlgui_memo.