feat(slices): этап 3.3 — UI стек панадаптеров

Несколько панадаптеров в главном окне (кнопка «⊞», до MAX_PANS):
- TPanafallPanel: регион в стеке (StackTop/Height), шапка с «×»,
  pan-aware ViewWindow, FirstSliceId; явный crArrow GL-канвам
  (WA_NativeWindow-поверхность на Wayland навсегда наследует курсор
  момента создания — прилипала «рука» кнопки «⊞»)
- MainForm: FPans[]/LayoutPanStack (доли + межпанные сплиттеры с живым
  призраком), создание/закрытие панов (DDC+слайс+вьюха), пиксели панов
  из display-потока под FPanCbLock, рендер по dirty в тике
- Мышь панов N: Ctrl+ЛКМ = слайс, drag маркера = ретюн слайса,
  drag фона = live-ретюн DDC (гистерезис 4px), клик = тюн слайса пана
  (PanClickTune, снап 100 Гц), флаг-диспетчер только на спектре
- Сплиттер спектр/водопад: drag-математика от региона пана
  (FSplitTopOff/FSplitAvailH из Layout), OnSplitterMoved у панов N
- Паны живут в Run-сессии, умирают на STOP

Проверено юзером на экране (3 раунда фидбека) + e2e против hpsdrsim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 22:56:49 +03:00
co-authored by Claude Fable 5
parent dae0c19375
commit 95b4f749be
4 changed files with 1059 additions and 54 deletions
+172 -26
View File
@@ -31,7 +31,7 @@ unit PanafallPanel;
interface
uses
Classes, SysUtils, Math, Controls, ExtCtrls, Graphics, Forms,
Classes, SysUtils, Math, Controls, ExtCtrls, StdCtrls, Graphics, Forms,
LCLIntf, LCLType,
OpenGLContextEx,
AppTheme, WDSPEngine, RadioController, VfoOverlay,
@@ -70,6 +70,10 @@ type
FSplitterDrag: Boolean;
FSplitterDragY0: Integer;
FSplitterSH0: Integer;
// Геометрия последнего Layout для drag-математики: верх области спектра и
// высота спектр+водопад ВНУТРИ региона пана (не всего родителя!).
FSplitTopOff: Integer;
FSplitAvailH: Integer; // 0 = сплиттер сейчас нелегитимен
FOnSplitterMoved: TNotifyEvent;
@@ -85,6 +89,27 @@ type
FOnSliceClosed: TSliceClosedEvent;
FOnFlagsInvalidate: TNotifyEvent;
// ---- Стек панов (этап 3.3) ----
FPanId: Integer; // 0 = главный пан
FStackTop: Integer; // Y-начало региона пана внутри родителя
FStackHeight: Integer; // высота региона (0 = до низа родителя)
FHeaderPanel: TPanel; // шапка пана (видна при панах >= 2)
FHeaderLabel: TLabel;
FBtnClosePan: TFlatButton; // «×» в шапке (только паны N>0)
FBtnAddPan: TFlatButton; // «⊞» в ряду пан/зума (только пан 0)
FHeaderVisible: Boolean;
FShowZoomRow: Boolean; // False у панов N>0 (пан-зум — этап 3.3+)
FOnPanClose: TNotifyEvent;
FOnAddPan: TNotifyEvent;
FAddPanWanted: Boolean; // хозяин: показывать «⊞» в ряду пан/зума
procedure BtnClosePanClick(Sender: TObject);
procedure BtnAddPanClick(Sender: TObject);
procedure PositionHeaderChildren(RW, H: Integer);
// Видимое частотное окно ЭТОГО пана: пан 0 — окно главного (зум/пан),
// паны N — центр их DDC ± rate/2 (пан-зум — этап 3.3+).
procedure ViewWindow(out VC, VS: Double);
procedure SplitterMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure SplitterMouseMove(Sender: TObject; Shift: TShiftState;
@@ -138,6 +163,25 @@ type
property SpectrumHeight: Integer read FSpectrumHeight;
property WaterfallHeight: Integer read FWaterfallHeight;
property SplitterRatio: Double read FSplitterRatio write FSplitterRatio;
// ---- Стек панов (этап 3.3) ----
// Регион пана внутри родителя задаёт хозяин ПЕРЕД Layout (стек панов).
property PanId: Integer read FPanId write FPanId;
property StackTop: Integer read FStackTop write FStackTop;
property StackHeight: Integer read FStackHeight write FStackHeight;
// Шапка пана: текст ставит хозяин (частота/rate), «×» → OnPanClose.
property HeaderVisible: Boolean read FHeaderVisible write FHeaderVisible;
procedure SetHeaderText(const S: string);
property HeaderPanel: TPanel read FHeaderPanel;
property HeaderLabel: TLabel read FHeaderLabel;
property BtnClosePan: TFlatButton read FBtnClosePan;
property BtnAddPan: TFlatButton read FBtnAddPan;
// Ряд пан/зума: у панов N>0 скрыт (их зум — этап 3.3+).
property ShowZoomRow: Boolean read FShowZoomRow write FShowZoomRow;
// «⊞» в ряду пан/зума (пан 0): показать/спрятать решает хозяин.
property AddPanEnabled: Boolean read FAddPanWanted write FAddPanWanted;
property OnPanClose: TNotifyEvent read FOnPanClose write FOnPanClose;
property OnAddPan: TNotifyEvent read FOnAddPan write FOnAddPan;
// Пользователь отпустил сплиттер (ratio уже обновлён) — хозяин делает
// полный пересчёт (у него wideband/S-метр/оверлеи).
property OnSplitterMoved: TNotifyEvent read FOnSplitterMoved write FOnSplitterMoved;
@@ -148,6 +192,8 @@ type
procedure AttachMainFlag(O: TVfoOverlay);
// SliceId флага, чьё событие сейчас обрабатывается (0 = главный).
function CurSliceId: Integer;
// Первый слайс панели (0 = слайсов B+ нет) — цель клика-тюна пана N.
function FirstSliceId: Integer;
function FindSliceFlag(SliceId: Integer): TVfoOverlay;
procedure CreateSliceFlag(SliceId: Integer);
procedure DestroySliceFlag(SliceId: Integer);
@@ -199,6 +245,8 @@ begin
FZoomBar := TPanZoomBar.Create;
FSplitterRatio := 0.40;
FSplitterDrag := False;
FShowZoomRow := True; // паны N>0 выключают (их зум — этап 3.3+)
FHeaderVisible := False; // шапка появляется при панах >= 2
end;
destructor TPanafallPanel.Destroy;
@@ -239,6 +287,11 @@ begin
TPaintBox(FPbSpectrum).OnMouseLeave := SpectrumMouseLeave;
end;
FPbSpectrum.Parent := AParent;
// ЯВНЫЙ crArrow (не crDefault!): GL-виджет с WA_NativeWindow — отдельная
// Wayland-поверхность; без явного курсора она навсегда наследует курсор,
// бывший под мышью в момент создания (пан создаётся кнопкой «⊞» с
// crHandPoint → «рука» прилипала ко всему пану).
FPbSpectrum.Cursor := crArrow;
FPbRuler := TPaintBox.Create(Self);
FPbRuler.Parent := AParent;
@@ -277,6 +330,7 @@ begin
TPaintBox(FPbWaterfall).OnMouseUp := WaterfallMouseUp;
end;
FPbWaterfall.Parent := AParent;
FPbWaterfall.Cursor := crArrow; // см. комментарий у FPbSpectrum.Cursor
// ---- Панель пана/зума под водопадом ----
// Кнопки создаём с дефолтными цветами; финальная тема — у хозяина
@@ -292,27 +346,96 @@ begin
FBtnZoomOut := MakeFlatBtn(AParent, '', 0, 0, 10, 10, ZoomOutClick);
FBtnZoomDef := MakeFlatBtn(AParent, '⌂', 0, 0, 10, 10, ZoomDefClick);
FBtnZoomIn := MakeFlatBtn(AParent, '+', 0, 0, 10, 10, ZoomInClick);
// ---- Кнопка «добавить пан» в ряду пан/зума (хозяин включает на пане 0) ----
FBtnAddPan := MakeFlatBtn(AParent, '⊞', 0, 0, 10, 10, BtnAddPanClick);
FBtnAddPan.Hint := 'Add panadapter';
FBtnAddPan.ShowHint := True;
FBtnAddPan.Visible := False;
// ---- Шапка пана (тонкая полоска над спектром; видна при панах >= 2) ----
FHeaderPanel := TPanel.Create(Self);
FHeaderPanel.Parent := AParent;
FHeaderPanel.BevelOuter := bvNone;
FHeaderPanel.Visible := False;
FHeaderLabel := TLabel.Create(Self);
FHeaderLabel.Parent := FHeaderPanel;
FHeaderLabel.AutoSize := True;
FHeaderLabel.Left := 8;
FBtnClosePan := MakeFlatBtn(FHeaderPanel, '×', 0, 0, 10, 10, BtnClosePanClick);
FBtnClosePan.Visible := False; // хозяин включает у панов N>0
end;
procedure TPanafallPanel.BtnClosePanClick(Sender: TObject);
begin
if Assigned(FOnPanClose) then FOnPanClose(Self);
end;
procedure TPanafallPanel.BtnAddPanClick(Sender: TObject);
begin
if Assigned(FOnAddPan) then FOnAddPan(Self);
end;
procedure TPanafallPanel.SetHeaderText(const S: string);
begin
if FHeaderLabel <> nil then FHeaderLabel.Caption := S;
end;
procedure TPanafallPanel.ViewWindow(out VC, VS: Double);
begin
VC := 0; VS := 0;
if FController = nil then Exit;
if FPanId = 0 then
FController.GetViewWindow(VC, VS)
else
begin
VC := FController.PanDDCFreq(FPanId);
VS := FController.PanDDCRateKHz(FPanId) * 1000.0;
end;
end;
procedure TPanafallPanel.PositionHeaderChildren(RW, H: Integer);
var BtnS: Integer;
begin
if FHeaderLabel <> nil then
FHeaderLabel.Top := (H - FHeaderLabel.Height) div 2;
if FBtnClosePan <> nil then
begin
BtnS := H - 4;
FBtnClosePan.SetBounds(RW - BtnS - 2, 2, BtnS, BtnS);
end;
end;
procedure TPanafallPanel.PositionPanZoomBar(BottomY, RW, H: Integer;
AVisible: Boolean);
var BtnW, Gap, StripW, X: Integer;
var BtnW, Gap, StripW, X, NBtns: Integer; WantAdd: Boolean;
begin
if (FPbPanZoom = nil) or (FBtnZoomIn = nil) then Exit;
// «⊞» добавления пана живёт в этом же ряду; хозяин включает его флагом
// AddPanEnabled (Visible кнопки управляется здесь вместе с рядом).
WantAdd := FAddPanWanted and (FBtnAddPan <> nil);
FPbPanZoom.Visible := AVisible;
FBtnZoomIn.Visible := AVisible;
FBtnZoomDef.Visible := AVisible;
FBtnZoomOut.Visible := AVisible;
if FBtnAddPan <> nil then FBtnAddPan.Visible := AVisible and WantAdd;
if not AVisible then Exit;
Gap := MulDiv(2, Screen.PixelsPerInch, 96);
BtnW := H; // квадратные кнопки в высоту строки
StripW := RW - 3 * BtnW - 4 * Gap;
NBtns := 3;
if WantAdd then NBtns := 4;
StripW := RW - NBtns * BtnW - (NBtns + 1) * Gap;
if StripW < 20 then StripW := 20;
FPbPanZoom.SetBounds(0, BottomY, StripW, H);
X := StripW + Gap;
FBtnZoomOut.SetBounds(X, BottomY, BtnW, H); Inc(X, BtnW + Gap);
FBtnZoomDef.SetBounds(X, BottomY, BtnW, H); Inc(X, BtnW + Gap);
FBtnZoomIn.SetBounds(X, BottomY, BtnW, H);
if WantAdd then
begin
Inc(X, BtnW + Gap);
FBtnAddPan.SetBounds(X, BottomY, BtnW, H);
end;
FPbPanZoom.Invalidate;
end;
@@ -327,20 +450,38 @@ var
AvailH: Integer;
RULER_H: Integer;
PANZOOM_H: Integer;
HEADER_H: Integer;
ShowPZ: Boolean;
begin
if (FParent = nil) or (FPbSpectrum = nil) then Exit;
FShowSpectrum := AShowSpectrum;
FShowWaterfall := AShowWaterfall;
FSplitAvailH := 0; // валидно только в режиме «спектр+водопад» (ниже)
RULER_H := MulDiv(18, Screen.PixelsPerInch, 96);
RW := FParent.ClientWidth;
RH := FParent.ClientHeight;
// Регион пана в стеке: [FStackTop .. RH). 0/0 = весь родитель (один пан).
if FStackHeight > 0 then
RH := FStackTop + FStackHeight
else
RH := FParent.ClientHeight;
// Шапка пана — первой строкой региона (видна при панах >= 2).
HEADER_H := MulDiv(20, Screen.PixelsPerInch, 96);
if FHeaderPanel <> nil then
begin
FHeaderPanel.Visible := FHeaderVisible;
if FHeaderVisible then
begin
FHeaderPanel.SetBounds(0, FStackTop, RW, HEADER_H);
PositionHeaderChildren(RW, HEADER_H);
end;
end;
// Резервируем низ под панель пана/зума (видна, если виден спектр или водопад).
PANZOOM_H := MulDiv(22, Screen.PixelsPerInch, 96);
ShowPZ := AShowSpectrum or AShowWaterfall;
ShowPZ := (AShowSpectrum or AShowWaterfall) and FShowZoomRow;
PositionPanZoomBar(RH - PANZOOM_H, RW, PANZOOM_H, ShowPZ);
if ShowPZ then RH := RH - PANZOOM_H;
TopOff := ATopOffset;
TopOff := ATopOffset + FStackTop;
if FHeaderVisible then Inc(TopOff, HEADER_H);
// ---- Оба скрыты ----
if (not AShowSpectrum) and (not AShowWaterfall) then
@@ -417,6 +558,9 @@ begin
AvailH := RH - TopOff - RULER_H - SPLITTER_H;
if AvailH < (MIN_SH + MIN_WH) then Exit;
// Запоминаем геометрию для drag-математики сплиттера (регион пана в стеке).
FSplitTopOff := TopOff;
FSplitAvailH := AvailH;
// Ограничиваем соотношение, чтобы каждая зона имела минимальный размер
if FSplitterRatio < MIN_SH / AvailH then
@@ -494,15 +638,14 @@ const
begin
RULER_H := MulDiv(18, Screen.PixelsPerInch, 96);
if not FSplitterDrag then Exit;
// Геометрия региона ЭТОГО пана из последнего Layout (стек панов!),
// а не от всего родителя.
TopOff := FSplitTopOff;
AvailH := FSplitAvailH;
if AvailH <= 0 then Exit;
P := FParent.ScreenToClient(FSplitter.ClientToScreen(Point(X, Y)));
DeltaY := P.Y - FSplitterDragY0;
TopOff := 0;
// Низ зарезервирован под панель пана/зума (она видна, пока виден водопад).
AvailH := FParent.ClientHeight - TopOff - RULER_H - SPLITTER_H
- MulDiv(22, Screen.PixelsPerInch, 96);
if AvailH <= 0 then Exit;
NewSH := FSplitterSH0 + DeltaY;
if NewSH < MIN_SH then NewSH := MIN_SH;
if NewSH > AvailH - MIN_WH then NewSH := AvailH - MIN_WH;
@@ -519,14 +662,11 @@ var
P: TPoint;
DeltaY: Integer;
NewSH: Integer;
TopOff, AvailH: Integer;
RULER_H: Integer;
AvailH: Integer;
const
SPLITTER_H = 5;
MIN_SH = 60;
MIN_WH = 40;
MIN_SH = 60;
MIN_WH = 40;
begin
RULER_H := MulDiv(18, Screen.PixelsPerInch, 96);
if not FSplitterDrag then Exit;
FSplitterDrag := False;
{$IFDEF WINDOWS}
@@ -537,10 +677,8 @@ begin
P := FParent.ScreenToClient(FSplitter.ClientToScreen(Point(X, Y)));
DeltaY := P.Y - FSplitterDragY0;
TopOff := 0;
// Низ зарезервирован под панель пана/зума (она видна, пока виден водопад).
AvailH := FParent.ClientHeight - TopOff - RULER_H - SPLITTER_H
- MulDiv(22, Screen.PixelsPerInch, 96);
// Высота спектр+водопад региона ЭТОГО пана из последнего Layout (стек панов!).
AvailH := FSplitAvailH;
if AvailH <= 0 then Exit;
NewSH := FSplitterSH0 + DeltaY;
@@ -595,6 +733,14 @@ begin
if Assigned(FActiveFlag) then Result := FActiveFlag.SliceId else Result := 0;
end;
function TPanafallPanel.FirstSliceId: Integer;
var i: Integer;
begin
Result := 0;
for i := 0 to High(FSliceFlags) do
if FSliceFlags[i] <> nil then Exit(FSliceFlags[i].SliceId);
end;
function TPanafallPanel.FindSliceFlag(SliceId: Integer): TVfoOverlay;
var i: Integer;
begin
@@ -647,7 +793,7 @@ begin
O.Free;
if Assigned(FOnSliceClosed) then FOnSliceClosed(SliceId);
UpdateMainFlagVisibility; // закрыт последний слайс при открытой панели → скрыть флаг A
if Assigned(FOnFlagsInvalidate) then FOnFlagsInvalidate(nil);
if Assigned(FOnFlagsInvalidate) then FOnFlagsInvalidate(Self); // Self = панель (роутинг по пану)
end;
procedure TPanafallPanel.RequestSliceClose(SliceId: Integer);
@@ -675,7 +821,7 @@ var
begin
Result := 0;
if (FPbSpectrum = nil) or (FPbSpectrum.Width <= 0) or (FController = nil) then Exit;
FController.GetViewWindow(VC, VS);
ViewWindow(VC, VS);
if VS <= 0 then Exit;
for i := 0 to High(FSliceFlags) do
begin
@@ -738,7 +884,7 @@ var
begin
if (FPbSpectrum = nil) or (FController = nil) then Exit;
W := FPbSpectrum.Width;
FController.GetViewWindow(VC, VS);
ViewWindow(VC, VS);
if (W <= 0) or (VS <= 0) then Exit;
// 1. Собрать видимые флаги + их идеальный Left рядом со своей полосой.
@@ -809,7 +955,7 @@ begin
Moved := True;
end;
end;
if Moved and Assigned(FOnFlagsInvalidate) then FOnFlagsInvalidate(nil);
if Moved and Assigned(FOnFlagsInvalidate) then FOnFlagsInvalidate(Self);
end;
// Флаг главного приёмника (слайс A) виден, когда СКРЫТА левая панель хозяина