diff --git a/MainForm.pas b/MainForm.pas
index b67f9fe..3de145c 100644
--- a/MainForm.pas
+++ b/MainForm.pas
@@ -24,7 +24,7 @@ interface
uses
Classes, SysUtils, StrUtils, FreqDisplay, DeviceForm, VfoOverlay, SampleRateOverlay, BandPlanOverlay,
FlatButton, FlatSlider, FlatDropDown, AppTheme, Forms, Controls, Graphics, Dialogs,
- StdCtrls, ExtCtrls, Buttons, Menus, Math, Types,
+ StdCtrls, ExtCtrls, Buttons, Menus, Math, Types, SyncObjs,
OpenGLContextEx,
LCLIntf, LCLType, GraphType,
HPSDRProtocol, HPSDRNetwork,
@@ -159,6 +159,31 @@ type
// (исторические имена; постепенная миграция на FPan.*). Размеры областей —
// FPan.SpectrumWidth/SpectrumHeight/WaterfallHeight.
FPan: TPanafallPanel;
+ // ---- Стек панов (этап 3.3): FPans[0] = FPan, FPans[N>0] — доп. паны на
+ // своих DDC (nil = не создан). Сплиттер FPanSplitters[i] стоит НАД паном i.
+ FPans: array[0..MAX_PANS-1] of TPanafallPanel;
+ FPanSplitters: array[1..MAX_PANS-1] of TPanel;
+ FPanShare: array[0..MAX_PANS-1] of Double; // доля высоты стека
+ FPanSpecDirty: array[0..MAX_PANS-1] of Boolean; // кадр пана готов к рендеру
+ FPanWfDirty: array[0..MAX_PANS-1] of Boolean;
+ FPanCbLock: SyncObjs.TCriticalSection; // жизнь FPans[i] vs пиксельные колбэки
+ // Мышь на пане N>0: drag слайса или drag центра пана (ретюн DDC).
+ FPanSliceDrag: Boolean;
+ FPanSliceDragId: Integer;
+ FPanCenterDrag: Boolean;
+ FPanCenterMoved: Boolean; // был реальный сдвиг (иначе up = клик-тюн)
+ FPanCenterDragX0: Integer;
+ FPanCenterDragF0: Double;
+ FPanMousePan: TPanafallPanel; // пан, в котором идёт drag
+ // Сплиттер между панами (индекс пана, чей верхний сплиттер тащим; 0 = нет).
+ // Во время drag сплиттер двигается «призраком» (клампы в FPanSplitMin/MaxTop),
+ // пересчёт долей — по отпусканию.
+ FPanSplitDrag: Integer;
+ FPanSplitY0: Integer;
+ FPanSplitTop0: Integer;
+ FPanSplitMinTop: Integer;
+ FPanSplitMaxTop: Integer;
+ FPendingPanClose: Integer; // «×» шапки: PanId к закрытию в тике (0 = нет)
FSpecView: TSpectrumView;
FWidebandView: TWidebandView;
FUseOpenGLSpectrum: Boolean;
@@ -533,6 +558,44 @@ type
function CurSliceId: Integer; // SliceId обрабатываемого сейчас флага
procedure WireOverlayEvents(O: TVfoOverlay);
procedure AddSliceAtFreq(Hz: Double);
+ // --- Стек панов (этап 3.3) ---
+ function PanCount: Integer; // активных панов (>=1)
+ function PanFromSender(Sender: TObject): TPanafallPanel;
+ function PanOfOverlay(Sender: TObject): TPanafallPanel;
+ procedure PushFlagStateAllPans(SliceId: Integer);
+ procedure LayoutFlagsAllPans;
+ procedure OnAddPanClick(Sender: TObject); // «⊞» пана 0
+ procedure OnPanHeaderClose(Sender: TObject); // «×» шапки пана
+ procedure ClosePanadapter(PanId: Integer);
+ procedure DestroyAllExtraPans; // stop/выход
+ procedure UpdatePanHeaders; // текст+видимость шапок
+ procedure UpdateAddPanButton;
+ procedure AddSliceAtFreqPan(P: TPanafallPanel; Hz: Double);
+ procedure PanClickTune(P: TPanafallPanel; X, W: Integer); // клик по фону пана N
+ procedure PanViewWindow(P: TPanafallPanel; out VC, VS: Double);
+ procedure SyncPanViews; // центр/span вьюх панов
+ procedure MarkPanDirty(P: TPanafallPanel); // репейнт пана N
+ // Мышь панов N>0 (у пана 0 семантика ниже, PbSpectrum*).
+ procedure PanNSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
+ Shift: TShiftState; X, Y: Integer);
+ procedure PanNSpectrumMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
+ procedure PanNSpectrumMouseUp(Sender: TObject; Button: TMouseButton;
+ Shift: TShiftState; X, Y: Integer);
+ procedure PanNSpectrumMouseLeave(Sender: TObject);
+ procedure PanNSpectrumDblClick(Sender: TObject);
+ // Пиксели панов из display-потока движка.
+ procedure OnPanSpectrumReady(PanId: Integer; const Pixels: array of Single;
+ Count: Integer);
+ procedure OnPanWaterfallReady(PanId: Integer; const Pixels: array of Single;
+ Count: Integer);
+ // Сплиттеры между панами.
+ procedure PanSplitMouseDown(Sender: TObject; Button: TMouseButton;
+ Shift: TShiftState; X, Y: Integer);
+ procedure PanSplitMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
+ procedure PanSplitMouseUp(Sender: TObject; Button: TMouseButton;
+ Shift: TShiftState; X, Y: Integer);
+ procedure LayoutPanStack(AWideH: Integer); // регионы панов + Layout каждого
+ function Pan0HeaderH: Integer; // высота шапки пана 0 (0 без стека)
procedure PbSpectrumDblClick(Sender: TObject);
procedure TrkDriveChange(Sender: TObject);
procedure UpdateDriveLabel; // % (openHPSDR) / дБ (Pluto) рядом с ползунком drive
@@ -990,6 +1053,12 @@ begin
FPan.OnSliceClosed := OnPanSliceClosed;
FPan.OnFlagsInvalidate := OnVfoOverlayInvalidate;
FPan.AttachMainFlag(FVfoOverlay); // внутри: FSpecView.VfoOverlay := флаг A
+ // Стек панов (этап 3.3): пан 0 — в общем массиве; «⊞» добавляет пан.
+ FPan.PanId := 0;
+ FPan.OnAddPan := OnAddPanClick;
+ FPans[0] := FPan;
+ FPanShare[0] := 1.0;
+ FPanCbLock := SyncObjs.TCriticalSection.Create;
FSpecView.VfoA := FController.FVfoA;
FSpecView.VfoB := FController.FVfoB;
FSpecView.ActiveVfo := FController.FActiveVfo;
@@ -1040,6 +1109,9 @@ begin
FController.FDSPEngine.OnAudio := FController.OnAudioReady;
FController.FDSPEngine.OnSpectrum := FController.OnSpectrumReady;
FController.FDSPEngine.OnWaterfall := FController.OnWaterfallReady;
+ // Пиксели доп. панов (этап 3.3) — прямо в UI (display-поток; см. FPanCbLock).
+ FController.FDSPEngine.OnPanSpectrum := OnPanSpectrumReady;
+ FController.FDSPEngine.OnPanWaterfall := OnPanWaterfallReady;
FController.FDSPEngine.OnTXIQ := FController.OnTXIQ;
// Web-клиент перехватывает RX-аудио (моно-микс) у контроллера, если подключён.
FController.OnAudioConsume := FWebAdapter.AudioConsume;
@@ -1093,7 +1165,10 @@ begin
PanelSplitter := nil;
PbWaterfall := nil;
PbPanZoom := nil;
+ DestroyAllExtraPans; // доп. паны (DDC/движок/панели) — до пана 0
FreeAndNil(FPan);
+ FPans[0] := nil;
+ FreeAndNil(FPanCbLock);
FreeAndNil(FWidebandView);
// Сохраняем настройки при закрытии
@@ -1216,6 +1291,8 @@ begin
BtnStartStop.Caption := 'STOP';
StyleButton(BtnStartStop, True);
BtnMOX.Enabled := True;
+ UpdateAddPanButton; // «⊞» доступна только в Run
+ ResizeSpectrumPanels;
end
else
begin
@@ -1223,6 +1300,11 @@ begin
BtnStartStop.Caption := 'START';
StyleButton(BtnStartStop, False);
BtnMOX.Enabled := False;
+ // Доп. паны живут только в Run-сессии (их DDC уже погашены
+ // контроллером в StopRunning) — убираем и UI.
+ DestroyAllExtraPans;
+ UpdateAddPanButton;
+ ResizeSpectrumPanels;
end;
rfADCOverload:
if FSpecView <> nil then
@@ -2435,7 +2517,8 @@ begin
WideSpecH := Max(1, WideH - RULER_H);
if PbWideband <> nil then
begin
- PbWideband.SetBounds(0, 0, RW, WideSpecH);
+ // При стеке панов над wideband стоит шапка пана 0.
+ PbWideband.SetBounds(0, Pan0HeaderH, RW, WideSpecH);
PbWideband.Visible := FController.FShowWideband and (WideSpecH > 0);
if FController.FShowWideband and (RW > 0) and (WideSpecH > 0) then
begin
@@ -2445,7 +2528,7 @@ begin
end;
if PbWidebandRuler <> nil then
begin
- PbWidebandRuler.SetBounds(0, WideSpecH, RW, IfThen(FController.FShowWideband and (WideH > 0), RULER_H, 0));
+ PbWidebandRuler.SetBounds(0, Pan0HeaderH + WideSpecH, RW, IfThen(FController.FShowWideband and (WideH > 0), RULER_H, 0));
PbWidebandRuler.Visible := FController.FShowWideband and (WideH > 0);
if FController.FShowWideband and (RW > 0) and (WideH > 0) then
begin
@@ -2456,11 +2539,61 @@ begin
// S-метр позиционируется отдельно в ResizeSMeter
ResizeSMeter;
- // Стек спектр/линейка/сплиттер/водопад/пан-зум раскладывает панадаптер.
- FPan.Layout(WideH, FController.FShowSpectrum, FController.FShowWaterfall);
+ // Стек панов: регионы по долям, каждый пан раскладывает свой стек
+ // спектр/линейка/сплиттер/водопад/пан-зум внутри своего региона.
+ LayoutPanStack(WideH);
PositionSampleRateOverlay;
end;
+function TMainForm.Pan0HeaderH: Integer;
+begin
+ if PanCount > 1 then
+ Result := MulDiv(20, Screen.PixelsPerInch, 96) // = HEADER_H панели
+ else
+ Result := 0;
+end;
+
+procedure TMainForm.LayoutPanStack(AWideH: Integer);
+const
+ PAN_SPLIT_H = 5;
+var
+ ActiveIds: array[0..MAX_PANS-1] of Integer;
+ N, i, k, TotalH, UsableH, Y, Hi: Integer;
+ ShareSum: Double;
+begin
+ N := 0;
+ for i := 0 to MAX_PANS - 1 do
+ if FPans[i] <> nil then begin ActiveIds[N] := i; Inc(N); end;
+ if N = 0 then Exit;
+ TotalH := PanelRight.ClientHeight;
+ UsableH := TotalH - (N - 1) * PAN_SPLIT_H;
+ ShareSum := 0;
+ for k := 0 to N - 1 do ShareSum := ShareSum + FPanShare[ActiveIds[k]];
+ if ShareSum <= 0 then ShareSum := 1;
+ Y := 0;
+ for k := 0 to N - 1 do
+ begin
+ i := ActiveIds[k];
+ if k > 0 then
+ begin
+ FPanSplitters[i].SetBounds(0, Y, PanelRight.ClientWidth, PAN_SPLIT_H);
+ Inc(Y, PAN_SPLIT_H);
+ end;
+ if k = N - 1 then
+ Hi := TotalH - Y // последний пан добирает остаток (округления долей)
+ else
+ Hi := Round(UsableH * FPanShare[i] / ShareSum);
+ FPans[i].StackTop := Y;
+ FPans[i].StackHeight := Hi;
+ FPans[i].HeaderVisible := N > 1;
+ if i = 0 then
+ FPans[i].Layout(AWideH, FController.FShowSpectrum, FController.FShowWaterfall)
+ else
+ FPans[i].Layout(0, FController.FShowSpectrum, FController.FShowWaterfall);
+ Inc(Y, Hi);
+ end;
+end;
+
// ===========================================================================
// Dark Theme
// ===========================================================================
@@ -2536,6 +2669,23 @@ begin
if BtnZoomIn <> nil then StyleButton(BtnZoomIn, False);
if BtnZoomDef <> nil then StyleButton(BtnZoomDef, False);
if BtnZoomOut <> nil then StyleButton(BtnZoomOut, False);
+ // Стек панов: шапки/кнопки всех панелей (пан 0 — «⊞», паны N — «×»).
+ for i := 0 to MAX_PANS - 1 do
+ if FPans[i] <> nil then
+ begin
+ if FPans[i].BtnAddPan <> nil then StyleButton(FPans[i].BtnAddPan, False);
+ if FPans[i].BtnClosePan <> nil then StyleButton(FPans[i].BtnClosePan, False);
+ if FPans[i].HeaderPanel <> nil then
+ begin
+ FPans[i].HeaderPanel.Color := T.Panel;
+ FPans[i].HeaderLabel.Font.Color := T.Text;
+ end;
+ if i > 0 then
+ begin
+ FPans[i].SetFlagsTheme(T);
+ if FPanSplitters[i] <> nil then FPanSplitters[i].Color := T.Border;
+ end;
+ end;
if FPanZoomBar <> nil then
begin
FPanZoomBar.SetTheme(T);
@@ -2938,6 +3088,13 @@ var
ElapsedTick: QWord;
I: Integer;
begin
+ // «×» шапки пана: закрытие отложено сюда (нельзя free панель из её кнопки).
+ if FPendingPanClose <> 0 then
+ begin
+ I := FPendingPanClose;
+ FPendingPanClose := 0;
+ ClosePanadapter(I);
+ end;
// Синхронизируем размеры если размер панели изменился (после ресайза).
// Важно: проверяем только видимые панели — когда спектр скрыт,
// PbSpectrum.Height может быть ненулевым при FPan.SpectrumHeight=0,
@@ -3009,6 +3166,27 @@ begin
FSpecView.WDSPReady := FController.FWDSPReady;
// Флаги: метр/частота главного, позиции, троттленный S-метр слайсов.
FPan.TickFlags;
+ // Доп. паны: синк вьюх + тик флагов + рендер готовых кадров.
+ SyncPanViews;
+ for I := 1 to MAX_PANS - 1 do
+ if FPans[I] <> nil then
+ begin
+ FPans[I].TickFlags;
+ if FPanSpecDirty[I] then
+ begin
+ FPans[I].View.DrawSpectrum;
+ FPans[I].PbSpectrum.Invalidate;
+ if FPans[I].View.NeedsRulerRedraw then
+ FPans[I].PbRuler.Invalidate;
+ FPanSpecDirty[I] := False;
+ end;
+ if FPanWfDirty[I] then
+ begin
+ FPans[I].View.DrawWaterfall;
+ FPans[I].PbWaterfall.Invalidate;
+ FPanWfDirty[I] := False;
+ end;
+ end;
// Панорама и водопад теперь рисуются независимо по факту новых кадров.
// Это лучше совпадает с Thetis: маленький FFT даёт новые строки чаще.
if FSpectrumDirty then
@@ -3266,6 +3444,9 @@ begin
FController.FDSPEngine.OnAudio := FController.OnAudioReady;
FController.FDSPEngine.OnSpectrum := FController.OnSpectrumReady;
FController.FDSPEngine.OnWaterfall := FController.OnWaterfallReady;
+ // Пиксели доп. панов (этап 3.3) — прямо в UI (display-поток; см. FPanCbLock).
+ FController.FDSPEngine.OnPanSpectrum := OnPanSpectrumReady;
+ FController.FDSPEngine.OnPanWaterfall := OnPanWaterfallReady;
FController.FDSPEngine.OnTXIQ := FController.OnTXIQ;
// Иначе sound-card mic путь теряется после смены sample rate (колбэк
// навешивается в FormCreate, но новый движок про него не знал).
@@ -4823,8 +5004,8 @@ begin
if CurSliceId <> 0 then
begin
FController.SetSliceModeBW(CurSliceId, Mode, FilterBW);
- FPan.PushSliceFlagState(CurSliceId);
- FPan.LayoutFlags;
+ PushFlagStateAllPans(CurSliceId);
+ LayoutFlagsAllPans;
Exit;
end;
FController.FMode := Mode;
@@ -4944,7 +5125,7 @@ begin
if CurSliceId <> 0 then
begin
FController.SetSliceDevice(CurSliceId, DevIndex, DevName);
- FPan.PushSliceFlagState(CurSliceId); // обновить подсветку текущего устройства слайса
+ PushFlagStateAllPans(CurSliceId); // обновить подсветку текущего устройства слайса
Exit;
end;
ApplyAudioDevice(DevIndex, DevName);
@@ -4952,7 +5133,15 @@ begin
end;
procedure TMainForm.OnVfoOverlayInvalidate(Sender: TObject);
+var P: TPanafallPanel;
begin
+ // Флаг/панель доп. пана → грязним и репейнтим ЕГО канву, пан 0 не трогаем.
+ P := PanOfOverlay(Sender);
+ if (P <> nil) and (P <> FPan) then
+ begin
+ MarkPanDirty(P);
+ Exit;
+ end;
FSpectrumDirty := True;
if Assigned(FSpecView) then
begin
@@ -4981,8 +5170,14 @@ end;
// SliceId флага, чьё событие сейчас обрабатывается (0 = главный приёмник / слайс A).
function TMainForm.CurSliceId: Integer;
+// Диспетчер флагов синхронный — ненулевой CurSliceId сейчас максимум у одной
+// панели (той, чей флаг обрабатывает событие).
+var i: Integer;
begin
- Result := FPan.CurSliceId;
+ Result := 0;
+ for i := 0 to MAX_PANS - 1 do
+ if (FPans[i] <> nil) and (FPans[i].CurSliceId <> 0) then
+ Exit(FPans[i].CurSliceId);
end;
// Единая проводка событий флага (главного и слайсов) на общие обработчики.
@@ -5018,6 +5213,568 @@ begin
FActiveSliceId := Id;
end;
+// ===========================================================================
+// Стек панадаптеров (этап 3.3): доп. паны на своих DDC
+// ===========================================================================
+
+function TMainForm.PanCount: Integer;
+var i: Integer;
+begin
+ Result := 0;
+ for i := 0 to MAX_PANS - 1 do
+ if FPans[i] <> nil then Inc(Result);
+end;
+
+function TMainForm.PanFromSender(Sender: TObject): TPanafallPanel;
+// Контролы панели создаются с Owner=панель (Build) — восстановление пана
+// из Sender мышиного события.
+begin
+ Result := nil;
+ if (Sender is TComponent) and (TComponent(Sender).Owner is TPanafallPanel) then
+ Result := TPanafallPanel(TComponent(Sender).Owner);
+end;
+
+function TMainForm.PanOfOverlay(Sender: TObject): TPanafallPanel;
+// Панель, которой принадлежит источник события: сама панель (Sender=панель),
+// её флаг (Owner=панель) или главный флаг/nil → пан 0.
+begin
+ Result := FPan;
+ if Sender is TPanafallPanel then Exit(TPanafallPanel(Sender));
+ if (Sender is TComponent) and (TComponent(Sender).Owner is TPanafallPanel) then
+ Result := TPanafallPanel(TComponent(Sender).Owner);
+end;
+
+procedure TMainForm.PushFlagStateAllPans(SliceId: Integer);
+// Флаг слайса живёт ровно на одной панели; у остальных PushSliceFlagState
+// не найдёт флаг и выйдет.
+var i: Integer;
+begin
+ for i := 0 to MAX_PANS - 1 do
+ if FPans[i] <> nil then FPans[i].PushSliceFlagState(SliceId);
+end;
+
+procedure TMainForm.LayoutFlagsAllPans;
+var i: Integer;
+begin
+ for i := 0 to MAX_PANS - 1 do
+ if FPans[i] <> nil then FPans[i].LayoutFlags;
+end;
+
+procedure TMainForm.MarkPanDirty(P: TPanafallPanel);
+begin
+ if P = nil then Exit;
+ if P = FPan then
+ begin
+ FSpectrumDirty := True;
+ if Assigned(FSpecView) then FSpecView.SpectrumDirty := True;
+ if Assigned(PbSpectrum) then PbSpectrum.Invalidate;
+ Exit;
+ end;
+ FPanSpecDirty[P.PanId] := True;
+ P.View.SpectrumDirty := True;
+ if P.PbSpectrum <> nil then P.PbSpectrum.Invalidate;
+end;
+
+procedure TMainForm.PanViewWindow(P: TPanafallPanel; out VC, VS: Double);
+// Видимое окно доп. пана: центр DDC ± rate/2 (пан-зума пока нет).
+begin
+ VC := FController.PanDDCFreq(P.PanId);
+ VS := FController.PanDDCRateKHz(P.PanId) * 1000.0;
+ if VS <= 0 then VS := 384000;
+end;
+
+procedure TMainForm.AddSliceAtFreqPan(P: TPanafallPanel; Hz: Double);
+// Слайс в доп. пане (режим/полоса копируются с главного, звуковуха default).
+var Lo, Hi, Id: Integer;
+begin
+ if not FController.SliceFitsCapture(Hz, P.PanId) then Exit;
+ TRadioController.FilterEdgesFor(FController.FMode, FController.FFilterBW, Lo, Hi);
+ Id := FController.AddSlice(Hz, FController.FMode, Lo, Hi,
+ TRadioController.AGCModeFromUI(FController.FAGCMode), 0.7, -1, '',
+ P.PanId);
+ if Id < 0 then Exit;
+ P.CreateSliceFlag(Id);
+ FActiveSliceId := Id;
+end;
+
+procedure TMainForm.PanClickTune(P: TPanafallPanel; X, W: Integer);
+// Клик по фону доп. пана = перестроить его слайс на частоту клика (аналог
+// DoSpectrumClick главного: снап к 100 Гц). Цель — активный слайс, если он
+// живёт в этом пане, иначе первый слайс пана; слайсов нет — создать в точке.
+const
+ StepHz = 100;
+var
+ VC, VS, LoHz, HiHz: Double;
+ Hz: Int64;
+ Id: Integer;
+ S: TCtrlSlice;
+begin
+ PanViewWindow(P, VC, VS);
+ if (W <= 0) or (VS <= 0) then Exit;
+ Hz := Round(VC + (X / W - 0.5) * VS);
+ Hz := ((Hz + StepHz div 2) div StepHz) * StepHz;
+ FController.CapturedSpan(LoHz, HiHz, P.PanId);
+ if Hz < LoHz then Hz := Round(LoHz);
+ if Hz > HiHz then Hz := Round(HiHz);
+ if (FActiveSliceId <> 0) and FController.GetSlice(FActiveSliceId, S) and
+ (S.PanId = P.PanId) then
+ Id := FActiveSliceId
+ else
+ Id := P.FirstSliceId;
+ if Id = 0 then
+ begin
+ AddSliceAtFreqPan(P, Hz);
+ Exit;
+ end;
+ FController.SetSliceTarget(Id, Hz);
+ FActiveSliceId := Id;
+ P.PushSliceFlagState(Id);
+ P.LayoutFlags;
+ MarkPanDirty(P);
+end;
+
+procedure TMainForm.UpdatePanHeaders;
+// Текст/видимость шапок: у одного пана шапки нет, у стека — у всех.
+var
+ i, N: Integer;
+ P: TPanafallPanel;
+begin
+ N := PanCount;
+ for i := 0 to MAX_PANS - 1 do
+ begin
+ P := FPans[i];
+ if P = nil then Continue;
+ P.HeaderVisible := N > 1;
+ if P.BtnClosePan <> nil then P.BtnClosePan.Visible := (N > 1) and (i > 0);
+ if i = 0 then
+ P.SetHeaderText(Format('PAN 0 · %.6f MHz · %d kHz',
+ [FController.FCenterFreq / 1e6, FController.FSampleRate div 1000]))
+ else
+ P.SetHeaderText(Format('PAN %d · %.6f MHz · %d kHz',
+ [i, FController.PanDDCFreq(i) / 1e6, FController.PanDDCRateKHz(i)]));
+ end;
+end;
+
+procedure TMainForm.UpdateAddPanButton;
+var HasSlot: Boolean; i: Integer;
+begin
+ HasSlot := False;
+ for i := 1 to MAX_PANS - 1 do
+ if FPans[i] = nil then begin HasSlot := True; Break; end;
+ FPan.AddPanEnabled := (FController.BackendCaps.MaxPans > 1) and
+ FController.FRunning and HasSlot;
+end;
+
+procedure TMainForm.OnAddPanClick(Sender: TObject);
+// «⊞»: новый пан на своём DDC. Центр = частота активного VFO, rate 384к,
+// ADC 0; в центре автоматически создаётся первый слайс.
+var
+ PanId, i: Integer;
+ Freq: Double;
+ P: TPanafallPanel;
+ Split: TPanel;
+begin
+ if not FController.FRunning then Exit;
+ PanId := -1;
+ for i := 1 to MAX_PANS - 1 do
+ if FPans[i] = nil then begin PanId := i; Break; end;
+ if PanId < 0 then Exit;
+
+ Freq := ActiveVfoFreq;
+ if not FController.AddPanDDC(PanId, Freq, 384, 0) then Exit;
+
+ P := TPanafallPanel.Create(nil, FUseOpenGLSpectrum);
+ P.PanId := PanId;
+ P.Controller := FController;
+ P.ShowZoomRow := False; // пан-зум доп. панов — этап 3.3+
+ P.OnWireFlag := WireOverlayEvents;
+ P.OnSliceClosed := OnPanSliceClosed;
+ P.OnFlagsInvalidate := OnVfoOverlayInvalidate;
+ P.OnPanClose := OnPanHeaderClose;
+ P.OnSplitterMoved := RightPanelResize; // ratio обновлён → полный re-layout
+ P.SpectrumMouseDown := PanNSpectrumMouseDown;
+ P.SpectrumMouseMove := PanNSpectrumMouseMove;
+ P.SpectrumMouseUp := PanNSpectrumMouseUp;
+ P.SpectrumMouseLeave := PanNSpectrumMouseLeave;
+ P.SpectrumDblClick := PanNSpectrumDblClick;
+ P.WaterfallMouseDown := PanNSpectrumMouseDown; // X→частота одинаково
+ P.WaterfallMouseMove := PanNSpectrumMouseMove;
+ P.WaterfallMouseUp := PanNSpectrumMouseUp;
+ P.Build(PanelRight, Max(1, FController.FSpecMSAA));
+
+ // Параметры отображения — с главного view (пан-персональные — позже).
+ P.View.WfAGCEnabled := FController.FWfAGCEnabled;
+ P.View.WfNFEnabled := FController.FWfNFEnabled;
+ P.View.WfManualHigh := FController.FWfManualHigh;
+ P.View.WfManualLow := FController.FWfManualLow;
+ P.View.WfAGCOffset := FController.FWfAGCOffset;
+ P.View.WfPalette := FController.FWfPalette;
+ P.View.WfGamma := FController.FWfGamma;
+ P.View.SpecRefLevel := FController.FSpecRefLevel;
+ P.View.SpecRange := FController.FSpecRange;
+ P.View.SpecGridStep := FController.FSpecGridStep;
+ P.View.WfFrameInterval := EffectiveWfInterval;
+ P.View.PAMaxPower := FController.FPAMaxPower;
+ P.View.CenterFreq := Freq;
+ P.View.SpanHz := 384000;
+ // VFO/TX-маркеры главного на доп. пане не рисуем — уводим за пределы окна.
+ P.View.VfoA := -1e15;
+ P.View.VfoB := -1e15;
+ P.View.TXFreq := -1e15;
+
+ // Сплиттер над паном.
+ Split := TPanel.Create(Self);
+ Split.Parent := PanelRight;
+ Split.BevelOuter := bvNone;
+ Split.Color := TColor($00303030);
+ Split.Cursor := crVSplit;
+ Split.Tag := PanId;
+ Split.OnMouseDown := PanSplitMouseDown;
+ Split.OnMouseMove := PanSplitMouseMove;
+ Split.OnMouseUp := PanSplitMouseUp;
+ FPanSplitters[PanId] := Split;
+
+ // Публикуем пан для пиксельных колбэков (display-поток) и делим высоту поровну.
+ FPanCbLock.Enter;
+ try
+ FPans[PanId] := P;
+ finally
+ FPanCbLock.Leave;
+ end;
+ for i := 0 to MAX_PANS - 1 do
+ if FPans[i] <> nil then FPanShare[i] := 1.0 / PanCount;
+
+ AddSliceAtFreqPan(P, Freq);
+ ApplyDarkTheme; // темизация шапки/кнопок нового пана
+ UpdatePanHeaders;
+ UpdateAddPanButton;
+ ResizeSpectrumPanels;
+end;
+
+procedure TMainForm.OnPanHeaderClose(Sender: TObject);
+// «×» шапки: нельзя free панель из обработчика её же кнопки — откладываем
+// в тик (FPendingPanClose).
+begin
+ if Sender is TPanafallPanel then
+ FPendingPanClose := TPanafallPanel(Sender).PanId;
+end;
+
+procedure TMainForm.ClosePanadapter(PanId: Integer);
+var
+ P: TPanafallPanel;
+ S: TCtrlSlice;
+ i: Integer;
+begin
+ if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
+ P := FPans[PanId];
+ if P = nil then Exit;
+ // Отписываем от пиксельных колбэков ДО удаления DDC/панели.
+ FPanCbLock.Enter;
+ try
+ FPans[PanId] := nil;
+ finally
+ FPanCbLock.Leave;
+ end;
+ // Слайсы пана (со звуковухами) + движок + DDC.
+ FController.RemovePanDDC(PanId);
+ if (FActiveSliceId <> 0) and not FController.GetSlice(FActiveSliceId, S) then
+ FActiveSliceId := 0;
+ FreeAndNil(FPanSplitters[PanId]);
+ P.Free;
+ for i := 0 to MAX_PANS - 1 do
+ if FPans[i] <> nil then FPanShare[i] := 1.0 / PanCount;
+ UpdatePanHeaders;
+ UpdateAddPanButton;
+ ResizeSpectrumPanels;
+end;
+
+procedure TMainForm.DestroyAllExtraPans;
+var i: Integer;
+begin
+ for i := 1 to MAX_PANS - 1 do
+ if FPans[i] <> nil then ClosePanadapter(i);
+end;
+
+procedure TMainForm.SyncPanViews;
+// Центр/span вьюх доп. панов (аналог SyncSpecViewFreq главного, но без
+// VFO/TX-маркеров — они уведены за окно при создании).
+var i: Integer; P: TPanafallPanel;
+begin
+ for i := 1 to MAX_PANS - 1 do
+ begin
+ P := FPans[i];
+ if P = nil then Continue;
+ P.View.CenterFreq := FController.PanDDCFreq(i);
+ P.View.SpanHz := FController.PanDDCRateKHz(i) * 1000.0;
+ P.View.WDSPReady := FController.FWDSPReady;
+ end;
+ // Шапки следуют за частотами (Caption-сеттер — no-op при том же тексте).
+ if PanCount > 1 then UpdatePanHeaders;
+end;
+
+procedure TMainForm.OnPanSpectrumReady(PanId: Integer;
+ const Pixels: array of Single; Count: Integer);
+// Display-поток движка. Лок закрывает гонку с Close/AddPanadapter (UI).
+begin
+ FPanCbLock.Enter;
+ try
+ if (PanId >= 1) and (PanId < MAX_PANS) and (FPans[PanId] <> nil) then
+ begin
+ FPans[PanId].View.SetSpectrumData(Pixels, Count);
+ FPanSpecDirty[PanId] := True;
+ end;
+ finally
+ FPanCbLock.Leave;
+ end;
+end;
+
+procedure TMainForm.OnPanWaterfallReady(PanId: Integer;
+ const Pixels: array of Single; Count: Integer);
+begin
+ FPanCbLock.Enter;
+ try
+ if (PanId >= 1) and (PanId < MAX_PANS) and (FPans[PanId] <> nil) then
+ begin
+ FPans[PanId].View.SetWaterfallData(Pixels, Count);
+ if FPans[PanId].View.WaterfallDirty then FPanWfDirty[PanId] := True;
+ end;
+ finally
+ FPanCbLock.Leave;
+ end;
+end;
+
+// ---- Мышь доп. панов: тюн слайсов + drag центра (ретюн DDC). Семантика
+// главного пана (VFO/CTUN/маяк/оверлеи) сюда не переносится. ----
+
+procedure TMainForm.PanNSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
+ Shift: TShiftState; X, Y: Integer);
+var
+ P: TPanafallPanel;
+ VC, VS: Double;
+ Id, W: Integer;
+ IsSpec: Boolean;
+begin
+ P := PanFromSender(Sender);
+ if P = nil then Exit;
+ // Флаги и маркеры несущих живут ТОЛЬКО на спектре: координаты водопада в
+ // диспетчер флагов отдавать нельзя (его прямоугольники — в системе спектра).
+ IsSpec := TControl(Sender) = P.PbSpectrum;
+ if IsSpec and P.DispatchFlagsMouseDown(Button, X, Y) then
+ begin
+ P.ProcessPendingSliceClose;
+ Exit;
+ end;
+ W := TControl(Sender).Width;
+ if W <= 0 then Exit;
+ if (Button = mbLeft) and (ssCtrl in Shift) then
+ begin
+ PanViewWindow(P, VC, VS);
+ AddSliceAtFreqPan(P, PixelToFreq(X, W, VC, VS));
+ Exit;
+ end;
+ if Button = mbLeft then
+ begin
+ if IsSpec then
+ begin
+ Id := P.SliceAtPixel(X);
+ if Id <> 0 then
+ begin
+ FPanSliceDrag := True;
+ FPanSliceDragId := Id;
+ FPanMousePan := P;
+ FActiveSliceId := Id;
+ TControl(Sender).Cursor := crSizeWE;
+ Exit;
+ end;
+ end;
+ // Пустое место: армируем drag ЦЕНТРА пана (ретюн его DDC; слайсы стоят на
+ // своих абсолютных частотах). Клик без движения = тюн слайса пана (MouseUp).
+ // Курсор не трогаем до реального сдвига.
+ FPanCenterDrag := True;
+ FPanCenterMoved := False;
+ FPanMousePan := P;
+ FPanCenterDragX0 := X;
+ FPanCenterDragF0 := FController.PanDDCFreq(P.PanId);
+ end;
+end;
+
+procedure TMainForm.PanNSpectrumMouseMove(Sender: TObject; Shift: TShiftState;
+ X, Y: Integer);
+var
+ P: TPanafallPanel;
+ VC, VS, NewHz, LoHz, HiHz: Double;
+ W: Integer;
+begin
+ P := PanFromSender(Sender);
+ if P = nil then Exit;
+ W := TControl(Sender).Width;
+
+ if FPanSliceDrag and (FPanMousePan = P) then
+ begin
+ if (ssLeft in Shift) and (W > 0) then
+ begin
+ PanViewWindow(P, VC, VS);
+ NewHz := PixelToFreq(X, W, VC, VS);
+ FController.CapturedSpan(LoHz, HiHz, P.PanId);
+ if NewHz < LoHz then NewHz := LoHz;
+ if NewHz > HiHz then NewHz := HiHz;
+ FController.SetSliceTarget(FPanSliceDragId, NewHz);
+ P.PushSliceFlagState(FPanSliceDragId);
+ P.LayoutFlags;
+ MarkPanDirty(P);
+ end;
+ Exit;
+ end;
+
+ if FPanCenterDrag and (FPanMousePan = P) then
+ begin
+ if (ssLeft in Shift) and (W > 0) then
+ begin
+ // Гистерезис 4px: до него это ещё «клик» (тюн по MouseUp), не drag.
+ if (not FPanCenterMoved) and (Abs(X - FPanCenterDragX0) < 4) then Exit;
+ if not FPanCenterMoved then
+ begin
+ FPanCenterMoved := True;
+ TControl(Sender).Cursor := crSizeWE;
+ end;
+ PanViewWindow(P, VC, VS);
+ NewHz := FPanCenterDragF0 - (X - FPanCenterDragX0) * VS / W;
+ NewHz := Max(FController.BackendCaps.MinFreqHz,
+ Min(FController.BackendCaps.MaxFreqHz, NewHz));
+ FController.SetPanDDCFreq(P.PanId, NewHz);
+ P.View.CenterFreq := NewHz;
+ P.LayoutFlags;
+ UpdatePanHeaders;
+ MarkPanDirty(P);
+ end;
+ Exit;
+ end;
+
+ // Hover-логика (флаги, маркеры несущих) — только на спектре; на водопаде
+ // курсор остаётся обычным, как у главного пана.
+ if TControl(Sender) = P.PbSpectrum then
+ begin
+ P.DispatchFlagsMouseMove(X, Y);
+ if P.SliceAtPixel(X) <> 0 then
+ TControl(Sender).Cursor := crSizeWE
+ else
+ TControl(Sender).Cursor := crArrow; // crDefault «снимает» курсор — см. Build
+ end;
+end;
+
+procedure TMainForm.PanNSpectrumMouseUp(Sender: TObject; Button: TMouseButton;
+ Shift: TShiftState; X, Y: Integer);
+var P: TPanafallPanel; W: Integer;
+begin
+ P := PanFromSender(Sender);
+ if P = nil then Exit;
+ if FPanSliceDrag and (FPanMousePan = P) then
+ begin
+ FPanSliceDrag := False;
+ FPanMousePan := nil;
+ TControl(Sender).Cursor := crArrow;
+ Exit;
+ end;
+ if FPanCenterDrag and (FPanMousePan = P) then
+ begin
+ FPanCenterDrag := False;
+ FPanMousePan := nil;
+ TControl(Sender).Cursor := crArrow;
+ // Пресс без движения = клик: перестроить слайс пана на частоту клика.
+ W := TControl(Sender).Width;
+ if (Button = mbLeft) and (not FPanCenterMoved) and (W > 0) then
+ PanClickTune(P, X, W);
+ Exit;
+ end;
+ if (TControl(Sender) = P.PbSpectrum) and P.DispatchFlagsMouseUp(X, Y) then
+ P.ProcessPendingSliceClose;
+end;
+
+procedure TMainForm.PanNSpectrumMouseLeave(Sender: TObject);
+var P: TPanafallPanel;
+begin
+ P := PanFromSender(Sender);
+ if P <> nil then P.DispatchFlagsMouseLeave;
+end;
+
+procedure TMainForm.PanNSpectrumDblClick(Sender: TObject);
+begin
+ // Дабл-клик на доп. пане пока без действия.
+end;
+
+// ---- Сплиттеры между панами: пересчёт долей по отпусканию мыши. ----
+
+procedure TMainForm.PanSplitMouseDown(Sender: TObject; Button: TMouseButton;
+ Shift: TShiftState; X, Y: Integer);
+const
+ PAN_SPLIT_H = 5;
+var
+ BelowId, AboveId, i, TotalH, MinPx: Integer;
+ Split: TPanel;
+begin
+ if Button <> mbLeft then Exit;
+ Split := TPanel(Sender);
+ BelowId := Split.Tag;
+ // Пан над сплиттером — ближайший активный с меньшим PanId.
+ AboveId := -1;
+ for i := BelowId - 1 downto 0 do
+ if FPans[i] <> nil then begin AboveId := i; Break; end;
+ if (AboveId < 0) or (FPans[BelowId] = nil) then Exit;
+ TotalH := PanelRight.ClientHeight;
+ if TotalH <= 0 then Exit;
+ // Границы призрака: каждому пану — минимум 15% стека (= кламп долей в MouseUp).
+ MinPx := Round(0.15 * TotalH);
+ FPanSplitMinTop := FPans[AboveId].StackTop + MinPx;
+ FPanSplitMaxTop := FPans[BelowId].StackTop + FPans[BelowId].StackHeight
+ - MinPx - PAN_SPLIT_H;
+ FPanSplitDrag := BelowId;
+ FPanSplitY0 := Mouse.CursorPos.Y;
+ FPanSplitTop0 := Split.Top;
+ Split.Color := TColor($005050A0); // подсветка при захвате (как у внутрипанного)
+end;
+
+procedure TMainForm.PanSplitMouseMove(Sender: TObject; Shift: TShiftState;
+ X, Y: Integer);
+var NewTop: Integer;
+begin
+ // Живой призрак: двигаем сам сплиттер, пересчёт регионов — по MouseUp.
+ if (FPanSplitDrag = 0) or (TPanel(Sender).Tag <> FPanSplitDrag) then Exit;
+ NewTop := FPanSplitTop0 + (Mouse.CursorPos.Y - FPanSplitY0);
+ if NewTop < FPanSplitMinTop then NewTop := FPanSplitMinTop;
+ if NewTop > FPanSplitMaxTop then NewTop := FPanSplitMaxTop;
+ TPanel(Sender).Top := NewTop;
+end;
+
+procedure TMainForm.PanSplitMouseUp(Sender: TObject; Button: TMouseButton;
+ Shift: TShiftState; X, Y: Integer);
+var
+ BelowId, AboveId, i, DeltaY, TotalH: Integer;
+ DShare, MinShare: Double;
+begin
+ if FPanSplitDrag = 0 then Exit;
+ BelowId := FPanSplitDrag;
+ FPanSplitDrag := 0;
+ TPanel(Sender).Color := TColor($00303030);
+ // Пан над сплиттером — ближайший активный с меньшим PanId.
+ AboveId := -1;
+ for i := BelowId - 1 downto 0 do
+ if FPans[i] <> nil then begin AboveId := i; Break; end;
+ if (AboveId < 0) or (FPans[BelowId] = nil) then Exit;
+ TotalH := PanelRight.ClientHeight;
+ if TotalH <= 0 then Exit;
+ // Дельта — по финальному положению призрака (он уже клампился в Move).
+ DeltaY := TPanel(Sender).Top - FPanSplitTop0;
+ DShare := DeltaY / TotalH;
+ MinShare := 0.15;
+ if FPanShare[AboveId] + DShare < MinShare then
+ DShare := MinShare - FPanShare[AboveId];
+ if FPanShare[BelowId] - DShare < MinShare then
+ DShare := FPanShare[BelowId] - MinShare;
+ FPanShare[AboveId] := FPanShare[AboveId] + DShare;
+ FPanShare[BelowId] := FPanShare[BelowId] - DShare;
+ ResizeSpectrumPanels;
+end;
+
procedure TMainForm.OnVfoOverlaySliceSelect(Sender: TObject);
begin
if Sender is TVfoOverlay then
@@ -5025,12 +5782,14 @@ begin
end;
procedure TMainForm.OnVfoOverlayClose(Sender: TObject);
+var P: TPanafallPanel;
begin
if not (Sender is TVfoOverlay) then Exit;
if TVfoOverlay(Sender).IsMain then Exit;
// Нельзя освобождать флаг внутри его же обработчика мыши — панель пометит
// и снимет его после возврата из диспетчера (ProcessPendingSliceClose).
- FPan.RequestSliceClose(TVfoOverlay(Sender).SliceId);
+ P := PanOfOverlay(Sender);
+ if P <> nil then P.RequestSliceClose(TVfoOverlay(Sender).SliceId);
end;
// FPan закрыл слайс («×») — сбрасываем активный, если это был он.
@@ -5598,17 +6357,25 @@ var
Delta: Int64;
Base: Int64;
NewFreq: Int64;
- SliceId: Integer;
+ SliceId, i: Integer;
SLo, SHi, SNew: Double;
Sl: TCtrlSlice;
+ SlicePan: TPanafallPanel;
begin
// Над дисплеем VFO (A или B) колесо обрабатывает сам TFreqDisplay поразрядно
// (включая неактивный VFO). Не дублируем здесь, иначе менялись бы обе частоты:
// виджет крутил бы B, а этот хендлер — активный A.
if OverFreqDisp(FreqDispA) or OverFreqDisp(FreqDispB) then Exit;
- // Колесо над маркером слайса — двигаем частоту ЭТОГО слайса, а не главного VFO.
- SliceId := FPan.SliceUnderCursor;
+ // Колесо над маркером слайса (любого пана) — двигаем частоту ЭТОГО слайса.
+ SliceId := 0;
+ SlicePan := nil;
+ for i := 0 to MAX_PANS - 1 do
+ if FPans[i] <> nil then
+ begin
+ SliceId := FPans[i].SliceUnderCursor;
+ if SliceId <> 0 then begin SlicePan := FPans[i]; Break; end;
+ end;
if (SliceId <> 0) and FController.GetSlice(SliceId, Sl) then
begin
if (ssCtrl in Shift) and (ssShift in Shift) then Step := 1000000
@@ -5622,14 +6389,13 @@ begin
if Delta > 0 then SNew := Base + Step
else if Round(Sl.TargetHz) = Base then SNew := Base - Step
else SNew := Base;
- FController.CapturedSpan(SLo, SHi);
+ FController.CapturedSpan(SLo, SHi, Sl.PanId); // полоса ЕГО пана
if SNew < SLo then SNew := SLo;
if SNew > SHi then SNew := SHi;
FController.SetSliceTarget(SliceId, SNew);
- FPan.PushSliceFlagState(SliceId);
- FPan.LayoutFlags;
- FSpectrumDirty := True;
- PbSpectrum.Invalidate;
+ SlicePan.PushSliceFlagState(SliceId);
+ SlicePan.LayoutFlags;
+ MarkPanDirty(SlicePan);
FActiveSliceId := SliceId;
Handled := True;
Exit;
diff --git a/PanafallPanel.pas b/PanafallPanel.pas
index 8d37216..0878488 100644
--- a/PanafallPanel.pas
+++ b/PanafallPanel.pas
@@ -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) виден, когда СКРЫТА левая панель хозяина
diff --git a/doc/SLICES_PLAN.md b/doc/SLICES_PLAN.md
index 9c63086..1cf2df1 100644
--- a/doc/SLICES_PLAN.md
+++ b/doc/SLICES_PLAN.md
@@ -89,7 +89,8 @@ SmartSDR НЕ умеет — наш pop-out будет улучшением по
один слайс в его центре (поведение Flex).
3. ADC-источник — свойство **панадаптера**, не слайса (бейдж `A1`/`A2` в шапке).
В отличие от Flex показываем именно АЦП, а не антенну: на HPSDR-платах
- маппинг «вход→АЦП» жёсткий, антенная абстракция не нужна.
+ маппинг «вход→АЦП» жёсткий. Антенны ADC0 (Alex ANT1/2/3) — отдельная
+ ГЛОБАЛЬНАЯ ручка, общая для всех панов этого АЦП (детали в 3.4).
4. Буквы слайсов — глобальный пул A..G через все паны (как во Flex A-H):
буква = личность слайса, пан = место жительства.
5. Возможности бэкенда описывает запись capabilities (см. таблицу) — UI по ней
@@ -342,14 +343,99 @@ ChangeSampleRate).
независим от DDC, TX-частота = частота слайса. (Полный мультислайс-TX —
отдельная старая задача, тут ничего нового не ломаем.)
-### 3.4 — ADC-селектор панадаптера
+**СТАТУС 3.3 (2026-07-11): БАЗОВЫЙ UI РЕАЛИЗОВАН, собирается (GUI+демон),
+бэкенд-тракт проверен END-TO-END против эмулятора hpsdrsim -hermes -P2
+(scratchpad pantest2: DDC1 включается, IQ течёт, спектр/водопад пана рисуются,
+слайс живой S-метр −95дБм, ретюн/снятие чистые, seq-ошибок нет). НА ЭКРАНЕ
+НЕ ПРОВЕРЕНО.** Что сделано:
+
+- `TPanafallPanel`: `PanId`, регион в стеке (`StackTop/StackHeight`, Layout
+ считает внутри региона), шапка (TPanel+TLabel+«×»→OnPanClose; видна при
+ панах ≥2), кнопка «⊞» в ряду пан/зума (`AddPanEnabled`, только пан 0),
+ `ShowZoomRow=False` у панов N (их зум позже), pan-aware `ViewWindow`
+ (SliceAtPixel/LayoutFlags от окна СВОЕГО пана), `FOnFlagsInvalidate(Self)`.
+- MainForm: `FPans[0..MAX_PANS-1]` (пан 0 = FPan), `LayoutPanStack` (доли
+ `FPanShare` + межпанные сплиттеры, пересчёт по MouseUp), «⊞» →
+ `OnAddPanClick` (AddPanDDC на частоте активного VFO, rate 384к, автослайс
+ в центре, копия display-параметров view, ApplyDarkTheme), «×» шапки →
+ отложенное `ClosePanadapter` в тике (нельзя free из своей кнопки).
+- Пиксели: `engine.OnPanSpectrum/OnPanWaterfall` → MainForm напрямую
+ (display-поток, `FPanCbLock` закрывает гонку с созданием/закрытием пана);
+ рендер в SpectrumTimerTick по dirty-флагам, `SyncPanViews` держит
+ центр/span вьюх и текст шапок.
+- Мышь панов N (НОВЫЕ простые обработчики, VFO/CTUN/маяк/оверлеи главного
+ сюда не переносились — решение по B2 выполнено): Ctrl+ЛКМ = слайс в этом
+ пане, ЛКМ по маркеру = drag слайса (кламп полосой пана), ЛКМ по фону =
+ drag ЦЕНТРА пана (live-ретюн DDC), колесо над флагом любого пана = тюн
+ того слайса; флаги/CurSliceId/инвалидции обобщены на все панели
+ (PanOfOverlay/PushFlagStateAllPans/LayoutFlagsAllPans).
+- Паны живут только в Run-сессии: на STOP UI убирает их вместе с контроллером.
+- Отложено: пан-зум панов N, per-pan детектор/усреднение, персист (после 3.3),
+ wideband при маленьком регионе пана 0 может вылезти за регион (редкий кейс),
+ колесо над фоном пана N крутит главный VFO (как везде вне слайсов).
+
+**ФИКСЫ ПО ПЕРВОМУ ЭКРАННОМУ ТЕСТУ (2026-07-11, фидбек юзера):**
+
+1. **Сплиттер спектр/водопад был сломан при ≥2 панах**: drag-математика
+ (`SplitterMouseMove/Up`) считала доступную высоту от ВСЕГО родителя
+ (`FParent.ClientHeight`, `TopOff=0`), игнорируя регион пана в стеке,
+ шапку и wideband. Симптомы: у пана 0 ratio занижался (спектр сжимался
+ при тяге вниз), у пана 1 сплиттер прыгал в регион пана 0. Фикс: Layout
+ запоминает `FSplitTopOff/FSplitAvailH` (геометрия «спектр+водопад»
+ СВОЕГО региона), drag использует только их.
+2. **Межпанный сплиттер** не давал обратной связи до отпускания — теперь
+ живой «призрак» (сам сплиттер едет за мышью, клампится 15%-минимумом
+ обоих панов, подсветка при захвате), доли пересчитываются по MouseUp
+ от финального положения призрака.
+3. **Клик по фону пана N не перестраивал частоту** (только drag центра).
+ Теперь пресс без движения (<4px) = клик-тюн `PanClickTune`: слайс пана
+ (активный, если он этого пана, иначе первый; нет — создаётся) на частоту
+ клика со снапом 100 Гц и клампом полосой пана. Drag центра включается
+ после гистерезиса 4px, курсор `crSizeWE` — только с этого момента
+ (раньше менялся на голый пресс).
+4. **Диспетчер флагов/маркеров на панах N** получал координаты ВОДОПАДА
+ (прямоугольники флагов — в системе спектра): клики в верхней части
+ водопада могли молча съедаться невидимым флагом. Теперь флаги/hover —
+ только для событий спектра, курсор на водопаде не трогаем.
+5. **(2-й раунд фидбека)** У панов N сплиттер спектр/водопад «залипал
+ призраком» (спектр не пересчитывался, при следующем drag прыжок к
+ старому ratio): в `OnAddPanClick` не был подвешен `OnSplitterMoved`
+ (у пана 0 = `RightPanelResize` → полный re-layout). Подвешен тот же
+ обработчик. Пан 0 и межпанные сплиттеры юзер подтвердил рабочими.
+6. **Курсор-«рука» на всей площади панов N (разгадан, 3-й раунд)**: юзер
+ заметил, что рука = курсор кнопки «⊞». Механизм: GL-канвы — QOpenGLWidget
+ с `WA_NativeWindow` = отдельная Wayland-поверхность; `crDefault` в LCL
+ означает «курсор не задан», и поверхность НАВСЕГДА наследует курсор,
+ бывший под мышью в момент создания виджета. Пан N создаётся кликом по
+ «⊞» (`crHandPoint` у TFlatButton) → рука прилипает; пан 0 создаётся на
+ старте (мышь нигде) → норм. Фикс: `Build` ставит спектру/водопаду ЯВНЫЙ
+ `crArrow` (реальный push), обработчики панов N восстанавливают `crArrow`,
+ не `crDefault`. ⚠Урок на 3.5 (pop-out) и вообще: любой динамически
+ создаваемый нативный GL-виджет должен получать явный курсор при создании.
+
+Статус: собирается (GUI+демон), НА ЭКРАНЕ НЕ ПРОВЕРЕНО (юзер проверяет).
+Открыто из фидбека: юзер видел курсор-«палец» на водопаде пана N — в коде
+панов N `crHandPoint` нигде не ставится (подозрение: артефакт сломанной
+раскладки, перекрытие чужим контролом); перепроверить после фиксов.
+
+### 3.4 — ADC-селектор панадаптера (+ антенны Alex, уточнено 2026-07-11)
+
+Источник сигнала пана — ДВЕ независимые ручки, не одна:
+
+1. **АЦП (per-pan)**: `TPanafall.ADCSource: 0/1`; в шапке пана селектор/бейдж
+ `A1`/`A2`. Показывается только при `NumADCs=2` (детект платы уже есть —
+ `NumADCs` в ConfigureDDCs-пути). Переключение = `ConfigureDDCs` с новым
+ ADCSource для DDC этого пана. Пан 0 тоже можно перекинуть на ADC1 — это
+ закрывает сценарий старой ветки `feature/rx2-support` как частный случай.
+2. **Антенна первого АЦП (глобальная, НЕ per-pan)**: у плат с Alex на ADC0
+ уже есть аппаратный селектор RX-антенны **ANT1/ANT2/ANT3** (+ EXT-входы) —
+ реализован per-band в Antenna/Alex-табе (`Alex.RxAnt[band]`, XVTR-override).
+ Селектор один на весь ADC0 ⇒ антенну нельзя сделать свойством пана: смена
+ антенны меняет её для ВСЕХ панов, сидящих на ADC0. В UI пана максимум —
+ бейдж текущей антенны (например `A1·ANT2`) и/или шорткат к глобальному
+ переключателю с явным пониманием, что это общая ручка. ADC1 (RX2-вход) —
+ отдельный разъём без Alex-коммутации.
-- `TPanafall.ADCSource: 0/1`; в шапке пана селектор/бейдж `A1`/`A2`.
- Показывается только при `NumADCs=2` (детект платы уже есть — `NumADCs`
- в ConfigureDDCs-пути).
-- Переключение = `ConfigureDDCs` с новым ADCSource для DDC этого пана.
-- Пан 0 тоже можно перекинуть на ADC1 — это закрывает сценарий старой ветки
- `feature/rx2-support` (вторая антенна) бесплатно, как частный случай.
- Diversity (sync-DDC, оба АЦП когерентно в один канал) — НЕ здесь; протокол
умеет, дизайну панов не мешает, отдельная будущая фича.
@@ -437,6 +523,11 @@ ChangeSampleRate).
осознанно: только явное создание пана (решение «сначала статика» остаётся).
- Настройки водопада (палитра/AGC/avg) per-pan или глобально — стартуем с
наследования глобальных, разделим по запросу.
+- **Компоновка панов сеткой** (запрос юзера 2026-07-11): не только вертикальный
+ стек — например, пан 0 сверху во всю ширину, паны B и C ниже бок о бок
+ (пол-ширины каждый), и т.п. Идея на после 3.3/3.5: обобщить `LayoutPanStack`
+ до грид-раскладки (регион пана = прямоугольник, а не полоса; `StackTop/Height`
+ → `Rect`), сплиттеры по обеим осям. Не блокер текущего этапа.
## Отвергнутые альтернативы (для истории)
diff --git a/ewsdr.lpi b/ewsdr.lpi
index 61aea45..c17cf9f 100644
--- a/ewsdr.lpi
+++ b/ewsdr.lpi
@@ -17,9 +17,9 @@
-
+
-
+
@@ -94,6 +94,8 @@
+
+