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
+784 -18
View File
@@ -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;