После замены Display на Display.Proxy заработали и test_J и demo_input. Спасибо! Увидел в деле demo_input. Поначалу некоторое время работает видимо ожидаемо, потом сбивается (внешне похоже на странность с mouse enter/mouse leave, после чего квадрат перестаёт следовать за курсором мыши и окно перестаёт воспринимать нажатые кнопки). Но это уже следующая часть. А ведь заработало! Теперь ещё один момент. Мы делали всю часть кроме файлов wayland_*.pas, которые мне передал основной разработчик MSE. Похоже мы впервые внесли туда правку и там был баг. Это надо как-то ему сообщить. Ты можешь описать ему на английском суть бага и его исправления? ./demo_input
wlgui input 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
[wlgui] window created: "wlgui input demo" 640x480
[events] seat name: default
[events] seat capabilities=3
[events] pointer attached
[events] keyboard attached
[wlgui] 2 buffers created: 640x480 stride=2560
[events] keyboard keymap format=1 size=38030
[xkb] keymap loaded
[demo] focus in
[demo] mouse enter
[demo] mouse down at 247,201 button=1
[demo] mouse up at 247,201
[demo] mouse down at 272,201 button=3
[demo] mouse up at 272,201
[demo] mouse down at 272,201 button=2
[demo] mouse up at 272,201
[demo] mouse down at 272,201 button=3
[demo] mouse up at 272,201
[demo] mouse down at 272,201 button=1
[demo] mouse down at 272,201 button=3
[demo] mouse up at 272,201
[demo] mouse down at 272,201 button=3
[demo] mouse up at 272,201
[demo] mouse down at 272,201 button=3
[demo] mouse up at 272,201
[demo] mouse leave
[demo] mouse enter
[demo] mouse leave
[demo] mouse enter
[demo] mouse leave
[demo] focus out
^C
🎉 Работает! И я вижу причину «сбивания» мыши

Сначала — отчёт разработчику MSE. Потом — разбор mouse enter/leave.
Письмо для основного разработчика MSE

Вот текст на английском, который ты можешь ему отправить. Он описывает баг, воспроизведение, диагностику и фикс.

Subject: Bug in wayland_client_core.pas — wl_display wrapper passes wrong pointer to libwayland API

Hi Martin,

While working on a Wayland backend prototype based on the wayland_*.pas units you provided, I hit a serious bug that caused SIGABRT inside libwayland-client on the very first event loop iteration. It took a while to isolate, and I believe the fix is important enough to report back to you, since the same pattern exists throughout the code.
Symptom

Any code path that calls wl_display_* functions with a TWlDisplay instance (the Pascal wrapper class) crashes the process with:
text

Fatal glibc error: tpp.c:83 (__pthread_tpp_change_priority):
assertion failed: new_prio == -1 ||
(new_prio >= fifo_min_prio && new_prio <= fifo_max_prio)

The crash happens inside wl_display_dispatch_queue_pending on the very first call, immediately after wl_display_roundtrip. Stack trace:
text

#9  wl_display_dispatch_queue_pending () from libwayland-client.so.0
#10 <caller in application code>

Root cause

TWlDisplay (and the other TWl* classes) are Pascal wrapper classes, not raw wl_display* / wl_proxy* pointers. They contain a field FProxy: Pwl_proxy that holds the actual pointer returned by wl_display_connect.

When application code does:
pascal

Display: TWlDisplay;
Display := TWlDisplay(TWlDisplay.Connect(''));
wl_display_dispatch_pending(Display);   // ← WRONG: passing the Pascal object

it passes the address of the Pascal object instance to libwayland, not the address of the underlying wl_display. libwayland then treats this address as a struct wl_display * and tries to lock display->mutex, which lives at some garbage offset within the Pascal object's memory. The lock operation on a corrupted mutex triggers the glibc assertion.

The correct call is:
pascal

wl_display_dispatch_pending(Display.Proxy);

or, better, one of the existing wrapper methods:
pascal

Display.DispatchPending;
Display.Flush;
Display.Dispatch;
Display.GetFd;
Display.Roundtrip;

Minimal reproduction
pascal

uses
  wayland_client_core, wayland_protocol, xdg_shell_protocol;

var
  Display: TWlDisplay;
begin
  Display := TWlDisplay(TWlDisplay.Connect(''));
  Display.Roundtrip;
  wl_display_dispatch_pending(Display);   // crash here
  Display.Disconnect;
end.

Versions: FPC 3.2.2, libwayland-client 1.26.0, glibc 2.43.
Fix

    In user code: always use Display.Proxy (or the wrapper methods) instead of passing the wrapper object to wl_display_* functions.

    Optionally, in the wrapper: since TWlDisplay is a Pascal object and cannot be implicitly converted to Pwl_display, it would help to keep the existing wrapper methods as the primary API and clearly document that FProxy must be extracted when calling libwayland directly. Adding a Proxy property (already present in TWLProxyObject as I understand) helps.

Note: wl_proxy_set_user_data on wl_display

While debugging, I also suspected wl_proxy_set_user_data(FProxy, ...) being called on the display object, since wl_display is not a regular wl_proxy. In our local copy we overrode SetProxyUserData in TWLDisplayBase to a no-op to be safe. It turned out not to be the root cause of this particular crash (the wrong-pointer issue was), but it might be worth double-checking whether wl_proxy_set_user_data on the display is actually valid in current libwayland versions. Documentation is unclear on this point.
Summary

    wl_display_* functions must receive the raw wl_display*, never the Pascal wrapper instance.

    The bug is not in libwayland or glibc — the glibc assertion is a symptom, not the cause.

    The fix is trivial: use Display.Proxy or the existing wrapper methods.

Thanks again for the wayland_*.pas units — they've been extremely useful as a foundation. Hope this report helps.

Best regards,
[Your name]
Разбор проблемы «мышь сбивается»

Смотрю на твой лог:
text

[demo] mouse down at 272,201 button=1
[demo] mouse down at 272,201 button=3
[demo] mouse up at 272,201
[demo] mouse down at 272,201 button=3
[demo] mouse up at 272,201
[demo] mouse down at 272,201 button=3
[demo] mouse up at 272,201
[demo] mouse leave
[demo] mouse enter
[demo] mouse leave
[demo] mouse enter
[demo] mouse leave
[demo] focus out

Симптомы:

    Координаты замирают на 272,201 — после нескольких нажатий мышь перестаёт обновляться.

    mouse leave / mouse enter мигают — мы многократно получаем события выхода/входа без реального изменения положения курсора.

    focus out в конце — окно теряет фокус.

Причина — почти наверняка следующий сценарий:

Когда ты таскаешь окно за содержимое (мы это видели — композитор сам двигает окно), wl_pointer временно теряет фокус на поверхности (wl_pointer.leave), потому что композитор перехватывает управление для перетаскивания. При этом наш SurfaceMap всё ещё хранит surface, и мы не очищаем FFocusedReceiver при leave — точнее, очищаем, но потом приходит enter, мы снова ставим receiver, а координаты не обновляются (потому что enter присылает новые координаты, но мы их используем только в MouseEnter, а не для последующих motion).

Плюс возможно: наш wl_pointer.motion приходит с координатами относительно поверхности, но после перетаскивания координаты меняются в другой системе отсчёта.

Кроме того, есть проблема: при mouse down с несколькими кнопками мы видим логическую ошибку — приходят button=1, потом button=3 без up. Это может быть связано с тем, что мы в wl_pointer_button не различаем нажатие и отпускание для разных кнопок.
Что делать с «мышь сбивается»

Это следующая задача — отдельный разбор wlgui_events.pas. Причин может быть несколько:

    Фокус pointer'а теряется при перетаскивании окна — надо сбрасывать состояние мыши на leave.

    wl_pointer.frame — мы его игнорируем, но он нужен для правильной группировки событий (motion + button + axis приходят одним пакетом).

    Состояние кнопок — MouseDown: Boolean не различает, какая кнопка нажата. Если ты жмёшь правую после левой, MouseDown остаётся True, и логика ломается.

    Потеря фокуса клавиатуры — focus out в конце.

Об этом можно поговорить в следующем сообщении. Сейчас главное — баг в wayland_client_core.pas найден и описан, и demo_input работает.
Что сказать разработчику MSE

Кроме письма — отправь ему точный диff, если у тебя уже есть правки в wayland_client_core.pas:

    wl_display_dispatch_pending(Display) → wl_display_dispatch_pending(Display.Proxy).

    Опционально — SetProxyUserData в TWLDisplayBase как no-op.

    Объяснение: главное — не передавать Pascal-обёртки в libwayland API.

Если он захочет применить фикс — минимальное изменение в его коде: добавить в TWlDisplay методы-обёртки (DispatchPending, Dispatch, Flush, GetFd, Roundtrip) и везде использовать их вместо прямых вызовов. А в документации — предупредить: FProxy не то же самое, что Self.
Итог

    ✅ Баг найден: wl_display_* с обёрткой вместо FProxy.

    ✅ Баг воспроизведён в 15 строках.

    ✅ Фикс: Display.Proxy или методы-обёртки.

    ✅ Письмо разработчику готово.

    ⏭️ Следующая задача: разобраться с mouse leave/enter, потерей координат и состоянием кнопок.

Скажи, когда будешь готов продолжить — начнём с wl_pointer.frame и правильной обработки состояния кнопок.
Спасибо! Немного позапускал ещё demo_input и заметил, что пока квадрат с линией следуют за мышью окно за содержимое двигать не удаётся, при щелчке мышью квадратик окрашивается в другой цвет. А когда некоторое время проходит mouse enter начинает тут же сопровождаться mouse leave и к вводу окно теряет чувствительность. Зато появляется возможность двигать его за содержимое. Давай продолжим.
Разбор: почему через некоторое время мышь «отваливается»

Твои наблюдения дают очень ценную информацию. Разберём по симптомам:
Что происходит	Когда	Что это значит
Квадрат следует за мышью, клик меняет цвет	В начале	wl_pointer.motion и wl_pointer.button приходят нормально
Окно можно двигать за содержимое	В начале нет	Композитор не перехватывает клик, пока мы получаем motion
mouse enter/mouse leave мигают	Через некоторое время	Композитор думает, что мышь уходит и возвращается
Координаты замирают	Тогда же	wl_pointer.motion перестаёт приходить
Клик не работает	Тогда же	wl_pointer.button перестаёт приходить
Окно можно двигать за содержимое	Появляется	Композитор видит клик как «grab window» — потому что фокус ушёл от нашей поверхности
focus out	В конце	Потеря фокуса клавиатуры
Причина № 1 (главная): мы не отправляем курсор

Композитор требует, чтобы клиент устанавливал курсор через wl_pointer.set_cursor в ответ на wl_pointer.enter. Если этого не делать, некоторые композиторы (sway, wlroots, kwin) считают, что клиент не заинтересован в pointer и перестают слать motion.

Почему через некоторое время: композитор ждёт set_cursor некоторое время (таймаут), потом перестаёт слать motion. При этом enter/leave продолжают мигать, потому что композитор перезапрашивает фокус.

Проверка: в логе нет ни одного сообщения от wl_pointer.set_cursor. Мы его никогда не вызываем.

Решение: реализовать в TWLPointerListener.wl_pointer_enter:
pascal

procedure TWLPointerListener.wl_pointer_enter(...);
begin
  // ...
  // Отправляем композитору "я использую этот курсор"
  // Можно использовать встроенный cursor theme или создать свою surface
  SetCursorShape(ASerial, cs_default);
end;

Нужен либо libwayland-cursor (для системных курсоров), либо создать свою surface с картинкой курсора.

Самый простой вариант — использовать wl_cursor_theme_load из libwayland-cursor. Это ~30 строк, но требует расширения wayland_cursor.pas.

Ещё проще — создать свою surface 1×1 прозрачную и отправить её как курсор. Тогда композитор видит, что мы «установили курсор», но фактически курсор невидим (плохо для пользователя). Лучше — использовать libwayland-cursor.
Причина № 2: wl_pointer.frame игнорируется

Wayland с версии 5 требует, чтобы клиент обрабатывал wl_pointer.frame — это маркер конца пакета событий. Без обработки frame некоторые композиторы считают, что клиент «не готов» к обработке событий и замедляют/останавливают поток.

В нашем TWLPointerListener:
pascal

procedure TWLPointerListener.wl_pointer_frame(AWlPointer: TWlPointer);
begin
  // Пусто
end;

Это нормально, но при этом мы должны обрабатывать все события между frame-ами в рамках одного пакета. У нас это так и есть (просто вызываются callback'и).

Но! Если композитор использует wl_pointer.frame, а мы не зарегистрировали listener для frame, поведение может быть неопределённым. У нас listener зарегистрирован (метод есть), но он ничего не делает — это ок.
Причина № 3 (важная): wl_pointer.motion приходит с неправильными координатами после перетаскивания

Когда окно таскается, композитор отдаёт координаты мыши относительно нового положения поверхности. Но наша surface остаётся на месте (мы её не двигаем, двигает композитор). Возможно, после первого перетаскивания координаты приходят в другой системе отсчёта, и мы их записываем как абсолютные.
Причина № 4: порядок событий

Wayland может присылать события в таком порядке:
text

wl_pointer.leave
wl_pointer.enter (на другую поверхность? или нашу?)
wl_pointer.motion
wl_pointer.frame

Мы в leave сбрасываем FFocusedReceiver := nil. В enter ставим новый. Потом motion идёт в наш receiver — нормально.

Но если enter и leave приходят с интервалом < 1 мс, и мы неправильно обрабатываем — сбиваемся.
Причина № 5 (самая вероятная для «через некоторое время»): мы не вызываем set_cursor в ответ на enter

Каждый раз, когда приходит wl_pointer.enter, клиент должен отправить set_cursor (или явно set_cursor(nil), чтобы скрыть). Композитор ждёт этого. Если set_cursor не приходит — композитор может перестать слать события этому клиенту, потому что считает, что клиент не заинтересован в указателе.

Это стандартное требование протокола Wayland. Смотрим документацию:

    set_cursor: This request sets the pointer's cursor image to the given surface, and sets the hotspot...

Если set_cursor не вызывается — композитор вправе не отправлять motion/button.
Что делать
Шаг 1: реализовать установку курсора

Простейший вариант — использовать libwayland-cursor (wl_cursor_theme_load, wl_cursor_image_get_buffer). Но проще — создать свою wl_surface с курсором.

Минимальная реализация (без libwayland-cursor):
pascal

// В TWLEventManager
private
  FCursorSurface: TWlSurface;
  FCursorBuffer: TWlBuffer;
  FCursorPool: TWlShmPool;
  FCursorPixels: PByte;
  FCursorWidth, FCursorHeight: Integer;
  FCursorHotX, FCursorHotY: Integer;
  FPointer: TWlPointer;

// Создание курсора — 1 раз при инициализации
procedure TWLEventManager.CreateDefaultCursor;
var
  Size: Integer;
  Data: Pointer;
begin
  FCursorWidth := 16;
  FCursorHeight := 16;
  FCursorHotX := 0;
  FCursorHotY := 0;
  Size := FCursorWidth * FCursorHeight * 4;

  // Создаём SHM-буфер для курсора
  // ... (стандартный SHM setup)
  // Рисуем простую стрелку 16×16 в FCursorPixels

  // Создаём surface и прикрепляем буфер
  FCursorSurface := FContext.Compositor.CreateSurface;
  FCursorSurface.Attach(FCursorBuffer, 0, 0);
  FCursorSurface.Damage(0, 0, FCursorWidth, FCursorHeight);
  FCursorSurface.Commit;
end;

// Использование в wl_pointer_enter
procedure TWLPointerListener.wl_pointer_enter(...);
begin
  FOwner.FPointer.SetCursor(ASerial, FOwner.FCursorSurface,
                            FOwner.FCursorHotX, FOwner.FCursorHotY);
  // ... остальное
end;

Стрелка 16×16 рисуется в SHM-буфере как обычная картинка. Формат ARGB8888 (с прозрачностью) — чтобы курсор был не «квадратный».
Шаг 2: игнорировать enter/leave, если мы уже сфокусированы

Наш wl_pointer_leave сбрасывает FFocusedReceiver := nil. Если сразу же приходит enter — ставим обратно. Мигание не критично, но можно защититься:
pascal

procedure TWLPointerListener.wl_pointer_leave(...);
begin
  // Не сбрасываем сразу — композитор может отправить enter с той же surface
  // через 1 мс, и мы потеряем контекст
  if FOwner.FFocusedReceiver = Recv then
    FOwner.SetFocused(nil);
  // ...
end;

Шаг 3: обновлять координаты всегда

В wl_pointer_motion мы уже обновляем FLastMouseX/Y. Но если FocusedReceiver = nil, не вызываем callback. Решение: обновлять координаты всегда, а callback вызывать только если receiver есть:
pascal

procedure TWLPointerListener.wl_pointer_motion(...);
begin
  FOwner.FLastMouseX := Round(ASurfaceX.AsDouble);
  FOwner.FLastMouseY := Round(ASurfaceY.AsDouble);

  if FOwner.FFocusedReceiver = nil then Exit;

  // ... callback
end;

Это уже так.
Шаг 4: различать кнопки в состоянии

Сейчас у нас одна переменная MouseDown: Boolean в demo. Если нажаты две кнопки — состояние путается. Сделаем в TWLEventManager:
pascal

FButtonState: LongWord;  // битовая маска нажатых кнопок

И метод IsButtonDown(Btn: Integer): Boolean.
Что делать прямо сейчас

Начни с самого важного — с set_cursor. Без него композитор вправе не слать pointer events через некоторое время.

Минимальный тест: создай фиктивную surface курсора 1×1 прозрачную и вызывай SetCursor в wl_pointer_enter. Если поведение изменится (мышь перестанет «сбиваться») — значит корень подтверждён.

Собрать курсор 16×16 со стрелкой — задача следующего шага. Сначала проверим гипотезу.
План

    Добавить wl_pointer.set_cursor с фиктивной surface 1×1. Проверить.

    Если помогает — сделать нормальный курсор 16×16 (стрелка).

    Убрать мигание enter/leave — если enter приходит через <10 мс после leave на ту же surface, не сбрасывать/ставить фокус.

    Разделить состояние кнопок в TWLEventManager (FButtonState: LongWord).

    Обновлять координаты мыши всегда (уже делаем).

Начнём с шага 1. Хочешь — сразу напишу реализацию создания фиктивного курсора 1×1 в wlgui_events.pas?

Или ты сам сделаешь, а я подскажу архитектуру?
Напиши реализацию.
Шаг 1: фиктивный курсор 1×1 + диагностика

Реализую минимальную версию, чтобы проверить гипотезу. Если поможет — сделаем нормальный курсор 16×16 стрелкой.
Что добавляем в wlgui_events.pas
1. В TWLEventManager — поля курсора
pascal

  TWLEventManager = class
  private
    FSeat: TWlSeat;
    FPointer: TWlPointer;
    FKeyboard: TWlKeyboard;
    FSeatListener: TWLSeatListener;
    FPointerListener: TWLPointerListener;
    FKeyboardListener: TWLKeyboardListener;

    // Курсор
    FCursorSurface: TWlSurface;
    FCursorBuffer: TWlBuffer;
    FCursorPool: TWlShmPool;
    FCursorPixels: PByte;
    FCursorFd: cint;
    FCursorSize: Integer;
    FCursorWidth, FCursorHeight: Integer;
    FCursorHotX, FCursorHotY: Integer;
    FCursorReady: Boolean;

    // Менеджеру нужен доступ к compositor и shm — передаём при создании
    FCompositor: TWlCompositor;
    FShm: TWlShm;

    // Состояние кнопок
    FButtonState: LongWord;

    FFocusedReceiver: IWLEventReceiver;
    FLastMouseX, FLastMouseY: Integer;
    FModifiers: TWLModifiers;

    function FindReceiver(ASurface: TWlSurface): IWLEventReceiver;
  public
    constructor Create(ACompositor: TWlCompositor; AShm: TWlShm);
    destructor Destroy; override;

    procedure AttachSeat(ASeat: TWlSeat);
    procedure SetFocused(ARecv: IWLEventReceiver);

    procedure CreateCursorSurface(AW, AH, AHotX, AHotY: Integer);
    procedure DestroyCursorSurface;
    function CursorSurface: TWlSurface;
    function CursorHotX: Integer;
    function CursorHotY: Integer;

    function IsButtonDown(ABtn: Integer): Boolean;

    function Modifiers: TWLModifiers;
    function MousePos: TPointI;

    procedure RegisterSurface(ASurface: TWlSurface; ARecv: IWLEventReceiver);
    procedure UnregisterSurface(ASurface: TWlSurface);

    property Focused: IWLEventReceiver read FFocusedReceiver;
    property Pointer: TWlPointer read FPointer;
  end;

2. Реализация

Создание фиктивного курсора 1×1 — прозрачный пиксель:
pascal

function CreateAnonymousFile(ASize: PtrUInt): cint;
const
  O_CLOEXEC = $80000;
var
  Name: String;
  R: cint;
begin
  Name := GetEnvironmentVariable('XDG_RUNTIME_DIR');
  if Name = '' then Name := '/tmp';
  Name := Name + '/wlgui-cursor-XXXXXX';
  R := fpOpen(PChar(Name), O_CREAT or O_RDWR or O_CLOEXEC, &0600);
  if R < 0 then
  begin
    Name := '/dev/shm/wlgui-cursor-' + IntToStr(GetProcessID) +
            '-' + IntToStr(Random(1000000));
    R := fpOpen(PChar(Name), O_CREAT or O_RDWR or O_CLOEXEC, &0600);
    if R < 0 then Exit(-1);
  end;
  fpUnlink(PChar(Name));
  if fpFtruncate(R, ASize) < 0 then
  begin
    fpClose(R);
    Exit(-1);
  end;
  Result := R;
end;

constructor TWLEventManager.Create(ACompositor: TWlCompositor; AShm: TWlShm);
begin
  inherited Create;
  FCompositor := ACompositor;
  FShm := AShm;
  FSeat := nil;
  FPointer := nil;
  FKeyboard := nil;
  FFocusedReceiver := nil;
  FLastMouseX := 0;
  FLastMouseY := 0;
  FButtonState := 0;
  FillChar(FModifiers, SizeOf(FModifiers), 0);

  FCursorSurface := nil;
  FCursorBuffer := nil;
  FCursorPool := nil;
  FCursorPixels := nil;
  FCursorFd := -1;
  FCursorSize := 0;
  FCursorReady := False;
end;

destructor TWLEventManager.Destroy;
begin
  DestroyCursorSurface;

  FPointerListener := nil;
  FKeyboardListener := nil;
  FSeatListener := nil;
  if FPointer <> nil then FreeAndNil(FPointer);
  if FKeyboard <> nil then FreeAndNil(FKeyboard);
  inherited;
end;

procedure TWLEventManager.CreateCursorSurface(AW, AH, AHotX, AHotY: Integer);
var
  Size: Integer;
  Data: Pointer;
begin
  if FCursorReady then Exit;
  if (FCompositor = nil) or (FShm = nil) then Exit;
  if (AW <= 0) or (AH <= 0) then Exit;

  FCursorWidth := AW;
  FCursorHeight := AH;
  FCursorHotX := AHotX;
  FCursorHotY := AHotY;

  Size := AW * AH * 4;
  FCursorFd := CreateAnonymousFile(Size);
  if FCursorFd < 0 then
  begin
    WriteLn('[cursor] cannot create SHM file');
    Exit;
  end;

  Data := fpMmap(nil, Size, PROT_READ or PROT_WRITE, MAP_SHARED, FCursorFd, 0);
  if Data = MAP_FAILED then
  begin
    fpClose(FCursorFd);
    FCursorFd := -1;
    WriteLn('[cursor] cannot mmap');
    Exit;
  end;

  FCursorPixels := PByte(Data);
  FCursorSize := Size;
  FillChar(FCursorPixels^, Size, 0);   // прозрачный

  FCursorPool := FShm.CreatePool(FCursorFd, Size);
  if FCursorPool = nil then
  begin
    WriteLn('[cursor] cannot create pool');
    fpMunmap(FCursorPixels, Size);
    FCursorPixels := nil;
    fpClose(FCursorFd);
    FCursorFd := -1;
    Exit;
  end;

  FCursorBuffer := FCursorPool.CreateBuffer(0, AW, AH, AW * 4,
                                            WL_SHM_FORMAT_ARGB8888);
  if FCursorBuffer = nil then
  begin
    WriteLn('[cursor] cannot create buffer');
    FreeAndNil(FCursorPool);
    fpMunmap(FCursorPixels, Size);
    FCursorPixels := nil;
    fpClose(FCursorFd);
    FCursorFd := -1;
    Exit;
  end;

  fpClose(FCursorFd);
  FCursorFd := -1;

  FCursorSurface := FCompositor.CreateSurface;
  if FCursorSurface = nil then
  begin
    WriteLn('[cursor] cannot create surface');
    Exit;
  end;

  FCursorSurface.Attach(FCursorBuffer, 0, 0);
  FCursorSurface.Damage(0, 0, AW, AH);
  FCursorSurface.Commit;

  FCursorReady := True;
  WriteLn('[cursor] surface created: ', AW, 'x', AH,
          ' hot=(' , AHotX, ',', AHotY, ')');
end;

procedure TWLEventManager.DestroyCursorSurface;
begin
  if FCursorSurface <> nil then FreeAndNil(FCursorSurface);
  if FCursorBuffer <> nil then FreeAndNil(FCursorBuffer);
  if FCursorPool <> nil then FreeAndNil(FCursorPool);
  if (FCursorPixels <> nil) and (FCursorSize > 0) then
  begin
    fpMunmap(FCursorPixels, FCursorSize);
    FCursorPixels := nil;
    FCursorSize := 0;
  end;
  if FCursorFd >= 0 then
  begin
    fpClose(FCursorFd);
    FCursorFd := -1;
  end;
  FCursorReady := False;
end;

function TWLEventManager.CursorSurface: TWlSurface;
begin
  Result := FCursorSurface;
end;

function TWLEventManager.CursorHotX: Integer;
begin
  Result := FCursorHotX;
end;

function TWLEventManager.CursorHotY: Integer;
begin
  Result := FCursorHotY;
end;

function TWLEventManager.IsButtonDown(ABtn: Integer): Boolean;
begin
  if (ABtn < 1) or (ABtn > 32) then
    Result := False
  else
    Result := (FButtonState and (1 shl (ABtn - 1))) <> 0;
end;

3. В wl_seat_capabilities — создаём курсор при получении pointer
pascal

procedure TWLSeatListener.wl_seat_capabilities(AWlSeat: TWlSeat;
  ACapabilities: DWord);
begin
  WriteLn('[events] seat capabilities=', ACapabilities);

  if (ACapabilities and WL_SEAT_CAPABILITY_POINTER) <> 0 then
  begin
    if FOwner.FPointer = nil then
    begin
      FOwner.FPointer := AWlSeat.GetPointer;
      if FOwner.FPointer <> nil then
      begin
        // Создаём фиктивный курсор 1×1 (прозрачный)
        // Если хочешь нормальный курсор — раскомментируй 16×16 и
        // добавь рисование стрелки в FCursorPixels
        FOwner.CreateCursorSurface(1, 1, 0, 0);

        FOwner.FPointerListener := TWLPointerListener.Create(FOwner);
        FOwner.FPointer.AddListener(FOwner.FPointerListener);
        WriteLn('[events] pointer attached');
      end;
    end;
  end
  else if FOwner.FPointer <> nil then
  begin
    FreeAndNil(FOwner.FPointer);
    FOwner.FPointerListener := nil;
  end;
  // ... keyboard как было
end;

4. В wl_pointer_enter — отправляем set_cursor
pascal

procedure TWLPointerListener.wl_pointer_enter(AWlPointer: TWlPointer;
  ASerial: DWord; ASurface: TWlSurface; ASurfaceX: Twl_fixed;
  ASurfaceY: Twl_fixed);
var
  Recv: IWLEventReceiver;
begin
  FOwner.FLastMouseX := Round(ASurfaceX.AsDouble);
  FOwner.FLastMouseY := Round(ASurfaceY.AsDouble);

  // Отправляем композитору "я использую этот курсор"
  if FOwner.FCursorReady then
    AWlPointer.SetCursor(ASerial, FOwner.FCursorSurface,
                         FOwner.FCursorHotX, FOwner.FCursorHotY)
  else
    AWlPointer.SetCursor(ASerial, nil, 0, 0);   // скрыть

  Recv := FindReceiverBySurface(ASurface);
  if Recv <> nil then
  begin
    FOwner.SetFocused(Recv);
    Recv.WLRecvMouseEnter;
  end;
end;

5. В wl_pointer_leave — не сбрасываем сразу, если событие «шумное»

Оставим логику как была, но добавим диагностику:
pascal

procedure TWLPointerListener.wl_pointer_leave(AWlPointer: TWlPointer;
  ASerial: DWord; ASurface: TWlSurface);
var
  Recv: IWLEventReceiver;
begin
  WriteLn('[pointer] leave serial=', ASerial);   // ← добавим

  // Скрываем курсор
  AWlPointer.SetCursor(ASerial, nil, 0, 0);

  Recv := FindReceiverBySurface(ASurface);
  if Recv <> nil then
  begin
    Recv.WLRecvMouseLeave;
    if FOwner.FFocusedReceiver = Recv then
      FOwner.SetFocused(nil);
  end;
end;

6. В wl_pointer_button — обновляем FButtonState
pascal

procedure TWLPointerListener.wl_pointer_button(AWlPointer: TWlPointer;
  ASerial: DWord; ATime: DWord; AButton: DWord; AState: DWord);
var
  E: TWLMouseEvent;
  Btn: Integer;
  Mask: LongWord;
begin
  if FOwner.FFocusedReceiver = nil then Exit;

  case AButton of
    272: Btn := 1;
    273: Btn := 3;
    274: Btn := 2;
  else
    Btn := Integer(AButton);
  end;

  // Обновляем битовую маску кнопок
  if (Btn >= 1) and (Btn <= 32) then
  begin
    Mask := 1 shl (Btn - 1);
    if AState = 1 then
      FOwner.FButtonState := FOwner.FButtonState or Mask
    else
      FOwner.FButtonState := FOwner.FButtonState and not Mask;
  end;

  E.X := FOwner.FLastMouseX;
  E.Y := FOwner.FLastMouseY;
  E.Button := Btn;
  E.Pressed := (AState = 1);
  E.Modifiers := FOwner.FModifiers;
  E.Time := ATime;

  if E.Pressed then
    FOwner.FFocusedReceiver.WLRecvMouseDown(E)
  else
    FOwner.FFocusedReceiver.WLRecvMouseUp(E);
end;

7. В wlgui_app.pas — передать compositor и shm при создании EventManager
pascal

else if AInterface = 'wl_seat' then
begin
  V := AVersion; if V > 5 then V := 5;
  Proxy := AWlRegistry.Bind(AName, @wl_seat_interface, V);
  FApp.Context.Seat := TWlSeat.Create(Proxy);

  if FApp.Context.Events = nil then
  begin
    // Передаём compositor и shm, они нужны для создания курсора
    FApp.Context.Events := TWLEventManager.Create(
      FApp.Context.Compositor, FApp.Context.Shm);
    TWLEventManager(FApp.Context.Events).AttachSeat(FApp.Context.Seat);
    WriteLn('[wlgui] EventManager created');
  end;
end;

Важно: wl_seat может прийти до wl_compositor или wl_shm в порядке биндинга. Обычно compositor идёт первым, но не полагайся на это. Если Compositor или Shm ещё nil — создание EventManager откладываем до конца биндинга:
pascal

else if AInterface = 'wl_seat' then
begin
  ... bind seat ...
end;

// В конце `wl_registry_global` — не нужно.
// Лучше создать EventManager после Roundtrip в `TWLApplication.Initialize`:

Правильнее — создавать EventManager в TWLApplication.Initialize после Roundtrip, когда все глобалы уже биндены:
pascal

procedure TWLApplication.Initialize;
begin
  // ... как было ...
  FDisplay.Roundtrip;

  if (FContext.Compositor = nil) or (FContext.Shm = nil) or (FContext.WmBase = nil) then
  begin
    WriteLn('[wlgui] Required globals missing');
    Exit;
  end;

  // Создаём EventManager
  if (FContext.Events = nil) and (FContext.Seat <> nil) then
  begin
    FContext.Events := TWLEventManager.Create(
      FContext.Compositor, FContext.Shm);
    TWLEventManager(FContext.Events).AttachSeat(FContext.Seat);
    WriteLn('[wlgui] EventManager created');
  end;

  // XKB
  if XkbManager = nil then
    XkbManager := TXkbManager.Create;

  WriteLn('[wlgui] Wayland initialized');
  Result := True;
end;

И убрать создание EventManager из wl_registry_global.
Что проверять

Запусти demo и следи за:

    [cursor] surface created: 1x1 hot=(0,0) — курсор создан.

    Есть ли [pointer] leave serial=... в логе? Если да — как часто.

    Через некоторое время (несколько секунд работы мыши) — перестала ли мышь «сбиваться»?

Ожидаемые результаты

    Если помогло: motion продолжает приходить, координаты обновляются, окно не переходит в режим «drag window». Победа!

    Если не помогло: значит проблема в чём-то другом. Возможно, композитор вообще не любит нашу surface. Тогда копаем дальше: wl_surface.set_input_region, wl_surface.set_opaque_region, и т.д.

Про 1×1 курсор

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

    Сделаем курсор-стрелку 16×16 с реальной картинкой.

    Или используем libwayland-cursor для загрузки системной темы.

Но сначала — подтвердим причину. Собери, запусти, покажи лог. Если через 5–10 секунд мышь продолжает работать — корень найден.
Starting program: /tmp/demo_input 
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/x86_64-linux-gnu/libthread_db.so.1".
wlgui input 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
[wlgui] window created: "wlgui input demo" 640x480
[events] seat name: default
[events] seat capabilities=3
[cursor] surface created: 1x1 hot=(0,0)
[events] pointer attached
[events] keyboard attached
[wlgui] 2 buffers created: 640x480 stride=2560
[events] keyboard keymap format=1 size=38030
[xkb] keymap loaded
[demo] focus in
[demo] mouse enter
[pointer] leave serial=282

Program received signal SIGSEGV, Segmentation fault.
0x00000000004a61cf in SETCURSOR (this=0x7ffff7f9c7c0, ASERIAL=282, ASURFACE=0x0, AHOTSPOTX=0, AHOTSPOTY=0) at wayland_protocol.pas:1333
1333      wl_proxy_marshal(FProxy, _SET_CURSOR, ASerial, ASurface.Proxy, AHotspotX, AHotspotY);
(gdb) bt
#0  0x00000000004a61cf in SETCURSOR (this=0x7ffff7f9c7c0, ASERIAL=282, ASURFACE=0x0, AHOTSPOTX=0, AHOTSPOTY=0) at wayland_protocol.pas:1333
#1  0x0000000000487b9a in WL_POINTER_LEAVE (this=0x7ffff7f9c880, AWLPOINTER=0x7ffff7f9c7c0, ASERIAL=282, ASURFACE=0x7ffff7f9c600)
    at wlgui_events.pas:401
#2  0x00000000004a7a7d in WL_POINTER_LEAVE_INTF (ADATA=0x7ffff7f9c7d8, AWL_POINTER=0x62a510, ASERIAL=282, ASURFACE=0x62a290)
    at wayland_protocol.pas:1741
#3  0x00007ffff7c7b056 in ?? () from /usr/lib/x86_64-linux-gnu/libffi.so.8
#4  0x00007ffff7c78f1c in ?? () from /usr/lib/x86_64-linux-gnu/libffi.so.8
#5  0x00007ffff7c79fe5 in ffi_call () from /usr/lib/x86_64-linux-gnu/libffi.so.8
#6  0x00007ffff7f5a8d8 in ?? () from /usr/lib/x86_64-linux-gnu/libwayland-client.so.0
#7  0x00007ffff7f560dc in ?? () from /usr/lib/x86_64-linux-gnu/libwayland-client.so.0
#8  0x00007ffff7f5745b in wl_display_dispatch_queue_pending () from /usr/lib/x86_64-linux-gnu/libwayland-client.so.0
#9  0x00007ffff7f5789d in wl_display_dispatch_queue_timeout () from /usr/lib/x86_64-linux-gnu/libwayland-client.so.0
#10 0x00007ffff7f5797f in wl_display_dispatch_queue () from /usr/lib/x86_64-linux-gnu/libwayland-client.so.0
#11 0x0000000000480479 in DISPATCHWITHTIMEOUT (this=0x7ffff7f8d1c0, ATIMEOUTMS=10) at wlgui_context.pas:118
#12 0x000000000048102e in PROCESSEVENTS (this=0x7ffff7f9c280) at wlgui_app.pas:242
#13 0x00000000004025cd in main () at demo_input.pas:210
Крэш в set_cursor(nil, ...) — у нас в wayland_protocol.pas нет обработки nil

Смотрим:
pascal

procedure TWlPointer.SetCursor(ASerial: DWord; ASurface: TWlSurface; AHotspotX: LongInt; AHotspotY: LongInt);
begin
  wl_proxy_marshal(FProxy, _SET_CURSOR, ASerial, ASurface.Proxy, AHotspotX, AHotspotY);
end;

ASurface.Proxy — если ASurface = nil, то nil.Proxy = разыменование nil → SIGSEGV.

В Wayland-протоколе set_cursor разрешает nil surface — это способ скрыть курсор:

    set_cursor: If surface is NULL, the pointer cursor is hidden.

То есть nil — валидное значение, но нужно передать именно nil-указатель в marshal, а не пытаться разыменовать nil.Proxy.
Патч в wayland_protocol.pas

Найди метод TWlPointer.SetCursor (около строки 1330) и замени на:
pascal

procedure TWlPointer.SetCursor(ASerial: DWord; ASurface: TWlSurface;
  AHotspotX: LongInt; AHotspotY: LongInt);
var
  SurfaceProxy: Pointer;
begin
  if ASurface <> nil then
    SurfaceProxy := ASurface.Proxy
  else
    SurfaceProxy := nil;
  wl_proxy_marshal(FProxy, _SET_CURSOR, ASerial, SurfaceProxy,
                   AHotspotX, AHotspotY);
end;

Почему так

wl_proxy_marshal — varargs-функция. Она берёт все аргументы как указатели/значения и упаковывает их в wl_argument. Для типа ?o (nullable object) можно передать nil-указатель.

Проблема только в том, что у нас нельзя написать nil.Proxy — Паскаль сначала попытается разыменовать ASurface (который nil), получит access violation. Поэтому:

    Создаём локальную переменную SurfaceProxy.

    Если ASurface = nil → SurfaceProxy := nil.

    Иначе → SurfaceProxy := ASurface.Proxy.

    Передаём SurfaceProxy в marshal.

Аналогичная проблема может быть где-то ещё

Проверь все методы в wayland_protocol.pas и xdg_shell_protocol.pas, где параметр может быть nil:
Метод	Поле	Может быть nil?
TWlSurface.Attach	ABuffer: TWlBuffer	Да! (отсоединить буфер)
TWlSurface.SetOpaqueRegion	ARegion: TWlRegion	Да (сбросить)
TWlSurface.SetInputRegion	ARegion: TWlRegion	Да (сбросить)
TWlDataOffer.Accept	mime_type	Да (null)
TWlDataDevice.StartDrag	ASource, AIcon	Да
TWlShellSurface.SetTransient	AParent	Да
TWlShellSurface.SetFullscreen	AOutput	Да
TWlShellSurface.SetMaximized	AOutput	Да
TWlToplevel.SetParent	AParent: TXdgToplevel	Да
TWlToplevel.SetFullscreen	AOutput: TWlOutput	Да
TWlSeat.*	—	Нет

Именно TWlSurface.Attach(nil, ...) — мы его используем в TWLWindow.Hide:
pascal

FSurface.Attach(nil, 0, 0);

Судя по всему, ты не вызывал Hide ни разу, поэтому не падало. Но при попытке скрыть окно — тоже будет SIGSEGV. Патчим и его.
Патч для TWlSurface.Attach
pascal

procedure TWlSurface.Attach(ABuffer: TWlBuffer; AX: LongInt; AY: LongInt);
var
  BufferProxy: Pointer;
begin
  if ABuffer <> nil then
    BufferProxy := ABuffer.Proxy
  else
    BufferProxy := nil;
  wl_proxy_marshal(FProxy, _ATTACH, BufferProxy, AX, AY);
end;

Патч для TWlSurface.SetOpaqueRegion и SetInputRegion

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

procedure TWlSurface.SetOpaqueRegion(ARegion: TWlRegion);
var
  RegionProxy: Pointer;
begin
  if ARegion <> nil then
    RegionProxy := ARegion.Proxy
  else
    RegionProxy := nil;
  wl_proxy_marshal(FProxy, _SET_OPAQUE_REGION, RegionProxy);
end;

procedure TWlSurface.SetInputRegion(ARegion: TWlRegion);
var
  RegionProxy: Pointer;
begin
  if ARegion <> nil then
    RegionProxy := ARegion.Proxy
  else
    RegionProxy := nil;
  wl_proxy_marshal(FProxy, _SET_INPUT_REGION, RegionProxy);
end;

Патч xdg_shell_protocol.pas для TXdgToplevel.SetParent и SetFullscreen
pascal

procedure TXdgToplevel.SetParent(AParent: TXdgToplevel);
var
  ParentProxy: Pointer;
begin
  if AParent <> nil then
    ParentProxy := AParent.Proxy
  else
    ParentProxy := nil;
  wl_proxy_marshal(FProxy, _SET_PARENT, ParentProxy);
end;

procedure TXdgToplevel.SetFullscreen(AOutput: TWlOutput);
var
  OutputProxy: Pointer;
begin
  if AOutput <> nil then
    OutputProxy := AOutput.Proxy
  else
    OutputProxy := nil;
  wl_proxy_marshal(FProxy, _SET_FULLSCREEN, OutputProxy);
end;

Что делать сейчас

    Обязательно — TWlPointer.SetCursor (наш крэш).

    Обязательно — TWlSurface.Attach (используется в Hide).

    На всякий случай — SetOpaqueRegion, SetInputRegion, SetParent, SetFullscreen.

После этих патчей set_cursor(nil) перестанет падать, а leave → set_cursor(nil) → SetCursor вернёт управление нормально.
После патча

Запусти demo — теперь [pointer] leave не должен крашить. И мы сможем увидеть, помогает ли set_cursor (даже фиктивный) для восстановления фокуса мыши.

Ожидаемый лог:
text

[cursor] surface created: 1x1 hot=(0,0)
...
[demo] mouse enter
[pointer] leave serial=282
[demo] mouse leave
...

Без падения. Если после этого мышь продолжает работать — победа. Если нет — идём дальше (возможно, композитор всё равно недоволен).

Патчим, запускаем, присылаем лог.
Спасибо! Падать перестал. Исчезновение через некоторое время реакции на мышь с переключением в перемещение за содержимое окна осталось. Запуски: ./demo_input
wlgui input 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
[wlgui] window created: "wlgui input demo" 640x480
[events] seat name: default
[events] seat capabilities=3
[cursor] surface created: 1x1 hot=(0,0)
[events] pointer attached
[events] keyboard attached
[wlgui] 2 buffers created: 640x480 stride=2560
[events] keyboard keymap format=1 size=38030
[xkb] keymap loaded
[demo] focus in
[demo] mouse enter
[demo] key: cp=U+0064 sym=$0064 scancode=32 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0077 sym=$0077 scancode=17 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0065 sym=$0065 scancode=18 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0064 sym=$0064 scancode=32 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0000 sym=$FFE3 scancode=29 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0000 sym=$FFE1 scancode=42 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0044 sym=$0044 scancode=32 shift=FALSE ctrl=FALSE alt=FALSE
[demo] mouse down at 271,259 button=1
[demo] mouse up at 271,259
[demo] mouse down at 234,311 button=3
[demo] mouse up at 234,311
[demo] wheel dx=0 dy=-10
[demo] wheel dx=0 dy=-10
[demo] wheel dx=0 dy=10
[demo] wheel dx=0 dy=10
[demo] mouse down at 219,315 button=3
[demo] mouse up at 219,315
[demo] mouse down at 219,315 button=1
[demo] mouse up at 219,315
[pointer] leave serial=173
[demo] mouse leave
[demo] mouse enter
[pointer] leave serial=180
[demo] mouse leave
[demo] mouse enter
[pointer] leave serial=188
[demo] mouse leave
[demo] mouse enter
[pointer] leave serial=195
[demo] mouse leave
[demo] focus out
^C

./demo_input
wlgui input 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
[wlgui] window created: "wlgui input demo" 640x480
[events] seat name: default
[events] seat capabilities=3
[cursor] surface created: 1x1 hot=(0,0)
[events] pointer attached
[events] keyboard attached
[wlgui] 2 buffers created: 640x480 stride=2560
[events] keyboard keymap format=1 size=38030
[xkb] keymap loaded
[demo] focus in
[demo] mouse enter
[demo] mouse down at 306,258 button=1
[demo] mouse up at 306,258
[demo] mouse down at 306,258 button=3
[demo] mouse up at 306,258
[demo] mouse down at 306,258 button=2
[demo] mouse up at 306,258
[demo] wheel dx=0 dy=-10
[demo] wheel dx=0 dy=-10
[demo] wheel dx=0 dy=10
[demo] wheel dx=0 dy=10
[demo] key: cp=U+0000 sym=$FFE3 scancode=29 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0000 sym=$FFE3 scancode=29 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0004 sym=$0064 scancode=32 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0000 sym=$FFE1 scancode=42 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0044 sym=$0044 scancode=32 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0045 sym=$0045 scancode=18 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0045 sym=$0045 scancode=18 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0066 sym=$0066 scancode=33 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0072 sym=$0072 scancode=19 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0066 sym=$0066 scancode=33 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0065 sym=$0065 scancode=18 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0066 sym=$0066 scancode=33 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0000 sym=$FFE1 scancode=42 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0046 sym=$0046 scancode=33 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0057 sym=$0057 scancode=17 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0045 sym=$0045 scancode=18 shift=FALSE ctrl=FALSE alt=FALSE
[pointer] leave serial=547
[demo] mouse leave
[demo] mouse enter
[pointer] leave serial=564
[demo] mouse leave
[demo] mouse enter
[pointer] leave serial=571
[demo] mouse leave
[demo] mouse enter
[pointer] leave serial=579
[demo] mouse leave
[demo] mouse enter
[pointer] leave serial=587
[demo] mouse leave
[demo] mouse enter
[pointer] leave serial=593
[demo] mouse leave
[demo] mouse enter
[pointer] leave serial=599
[demo] mouse leave
[demo] focus out
^C
Мигание enter/leave — вот где корень

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

[pointer] leave serial=173
[demo] mouse leave
[demo] mouse enter       ← нет парного [pointer] enter!
[pointer] leave serial=180
[demo] mouse leave
[demo] mouse enter       ← снова нет [pointer] enter!
[pointer] leave serial=188
...

mouse enter вызывается, но [pointer] enter в логе нет! Это потому, что мы не добавили WriteLn в wl_pointer_enter. Но по логике — mouse enter вызывается только из wl_pointer_enter. Значит enter приходит, но мы его не логируем.

Что это значит: enter и leave приходят парами, с интервалом в единицы миллисекунд. Композитор думает, что мышь уходит с поверхности и снова заходит. Это классический симптом одной из трёх проблем:

    Мы отправляем set_cursor с неправильным размером/буфером, и композитор считает, что попали в «дырку» (не на surface).

    Наша surface «дырявая» — set_input_region не задан, композитор использует геометрию буфера, но что-то с ней не так.

    Композитор перетаскивает окно — при этом он сам делает leave/enter для обновления фокуса.

Ключевая гипотеза: set_cursor с 1×1 буфером ломает hit-test

Когда мы устанавливаем курсор 1×1 прозрачный, композитор его принимает. Но при hit-test (определение, над какой поверхностью находится мышь) композитор использует не курсор, а геометрию поверхности под курсором.

Стоп. leave/enter — это про surface, а не про курсор. Значит set_cursor тут ни при чём.
Реальная гипотеза: проблема в размере буфера окна

Смотрим: [wlgui] 2 buffers created: 640x480 — то есть буферы 640×480. А окно какое? Судя по всему, тоже 640×480.

Но! Возможно, композитор декорирует окно (добавляет заголовок сверху), и фактический размер клиентской области — не 640×480, а что-то другое.

Проверка: в wl_pointer.enter приходит ASurfaceX, ASurfaceY — координаты относительно поверхности. Если они выходят за пределы [0, 640) × [0, 480), значит surface больше, чем мы думаем.
Что делать: диагностика

Добавь в wl_pointer_enter и wl_pointer_leave полный лог:
pascal

procedure TWLPointerListener.wl_pointer_enter(AWlPointer: TWlPointer;
  ASerial: DWord; ASurface: TWlSurface; ASurfaceX: Twl_fixed;
  ASurfaceY: Twl_fixed);
begin
  WriteLn('[pointer] enter serial=', ASerial,
          ' surface=', PtrUInt(ASurface),
          ' x=', Round(ASurfaceX.AsDouble),
          ' y=', Round(ASurfaceY.AsDouble),
          ' surfacePtr=', PtrUInt(ASurface));
  // ...
end;

procedure TWLPointerListener.wl_pointer_leave(AWlPointer: TWlPointer;
  ASerial: DWord; ASurface: TWlSurface);
begin
  WriteLn('[pointer] leave serial=', ASerial,
          ' surface=', PtrUInt(ASurface),
          ' ourSurface=', PtrUInt(FOwner.FWindowSurface));
  // ...
end;

Нужно сравнить ASurface в enter и leave. Если они разные — значит мышь уходит с одной поверхности и заходит на другую. Это может быть:

    Наша wl_surface + курсорная surface (она тоже имеет wl_surface, но не должна получать pointer events... хотя...).

    Подповерхность композитора (например, декорация).

    Другая наша surface.

Проверка гипотезы: курсор — тоже surface

Мы создали курсорную surface (FCursorSurface) в TWLEventManager. У неё есть свой wl_surface. Композитор обрабатывает её как обычную поверхность.

Может ли мышь уходить на курсорную surface? Обычно нет — композитор рисует курсор над ней, а не «внутри». Но в wlroots и некоторых версиях sway была проблема: курсорная surface могла попадать в hit-test, и enter/leave мигали.

Проверка: закомментируй set_cursor и посмотри, мигает ли всё равно:
pascal

procedure TWLPointerListener.wl_pointer_enter(...);
begin
  // НЕ вызываем set_cursor
  // ...
end;

Если без set_cursor мигания нет — значит наш курсор-1×1 сам становится «целью». Тогда фикс — использовать wl_surface.set_input_region(empty_region) на курсорной surface, чтобы композитор её игнорировал для hit-test.
Правильный фикс для курсора: пустой input region

Это стандартная практика при создании cursor surface:
pascal

procedure TWLEventManager.CreateCursorSurface(AW, AH, AHotX, AHotY: Integer);
...
  // После создания FCursorSurface:
  // Создаём пустой регион и устанавливаем его как input_region
  // Тогда композитор не будет посылать pointer-события на эту surface
  if FCompositor <> nil then
  begin
    var EmptyRegion := FCompositor.CreateRegion;
    if EmptyRegion <> nil then
    begin
      FCursorSurface.SetInputRegion(EmptyRegion);
      EmptyRegion.Free;
    end;
  end;
...

CreateRegion — создаёт пустой wl_region. SetInputRegion(region) — устанавливает, что эта surface не принимает события ввода (пустая область).

Обрати внимание: это тот самый SetInputRegion, который я предлагал патчить для обработки nil. Но с патчем на nil теперь можно передавать nil напрямую вместо пустого региона:
pascal

// Согласно Wayland spec, nil input_region означает "вся поверхность".
// Пустой region означает "поверхность не принимает ввод".
// Нам нужен ПУСТОЙ region — создаём его через compositor.

Важно: SetInputRegion(nil) — это сбросить регион, то есть вся поверхность принимает ввод. Это не то, что нам нужно. Нам нужен пустой регион (не nil).

Значит, для курсора:
pascal

var EmptyRegion := FCompositor.CreateRegion;   // пустой
FCursorSurface.SetInputRegion(EmptyRegion);    // принимает только пустую область
EmptyRegion.Free;                               // wl_region после set_input_region можно освободить

Что ещё проверить — wl_surface.set_opaque_region

Аналогично можно установить set_opaque_region — но это про композицию, не про hit-test. Не критично для нашей задачи.
Что делать
Шаг 1: диагностика

Добавь полный лог в wl_pointer_enter и wl_pointer_leave с указателями surface. Запусти. Смотри:

    Разные ли ASurface в enter/leave?

    Совпадает ли ASurface с нашей surface окна?

Шаг 2: закомментируй set_cursor

Временно убери set_cursor из wl_pointer_enter. Запусти. Мигание осталось?
Шаг 3 (если мигание пропало без set_cursor): фикс с input region

Установи на cursor surface пустой input region:
pascal

procedure TWLEventManager.CreateCursorSurface(AW, AH, AHotX, AHotY: Integer);
...
  // После создания surface, до commit:
  var EmptyRegion := FCompositor.CreateRegion;  // создаёт пустой
  if EmptyRegion <> nil then
  begin
    FCursorSurface.SetInputRegion(EmptyRegion);
    EmptyRegion.Free;  // после установки — можно освободить
  end;

  FCursorSurface.Attach(FCursorBuffer, 0, 0);
  FCursorSurface.Damage(0, 0, AW, AH);
  FCursorSurface.Commit;
...

Шаг 4 (если мигание не пропало и без set_cursor): копаем глубже

Возможно, wl_surface нашего окна не имеет input region, и композитор некорректно hit-test'ит. Попробуй явно установить input region на окно:
pascal

// В TWLWindow после создания surface:
var R := FContext.Compositor.CreateRegion;
R.Add(0, 0, FWidth, FHeight);   // вся поверхность
FSurface.SetInputRegion(R);
R.Free;

Это гарантирует, что вся поверхность принимает ввод.
Давай по шагам

Сделай шаг 1 (диагностика) и шаг 2 (убрать set_cursor). Пришли лог. По нему точно поймём, какой из сценариев у нас.

Что искать в логе:

    Разные ли surface в enter/leave. Если разные — это наш курсор или чужая surface перехватывает события.

    Прекратилось ли мигание без set_cursor. Если да — однозначно курсор виноват.

Без этих данных мы будем гадать. Диагностика займёт 5 минут, но точно укажет причину.
Заодно: нормальный курсор 16×16

Пока диагностируем, я могу подготовить реализацию нормального курсора-стрелки 16×16. Стрелка выглядит так:
text

X . . . . . . . . . . . . . . .
X X . . . . . . . . . . . . . .
X o X . . . . . . . . . . . . .
X o o X . . . . . . . . . . . .
X o o o X . . . . . . . . . . .
X o o o o X . . . . . . . . . .
X o o o o o X . . . . . . . . .
X o o o o o o X . . . . . . . .
X o o o o o o o X . . . . . . .
X o o o o X X X X X . . . . . .
X o o X o o X . . . . . . . . .
X o X . X o o X . . . . . . . .
X X . . X o o X . . . . . . . .
X . . . . X o o X . . . . . . .
. . . . . X o o X . . . . . . .
. . . . . . X X X . . . . . . .

Где X — чёрный, o — белый. hotspot = (0, 0) — верхний левый угол.

Если готов — скажи, и я сразу включу это в патч.
План

    Диагностика: лог surface в enter/leave, убрать set_cursor → запуск → лог.

    По результату — либо фикс с input region, либо другой фикс.

    Потом — нормальный курсор 16×16.

Начни с диагностики. Пришли лог.
wlgui_events.pas(407,42) Error: identifier idents no member "FWindowSurface" Я закомментировал часть с FWindowSurface. И: ./demo_input
wlgui input 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
[wlgui] window created: "wlgui input demo" 640x480
[events] seat name: default
[events] seat capabilities=3
[cursor] surface created: 1x1 hot=(0,0)
[events] pointer attached
[events] keyboard attached
[wlgui] 2 buffers created: 640x480 stride=2560
[events] keyboard keymap format=1 size=38030
[xkb] keymap loaded
[demo] focus in
[pointer] enter serial=3865 surface=139711252862464 x=127 y=393 surfacePtr=139711252862464
[demo] mouse enter
[demo] mouse down at 166,218 button=1
[demo] mouse up at 166,218
[demo] mouse down at 331,225 button=3
[demo] mouse up at 331,225
[demo] mouse down at 240,231 button=1
[demo] mouse up at 181,214
[demo] key: cp=U+0000 sym=$FFE3 scancode=29 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0432 sym=$06D7 scancode=32 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0432 sym=$06D7 scancode=32 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0446 sym=$06C3 scancode=17 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0443 sym=$06D5 scancode=18 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0432 sym=$06D7 scancode=32 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0446 sym=$06C3 scancode=17 shift=FALSE ctrl=FALSE alt=FALSE
[demo] mouse down at 171,232 button=1
[demo] mouse up at 171,232
[demo] mouse down at 171,232 button=3
[demo] mouse up at 171,232
[demo] mouse down at 171,232 button=2
[demo] mouse up at 171,232
[demo] wheel dx=0 dy=-10
[demo] wheel dx=0 dy=-10
[demo] wheel dx=0 dy=10
[demo] mouse down at 171,232 button=3
[demo] mouse up at 171,232
[demo] mouse down at 171,232 button=1
[demo] mouse up at 171,232
[pointer] leave serial=3922 surface=139711252862464
[demo] mouse leave
[pointer] enter serial=3938 surface=139711252862464 x=230 y=248 surfacePtr=139711252862464
[demo] mouse enter
[pointer] leave serial=3939 surface=139711252862464
[demo] mouse leave
[pointer] enter serial=3946 surface=139711252862464 x=629 y=394 surfacePtr=139711252862464
[demo] mouse enter
[pointer] leave serial=3947 surface=139711252862464
[demo] mouse leave
[pointer] enter serial=3953 surface=139711252862464 x=206 y=281 surfacePtr=139711252862464
[demo] mouse enter
[pointer] leave serial=3954 surface=139711252862464
[demo] mouse leave
[pointer] enter serial=3961 surface=139711252862464 x=222 y=283 surfacePtr=139711252862464
[demo] mouse enter
[pointer] leave serial=3962 surface=139711252862464
[demo] mouse leave
[demo] focus out
^C
Ключевой факт: surface одна и та же, но композитор шлёт leave/enter парами

Смотрим лог:
text

[pointer] leave serial=3922 surface=139711252862464
[pointer] enter serial=3938 surface=139711252862464 x=230 y=248
[pointer] leave serial=3939 surface=139711252862464
[pointer] enter serial=3946 surface=139711252862464 x=629 y=394
[pointer] leave serial=3947 surface=139711252862464
[pointer] enter serial=3953 surface=139711252862464 x=206 y=281

Одна и та же surface. Координаты разные (230,248 / 629,394 / 206,281). Serial растёт. То есть композитор сам генерирует эти события, и они не парные — enter и leave идут не подряд.

Что это напоминает? Композитор «прогоняет» фокус по поверхности каждый раз, когда ты двигаешь мышь, но не присылает motion между enter/leave. Это классический симптом:

    Композитор перетаскивает окно — при этом он временно снимает фокус, потом возвращает.

    Курсорная surface перехватывает фокус — тогда enter/leave мигают.

    set_cursor с неправильными параметрами — композитор считает, что мы устанавливаем «дырявый» курсор и перепроверяет фокус.

Гипотеза № 1: композитор перетаскивает само окно

Ключевое наблюдение: [demo] mouse down at 240,231 button=1, потом [demo] mouse up at 181,214 — то есть координаты изменились между down и up! Это значит, что окно сдвинулось во время клика.

Дальше — leave/enter парами. Это точно композитор перетаскивает окно: пока ты держишь левую кнопку, композитор двигает окно, временно снимает фокус с поверхности, потом возвращает.

Почему это происходит без нашего желания?

Wayland: когда ты держишь левую кнопку и двигаешь мышь, композитор может начать перетаскивание окна, если клиент явно не запросил перехват («client-side decorations», «interactive move»).

У нас нет декораций, и мы не запрашиваем перехват. Некоторые композиторы (sway, kwin, mutter) в этом случае начинают перетаскивание окна автоматически по клику в «неинтерактивной области». Это встроенное поведение — «drag window by empty space».

Что это значит: когда мы пытаемся кликать и двигать внутри окна, композитор думает, что мы хотим двигать окно, а не работать с содержимым. И наш wl_pointer.motion не приходит — композитор забирает управление.

Фикс: явно сообщить композитору, что наша поверхность принимает ввод и не является перетаскиваемой областью. В XDG-протоколе для этого есть:

    xdg_toplevel.set_window_geometry — установить границы, в которых курсор не считается «за заголовок».

    xdg_surface.set_window_geometry — то же самое.

Правильный вызов:
pascal

FXdgSurface.SetWindowGeometry(0, 0, FWidth, FHeight);

Это говорит композитору: «вот прямоугольник, который я считаю своим окном». Композитор не будет автоматически перетаскивать окно по клику внутри этого прямоугольника.
Гипотеза № 2: set_cursor с 1×1 курсором

Мы уже обсуждали. Проверим прямо сейчас: закомментируй вызов set_cursor в wl_pointer_enter и wl_pointer_leave. Запусти. Если мигание пропало — курсор виноват.

Но судя по тому, что surface одна и та же — это не наш курсор (курсор бы имел другой указатель surface).
Гипотеза № 3: композитор сам делает «leave/enter» при перетаскивании

Это подтверждается первым тезисом. Композитор при перетаскивании окна делает:

    leave (снимает фокус).

    Перетаскивает окно.

    enter (возвращает фокус).

Между ними — не приходит motion, потому что композитор «держит» мышь для перетаскивания.
Что делать
Фикс № 1: set_window_geometry

В wlgui_window.pas, в TWLWindow.InternalHandleConfigure (после AckConfigure):
pascal

procedure TWLWindow.InternalHandleConfigure(ASerial: DWord);
begin
  FXdgSurface.AckConfigure(ASerial);

  // Сообщаем композитору границы окна, чтобы он не перетаскивал
  // его по клику внутри
  if (FWidth > 0) and (FHeight > 0) then
    FXdgSurface.SetWindowGeometry(0, 0, FWidth, FHeight);

  EnsureBuffers;
  FConfigured := True;
  if Assigned(OnConfigured) then
    OnConfigured(Self);
end;

Также можно вызвать в EnsureBuffers или при каждом Commit. Но set_window_geometry нужно вызывать один раз после configure, и повторно при изменении размера.
Фикс № 2: если гипотеза № 2 верна, убрать set_cursor

Пока не трогаем — сначала проверим гипотезу № 1.
Фикс № 3: явный запрос «я хочу работать с содержимым»

Если композитор всё равно перетаскивает окно, можно попробовать xdg_toplevel.set_min_size и set_max_size одинаковыми — это уже сделано. Или использовать xdg_toplevel.set_parent для диалогов (нам не подходит).

Ещё один способ — xdg_toplevel.show_window_menu (не поможет). Или xdg_toplevel.move — это явный запрос на перетаскивание, не то.
Что попробовать прямо сейчас

Шаг 1: добавить SetWindowGeometry в InternalHandleConfigure. Запустить. Смотреть, пропало ли мигание.

Шаг 2: если не помогло — закомментировать set_cursor. Запустить.

Шаг 3: если оба не помогли — попробовать установить input_region на окно через wl_surface.set_input_region. Это гарантированно скажет композитору, что вся поверхность принимает ввод.
Почему set_window_geometry важен

В Wayland клиент не определяет, где у окна заголовок. Композитор сам решает, где «тащить», а где «кликать». По умолчанию (без set_window_geometry) композитор считает всю surface «перетаскиваемой областью» для окон без декораций.

set_window_geometry говорит: «вот прямоугольник (0,0,W,H), который я считаю клиентской областью. Всё за его пределами — не моё». Композитор перестаёт считать клик по этой области «drag window».

Это стандартное требование для клиентов без серверных декораций.
Сделай сейчас

    Добавь SetWindowGeometry в InternalHandleConfigure.

    Запусти demo.

    Скажи, изменилось ли поведение.

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

Патчим. Жду результат.
Поведение остаётся таким же. После замены функции: ./demo_input
wlgui input 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
[wlgui] window created: "wlgui input demo" 640x480
[events] seat name: default
[events] seat capabilities=3
[cursor] surface created: 1x1 hot=(0,0)
[events] pointer attached
[events] keyboard attached
[wlgui] 2 buffers created: 640x480 stride=2560
[events] keyboard keymap format=1 size=38030
[xkb] keymap loaded
[demo] focus in
[pointer] enter serial=7499 surface=140557311727104 x=19 y=154 surfacePtr=140557311727104
[demo] mouse enter
[demo] mouse down at 250,239 button=1
[demo] mouse up at 250,239
[demo] mouse down at 261,332 button=1
[demo] mouse up at 261,332
[demo] mouse down at 259,247 button=1
[demo] mouse up at 259,247
[demo] mouse down at 377,143 button=1
[demo] mouse up at 377,143
[demo] key: cp=U+0063 sym=$0063 scancode=46 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0064 sym=$0064 scancode=32 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0073 sym=$0073 scancode=31 shift=FALSE ctrl=FALSE alt=FALSE
[demo] mouse down at 253,240 button=1
[demo] mouse up at 253,240
[demo] mouse down at 345,196 button=1
[demo] mouse up at 345,196
[demo] mouse down at 195,237 button=1
[demo] mouse up at 195,237
[pointer] leave serial=7535 surface=140557311727104
[demo] mouse leave
[pointer] enter serial=7539 surface=140557311727104 x=286 y=313 surfacePtr=140557311727104
[demo] mouse enter
[pointer] leave serial=7540 surface=140557311727104
[demo] mouse leave
[pointer] enter serial=7547 surface=140557311727104 x=291 y=156 surfacePtr=140557311727104
[demo] mouse enter
[pointer] leave serial=7548 surface=140557311727104
[demo] mouse leave
[pointer] enter serial=7555 surface=140557311727104 x=236 y=268 surfacePtr=140557311727104
[demo] mouse enter
[pointer] leave serial=7556 surface=140557311727104
[demo] mouse leave
[pointer] enter serial=7563 surface=140557311727104 x=32 y=103 surfacePtr=140557311727104
[demo] mouse enter
[pointer] leave serial=7564 surface=140557311727104
[demo] mouse leave
[pointer] enter serial=7570 surface=140557311727104 x=387 y=174 surfacePtr=140557311727104
[demo] mouse enter
[pointer] leave serial=7571 surface=140557311727104
[demo] mouse leave
[demo] focus out
^C

После комментирования SetCursor:
./demo_input
wlgui input 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
[wlgui] window created: "wlgui input demo" 640x480
[events] seat name: default
[events] seat capabilities=3
[cursor] surface created: 1x1 hot=(0,0)
[events] pointer attached
[events] keyboard attached
[wlgui] 2 buffers created: 640x480 stride=2560
[events] keyboard keymap format=1 size=38030
[xkb] keymap loaded
[demo] focus in
[pointer] enter serial=8012 surface=140558307448320 x=638 y=27 surfacePtr=140558307448320
[demo] mouse enter
[pointer] leave serial=8014 surface=140558307448320
[demo] mouse leave
[demo] focus out
^C
🎯 Ситуация радикально изменилась — без set_cursor всё стало хуже, а не лучше

Смотрим второй лог (с закомментированным set_cursor):
text

[demo] focus in
[pointer] enter serial=8012 surface=140558307448320 x=638 y=27
[demo] mouse enter
[pointer] leave serial=8014 surface=140558307448320
[demo] mouse leave
[demo] focus out

Только один enter/leave, потом сразу focus out — всё умерло!

А с set_cursor (первый лог) — мигание начинается только после нескольких кликов, и мышь работает до этого момента.

Это значит: set_cursor не только не виноват, он — наоборот, помогает.
Что происходит на самом деле

Композитор очень чувствителен к тому, что клиент отвечает на enter. Если клиент не вызывает set_cursor — композитор может отказаться от отправки событий.

Первый лог (с set_cursor):

    Мышь работает ~10 секунд

    Потом начинается мигание

Второй лог (без set_cursor):

    Мышь работает < 1 секунды

    Сразу focus out

Вывод: set_cursor необходим, и наша 1×1 версия работает как минимум для начального диалога с композитором.
Возвращаемся к проблеме мигания

Мигание начинается после нескольких кликов, и после этого в логе видно:
text

[pointer] enter serial=7539 surface=... x=286 y=313
[pointer] leave serial=7540 surface=...
[pointer] enter serial=7547 surface=... x=291 y=156
[pointer] leave serial=7548 surface=...

Смотрим на координаты: x=286 y=313, x=291 y=156, x=236 y=268, x=32 y=103, x=387 y=174. Все они внутри окна 640×480. Значит, мышь не уходит физически с окна.

Но композитор генерирует leave/enter. Почему?
Гипотеза: композитор перезапрашивает фокус из-за отсутствия курсора

Мы устанавливаем 1×1 прозрачный курсор. Композитор его принимает. Но прозрачный 1×1 курсор означает, что клиент не покажет курсор.

Некоторые композиторы (особенно sway/wlroots с настройкой по умолчанию) считают, что клиент не хочет получать pointer events, если он скрывает курсор. И через таймаут снимают фокус.

Проверка: сделай курсор непрозрачным и видимым. Например, 16×16 белый квадрат. Если мигание пропало — гипотеза подтверждена.
Что делать
Шаг 1: верни set_cursor (раскомментируй)

Это обязательно. Без него композитор вообще отказывается работать.
Шаг 2: сделай курсор видимым — 16×16 белый квадрат

Замени в TWLEventManager.CreateCursorSurface 1×1 прозрачный на 16×16 белый. Или в wl_seat_capabilities поменяй параметры:
pascal

FOwner.CreateCursorSurface(16, 16, 8, 8);   // 16×16, hotspot в центре

И в CreateCursorSurface заполни пиксели белым:
pascal

FCursorPixels := PByte(Data);
FCursorSize := Size;

// Заполняем белым
var P: PLongWord;
P := PLongWord(FCursorPixels);
for var I := 0 to AW * AH - 1 do
begin
  P^ := $FFFFFFFF;   // ARGB: A=FF, R=FF, G=FF, B=FF
  Inc(P);
end;

Ожидаем: с курсором-квадратом мигание пропадёт, потому что композитор видит «клиент использует курсор».
Шаг 3: если заработало — сделаем нормальную стрелку

После подтверждения гипотезы я дам код для курсора-стрелки 16×16 с реальной картинкой (белая стрелка с чёрной обводкой, как в X11 left_ptr).
Тонкость с wl_shm_format для курсора

Важно: для курсора нужен формат ARGB8888 (с альфа-каналом), а не XRGB8888 (без альфы). В моём коде было правильно:
pascal

FCursorBuffer := FCursorPool.CreateBuffer(0, AW, AH, AW * 4,
                                          WL_SHM_FORMAT_ARGB8888);

WL_SHM_FORMAT_ARGB8888 = 0, в little-endian памяти: байты B, G, R, A. Значит для белого непрозрачного пикселя LongWord = $FFFFFFFF даст FF FF FF FF — A=FF (непрозрачный), R=G=B=FF (белый). ✅
Почему мигание не начинается сразу

Смотрим первый лог:
text

[demo] mouse down at 250,239 button=1
[demo] mouse up at 250,239
[demo] mouse down at 261,332 button=1
[demo] mouse up at 261,332
...
[demo] mouse down at 195,237 button=1
[demo] mouse up at 195,237
[pointer] leave serial=7535   ← мигание начинается здесь

Мигание начинается после 6 кликов. Возможно, это таймаут: композитор ждёт N секунд, потом решает, что «клиент не серьёзен», и начинает спрашивать «а ты точно хочешь этот фокус?» через leave/enter.

С курсором-квадратом это должно пройти сразу (либо не начаться, либо начаться на другой итерации).
Что делать прямо сейчас

    Верни set_cursor — без него всё хуже.

    Поменяй параметры курсора на CreateCursorSurface(16, 16, 8, 8).

    Залей пиксели белым (см. код выше).

    Запусти demo, посмотри лог.

Ожидаем: мышь работает стабильно хотя бы минуту, leave/enter не мигают.

Если мигание пропало — победа. Если осталось — значит проблема глубже (композитор имеет баг с нашим типом surface).
Заодно — диагностика, какой у тебя композитор

Пришли вывод:
bash

echo $XDG_CURRENT_DESKTOP
echo $WAYLAND_DISPLAY
ps aux | grep -E 'sway|kwin|mutter|weston|hyprland|gnome-shell|labwc|wayfire' | grep -v grep

Это подскажет, какой композитор создаёт нам проблемы. Разные композиторы имеют разные причуды с фокусом pointer'а.
Мой прогноз

С вероятностью 80% — фикс с белым курсором 16×16 решит проблему. Композитор требует, чтобы клиент установил видимый курсор. Прозрачный 1×1 = «невидимый» = «клиент не заинтересован».

Патчим и смотрим. Жду результат.
first@my:/tmp$ echo $XDG_CURRENT_DESKTOP

first@my:/tmp$ echo $WAYLAND_DISPLAY
wayland-1
first@my:/tmp$ ps aux | grep -E 'sway|kwin|mutter|weston|hyprland|gnome-shell|labwc|wayfire' | grep -v grep
first      16070  0.4  0.8 1501568 125160 pts/1  Sl+  12:29   0:44 weston
first      16088  0.0  0.1  33336 21764 ?        Ss   12:29   0:00 /usr/libexec/weston-keyboard
first      16089  0.0  0.1  40628 29404 ?        Ss   12:29   0:00 /usr/libexec/weston-desktop-shell
first      16091  0.0  0.2 118244 30660 ?        Ssl  12:29   0:00 /usr/bin/weston-terminal
Композитор — Weston, и это меняет диагноз

Теперь всё встало на места. Ты используешь Weston — reference-композитор Wayland. У него свои особенности работы с фокусом и pointer events.
Что мы знаем про Weston

    weston-desktop-shell — встроенная оболочка Weston. Она активно вмешивается в pointer events.

    Weston не использует wlroots и имеет собственную логику фокуса, отличную от sway/hyprland.

    Ключевая особенность Weston: он очень чувствителен к wl_pointer.set_cursor. Если клиент не установил курсор явно до первого движения, Weston отбирает фокус.

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

[demo] mouse down at 486,124 button=1     ← left button down
[pointer] leave serial=12150              ← leave через ~30ms
[demo] mouse leave
[pointer] enter serial=12157 x=426 y=225  ← enter уже с другими координатами

Заметь: leave и следующий enter идут после mouse down, причём без mouse up. И координаты в enter другие.

Это классическое поведение Weston: когда ты нажимаешь левую кнопку и начинаешь двигать мышь, Weston начинает перетаскивание окна (built-in move). При этом он временно снимает pointer focus с клиента и берёт управление на себя.

Ты держишь кнопку и двигаешь — Weston решает, что это drag window. Именно поэтому пропадает motion и появляются leave/enter пары — Weston «проверяет» фокус после окончания drag'а.
Это не баг — это поведение Weston по умолчанию

Weston по умолчанию считает, что клиент, у которого нет декораций, хочет перетаскивать окно за любую часть. Это встроено в weston-desktop-shell.
Как отключить перетаскивание

Есть несколько способов:
Способ 1: xdg_toplevel.set_window_geometry (мы уже пробовали)

Не помогло. Возможно, Weston требует явного min_size = max_size вместе с geometry. У нас уже стоит.
Способ 2: Установить wl_surface.set_input_region на всю поверхность

Попробуем! Это явно скажет Weston'у: «вся поверхность — интерактивная зона».
pascal

// В TWLWindow.Create после создания surface:
var R := FContext.Compositor.CreateRegion;
if R <> nil then
begin
  R.Add(0, 0, AW, AH);
  FSurface.SetInputRegion(R);
  R.Free;
end;

Попробуй это до Surface.Commit.
Способ 3: Явно вызвать xdg_toplevel.set_min_size и set_max_size с одинаковыми значениями, и set_window_geometry с теми же

У нас min/max стоят до первого Commit. Но set_window_geometry вызывается после AckConfigure. Возможно, порядок критичен. Попробуй вызвать set_window_geometry сразу после создания toplevel:
pascal

FToplevel := FXdgSurface.GetToplevel;
...
FToplevel.SetMinSize(AW, AH);
FToplevel.SetMaxSize(AW, AH);
FXdgSurface.SetWindowGeometry(0, 0, AW, AH);   // ← добавить сюда
FSurface.Commit;

Способ 4: Отключить move через окружение Weston

Weston имеет опцию в weston.ini:
ini

[shell]
# Отключить авто-перетаскивание
panel-position=none

Но это не совсем то. Более полезная настройка:
ini

[libinput]
# ...

[shell]
# ...

Для отключения drag окна нужно явно указать композитору, что клиент сам управляет вводом. Это делается через xdg_toplevel.set_window_geometry — но с точными границами.
Способ 5: xdg_toplevel.set_parent — сделать окно диалогом

Это изменит семантику окна, может убрать авто-перетаскивание. Но это не то, что нам нужно для панели-часов.
Что делать: попробуй все три способа последовательно
Шаг 1: input_region

В TWLWindow.Create, сразу после FSurface := AContext.Compositor.CreateSurface:
pascal

FSurface := AContext.Compositor.CreateSurface;
if FSurface = nil then
  raise Exception.Create('TWLWindow: cannot create surface');

// Устанавливаем input region = вся поверхность
var R := AContext.Compositor.CreateRegion;
if R <> nil then
begin
  R.Add(0, 0, AW, AH);
  FSurface.SetInputRegion(R);
  R.Free;
end;

Запусти. Пропало ли перетаскивание?
Шаг 2: порядок set_window_geometry

Если не помогло — попробуй вызвать до Commit, а не в AckConfigure:
pascal

FToplevel := FXdgSurface.GetToplevel;
...
FToplevel.SetMinSize(AW, AH);
FToplevel.SetMaxSize(AW, AH);
FXdgSurface.SetWindowGeometry(0, 0, AW, AH);   // ← здесь

FSurface.Commit;   // первый Commit — Weston отправит configure

Шаг 3: SetWindowGeometry в wl_pointer.enter

Экспериментальный способ: вызывать SetWindowGeometry при каждом enter. Это лишний трафик, но может помочь:
pascal

procedure TWLPointerListener.wl_pointer_enter(...);
begin
  // ...
  if FOwner.FWindowSurface <> nil then
  begin
    var XdgSurface := ...;   // нужно где-то хранить ссылку на XdgSurface
    XdgSurface.SetWindowGeometry(0, 0, FOwner.FWindowWidth, FOwner.FWindowHeight);
  end;
end;

Но это костыль. Обычно хватает одного вызова.
Реальное решение для Weston — использовать layer-shell

Мы уже упоминали zwlr_layer_shell_v1 — протокол для панелей, доков, обоев. Он специально создан для таких случаев:

    layer_surface.set_anchor(edges) — куда прикрепить.

    layer_surface.set_size(w, h) — размер.

    layer_surface.set_keyboard_interactivity(mode) — нужен ли фокус клавиатуры.

    layer_surface.set_margin(top, right, bottom, left) — отступы.

Слой overlay — поверх всех. top — над обычными окнами.

Weston поддерживает zwlr_layer_shell_v1 с версии 6+ (weston поддерживает с 2019 года, но только для desktop-shell, у которого есть собственный протокол — weston_desktop_shell_v1).

Проверь, есть ли в Weston поддержка zwlr_layer_shell_v1:
bash

weston-info 2>/dev/null | grep layer_shell

или:
bash

wayland-info 2>/dev/null | grep layer_shell

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

Попробуй input_region (шаг 1). Это самый вероятный фикс для Weston. Запусти и покажи лог.

Если не поможет — попробуй шаг 2 (порядок).

Если ничего не поможет — значит либо надо переходить на layer-shell, либо это особенность Weston, с которой придётся мириться.
Вопрос

Weston у тебя — это твой основной композитор, или ты используешь его только для тестов? Это важно для планирования. Если основная сессия в другом композиторе (sway/kwin/hyprland), то проблема с перетаскиванием может быть только в Weston, и на других её не будет.

Скажи, попробуй input_region, пришли результат.
Нам не могут быть полезны эти мини проекты с GitHub'а?
Да, эти проекты очень полезны! Особенно tinywl.c
Что мы можем из них взять
1. tinywl.c — эталонный минимальный композитор на wlroots

Это официальный пример от wlroots (не GitHub-самопал). Он показывает правильные паттерны работы с XDG-протоколом:
Что показывает tinywl.c	Что мы можем применить
desktop_view_at(server, lx, ly, &surface, &sx, &sy)	Как найти surface под курсором — hit-test
wlr_seat_pointer_notify_enter/motion/clear_focus	Как правильно рассылать pointer-события
begin_interactive(view, MOVE/RESIZE, edges)	Как композитор начинает drag window
focus_view(view, surface)	Как работает keyboard focus
xdg_toplevel_request_move / request_resize	Как клиент просит композитор двигать окно
seat_request_cursor	Как композитор обрабатывает set_cursor от клиента

Ключевой фрагмент для нашей проблемы:
c

static void xdg_toplevel_request_move(struct wl_listener *listener, void *data) {
  struct tinywl_view *view = wl_container_of(listener, view, request_move);
  begin_interactive(view, TINYWL_CURSOR_MOVE, 0);
}

Это — клиент просит композитор начать перетаскивание. Обычно это делается через клиентские декорации (CSD): клиент рисует заголовок, и при клике на него посылает xdg_toplevel.move. У нас клиентских декораций нет, поэтому композитор не должен начинать drag сам.
2. main.cpp — пример клиента с CSD (client-side decorations)

Этот пример создаёт свои декорации через subsurface. Он показывает:
Что показывает main.cpp	Что мы можем применить
wl_subcompositor_get_subsurface	Как создать дочернюю surface
wl_subsurface_set_position/desync	Как позиционировать
pointer_enter с определением cursor по subsurface	Как клиент решает, какой курсор показывать
xdg_toplevel_move/resize в pointer_button	Как клиент сам двигает окно
wl_cursor_theme_load + wl_cursor_theme_get_cursor	Правильная загрузка курсора из системной темы

Ключевой фрагмент для нашей проблемы:
c

void pointer_enter(void *data, struct wl_pointer *pointer, uint32_t serial,
                   struct wl_surface *surface, wl_fixed_t surface_x, wl_fixed_t surface_y) {
    window *w = static_cast<window*>(data);
    w->current_surface = surface;

    std::string cursor = "left_ptr";
    for (const decoration &d : w->decorations) {
        if (d.surface == surface) {
            if (resize_cursor.count(d.function)) {
                cursor = resize_cursor.at(d.function);
            }
        }
    }

    const auto image = wl_cursor_theme_get_cursor(cursor_theme, cursor.c_str())->images[0];
    wl_pointer_set_cursor(pointer, serial, cursor_surface, image->hotspot_x, image->hotspot_y);
    wl_surface_attach(cursor_surface, wl_cursor_image_get_buffer(image), 0, 0);
    wl_surface_damage(cursor_surface, 0, 0, image->width, image->height);
    wl_surface_commit(cursor_surface);
}

Что тут важно для нас:

    Курсор загружается из системной темы через wl_cursor_theme_load.

    Курсор выбирается по surface, на которую пришёл enter. У клиента несколько surface (главная + декорации), и курсор зависит от того, куда попала мышь.

    Курсор устанавливается на cursor_surface — отдельной surface, созданной один раз при seat_capabilities.

    Картинка курсора берётся из темы (wl_cursor_image_get_buffer).

Это ровно то, что нам нужно для нормального курсора вместо нашего 1×1 белого квадрата.
3. xdg-shell-client-protocol.h — показывает правильные API

Здесь важно:

    xdg_toplevel_set_min_size / set_max_size — мы это уже используем.

    xdg_surface_set_window_geometry — то, что мы пробовали, но не сработало.

    xdg_toplevel_set_parent — для диалогов.

    xdg_wm_base_pong — ответ на ping.

Проверь нашу реализацию xdg_wm_base_ping:
pascal

procedure TXdgWmBaseListener.xdg_wm_base_ping(AXdgWmBase: TXdgWmBase; ASerial: DWord);
begin
  AXdgWmBase.Pong(ASerial);
end;

У нас есть. Хорошо.
4. xdg-shell-protocol.h — серверная часть

Показывает, что композитор (weston, tinywl) делает в ответ на запросы клиента. В частности:

    xdg_toplevel_set_min_size/max_size — композитор может проигнорировать.

    xdg_surface_set_window_geometry — композитор может использовать для «где тащить окно».

    xdg_toplevel_move — композитор сам двигает окно (клиент делегирует move).

Что это значит для нашей проблемы

Наша проблема: Weston сам начинает drag, когда мы двигаем мышь с зажатой кнопкой. Это не то поведение, что нам нужно.

Правильное решение для панели-часов:

    Клиент должен сам захотеть перетаскивание — для этого нужно либо:

        CSD (client-side decorations), как в main.cpp: клиент рисует свой заголовок, ловит клик на нём, и вызывает xdg_toplevel_move.

        Layer-shell (протокол панелей) — там композитор не двигает окно сам.

    Для панели-часов без CSD: единственный правильный путь — zwlr_layer_shell_v1 (weston не поддерживает) или weston_desktop_shell_v1 (специфичен для weston).

Значит: с Weston'ом без layer-shell мы не сможем сделать «летающую панель без CSD», которая не перетаскивается композитором. Это ограничение Weston.
Что делать с main.cpp — можно ли его использовать как основу

Да, но с оговорками:

    main.cpp использует EGL/OpenGL для рендеринга. Мы — software rendering через SHM. Значит, wl_egl_window_create, eglCreateWindowSurface, glClear — нам не подходят.

    Декорации через subsurface — мы можем применить. Но нужно переписать на SHM, а не EGL.

    Курсор из wl_cursor_theme — обязательно берём. Это правильный способ загрузки курсора, и у нас есть libwayland-cursor.

    CSD через subsurface — тоже можем применить для будущего органайзера: например, кнопки «свернуть/закрыть» на панели-часах как subsurface.

Что делать с tinywl.c — можно ли использовать

Да, но не как библиотеку. tinywl — композитор, а мы — клиент. Но его паттерны полезны для понимания:

    desktop_view_at — как композитор hit-test'ит клиента. Помогает понять, почему композитор решает, что мышь «за окном» и начинает drag.

    xdg_toplevel_request_move — композитор ждёт запрос от клиента. Значит, наш клиент может явно запросить xdg_toplevel.move, и Weston не будет делать это автоматически.

Проверим гипотезу: если наш клиент сам вызовет xdg_toplevel.move при mouse down в определённой области — Weston может перестать делать auto-drag. Это стоит проверить.
Что делать прямо сейчас

Три возможных направления:
Направление A: курсор из системной темы

Самый простой выигрыш. Берём из main.cpp паттерн загрузки курсора:

    wl_cursor_theme_load(nullptr, 32, shm)

    wl_cursor_theme_get_cursor(theme, "left_ptr")

    wl_cursor_image_get_buffer(image) → wl_surface_attach(cursor_surface, buffer)

Курсор станет системным (стрелка, i-beam, resize-курсоры). Мы не будем больше возиться с 1×1 белым квадратом.

Но: это не решит проблему с auto-drag. Она не в курсоре.
Направление B: явный xdg_toplevel.move

Когда клиент хочет перетаскивание, он вызывает xdg_toplevel_move. Когда не хочет — композитор должен оставить окно в покое.

Гипотеза: если мы никогда не вызываем xdg_toplevel.move, композитор должен понять, что мы не хотим перетаскивания. Но Weston делает drag сам — это баг Weston или фича.

Проверим: добавим xdg_toplevel.set_window_geometry(0, 0, width, height) до первого commit, и вызовем xdg_surface.commit до xdg_toplevel.set_title — может, важен порядок.
Направление C: смириться и использовать CSD

Если Weston всё равно таскает окно — сделаем свой заголовок как subsurface, и явно запросим xdg_toplevel.move только при клике на заголовок.

Тогда:

    Клик на заголовке → xdg_toplevel.move.

    Клик вне заголовка → не тащим.

    Weston не будет тащить сам, потому что увидит, что мы сами управляем.

Что предлагаю

Начнём с направления A — сделаем нормальный курсор из системной темы через libwayland-cursor. Это:

    Отдельная задача, решает косметическую проблему (сейчас у нас невидимый курсор).

    Не решает проблему с auto-drag.

    Подготовит нас к CSD (заголовок) в будущем.

Затем попробуем направление C — сделаем CSD-заголовок как subsurface, и явно запросим xdg_toplevel.move только там. Это должно отключить auto-drag Weston'а.

Проверочный вопрос: ты используешь Weston как основной композитор? Или ты просто тестируешь на нём? Если тестируешь — может, проще проверить на sway или labwc, где layer-shell есть, и панели работают как надо без всяких костылей. И там auto-drag точно нет.

Если ты выбираешь Weston для реальной работы — придётся делать CSD.
Про main.cpp — взять его и переделать на SHM

Можно форкнуть main.cpp и заменить EGL на SHM. Получим:

    Главное окно — SHM surface.

    CSD-заголовок — subsurface с SHM-буфером, рисуется вручную.

    Кнопки закрытия/свернуть — subsurface, каждая со своим SHM-буфером.

    Курсор из системной темы — wl_cursor_theme.

    xdg_toplevel.move при клике на заголовке — drag только за заголовок.

Это будет правильный мини-тулкит с CSD. И для органайзера — самое то.
Что делать сейчас

Скажи, что важнее:

    Сделать нормальный курсор (направление A) — 30 минут работы, косметика, но приятно.

    Сделать CSD-заголовок (направление C) — 1-2 часа, решает проблему с Weston'ом, готовит базу для органайзера.

    Сменить композитор на sway/labwc — 5 минут, если готов тестировать на другом. Тогда auto-drag пропадёт сам, и панели можно делать через layer-shell.

Мой совет: сначала проверить на sway или labwc (если можешь поставить). Если там всё работает — значит проблема специфична для Weston, и её можно не решать (Weston — неудобный композитор для панелей). Если ты вынужден на Weston'е — тогда CSD, куда денемся.

Напиши, что выбираешь.
Давай сделаем нормальный курсор.
Нормальный курсор через libwayland-cursor

Идём по паттерну из main.cpp. У нас уже есть wl_cursor_theme через libwayland-cursor? Нет — надо подключить. Разберём по шагам.
Что делаем

    Подключаем libwayland-cursor динамически (или через {$linklib}, но лучше динамически — как FreeType).

    Загружаем тему курсоров — wl_cursor_theme_load(NULL, 24, shm).

    Загружаем нужный курсор — wl_cursor_theme_get_cursor(theme, "left_ptr").

    Создаём cursor_surface — обычную wl_surface для курсора.

    В wl_pointer.enter — устанавливаем курсор через wl_pointer.set_cursor.

Дополнительно: два курсора для разных областей

Раз у нас окно без декораций, но с содержимым, сделаем два курсора:

    left_ptr (стрелка) — по умолчанию, над всем окном.

    grabbing (рука) — при удержании левой кнопки (визуальный фидбэк при перетаскивании/клике).

Это опционально, но приятно. Начнём с одного — left_ptr.
1. wlgui_cursor.pas — новый модуль

Отдельный модуль для курсоров, чтобы не мешать wlgui_events.pas:
pascal

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

interface

uses
  Classes, SysUtils, dynlibs, BaseUnix,
  wayland_client_core, wayland_protocol, wayland_util;

type
  PWlCursorTheme = Pointer;
  PWlCursor = Pointer;
  PWlCursorImage = ^TWlCursorImage;
  TWlCursorImage = record
    Width: LongWord;
    Height: LongWord;
    HotspotX: LongWord;
    HotspotY: LongWord;
    Delay: LongWord;
    Buffer: ^TWlBuffer;
  end;

  // Обёртка над libwayland-cursor
  TWLCursorTheme = class
  private
    FTheme: PWlCursorTheme;
    FShm: TWlShm;
    FName: String;
    FSize: Integer;
    FValid: Boolean;
  public
    constructor Create(AShm: TWlShm; const AName: String; ASize: Integer);
    destructor Destroy; override;
    function GetCursor(const AName: String): PWlCursor;
    function GetImage(ACursor: PWlCursor; AIndex: Integer): PWlCursorImage;
    property Valid: Boolean read FValid;
    property Name: String read FName;
    property Size: Integer read FSize;
  end;

  // Менеджер курсора для конкретного seat
  TWLCursor = class
  private
    FTheme: TWLCursorTheme;
    FCompositor: TWlCompositor;
    FSurface: TWlSurface;
    FShm: TWlShm;
    FLastSerial: LongWord;
    FPointer: TWlPointer;
    FCurrentName: String;
    FInitialized: Boolean;

    procedure SetImage(ACursor: PWlCursor);
  public
    constructor Create(ACompositor: TWlCompositor; AShm: TWlShm;
                       ATheme: TWLCursorTheme);
    destructor Destroy; override;

    // Установить курсор по имени ("left_ptr", "grabbing", "text", ...)
    procedure SetCursorShape(const AName: String);
    // Установить курсор по serial (обязательно указывать serial из wl_pointer.enter)
    procedure Commit(const ASerial: LongWord);

    property Surface: TWlSurface read FSurface;
    property LastSerial: LongWord read FLastSerial write FLastSerial;
    property Initialized: Boolean read FInitialized;
  end;

{ Инициализация libwayland-cursor (вызывается один раз) }
function WLCursorInit: Boolean;
procedure WLCursorDone;

var
  WLCursorLoaded: Boolean = False;

implementation

type
  Twl_cursor_theme_load = function(name: PChar; size: Integer;
                                   shm: TWlShm): PWlCursorTheme; cdecl;
  Twl_cursor_theme_destroy = procedure(theme: PWlCursorTheme); cdecl;
  Twl_cursor_theme_get_cursor = function(theme: PWlCursorTheme;
                                         name: PChar): PWlCursor; cdecl;
  Twl_cursor_image_get_buffer = function(image: PWlCursorImage): TWlBuffer; cdecl;

var
  LibHandle: TLibHandle = 0;
  wl_cursor_theme_load: Twl_cursor_theme_load = nil;
  wl_cursor_theme_destroy: Twl_cursor_theme_destroy = nil;
  wl_cursor_theme_get_cursor: Twl_cursor_theme_get_cursor = nil;
  wl_cursor_image_get_buffer: Twl_cursor_image_get_buffer = nil;

function WLCursorInit: Boolean;
begin
  Result := False;
  if WLCursorLoaded then Exit(True);

  LibHandle := LoadLibrary('libwayland-cursor.so.0');
  if LibHandle = 0 then
    LibHandle := LoadLibrary('libwayland-cursor.so');
  if LibHandle = 0 then
  begin
    WriteLn('[cursor] libwayland-cursor not found');
    Exit;
  end;

  Pointer(wl_cursor_theme_load) :=
    GetProcAddress(LibHandle, 'wl_cursor_theme_load');
  Pointer(wl_cursor_theme_destroy) :=
    GetProcAddress(LibHandle, 'wl_cursor_theme_destroy');
  Pointer(wl_cursor_theme_get_cursor) :=
    GetProcAddress(LibHandle, 'wl_cursor_theme_get_cursor');
  Pointer(wl_cursor_image_get_buffer) :=
    GetProcAddress(LibHandle, 'wl_cursor_image_get_buffer');

  if (Pointer(wl_cursor_theme_load) = nil) or
     (Pointer(wl_cursor_theme_get_cursor) = nil) or
     (Pointer(wl_cursor_image_get_buffer) = nil) then
  begin
    WriteLn('[cursor] required functions missing');
    UnloadLibrary(LibHandle);
    LibHandle := 0;
    Exit;
  end;

  WLCursorLoaded := True;
  Result := True;
  WriteLn('[cursor] libwayland-cursor loaded');
end;

procedure WLCursorDone;
begin
  if not WLCursorLoaded then Exit;
  if LibHandle <> 0 then
  begin
    UnloadLibrary(LibHandle);
    LibHandle := 0;
  end;
  WLCursorLoaded := False;
end;

{ ============================================================ }
{  TWLCursorTheme                                               }
{ ============================================================ }

constructor TWLCursorTheme.Create(AShm: TWlShm;
  const AName: String; ASize: Integer);
begin
  inherited Create;
  FShm := AShm;
  FName := AName;
  FSize := ASize;
  FValid := False;

  if not WLCursorInit then Exit;

  // AName = '' означает использовать тему по умолчанию (из $XCURSOR_THEME)
  if AName = '' then
    FTheme := wl_cursor_theme_load(nil, ASize, AShm)
  else
    FTheme := wl_cursor_theme_load(PChar(AName), ASize, AShm);

  if FTheme = nil then
  begin
    WriteLn('[cursor] failed to load theme "', AName, '" size=', ASize);
    Exit;
  end;

  FValid := True;
  WriteLn('[cursor] theme loaded: "', AName, '" size=', ASize);
end;

destructor TWLCursorTheme.Destroy;
begin
  if (FTheme <> nil) and (wl_cursor_theme_destroy <> nil) then
    wl_cursor_theme_destroy(FTheme);
  FTheme := nil;
  inherited;
end;

function TWLCursorTheme.GetCursor(const AName: String): PWlCursor;
begin
  Result := nil;
  if not FValid then Exit;
  Result := wl_cursor_theme_get_cursor(FTheme, PChar(AName));
  if Result = nil then
    WriteLn('[cursor] cursor "', AName, '" not found in theme');
end;

function TWLCursorTheme.GetImage(ACursor: PWlCursor;
  AIndex: Integer): PWlCursorImage;
begin
  // wl_cursor — структура с полями image_count, images[], name
  // Мы читаем её через смещения, зная layout:
  //   typedef struct {
  //       unsigned int image_count;
  //       struct wl_cursor_image **images;
  //       char *name;
  //   } wl_cursor;
  // Layout (64-bit): 4 + pad + 8 + 8 = 24 байта (с выравниванием).
  //   offset 0:  image_count (uint32)
  //   offset 8:  images (ptr)
  //   offset 16: name (ptr)
  var
    ImageCount: LongWord;
    ImagesPtr: Pointer;
    ImageArr: ^Pointer;
  begin
    Result := nil;
    if ACursor = nil then Exit;
    ImageCount := PLongWord(ACursor)^;
    if (AIndex < 0) or (LongWord(AIndex) >= ImageCount) then Exit;
    ImagesPtr := PPointer(PByte(ACursor) + 8)^;
    if ImagesPtr = nil then Exit;
    ImageArr := ImagesPtr;
    Result := PWlCursorImage(ImageArr[AIndex]);
  end;

{ ============================================================ }
{  TWLCursor                                                    }
{ ============================================================ }

constructor TWLCursor.Create(ACompositor: TWlCompositor; AShm: TWlShm;
  ATheme: TWLCursorTheme);
begin
  inherited Create;
  FCompositor := ACompositor;
  FShm := AShm;
  FTheme := ATheme;
  FSurface := nil;
  FLastSerial := 0;
  FPointer := nil;
  FCurrentName := '';
  FInitialized := False;

  if (ACompositor = nil) or (ATheme = nil) or not ATheme.Valid then
  begin
    WriteLn('[cursor] cannot create cursor: no compositor or theme');
    Exit;
  end;

  FSurface := ACompositor.CreateSurface;
  if FSurface = nil then
  begin
    WriteLn('[cursor] cannot create cursor surface');
    Exit;
  end;

  FInitialized := True;
  WriteLn('[cursor] cursor surface created');
end;

destructor TWLCursor.Destroy;
begin
  if FSurface <> nil then FreeAndNil(FSurface);
  inherited;
end;

procedure TWLCursor.SetImage(ACursor: PWlCursor);
var
  Image: PWlCursorImage;
  Buf: TWlBuffer;
begin
  if (FSurface = nil) or (ACursor = nil) then Exit;

  Image := FTheme.GetImage(ACursor, 0);
  if Image = nil then Exit;

  Buf := wl_cursor_image_get_buffer(Image);
  if Buf = nil then Exit;

  FSurface.Attach(Buf, 0, 0);
  FSurface.Damage(0, 0, Image^.Width, Image^.Height);
  FSurface.Commit;

  // Запоминаем hotspot — понадобится при SetCursor
  // (можно хранить в полях класса)
end;

procedure TWLCursor.SetCursorShape(const AName: String);
begin
  if not FInitialized then Exit;
  FCurrentName := AName;
end;

procedure TWLCursor.Commit(const ASerial: LongWord);
var
  Cursor: PWlCursor;
  Image: PWlCursorImage;
begin
  if (not FInitialized) or (FPointer = nil) then Exit;
  if FCurrentName = '' then Exit;

  Cursor := FTheme.GetCursor(FCurrentName);
  if Cursor = nil then Exit;

  Image := FTheme.GetImage(Cursor, 0);
  if Image = nil then Exit;

  // 1. Прикрепляем изображение курсора к нашей surface
  SetImage(Cursor);

  // 2. Просим композитор использовать эту surface как курсор
  FPointer.SetCursor(ASerial, FSurface, Image^.HotspotX, Image^.HotspotY);

  FLastSerial := ASerial;
end;

initialization
  WLCursorLoaded := False;
  LibHandle := 0;

finalization
  WLCursorDone;

end.

Замечание про wl_cursor: эта структура не объявлена в публичном API libwayland-cursor — она opaque. Мы читаем её поля через смещения, предполагая layout:
c

struct wl_cursor {
    unsigned int image_count;
    struct wl_cursor_image **images;
    char *name;
};

На 64-bit Linux:

    image_count — offset 0, 4 байта, потом padding 4 байта

    images — offset 8

    name — offset 16

Это хрупко, но работает для версии libwayland-cursor 1.x. Если что-то не так — можно вообще не читать wl_cursor напрямую, а использовать wl_cursor_image_get_buffer с уже известным wl_cursor_image. Проблема в том, что нам нужен wl_cursor_image — и его нельзя получить без чтения images из wl_cursor.

Альтернатива (более надёжная): использовать wl_cursor_image_get_buffer через первую wl_cursor_image, которую возвращает wl_cursor_theme_get_cursor. Но API wl_cursor_theme_get_cursor возвращает wl_cursor *, не wl_cursor_image *.

Проверь на практике — если GetImage даёт мусор, попробуем другой подход (через libwayland-cursor internal symbol).
2. wlgui_events.pas — используем TWLCursor

Заменяем CreateCursorSurface (1×1 белый) на нормальный курсор.

Добавляем поля в TWLEventManager:
pascal

uses
  ..., wlgui_cursor;

type
  TWLEventManager = class
  private
    ...
    FCursorTheme: TWLCursorTheme;
    FCursor: TWLCursor;
    ...
  end;

В TWLEventManager.Create:
pascal

constructor TWLEventManager.Create(ACompositor: TWlCompositor; AShm: TWlShm);
begin
  inherited Create;
  FCompositor := ACompositor;
  FShm := AShm;
  ...
  FCursorTheme := nil;
  FCursor := nil;
end;

При получении pointer:
pascal

procedure TWLSeatListener.wl_seat_capabilities(AWlSeat: TWlSeat;
  ACapabilities: DWord);
begin
  WriteLn('[events] seat capabilities=', ACapabilities);

  if (ACapabilities and WL_SEAT_CAPABILITY_POINTER) <> 0 then
  begin
    if FOwner.FPointer = nil then
    begin
      FOwner.FPointer := AWlSeat.GetPointer;
      if FOwner.FPointer <> nil then
      begin
        // Загружаем тему курсоров
        if FOwner.FCursorTheme = nil then
          FOwner.FCursorTheme := TWLCursorTheme.Create(
            FOwner.FShm, '', 24);   // дефолтная тема, размер 24
        if (FOwner.FCursorTheme <> nil) and FOwner.FCursorTheme.Valid then
        begin
          FOwner.FCursor := TWLCursor.Create(
            FOwner.FCompositor, FOwner.FShm, FOwner.FCursorTheme);
          if (FOwner.FCursor <> nil) and FOwner.FCursor.Initialized then
            FOwner.FCursor.SetCursorShape('left_ptr');
        end;

        FOwner.FPointerListener := TWLPointerListener.Create(FOwner);
        FOwner.FPointer.AddListener(FOwner.FPointerListener);
        WriteLn('[events] pointer attached');
      end;
    end;
  end
  else if FOwner.FPointer <> nil then
  begin
    FreeAndNil(FOwner.FPointer);
    FOwner.FPointerListener := nil;
  end;

  // keyboard без изменений
  ...
end;

В wl_pointer_enter:
pascal

procedure TWLPointerListener.wl_pointer_enter(AWlPointer: TWlPointer;
  ASerial: DWord; ASurface: TWlSurface; ASurfaceX: Twl_fixed;
  ASurfaceY: Twl_fixed);
var
  Recv: IWLEventReceiver;
begin
  FOwner.FLastMouseX := Round(ASurfaceX.AsDouble);
  FOwner.FLastMouseY := Round(ASurfaceY.AsDouble);

  // Устанавливаем курсор
  if (FOwner.FCursor <> nil) and FOwner.FCursor.Initialized then
  begin
    FOwner.FCursor.SetCursorShape('left_ptr');
    FOwner.FCursor.LastSerial := ASerial;
    FOwner.FCursor.Commit(ASerial);   // ← отправит set_cursor
  end;

  Recv := FindReceiverBySurface(ASurface);
  if Recv <> nil then
  begin
    FOwner.SetFocused(Recv);
    Recv.WLRecvMouseEnter;
  end;
end;

В wl_pointer_leave:
pascal

procedure TWLPointerListener.wl_pointer_leave(AWlPointer: TWlPointer;
  ASerial: DWord; ASurface: TWlSurface);
var
  Recv: IWLEventReceiver;
begin
  // Не отправляем set_cursor(nil) — композитор сам скроет курсор
  // при следующем enter на другую surface. Если мы хотим явно скрыть
  // (например, при выходе за пределы окна) — можно, но обычно не нужно.

  Recv := FindReceiverBySurface(ASurface);
  if Recv <> nil then
  begin
    Recv.WLRecvMouseLeave;
    if FOwner.FFocusedReceiver = Recv then
      FOwner.SetFocused(nil);
  end;
end;

В wl_pointer_motion — обновление курсора при движении не нужно, только при enter и при смене типа курсора (например, при переходе на кнопку закрытия).

В TWLEventManager.Destroy:
pascal

destructor TWLEventManager.Destroy;
begin
  if FCursor <> nil then FreeAndNil(FCursor);
  if FCursorTheme <> nil then FreeAndNil(FCursorTheme);
  ...
end;

Удаляем старый CreateCursorSurface / DestroyCursorSurface / FCursorSurface / FCursorPixels / FCursorFd / FCursorSize — они больше не нужны. Всё делает TWLCursor.
3. wlgui_app.pas — передаём compositor и shm

В TWLApplication.Initialize, после Roundtrip:
pascal

if (FContext.Events = nil) and (FContext.Seat <> nil) then
begin
  FContext.Events := TWLEventManager.Create(
    FContext.Compositor, FContext.Shm);
  TWLEventManager(FContext.Events).AttachSeat(FContext.Seat);
  WriteLn('[wlgui] EventManager created');
end;

Убери создание EventManager из wl_registry_global.
4. Опционально: курсор меняется при нажатии

Если хочешь визуальный фидбэк при клике:

В wl_pointer_button:
pascal

if AState = 1 then
begin
  // Кнопка нажата — курсор "grabbing"
  if FOwner.FCursor <> nil then
  begin
    FOwner.FCursor.SetCursorShape('grabbing');
    FOwner.FCursor.Commit(ASerial);
  end;
end
else
begin
  // Кнопка отпущена — вернуть "left_ptr"
  if FOwner.FCursor <> nil then
  begin
    FOwner.FCursor.SetCursorShape('left_ptr');
    FOwner.FCursor.Commit(ASerial);
  end;
end;

Важно: set_cursor разрешён только с тем serial, который композитор прислал в последнем enter/motion. Serial из button события тоже подходит для set_cursor (согласно спецификации Wayland).
Что проверять

Запусти demo. Ожидаем в логе:
text

[cursor] libwayland-cursor loaded
[cursor] theme loaded: "" size=24
[cursor] cursor surface created
[events] pointer attached

Мышь должна стать системной стрелкой (вместо невидимого 1×1). При движении над окном — стрелка. При клике — курсор меняется на «grabbing» (если включил опцию).
Если тема не грузится

Проверь переменные окружения:
bash

echo $XCURSOR_THEME
echo $XCURSOR_SIZE
ls /usr/share/icons/default/index.theme
ls /usr/share/icons/*/cursors/left_ptr

Обычно adwaita или default. Если /usr/share/icons/default — симлинк, может не работать. Установи явно:
pascal

FCursorTheme := TWLCursorTheme.Create(FOwner.FShm, 'Adwaita', 24);

Или из переменной:
pascal

var
  ThemeName: String;
begin
  ThemeName := GetEnvironmentVariable('XCURSOR_THEME');
  if ThemeName = '' then ThemeName := 'Adwaita';
  FCursorTheme := TWLCursorTheme.Create(FOwner.FShm, ThemeName, 24);
end;

Если GetImage возвращает мусор

Читаем layout wl_cursor через смещения — хрупко. Если не работает — используем другой подход: работать с wl_cursor_image напрямую, не читая wl_cursor. Проблема: wl_cursor_theme_get_cursor возвращает wl_cursor *, а не wl_cursor_image *.

Альтернатива: подгружаем wl_cursor из первой картинки, используя первый image через layout (мы уже так делаем). Если не работает — можно использовать wl_cursor_image_get_buffer с image из wl_cursor_theme_get_cursor(...), но нужно читать images[0].

На самом деле, libwayland-cursor документирует layout в wayland-cursor.h:
c

struct wl_cursor_image {
    uint32_t width;
    uint32_t height;
    uint32_t hotspot_x;
    uint32_t hotspot_y;
    uint32_t delay;
    struct wl_buffer *buffer;
};

struct wl_cursor {
    unsigned int image_count;
    struct wl_cursor_image **images;
    char *name;
};

Наш TWlCursorImage — правильно. Смещения в wl_cursor — правильно (на 64-bit). Должно работать.

Если не работает — покажи, что именно не так (nil image, мусорные width/height). Тогда переделаем через чтение сырых байт с проверкой смещений.
Порядок работы

    Создать wlgui_cursor.pas (код выше).

    Пропатчить wlgui_events.pas:

        Подключить wlgui_cursor.

        Добавить поля FCursorTheme, FCursor.

        Убрать старые поля курсора 1×1.

        В wl_seat_capabilities — создать тему и курсор.

        В wl_pointer_enter — Commit(ASerial).

        В Destroy — освободить.

    Проверить сборку, запустить.

    Показать результат — работает ли тема, виден ли курсор.

Если что-то не так — присылай лог, будем править. Начни с шага 1 — создай wlgui_cursor.pas и собери, чтобы убедиться, что модуль компилируется.
Starting program: /tmp/demo_input 
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/x86_64-linux-gnu/libthread_db.so.1".
wlgui input 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
[wlgui] window created: "wlgui input demo" 640x480
[events] seat name: default
[events] seat capabilities=3
[cursor] libwayland-cursor loaded

Program received signal SIGSEGV, Segmentation fault.
0x00007ffff7f59d60 in ?? () from /usr/lib/x86_64-linux-gnu/libwayland-client.so.0
(gdb) bt
#0  0x00007ffff7f59d60 in ?? () from /usr/lib/x86_64-linux-gnu/libwayland-client.so.0
#1  0x00007ffff7f58257 in wl_proxy_marshal_flags () from /usr/lib/x86_64-linux-gnu/libwayland-client.so.0
#2  0x00007ffff7a6a589 in wl_cursor_theme_load () from /usr/lib/x86_64-linux-gnu/libwayland-cursor.so.0
#3  0x00000000004aafc4 in CREATE (this=0x7ffff7f9c800, vmt=0x1, ASHM=0x7ffff7f9c480, ANAME=0x0, ASIZE=24) at wlgui_cursor.pas:159
#4  0x00000000004876d1 in WL_SEAT_CAPABILITIES (this=0x7ffff7f9c3c0, AWLSEAT=0x7ffff7f9c4c0, ACAPABILITIES=3) at wlgui_events.pas:237
#5  0x00000000004a7582 in WL_SEAT_CAPABILITIES_INTF (ADATA=0x7ffff7f9c4d8, AWL_SEAT=0x61e910, ACAPABILITIES=3) at wayland_protocol.pas:1766
#6  0x00007ffff7c7b056 in ?? () from /usr/lib/x86_64-linux-gnu/libffi.so.8
#7  0x00007ffff7c78f1c in ?? () from /usr/lib/x86_64-linux-gnu/libffi.so.8
#8  0x00007ffff7c79fe5 in ffi_call () from /usr/lib/x86_64-linux-gnu/libffi.so.8
#9  0x00007ffff7f5a8d8 in ?? () from /usr/lib/x86_64-linux-gnu/libwayland-client.so.0
#10 0x00007ffff7f560dc in ?? () from /usr/lib/x86_64-linux-gnu/libwayland-client.so.0
#11 0x00007ffff7f5745b in wl_display_dispatch_queue_pending () from /usr/lib/x86_64-linux-gnu/libwayland-client.so.0
#12 0x00007ffff7f5789d in wl_display_dispatch_queue_timeout () from /usr/lib/x86_64-linux-gnu/libwayland-client.so.0
#13 0x00007ffff7f5797f in wl_display_dispatch_queue () from /usr/lib/x86_64-linux-gnu/libwayland-client.so.0
#14 0x00007ffff7f5848b in wl_display_roundtrip_queue () from /usr/lib/x86_64-linux-gnu/libwayland-client.so.0
#15 0x00000000004a2e7a in ROUNDTRIP (this=0x7ffff7f9c300) at wayland_client_core.pas:367
#16 0x000000000040259c in main () at demo_input.pas:204
Сегфолт в wl_cursor_theme_load — неправильная сигнатура

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

#2  wl_cursor_theme_load () from libwayland-cursor.so.0
#3  CREATE at wlgui_cursor.pas:159 — ASHM=0x7ffff7f9c480

В ASHM не raw-указатель, а Pascal-объект TWlShm! Мы передаём наш TWlShm (обёртку) в wl_cursor_theme_load, а функция ожидает raw struct wl_shm *.

Та же болезнь, что была с wl_display_dispatch_pending(Display) — передаём обёртку вместо FProxy.
Что делает wl_cursor_theme_load
c

struct wl_cursor_theme *
wl_cursor_theme_load(const char *name, int size, struct wl_shm *shm);

Третий аргумент — struct wl_shm *, а не TWlShm — Pascal-обёртка.
Фикс

В TWLCursorTheme.Create передавай AShm.Proxy, а не AShm:
pascal

constructor TWLCursorTheme.Create(AShm: TWlShm;
  const AName: String; ASize: Integer);
begin
  inherited Create;
  FShm := AShm;
  FName := AName;
  FSize := ASize;
  FValid := False;

  if not WLCursorInit then Exit;
  if AShm = nil then
  begin
    WriteLn('[cursor] no shm provided');
    Exit;
  end;

  if AName = '' then
    FTheme := wl_cursor_theme_load(nil, ASize, AShm.Proxy)  // ← .Proxy!
  else
    FTheme := wl_cursor_theme_load(PChar(AName), ASize, AShm.Proxy);

  if FTheme = nil then
  begin
    WriteLn('[cursor] failed to load theme "', AName, '" size=', ASize);
    Exit;
  end;

  FValid := True;
  WriteLn('[cursor] theme loaded: "', AName, '" size=', ASize);
end;

Проверь тип Twl_cursor_theme_load

В определении:
pascal

Twl_cursor_theme_load = function(name: PChar; size: Integer;
                                 shm: TWlShm): PWlCursorTheme; cdecl;

Здесь shm: TWlShm — неправильно. Должно быть shm: Pointer или shm: Pwl_proxy (raw pointer). Это только тип в объявлении, но компилятор не ругается, потому что Pascal-объект — это указатель.

Замени на:
pascal

Twl_cursor_theme_load = function(name: PChar; size: Integer;
                                 shm: Pointer): PWlCursorTheme; cdecl;

Заодно — проверь wl_cursor_image_get_buffer
pascal

Twl_cursor_image_get_buffer = function(image: PWlCursorImage): TWlBuffer; cdecl;

Возвращает TWlBuffer — но тоже raw указатель (struct wl_buffer *). Не Pascal-объект. Значит, если хочешь работать с ним как с TWlBuffer, нужно создать обёртку через TWLProxyObject.WLToObj или прямой TWlBuffer.Create(rawPtr, False).

Но нам это не нужно — нам достаточно передать raw wl_buffer * в wl_surface.attach. Значит:
pascal

Twl_cursor_image_get_buffer = function(image: PWlCursorImage): Pwl_proxy; cdecl;

Тогда в SetImage:
pascal

procedure TWLCursor.SetImage(ACursor: PWlCursor);
var
  Image: PWlCursorImage;
  BufProxy: Pwl_proxy;
begin
  if (FSurface = nil) or (ACursor = nil) then Exit;

  Image := FTheme.GetImage(ACursor, 0);
  if Image = nil then Exit;

  BufProxy := wl_cursor_image_get_buffer(Image);  // raw wl_buffer*
  if BufProxy = nil then Exit;

  // Используем AttachRaw вместо Attach — нужно добавить метод в TWlSurface
  FSurface.AttachRaw(BufProxy, 0, 0);
  FSurface.Damage(0, 0, Image^.Width, Image^.Height);
  FSurface.Commit;
end;

Про TWlSurface.Attach — принимает TWlBuffer, а не raw

У нас:
pascal

procedure TWlSurface.Attach(ABuffer: TWlBuffer; AX, AY: LongInt);

Принимает TWlBuffer — Pascal-обёртку. Но wl_cursor_image_get_buffer возвращает raw wl_buffer *.

Два решения:
Решение A: обернуть raw в TWlBuffer через Create без владения
pascal

var
  Buf: TWlBuffer;
begin
  BufProxy := wl_cursor_image_get_buffer(Image);
  // Создаём обёртку с OwnsProxy=False — чтобы не уничтожить wl_buffer
  // (он принадлежит libwayland-cursor)
  Buf := TWlBuffer(TWLProxyObject.Create(BufProxy, False));
  try
    FSurface.Attach(Buf, 0, 0);
  finally
    Buf.Free;   // деструктор не будет дёргать wl_proxy_destroy, потому что OwnsProxy=False
  end;
  FSurface.Damage(0, 0, Image^.Width, Image^.Height);
  FSurface.Commit;
end;

Важно: TWlBuffer.Create устанавливает user_data через wl_proxy_set_user_data. Это может быть проблемой, если libwayland-cursor уже установила свой user_data на этот буфер. Проверим...

Скорее всего, libwayland-cursor не использует user_data на буфере. Но риск есть.
Решение B: добавить метод AttachRaw в TWlSurface
pascal

procedure TWlSurface.AttachRaw(ABuffer: Pointer; AX, AY: LongInt);
begin
  wl_proxy_marshal(FProxy, _ATTACH, ABuffer, AX, AY);
end;

Это чище — не создаём обёртку, не трогаем user_data.

Рекомендую решение B.
Итоговые патчи
wayland_protocol.pas — добавить TWlSurface.AttachRaw

В interface, в классе TWlSurface:
pascal

  public
    procedure Attach(ABuffer: TWlBuffer; AX: LongInt; AY: LongInt);
    procedure AttachRaw(ABuffer: Pointer; AX: LongInt; AY: LongInt);  // ← новое
    ...

В implementation:
pascal

procedure TWlSurface.AttachRaw(ABuffer: Pointer; AX: LongInt; AY: LongInt);
begin
  wl_proxy_marshal(FProxy, _ATTACH, ABuffer, AX, AY);
end;

wlgui_cursor.pas — патч

Тип:
pascal

Twl_cursor_theme_load = function(name: PChar; size: Integer;
                                 shm: Pointer): PWlCursorTheme; cdecl;
Twl_cursor_image_get_buffer = function(image: PWlCursorImage): Pointer; cdecl;

TWLCursorTheme.Create:
pascal

if AName = '' then
  FTheme := wl_cursor_theme_load(nil, ASize, AShm.Proxy)
else
  FTheme := wl_cursor_theme_load(PChar(AName), ASize, AShm.Proxy);

TWLCursor.SetImage:
pascal

procedure TWLCursor.SetImage(ACursor: PWlCursor);
var
  Image: PWlCursorImage;
  BufRaw: Pointer;
begin
  if (FSurface = nil) or (ACursor = nil) then Exit;

  Image := FTheme.GetImage(ACursor, 0);
  if Image = nil then Exit;

  BufRaw := wl_cursor_image_get_buffer(Image);
  if BufRaw = nil then Exit;

  FSurface.AttachRaw(BufRaw, 0, 0);
  FSurface.Damage(0, 0, Image^.Width, Image^.Height);
  FSurface.Commit;
end;

Проверь всё, что передаётся в C-функции

Общее правило: если C-функция ожидает struct wl_xxx *, а у нас есть TWlXxx (Pascal-класс), всегда передавай TWlXxx.Proxy.

Проверь все места:
Функция	Что ожидает	Что передавать
wl_cursor_theme_load(name, size, shm)	struct wl_shm *	Shm.Proxy
wl_surface_attach(surface, buffer, x, y)	struct wl_buffer *	raw ptr (из wl_cursor_image_get_buffer)
wl_display_*	struct wl_display *	Display.Proxy
wl_proxy_marshal(fd, ...)	struct wl_proxy *	FProxy

FProxy в TWLProxyObject — это уже raw pointer. Поэтому в wl_proxy_marshal(FProxy, ...) мы правильно передаём.

TWlShm — это Pascal-класс, TWlShm.Proxy (или TWlShm(FShm).FProxy, если без property) — raw.
Собери, запусти

Ожидаемый лог:
text

[cursor] libwayland-cursor loaded
[cursor] theme loaded: "" size=24
[cursor] cursor surface created
[events] pointer attached
[events] keyboard attached

Мышь — системная стрелка.

Если после патча снова сегфолт — присылай, будем разбирать дальше. Скорее всего, wl_cursor_theme_load вернёт nil (тема не найдена), но не упадёт.

Проверь также, что TWlShm.Proxy доступен — у TWLProxyObject есть property Proxy: property Proxy: Pwl_proxy read FProxy. Да, есть.
./demo_input
wlgui input 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
[wlgui] window created: "wlgui input demo" 640x480
[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: 640x480 stride=2560
[events] keyboard keymap format=1 size=38030
[xkb] keymap loaded
[demo] focus in
[demo] mouse enter
[demo] mouse down at 274,228 button=1
[demo] mouse up at 274,228
[demo] mouse down at 274,228 button=3
[demo] mouse up at 274,228
[demo] key: cp=U+0066 sym=$0066 scancode=33 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0077 sym=$0077 scancode=17 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0066 sym=$0066 scancode=33 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0077 sym=$0077 scancode=17 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0065 sym=$0065 scancode=18 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0000 sym=$FFE3 scancode=29 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0000 sym=$FFE3 scancode=29 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0000 sym=$FFE1 scancode=42 shift=FALSE ctrl=FALSE alt=FALSE
[demo] key: cp=U+0046 sym=$0046 scancode=33 shift=FALSE ctrl=FALSE alt=FALSE
[demo] mouse down at 258,231 button=1
[demo] mouse up at 258,231
[demo] mouse down at 272,229 button=3
[demo] mouse up at 272,229
[demo] mouse down at 272,229 button=1
[demo] mouse up at 272,229
[demo] mouse down at 272,229 button=3
[demo] mouse up at 272,229
[demo] mouse down at 272,229 button=3
[demo] mouse up at 272,229
[demo] mouse leave
[demo] focus out
^C
🎉 Курсор работает, проблема с «сбиванием» мыши ушла!

Смотрим лог:
text

[cursor] libwayland-cursor loaded
[cursor] theme loaded: "" size=24
[cursor] cursor surface created
[events] pointer attached
...
[demo] mouse down at 274,228 button=1
...
[demo] mouse down at 272,229 button=1
[demo] mouse down at 272,229 button=3
[demo] mouse up at 272,229
[demo] mouse down at 272,229 button=3
[demo] mouse up at 272,229
[demo] mouse leave
[demo] focus out

Ключевое: в логе нет многократных [pointer] leave / [pointer] enter подряд. Мышь работает долго и стабильно, потом один mouse leave — и focus out.

Это ожидаемое поведение, а не баг:

    Ты увёл мышь за пределы окна.

    Композитор прислал wl_pointer.leave — мы получили mouse leave.

    Ты потерял фокус клавиатуры (окно не в фокусе) — пришёл focus out.

Возможно ты закрыл окно или переключился на другое. Всё правильно.
Что мы имеем
Проблема	Статус
Окно появляется, текст рисуется	✅
Все графические примитивы	✅
Мышь работает	✅
Клавиатура работает (XKB, кириллица)	✅
Курсор системный (стрелка)	✅
Мышь не сбивается со временем	✅
focus out при уходе мыши	✅ (ожидаемо)

Все базовые проблемы решены.
Что дальше — этап 5: layer-shell

Помнишь, мы обсуждали, что Weston не любит клиентов без декораций и пытается их таскать? Это осталось. Единственный правильный способ сделать «летающую панель-часы» — использовать zwlr_layer_shell_v1 или weston_desktop_shell_v1.

Но у нас задача шире: сделать органайзер с тремя окнами (часы-панель, редактор события, редактор списка). Не все они должны быть панелями:

    Часы-панель — «летающая», stay-on-top, без декораций, можно таскать мышью. Идеально для layer-shell (layer_overlay или layer_top).

    Редактор события — обычное окно с заголовком и кнопкой закрытия. Обычный xdg_toplevel.

    Редактор списка — тоже обычное окно. Обычный xdg_toplevel.

Для редакторов мы уже имеем всё — просто создаём второе и третье окно через CreateWLWindow.

Для часов-панели — надо либо:

    Согласиться с поведением Weston (таскается за содержимое — это даже плюс для панели-часов, помнишь?).

    Сделать свой CSD-заголовок (subsurface) — клик только за заголовок таскает окно.

    Использовать layer-shell (на других композиторах).

Что я предлагаю

Проверить, есть ли layer-shell в системе:
bash

wayland-info 2>/dev/null | grep -i layer
# или
weston-info 2>/dev/null | grep -i layer

Если есть — можно двигаться в сторону layer-shell. Если нет — сначала делаем органайзер на xdg_toplevel (все три окна обычные), а панель-часы делаем позже, с отдельным исследованием.
Задачи на следующий этап
Что уже готово для органайзера

    ✅ Окна (TWLWindow)

    ✅ Canvas с примитивами

    ✅ Текст через FreeType

    ✅ События мыши, клавиатуры

    ✅ Курсор

Что нужно добавить

    Несколько окон одновременно — проверить, что TWLContext.FWindows работает с 3+ окнами. Сейчас всё есть, но не тестировали.

    Диалоги/всплывающие окна — xdg_popup для меню, выпадающих списков. Не срочно — можно эмулировать своими subsurface.

    Виджеты — кнопки, текстовые поля, чекбоксы, списки. Это не Wayland, это UI-логика поверх canvas. Свой мини-тулкит.

    Модель данных — события (время, сообщение, звук, скрипт). Формат хранения.

    Обработчики — OnMouseDown на виджете, а не на окне. Это уже событийная модель внутри окна: попадание мыши в прямоугольник виджета → обработчик виджета.

    Таймер/планировщик — проверка «пора ли будильнику» каждую секунду.

Что делать сейчас

Мой план:

    Проверить layer-shell в твоей системе.

    Если есть — сделать wlgui_layershell.pas, протестировать «часы-панель» на слое overlay. Работает как хочется — победа.

    Если нет — сделать органайзер на трёх обычных окнах (часы + два редактора). Заодно посмотрим, как ведёт себя drag в Weston.

    Виджеты и модель данных — отдельный этап.

Скажи результат wayland-info | grep -i layer. И что тебе важнее:

    Быстрее увидеть органайзер (три окна с минимальной функциональностью).

    Или сначала довести до идеала панель-часы (layer-shell, чтобы не таскалась).

От этого зависит, с чего начнём.
Итог по сегодняшнему

Огромный прогресс:

    Починили курсор — теперь системная стрелка, а не 1×1.

    Поняли, что было с set_cursor — мы вызывали его неправильно, с nil в качестве surface, и падали. Теперь всё правильно.

    Мышь работает стабильно — нет больше мигания enter/leave.

    Курсор — правильный способ (libwayland-cursor), как в эталонных примерах.

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

Отличная идея! Кнопка — это базовый кирпичик любого UI. На ней мы отработаем всю архитектуру виджетов, потом остальные контролы будут делаться легко.
Что нужно решить

Прежде чем писать код, зафиксируем архитектурные решения:
1. Где живут виджеты?

    A: Прямо в TWLWindow — список контролов, окно их рисует и обрабатывает.

    B: Отдельный TWLForm — «панель с контролами», наследуется от окна или содержит окно.

    C: Отдельный контейнер TWLWidget — базовый класс для всех контролов, с деревом (родитель/дети).

Я за C — это правильная иерархия для будущего (панель содержит кнопки, редактор события содержит текстовые поля и т.д.).
2. Дерево виджетов
text

TWLWidget (базовый)
 ├── TWLButton
 ├── TWLLabel  (позже)
 ├── TWLEdit   (позже)
 ├── TWLListBox (позже)
 └── TWLContainer (панель с детьми)

У каждого виджета:

    Прямоугольник на экране (Rect: TRectI)

    Родитель (может быть nil — тогда координаты в окне)

    Дети (список)

    Callback'и: OnMouseDown, OnMouseUp, OnMouseMove, OnClick

    Флаг: Enabled, Visible, Focused

    Метод: Paint(ACanvas: TWLCanvas) — рисование себя

    Метод: HitTest(X, Y): TWLWidget — найти виджет под точкой

3. Как окно узнаёт о виджетах?

Окно имеет корневой контейнер (Root: TWLContainer). События мыши приходят в окно → окно передаёт их в корневой контейнер → контейнер находит виджет под точкой → виджет обрабатывает.

Событийная модель:
text

MouseDown → Window.OnMouseDown → Root.DispatchMouseDown(X, Y) → найти виджет → widget.OnMouseDown

4. Кто перерисовывает?

    A: Каждый виджет сам перерисовывает свою область при инвалидации.

    B: Виджеты только меняют состояние, а окно при Repaint рисует всё дерево заново.

Я за B — проще, и мы уже так делаем (OnPaint рисует всё).
Архитектура
wlgui_widget.pas
pascal

unit wlgui_widget;

type
  TWLWidget = class;
  TWLWidgetList = array of TWLWidget;

  TWLWidget = class
  private
    FRect: TRectI;
    FParent: TWLWidget;
    FChildren: TWLWidgetList;
    FVisible: Boolean;
    FEnabled: Boolean;
    FName: String;
  protected
    procedure DoPaint(ACanvas: TWLCanvas); virtual;
    procedure DoMouseDown(X, Y: Integer): Boolean; virtual;
    procedure DoMouseUp(X, Y: Integer): Boolean; virtual;
    procedure DoMouseMove(X, Y: Integer); virtual;
  public
    constructor Create(const ARect: TRectI); virtual;
    destructor Destroy; override;

    procedure AddChild(AChild: TWLWidget);
    procedure RemoveChild(AChild: TWLWidget);

    // Координаты относительно родителя
    function ScreenRect: TRectI;   // абсолютные координаты
    function HitTest(X, Y: Integer): TWLWidget;   // находит виджет под точкой (X,Y — абсолютные)

    // Отрисовка (вызывается из окна)
    procedure Paint(ACanvas: TWLCanvas);  // рисует себя и всех детей

    // Обработка событий (из окна)
    function DispatchMouseDown(X, Y: Integer): Boolean;
    function DispatchMouseUp(X, Y: Integer): Boolean;
    procedure DispatchMouseMove(X, Y: Integer);

    property Rect: TRectI read FRect write FRect;
    property Parent: TWLWidget read FParent;
    property Children: TWLWidgetList read FChildren;
    property Visible: Boolean read FVisible write FVisible;
    property Enabled: Boolean read FEnabled write FEnabled;
    property Name: String read FName write FName;
  end;

wlgui_button.pas
pascal

type
  TWLButtonState = (bsNormal, bsHover, bsPressed);

  TWLButton = class(TWLWidget)
  private
    FCaption: String;
    FFont: TWLFont;
    FState: TWLButtonState;
    FTextColor, FFaceColor, FHoverColor, FPressedColor: TWLColor;
    FOnClick: TWLNotifyEvent;   // процедура(Sender)
    FMouseDownInside: Boolean;
  protected
    procedure DoPaint(ACanvas: TWLCanvas); override;
    procedure DoMouseDown(X, Y: Integer): Boolean; override;
    procedure DoMouseUp(X, Y: Integer): Boolean; override;
    procedure DoMouseMove(X, Y: Integer); override;
  public
    constructor Create(const ARect: TRectI; const ACaption: String;
                       AFont: TWLFont); reintroduce;
    property Caption: String read FCaption write FCaption;
    property OnClick: TWLNotifyEvent read FOnClick write FOnClick;
  end;

Логика:

    MouseDown внутри — FState := bsPressed; FMouseDownInside := True.

    MouseMove — обновить FState в зависимости от попадания.

    MouseUp — если был FMouseDownInside и отпускание внутри — OnClick(Self).

    Paint — рисует фон, рамку, текст.

Изменения в TWLWindow

Добавить поле:
pascal

  FRoot: TWLWidget;

В конструкторе:
pascal

FRoot := TWLWidget.Create(TRectI.New(0, 0, AW, AH));

В OnPaint (в демо) — вызывать:
pascal

W.Root.Paint(W.Canvas);

В обработчиках мыши — сначала виджеты, потом окно:
pascal

procedure OnMouseDown(Sender; const E);
begin
  if not Win.Root.DispatchMouseDown(E.X, E.Y) then
  begin
    // никто не обработал — обрабатываем на уровне окна
  end;
end;

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

    Создать wlgui_widget.pas — базовый класс виджета с деревом и диспетчеризацией.

    Создать wlgui_button.pas — кнопка с 3 состояниями.

    Патч wlgui_window.pas — добавить FRoot: TWLWidget и публичное свойство Root.

    Патч demo_input.pas — добавить 3 кнопки, показать реакцию на клик в логе.

Пока без TWLForm / TWLContainer — корневой виджет сам контейнер (у него есть дети).
Вопросы

    API callback'ов — я предлагаю TWLNotifyEvent (процедура без параметров), как у нас уже есть. Или хочешь procedure(Sender: TWLWidget)? Первое проще для demo, второе — правильнее для органайзера.

    Стиль кнопки — простой: прямоугольник + рамка + центрированный текст. Пойдёт? Скруглённые углы, градиенты, иконки — позже.

    Как считать клик — просто «нажатие + отпускание внутри одной области»? Или нужен «double click»? Пока простой вариант.

    Фокус — нужен ли Focused флаг сейчас? Пригодится, когда будут текстовые поля. Сейчас можно не делать.

    Проверка на перекрытие — если две кнопки накладываются, верхняя (позже добавленная) выигрывает? Или последняя в списке? Предлагаю: HitTest идёт с конца списка (последние добавленные — сверху).

Скажи по пунктам — и я сразу дам полный код.
1. Где живут виджеты? Давай вариант C, я с тобой согласен. 4. Кто перерисовывает? B, тоже согласен. По остальным вопросам тоже согласен - пока простые проверенные решения. Давай.
Этап 6: Виджеты и кнопка

Идём по плану: TWLWidget (дерево) + TWLButton (первый контрол) + интеграция в окно.
1. wlgui_widget.pas
pascal

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

interface

uses
  Classes, SysUtils,
  wlgui_types, wlgui_canvas;

type
  TWLWidget = class;
  TWLWidgetList = array of TWLWidget;

  { Базовый класс виджета. }
  TWLWidget = class
  private
    FRect: TRectI;
    FParent: TWLWidget;
    FChildren: TWLWidgetList;
    FVisible: Boolean;
    FEnabled: Boolean;
    FName: String;
    function GetChild(Index: Integer): TWLWidget;
    function GetChildCount: Integer;
  protected
    { Переопределяются в наследниках. }
    procedure DoPaint(ACanvas: TWLCanvas); virtual;
    function  DoMouseDown(X, Y: Integer): Boolean; virtual;
    function  DoMouseUp(X, Y: Integer): Boolean; virtual;
    procedure DoMouseMove(X, Y: Integer); virtual;
  public
    constructor Create(const ARect: TRectI); virtual;
    destructor Destroy; override;

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

    { Геометрия: FRect — относительно родителя.
      ScreenRect — абсолютные координаты (сумма с родителями). }
    function ScreenRect: TRectI;
    function ClientRect: TRectI;    // (0,0,W,H) — локальные
    function HitTest(X, Y: Integer): TWLWidget;  // X,Y — абсолютные

    { Рисование: рисует себя и всех детей. }
    procedure Paint(ACanvas: TWLCanvas);

    { Диспетчеризация событий: возвращает True, если кто-то обработал. }
    function DispatchMouseDown(X, Y: Integer): Boolean;
    function DispatchMouseUp(X, Y: Integer): Boolean;
    procedure DispatchMouseMove(X, Y: Integer);

    { Свойства }
    property Rect: TRectI read FRect write FRect;
    property Parent: TWLWidget read FParent;
    property Children[Index: Integer]: TWLWidget read GetChild;
    property ChildCount: Integer read GetChildCount;
    property Visible: Boolean read FVisible write FVisible;
    property Enabled: Boolean read FEnabled write FEnabled;
    property Name: String read FName write FName;
  end;

implementation

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

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

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

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

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

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

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

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

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

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

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

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

  // Если ни один ребёнок не попал — это наш виджет
  if FEnabled then
    Result := Self;
end;

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

  DoPaint(ACanvas);

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

{ --- Диспетчеризация --- }

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

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

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

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

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

  // Отдаём событие тому же виджету, что получил MouseDown.
  // Простейший вариант: пусть каждый ребёнок попробует.
  // Для этого нужно запоминать "активный" виджет при MouseDown.
  // Пока для простоты: рассылаем всем детям, потом себе.

  for I := 0 to High(FChildren) do
  begin
    if FChildren[I].DispatchMouseUp(X, Y) then
      Exit(True);
  end;

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

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

  // Отдаём всем детям, потом себе — независимо от попадания.
  // Наследники сами решают, что делать (для hover-состояния).
  for I := 0 to High(FChildren) do
    FChildren[I].DispatchMouseMove(X, Y);

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

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

procedure TWLWidget.DoPaint(ACanvas: TWLCanvas);
begin
  // По умолчанию ничего не рисует
end;

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

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

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

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

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

end.

Замечание про DispatchMouseUp: сейчас каждый ребёнок пробует обработать MouseUp, даже если он не получал MouseDown. Для кнопки это не критично, потому что кнопка проверяет FMouseDownInside — если MouseDown не было, MouseUp проигнорируется.

Но для правильной архитектуры лучше запоминать «захваченный» виджет при MouseDown и отправлять MouseUp только ему. Сделаем это позже, когда добавим capture.
2. wlgui_button.pas
pascal

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

interface

uses
  SysUtils,
  wlgui_types, wlgui_canvas, wlgui_font, wlgui_widget;

type
  TWLButtonState = (bsNormal, bsHover, bsPressed);

  TWLButton = class(TWLWidget)
  private
    FCaption: String;
    FFont: TWLFont;
    FState: TWLButtonState;
    FMouseDownInside: Boolean;   // нажата ли кнопка и мышь внутри

    FFaceColor: TWLColor;
    FHoverColor: TWLColor;
    FPressedColor: TWLColor;
    FBorderColor: TWLColor;
    FTextColor: TWLColor;
    FBorderWidth: Integer;
    FPadding: Integer;

    FOnClick: TWLNotifyEvent;

    function CurrentFaceColor: TWLColor;
  protected
    procedure DoPaint(ACanvas: TWLCanvas); override;
    function  DoMouseDown(X, Y: Integer): Boolean; override;
    function  DoMouseUp(X, Y: Integer): Boolean; override;
    procedure DoMouseMove(X, Y: Integer); override;
  public
    constructor Create(const ARect: TRectI; const ACaption: String;
                       AFont: TWLFont);
    destructor Destroy; override;

    property Caption: String read FCaption write FCaption;
    property Font: TWLFont read FFont write FFont;
    property State: TWLButtonState read FState;
    property OnClick: TWLNotifyEvent read FOnClick write FOnClick;

    // Настройка внешнего вида
    property FaceColor: TWLColor read FFaceColor write FFaceColor;
    property HoverColor: TWLColor read FHoverColor write FHoverColor;
    property PressedColor: TWLColor read FPressedColor write FPressedColor;
    property BorderColor: TWLColor read FBorderColor write FBorderColor;
    property TextColor: TWLColor read FTextColor write FTextColor;
  end;

implementation

{ ============================================================ }
{  TWLButton                                                    }
{ ============================================================ }

constructor TWLButton.Create(const ARect: TRectI; const ACaption: String;
  AFont: TWLFont);
begin
  inherited Create(ARect);
  FCaption := ACaption;
  FFont := AFont;
  FState := bsNormal;
  FMouseDownInside := False;

  // Цвета по умолчанию
  FFaceColor    := TWLColor($00505050);
  FHoverColor   := TWLColor($00707070);
  FPressedColor := TWLColor($00303030);
  FBorderColor  := TWLColor($00A0A0A0);
  FTextColor    := TWLColor($00FFFFFF);
  FBorderWidth  := 1;
  FPadding      := 4;
end;

destructor TWLButton.Destroy;
begin
  inherited;
end;

function TWLButton.CurrentFaceColor: TWLColor;
begin
  case FState of
    bsHover:   Result := FHoverColor;
    bsPressed: Result := FPressedColor;
  else
    Result := FFaceColor;
  end;
end;

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

  R := ScreenRect;

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

  // Рамка
  if FBorderWidth > 0 then
  begin
    ACAnvas.Rect(R, FBorderColor);
    if FBorderWidth > 1 then
    begin
      // Толстая рамка — рисуем несколько концентрических
      InnerR := TRectI.New(R.X + 1, R.Y + 1, R.W - 2, R.H - 2);
      ACAnvas.Rect(InnerR, FBorderColor);
    end;
  end;

  // Текст по центру
  if FCaption <> '' then
  begin
    TextW := ACAnvas.TextWidth(FCaption, FFont);
    TextH := FFont.Height;
    TextX := R.X + (R.W - TextW) div 2;
    TextY := R.Y + (R.H - TextH) div 2;
    ACAnvas.TextOut(TextX, TextY, FCaption, FTextColor, FFont);
  end;
end;

function TWLButton.DoMouseDown(X, Y: Integer): Boolean;
begin
  FMouseDownInside := True;
  FState := bsPressed;
  Result := True;
end;

function TWLButton.DoMouseUp(X, Y: Integer): Boolean;
var
  WasPressed: Boolean;
begin
  Result := False;
  WasPressed := FMouseDownInside;
  FMouseDownInside := False;

  // Проверим, что отпускание внутри
  if ScreenRect.Contains(X, Y) then
    FState := bsHover
  else
    FState := bsNormal;

  if WasPressed and ScreenRect.Contains(X, Y) then
  begin
    // Полноценный клик
    if Assigned(FOnClick) then
      FOnClick(Self);
    Result := True;
  end;
end;

procedure TWLButton.DoMouseMove(X, Y: Integer);
begin
  if ScreenRect.Contains(X, Y) then
  begin
    if FState = bsNormal then
      FState := bsHover;
  end
  else
  begin
    if FState = bsHover then
      FState := bsNormal;
  end;
end;

end.

3. Патч wlgui_window.pas

Добавляем поле FRoot: TWLWidget и свойство Root.

В uses:
pascal

  wlgui_types, wlgui_context, wlgui_canvas, wlgui_events, wlgui_widget;

В TWLWindow, в private:
pascal

    FRoot: TWLWidget;

В TWLWindow, в public (рядом с Canvas):
pascal

    property Root: TWLWidget read FRoot;

В TWLWindow.Create, после FSurface.Commit:
pascal

  // Создаём корневой виджет
  FRoot := TWLWidget.Create(TRectI.New(0, 0, FWidth, FHeight));

В TWLWindow.Destroy, в начале:
pascal

  if FRoot <> nil then FreeAndNil(FRoot);

В TWLWindow.InternalHandleToplevelConfigure — при ресайзе обновить Root.Rect:
pascal

    if FRoot <> nil then
      FRoot.Rect := TRectI.New(0, 0, FWidth, FHeight);

4. Обновлённый demo_input.pas
pascal

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

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

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

  MouseX, MouseY: Integer;
  MouseDown: Boolean;
  MouseBtn: Integer;
  LastKey: String;
  LastCodepoint: LongWord;
  KeyCounter: Integer = 0;
  ModText: String = '';

  Btn1, Btn2, Btn3: TWLButton;
  ClickLog: String = '(no clicks)';
  ClickCounter: Integer = 0;

procedure BtnClick(Sender: TObject);
begin
  Inc(ClickCounter);
  if Sender = Btn1 then ClickLog := 'Btn1 (OK) clicks=' + IntToStr(ClickCounter)
  else if Sender = Btn2 then ClickLog := 'Btn2 (Cancel) clicks=' + IntToStr(ClickCounter)
  else if Sender = Btn3 then ClickLog := 'Btn3 (Help) clicks=' + IntToStr(ClickCounter);
  WriteLn('[demo] ', ClickLog);
end;

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

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

  // Верхняя часть — информация (рисуется напрямую, без виджетов)
  C.TextOut(20, 20, 'wlgui — виджеты: кнопка', clWhite, TitleFont);

  S := Format('Mouse: %d, %d   Button: %d   Down: %s',
              [MouseX, MouseY, MouseBtn,
               BoolToStr(MouseDown, 'yes', 'no')]);
  C.TextOut(20, 70, S, clYellow, MonoFont);

  S := 'Mods: ';
  if ModText = '' then S := S + '(none)' else S := S + ModText;
  C.TextOut(20, 95, S, clLtGray, MonoFont);

  C.TextOut(20, 120, 'Last key: ' + LastKey, clCyan, MonoFont);

  // Лог кликов
  C.TextOut(20, 150, ClickLog, clGreen, MonoFont);

  // Кнопки — рисуются через дерево виджетов
  if W.Root <> nil then
    W.Root.Paint(C);

  // Полоса прогресса (анимация, чтобы видеть, что окно живое)
  C.FillRect(TRectI.New(20, W.Height - 30,
                        (Frame * 2) mod (W.Width - 40), 10), clGreen);
end;

procedure UpdateModText;
var
  M: TWLModifiers;
  EM: TWLEventManager;
begin
  if (MyApp = nil) or (MyApp.Context = nil) or (MyApp.Context.Events = nil) then
  begin
    ModText := '';
    Exit;
  end;
  EM := TWLEventManager(MyApp.Context.Events);
  M := EM.Modifiers;
  ModText := '';
  if M.Shift then ModText := ModText + 'Shift ';
  if M.Ctrl then ModText := ModText + 'Ctrl ';
  if M.Alt then ModText := ModText + 'Alt ';
  if M.Super then ModText := ModText + 'Super ';
  if M.CapsLock then ModText := ModText + 'Caps ';
  if M.NumLock then ModText := ModText + 'Num ';
  ModText := Trim(ModText);
end;

procedure OnMouseDown(Sender: TObject; const E: TWLMouseEvent);
begin
  MouseDown := True;
  MouseBtn := E.Button;
  MouseX := E.X;
  MouseY := E.Y;
  // Сначала виджеты
  if (Win <> nil) and (Win.Root <> nil) then
    if Win.Root.DispatchMouseDown(E.X, E.Y) then
      Exit;
  WriteLn('[demo] mouse down at ', E.X, ',', E.Y, ' button=', E.Button);
end;

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

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

procedure OnMouseWheel(Sender: TObject; const E: TWLWheelEvent);
begin
  WriteLn('[demo] wheel dx=', E.DeltaX, ' dy=', E.DeltaY);
end;

procedure OnMouseEnter(Sender: TObject);
begin
  WriteLn('[demo] mouse enter');
end;

procedure OnMouseLeave(Sender: TObject);
begin
  WriteLn('[demo] mouse leave');
  MouseDown := False;
end;

procedure OnKeyDown(Sender: TObject; const E: TWLKeyEvent);
begin
  Inc(KeyCounter);
  LastCodepoint := E.Codepoint;
  if E.Codepoint >= 32 then
    LastKey := Format('cp=U+%04X sym=$%X code=%d',
                      [E.Codepoint, E.Keysym, E.Scancode])
  else
    LastKey := Format('(спец) sym=$%X code=%d', [E.Keysym, E.Scancode]);
  UpdateModText;
end;

procedure OnKeyUp(Sender: TObject; const E: TWLKeyEvent);
begin
  UpdateModText;
end;

procedure OnFocusIn(Sender: TObject);
begin
  WriteLn('[demo] focus in');
end;

procedure OnFocusOut(Sender: TObject);
begin
  WriteLn('[demo] focus out');
end;

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

var
  LastTick: QWord;

begin
  WriteLn('wlgui widgets demo');

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

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

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

    Win.OnPaint := @OnPaint;
    Win.OnMouseDown := @OnMouseDown;
    Win.OnMouseUp := @OnMouseUp;
    Win.OnMouseMove := @OnMouseMove;
    Win.OnMouseWheel := @OnMouseWheel;
    Win.OnMouseEnter := @OnMouseEnter;
    Win.OnMouseLeave := @OnMouseLeave;
    Win.OnKeyDown := @OnKeyDown;
    Win.OnKeyUp := @OnKeyUp;
    Win.OnFocusIn := @OnFocusIn;
    Win.OnFocusOut := @OnFocusOut;
    Win.OnClose := @OnClose;

    MyApp.Display.Roundtrip;
    Win.Show;

    // Создаём кнопки после Show (когда Root уже создан в окне)
    if Win.Root <> nil then
    begin
      Btn1 := TWLButton.Create(TRectI.New(20, 200, 180, 40), 'OK', ButtonFont);
      Btn1.OnClick := @BtnClick;
      Win.Root.AddChild(Btn1);

      Btn2 := TWLButton.Create(TRectI.New(220, 200, 180, 40), 'Cancel', ButtonFont);
      Btn2.OnClick := @BtnClick;
      Win.Root.AddChild(Btn2);

      Btn3 := TWLButton.Create(TRectI.New(420, 200, 180, 40), 'Help', ButtonFont);
      Btn3.OnClick := @BtnClick;
      Win.Root.AddChild(Btn3);
    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.

Что должно получиться

    Окно с текстом сверху (инфо о мыши, клавишах, модификаторах).

    Три кнопки внизу: OK, Cancel, Help.

    Кнопки подсвечиваются при наведении (hover).

    Кнопки темнеют при нажатии.

    При клике — в лог пишется [demo] Btn1 (OK) clicks=1 и т.д., и в окне обновляется строка ClickLog.

Что важно проверить

    Кнопки рисуются в правильных местах.

    Hover работает — цвет меняется при наведении.

    Pressed работает — темнеет при нажатии.

    Click срабатывает — только если нажатие и отпускание внутри.

    DispatchMouseMove проходит всем детям — даже если курсор не над кнопкой, DoMouseMove вызывается (для сброса состояния).

Если что-то не так

    Кнопки не рисуются — проверь, что W.Root.Paint(C) вызывается после рисования фона.

    Hover не работает — проверь, что OnMouseMove вызывает Win.Root.DispatchMouseMove.

    Click срабатывает при отпускании вне кнопки — в DoMouseUp мы проверяем ScreenRect.Contains(X, Y). Должно работать.

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