Спасибо! ./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 тоже разумно.

Скажи, какой вариант — и приступаем.