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 uses
Classes, SysUtils, StrUtils, FreqDisplay, DeviceForm, VfoOverlay, SampleRateOverlay, BandPlanOverlay, Classes, SysUtils, StrUtils, FreqDisplay, DeviceForm, VfoOverlay, SampleRateOverlay, BandPlanOverlay,
FlatButton, FlatSlider, FlatDropDown, AppTheme, Forms, Controls, Graphics, Dialogs, FlatButton, FlatSlider, FlatDropDown, AppTheme, Forms, Controls, Graphics, Dialogs,
StdCtrls, ExtCtrls, Buttons, Menus, Math, Types, StdCtrls, ExtCtrls, Buttons, Menus, Math, Types, SyncObjs,
OpenGLContextEx, OpenGLContextEx,
LCLIntf, LCLType, GraphType, LCLIntf, LCLType, GraphType,
HPSDRProtocol, HPSDRNetwork, HPSDRProtocol, HPSDRNetwork,
@@ -159,6 +159,31 @@ type
// (исторические имена; постепенная миграция на FPan.*). Размеры областей — // (исторические имена; постепенная миграция на FPan.*). Размеры областей —
// FPan.SpectrumWidth/SpectrumHeight/WaterfallHeight. // FPan.SpectrumWidth/SpectrumHeight/WaterfallHeight.
FPan: TPanafallPanel; 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; FSpecView: TSpectrumView;
FWidebandView: TWidebandView; FWidebandView: TWidebandView;
FUseOpenGLSpectrum: Boolean; FUseOpenGLSpectrum: Boolean;
@@ -533,6 +558,44 @@ type
function CurSliceId: Integer; // SliceId обрабатываемого сейчас флага function CurSliceId: Integer; // SliceId обрабатываемого сейчас флага
procedure WireOverlayEvents(O: TVfoOverlay); procedure WireOverlayEvents(O: TVfoOverlay);
procedure AddSliceAtFreq(Hz: Double); 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 PbSpectrumDblClick(Sender: TObject);
procedure TrkDriveChange(Sender: TObject); procedure TrkDriveChange(Sender: TObject);
procedure UpdateDriveLabel; // % (openHPSDR) / дБ (Pluto) рядом с ползунком drive procedure UpdateDriveLabel; // % (openHPSDR) / дБ (Pluto) рядом с ползунком drive
@@ -990,6 +1053,12 @@ begin
FPan.OnSliceClosed := OnPanSliceClosed; FPan.OnSliceClosed := OnPanSliceClosed;
FPan.OnFlagsInvalidate := OnVfoOverlayInvalidate; FPan.OnFlagsInvalidate := OnVfoOverlayInvalidate;
FPan.AttachMainFlag(FVfoOverlay); // внутри: FSpecView.VfoOverlay := флаг A 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.VfoA := FController.FVfoA;
FSpecView.VfoB := FController.FVfoB; FSpecView.VfoB := FController.FVfoB;
FSpecView.ActiveVfo := FController.FActiveVfo; FSpecView.ActiveVfo := FController.FActiveVfo;
@@ -1040,6 +1109,9 @@ begin
FController.FDSPEngine.OnAudio := FController.OnAudioReady; FController.FDSPEngine.OnAudio := FController.OnAudioReady;
FController.FDSPEngine.OnSpectrum := FController.OnSpectrumReady; FController.FDSPEngine.OnSpectrum := FController.OnSpectrumReady;
FController.FDSPEngine.OnWaterfall := FController.OnWaterfallReady; FController.FDSPEngine.OnWaterfall := FController.OnWaterfallReady;
// Пиксели доп. панов (этап 3.3) — прямо в UI (display-поток; см. FPanCbLock).
FController.FDSPEngine.OnPanSpectrum := OnPanSpectrumReady;
FController.FDSPEngine.OnPanWaterfall := OnPanWaterfallReady;
FController.FDSPEngine.OnTXIQ := FController.OnTXIQ; FController.FDSPEngine.OnTXIQ := FController.OnTXIQ;
// Web-клиент перехватывает RX-аудио (моно-микс) у контроллера, если подключён. // Web-клиент перехватывает RX-аудио (моно-микс) у контроллера, если подключён.
FController.OnAudioConsume := FWebAdapter.AudioConsume; FController.OnAudioConsume := FWebAdapter.AudioConsume;
@@ -1093,7 +1165,10 @@ begin
PanelSplitter := nil; PanelSplitter := nil;
PbWaterfall := nil; PbWaterfall := nil;
PbPanZoom := nil; PbPanZoom := nil;
DestroyAllExtraPans; // доп. паны (DDC/движок/панели) — до пана 0
FreeAndNil(FPan); FreeAndNil(FPan);
FPans[0] := nil;
FreeAndNil(FPanCbLock);
FreeAndNil(FWidebandView); FreeAndNil(FWidebandView);
// Сохраняем настройки при закрытии // Сохраняем настройки при закрытии
@@ -1216,6 +1291,8 @@ begin
BtnStartStop.Caption := 'STOP'; BtnStartStop.Caption := 'STOP';
StyleButton(BtnStartStop, True); StyleButton(BtnStartStop, True);
BtnMOX.Enabled := True; BtnMOX.Enabled := True;
UpdateAddPanButton; // «⊞» доступна только в Run
ResizeSpectrumPanels;
end end
else else
begin begin
@@ -1223,6 +1300,11 @@ begin
BtnStartStop.Caption := 'START'; BtnStartStop.Caption := 'START';
StyleButton(BtnStartStop, False); StyleButton(BtnStartStop, False);
BtnMOX.Enabled := False; BtnMOX.Enabled := False;
// Доп. паны живут только в Run-сессии (их DDC уже погашены
// контроллером в StopRunning) — убираем и UI.
DestroyAllExtraPans;
UpdateAddPanButton;
ResizeSpectrumPanels;
end; end;
rfADCOverload: rfADCOverload:
if FSpecView <> nil then if FSpecView <> nil then
@@ -2435,7 +2517,8 @@ begin
WideSpecH := Max(1, WideH - RULER_H); WideSpecH := Max(1, WideH - RULER_H);
if PbWideband <> nil then if PbWideband <> nil then
begin begin
PbWideband.SetBounds(0, 0, RW, WideSpecH); // При стеке панов над wideband стоит шапка пана 0.
PbWideband.SetBounds(0, Pan0HeaderH, RW, WideSpecH);
PbWideband.Visible := FController.FShowWideband and (WideSpecH > 0); PbWideband.Visible := FController.FShowWideband and (WideSpecH > 0);
if FController.FShowWideband and (RW > 0) and (WideSpecH > 0) then if FController.FShowWideband and (RW > 0) and (WideSpecH > 0) then
begin begin
@@ -2445,7 +2528,7 @@ begin
end; end;
if PbWidebandRuler <> nil then if PbWidebandRuler <> nil then
begin 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); PbWidebandRuler.Visible := FController.FShowWideband and (WideH > 0);
if FController.FShowWideband and (RW > 0) and (WideH > 0) then if FController.FShowWideband and (RW > 0) and (WideH > 0) then
begin begin
@@ -2456,11 +2539,61 @@ begin
// S-метр позиционируется отдельно в ResizeSMeter // S-метр позиционируется отдельно в ResizeSMeter
ResizeSMeter; ResizeSMeter;
// Стек спектр/линейка/сплиттер/водопад/пан-зум раскладывает панадаптер. // Стек панов: регионы по долям, каждый пан раскладывает свой стек
FPan.Layout(WideH, FController.FShowSpectrum, FController.FShowWaterfall); // спектр/линейка/сплиттер/водопад/пан-зум внутри своего региона.
LayoutPanStack(WideH);
PositionSampleRateOverlay; PositionSampleRateOverlay;
end; 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 // Dark Theme
// =========================================================================== // ===========================================================================
@@ -2536,6 +2669,23 @@ begin
if BtnZoomIn <> nil then StyleButton(BtnZoomIn, False); if BtnZoomIn <> nil then StyleButton(BtnZoomIn, False);
if BtnZoomDef <> nil then StyleButton(BtnZoomDef, False); if BtnZoomDef <> nil then StyleButton(BtnZoomDef, False);
if BtnZoomOut <> nil then StyleButton(BtnZoomOut, 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 if FPanZoomBar <> nil then
begin begin
FPanZoomBar.SetTheme(T); FPanZoomBar.SetTheme(T);
@@ -2938,6 +3088,13 @@ var
ElapsedTick: QWord; ElapsedTick: QWord;
I: Integer; I: Integer;
begin begin
// «×» шапки пана: закрытие отложено сюда (нельзя free панель из её кнопки).
if FPendingPanClose <> 0 then
begin
I := FPendingPanClose;
FPendingPanClose := 0;
ClosePanadapter(I);
end;
// Синхронизируем размеры если размер панели изменился (после ресайза). // Синхронизируем размеры если размер панели изменился (после ресайза).
// Важно: проверяем только видимые панели — когда спектр скрыт, // Важно: проверяем только видимые панели — когда спектр скрыт,
// PbSpectrum.Height может быть ненулевым при FPan.SpectrumHeight=0, // PbSpectrum.Height может быть ненулевым при FPan.SpectrumHeight=0,
@@ -3009,6 +3166,27 @@ begin
FSpecView.WDSPReady := FController.FWDSPReady; FSpecView.WDSPReady := FController.FWDSPReady;
// Флаги: метр/частота главного, позиции, троттленный S-метр слайсов. // Флаги: метр/частота главного, позиции, троттленный S-метр слайсов.
FPan.TickFlags; 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 даёт новые строки чаще. // Это лучше совпадает с Thetis: маленький FFT даёт новые строки чаще.
if FSpectrumDirty then if FSpectrumDirty then
@@ -3266,6 +3444,9 @@ begin
FController.FDSPEngine.OnAudio := FController.OnAudioReady; FController.FDSPEngine.OnAudio := FController.OnAudioReady;
FController.FDSPEngine.OnSpectrum := FController.OnSpectrumReady; FController.FDSPEngine.OnSpectrum := FController.OnSpectrumReady;
FController.FDSPEngine.OnWaterfall := FController.OnWaterfallReady; FController.FDSPEngine.OnWaterfall := FController.OnWaterfallReady;
// Пиксели доп. панов (этап 3.3) — прямо в UI (display-поток; см. FPanCbLock).
FController.FDSPEngine.OnPanSpectrum := OnPanSpectrumReady;
FController.FDSPEngine.OnPanWaterfall := OnPanWaterfallReady;
FController.FDSPEngine.OnTXIQ := FController.OnTXIQ; FController.FDSPEngine.OnTXIQ := FController.OnTXIQ;
// Иначе sound-card mic путь теряется после смены sample rate (колбэк // Иначе sound-card mic путь теряется после смены sample rate (колбэк
// навешивается в FormCreate, но новый движок про него не знал). // навешивается в FormCreate, но новый движок про него не знал).
@@ -4823,8 +5004,8 @@ begin
if CurSliceId <> 0 then if CurSliceId <> 0 then
begin begin
FController.SetSliceModeBW(CurSliceId, Mode, FilterBW); FController.SetSliceModeBW(CurSliceId, Mode, FilterBW);
FPan.PushSliceFlagState(CurSliceId); PushFlagStateAllPans(CurSliceId);
FPan.LayoutFlags; LayoutFlagsAllPans;
Exit; Exit;
end; end;
FController.FMode := Mode; FController.FMode := Mode;
@@ -4944,7 +5125,7 @@ begin
if CurSliceId <> 0 then if CurSliceId <> 0 then
begin begin
FController.SetSliceDevice(CurSliceId, DevIndex, DevName); FController.SetSliceDevice(CurSliceId, DevIndex, DevName);
FPan.PushSliceFlagState(CurSliceId); // обновить подсветку текущего устройства слайса PushFlagStateAllPans(CurSliceId); // обновить подсветку текущего устройства слайса
Exit; Exit;
end; end;
ApplyAudioDevice(DevIndex, DevName); ApplyAudioDevice(DevIndex, DevName);
@@ -4952,7 +5133,15 @@ begin
end; end;
procedure TMainForm.OnVfoOverlayInvalidate(Sender: TObject); procedure TMainForm.OnVfoOverlayInvalidate(Sender: TObject);
var P: TPanafallPanel;
begin begin
// Флаг/панель доп. пана → грязним и репейнтим ЕГО канву, пан 0 не трогаем.
P := PanOfOverlay(Sender);
if (P <> nil) and (P <> FPan) then
begin
MarkPanDirty(P);
Exit;
end;
FSpectrumDirty := True; FSpectrumDirty := True;
if Assigned(FSpecView) then if Assigned(FSpecView) then
begin begin
@@ -4981,8 +5170,14 @@ end;
// SliceId флага, чьё событие сейчас обрабатывается (0 = главный приёмник / слайс A). // SliceId флага, чьё событие сейчас обрабатывается (0 = главный приёмник / слайс A).
function TMainForm.CurSliceId: Integer; function TMainForm.CurSliceId: Integer;
// Диспетчер флагов синхронный — ненулевой CurSliceId сейчас максимум у одной
// панели (той, чей флаг обрабатывает событие).
var i: Integer;
begin 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; end;
// Единая проводка событий флага (главного и слайсов) на общие обработчики. // Единая проводка событий флага (главного и слайсов) на общие обработчики.
@@ -5018,6 +5213,568 @@ begin
FActiveSliceId := Id; FActiveSliceId := Id;
end; 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); procedure TMainForm.OnVfoOverlaySliceSelect(Sender: TObject);
begin begin
if Sender is TVfoOverlay then if Sender is TVfoOverlay then
@@ -5025,12 +5782,14 @@ begin
end; end;
procedure TMainForm.OnVfoOverlayClose(Sender: TObject); procedure TMainForm.OnVfoOverlayClose(Sender: TObject);
var P: TPanafallPanel;
begin begin
if not (Sender is TVfoOverlay) then Exit; if not (Sender is TVfoOverlay) then Exit;
if TVfoOverlay(Sender).IsMain then Exit; if TVfoOverlay(Sender).IsMain then Exit;
// Нельзя освобождать флаг внутри его же обработчика мыши — панель пометит // Нельзя освобождать флаг внутри его же обработчика мыши — панель пометит
// и снимет его после возврата из диспетчера (ProcessPendingSliceClose). // и снимет его после возврата из диспетчера (ProcessPendingSliceClose).
FPan.RequestSliceClose(TVfoOverlay(Sender).SliceId); P := PanOfOverlay(Sender);
if P <> nil then P.RequestSliceClose(TVfoOverlay(Sender).SliceId);
end; end;
// FPan закрыл слайс («×») — сбрасываем активный, если это был он. // FPan закрыл слайс («×») — сбрасываем активный, если это был он.
@@ -5598,17 +6357,25 @@ var
Delta: Int64; Delta: Int64;
Base: Int64; Base: Int64;
NewFreq: Int64; NewFreq: Int64;
SliceId: Integer; SliceId, i: Integer;
SLo, SHi, SNew: Double; SLo, SHi, SNew: Double;
Sl: TCtrlSlice; Sl: TCtrlSlice;
SlicePan: TPanafallPanel;
begin begin
// Над дисплеем VFO (A или B) колесо обрабатывает сам TFreqDisplay поразрядно // Над дисплеем VFO (A или B) колесо обрабатывает сам TFreqDisplay поразрядно
// (включая неактивный VFO). Не дублируем здесь, иначе менялись бы обе частоты: // (включая неактивный VFO). Не дублируем здесь, иначе менялись бы обе частоты:
// виджет крутил бы B, а этот хендлер — активный A. // виджет крутил бы B, а этот хендлер — активный A.
if OverFreqDisp(FreqDispA) or OverFreqDisp(FreqDispB) then Exit; 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 if (SliceId <> 0) and FController.GetSlice(SliceId, Sl) then
begin begin
if (ssCtrl in Shift) and (ssShift in Shift) then Step := 1000000 if (ssCtrl in Shift) and (ssShift in Shift) then Step := 1000000
@@ -5622,14 +6389,13 @@ begin
if Delta > 0 then SNew := Base + Step if Delta > 0 then SNew := Base + Step
else if Round(Sl.TargetHz) = Base then SNew := Base - Step else if Round(Sl.TargetHz) = Base then SNew := Base - Step
else SNew := Base; else SNew := Base;
FController.CapturedSpan(SLo, SHi); FController.CapturedSpan(SLo, SHi, Sl.PanId); // полоса ЕГО пана
if SNew < SLo then SNew := SLo; if SNew < SLo then SNew := SLo;
if SNew > SHi then SNew := SHi; if SNew > SHi then SNew := SHi;
FController.SetSliceTarget(SliceId, SNew); FController.SetSliceTarget(SliceId, SNew);
FPan.PushSliceFlagState(SliceId); SlicePan.PushSliceFlagState(SliceId);
FPan.LayoutFlags; SlicePan.LayoutFlags;
FSpectrumDirty := True; MarkPanDirty(SlicePan);
PbSpectrum.Invalidate;
FActiveSliceId := SliceId; FActiveSliceId := SliceId;
Handled := True; Handled := True;
Exit; Exit;
+172 -26
View File
@@ -31,7 +31,7 @@ unit PanafallPanel;
interface interface
uses uses
Classes, SysUtils, Math, Controls, ExtCtrls, Graphics, Forms, Classes, SysUtils, Math, Controls, ExtCtrls, StdCtrls, Graphics, Forms,
LCLIntf, LCLType, LCLIntf, LCLType,
OpenGLContextEx, OpenGLContextEx,
AppTheme, WDSPEngine, RadioController, VfoOverlay, AppTheme, WDSPEngine, RadioController, VfoOverlay,
@@ -70,6 +70,10 @@ type
FSplitterDrag: Boolean; FSplitterDrag: Boolean;
FSplitterDragY0: Integer; FSplitterDragY0: Integer;
FSplitterSH0: Integer; FSplitterSH0: Integer;
// Геометрия последнего Layout для drag-математики: верх области спектра и
// высота спектр+водопад ВНУТРИ региона пана (не всего родителя!).
FSplitTopOff: Integer;
FSplitAvailH: Integer; // 0 = сплиттер сейчас нелегитимен
FOnSplitterMoved: TNotifyEvent; FOnSplitterMoved: TNotifyEvent;
@@ -85,6 +89,27 @@ type
FOnSliceClosed: TSliceClosedEvent; FOnSliceClosed: TSliceClosedEvent;
FOnFlagsInvalidate: TNotifyEvent; 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; procedure SplitterMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer); Shift: TShiftState; X, Y: Integer);
procedure SplitterMouseMove(Sender: TObject; Shift: TShiftState; procedure SplitterMouseMove(Sender: TObject; Shift: TShiftState;
@@ -138,6 +163,25 @@ type
property SpectrumHeight: Integer read FSpectrumHeight; property SpectrumHeight: Integer read FSpectrumHeight;
property WaterfallHeight: Integer read FWaterfallHeight; property WaterfallHeight: Integer read FWaterfallHeight;
property SplitterRatio: Double read FSplitterRatio write FSplitterRatio; 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 уже обновлён) — хозяин делает // Пользователь отпустил сплиттер (ratio уже обновлён) — хозяин делает
// полный пересчёт (у него wideband/S-метр/оверлеи). // полный пересчёт (у него wideband/S-метр/оверлеи).
property OnSplitterMoved: TNotifyEvent read FOnSplitterMoved write FOnSplitterMoved; property OnSplitterMoved: TNotifyEvent read FOnSplitterMoved write FOnSplitterMoved;
@@ -148,6 +192,8 @@ type
procedure AttachMainFlag(O: TVfoOverlay); procedure AttachMainFlag(O: TVfoOverlay);
// SliceId флага, чьё событие сейчас обрабатывается (0 = главный). // SliceId флага, чьё событие сейчас обрабатывается (0 = главный).
function CurSliceId: Integer; function CurSliceId: Integer;
// Первый слайс панели (0 = слайсов B+ нет) — цель клика-тюна пана N.
function FirstSliceId: Integer;
function FindSliceFlag(SliceId: Integer): TVfoOverlay; function FindSliceFlag(SliceId: Integer): TVfoOverlay;
procedure CreateSliceFlag(SliceId: Integer); procedure CreateSliceFlag(SliceId: Integer);
procedure DestroySliceFlag(SliceId: Integer); procedure DestroySliceFlag(SliceId: Integer);
@@ -199,6 +245,8 @@ begin
FZoomBar := TPanZoomBar.Create; FZoomBar := TPanZoomBar.Create;
FSplitterRatio := 0.40; FSplitterRatio := 0.40;
FSplitterDrag := False; FSplitterDrag := False;
FShowZoomRow := True; // паны N>0 выключают (их зум — этап 3.3+)
FHeaderVisible := False; // шапка появляется при панах >= 2
end; end;
destructor TPanafallPanel.Destroy; destructor TPanafallPanel.Destroy;
@@ -239,6 +287,11 @@ begin
TPaintBox(FPbSpectrum).OnMouseLeave := SpectrumMouseLeave; TPaintBox(FPbSpectrum).OnMouseLeave := SpectrumMouseLeave;
end; end;
FPbSpectrum.Parent := AParent; FPbSpectrum.Parent := AParent;
// ЯВНЫЙ crArrow (не crDefault!): GL-виджет с WA_NativeWindow — отдельная
// Wayland-поверхность; без явного курсора она навсегда наследует курсор,
// бывший под мышью в момент создания (пан создаётся кнопкой «⊞» с
// crHandPoint → «рука» прилипала ко всему пану).
FPbSpectrum.Cursor := crArrow;
FPbRuler := TPaintBox.Create(Self); FPbRuler := TPaintBox.Create(Self);
FPbRuler.Parent := AParent; FPbRuler.Parent := AParent;
@@ -277,6 +330,7 @@ begin
TPaintBox(FPbWaterfall).OnMouseUp := WaterfallMouseUp; TPaintBox(FPbWaterfall).OnMouseUp := WaterfallMouseUp;
end; end;
FPbWaterfall.Parent := AParent; FPbWaterfall.Parent := AParent;
FPbWaterfall.Cursor := crArrow; // см. комментарий у FPbSpectrum.Cursor
// ---- Панель пана/зума под водопадом ---- // ---- Панель пана/зума под водопадом ----
// Кнопки создаём с дефолтными цветами; финальная тема — у хозяина // Кнопки создаём с дефолтными цветами; финальная тема — у хозяина
@@ -292,27 +346,96 @@ begin
FBtnZoomOut := MakeFlatBtn(AParent, '', 0, 0, 10, 10, ZoomOutClick); FBtnZoomOut := MakeFlatBtn(AParent, '', 0, 0, 10, 10, ZoomOutClick);
FBtnZoomDef := MakeFlatBtn(AParent, '⌂', 0, 0, 10, 10, ZoomDefClick); FBtnZoomDef := MakeFlatBtn(AParent, '⌂', 0, 0, 10, 10, ZoomDefClick);
FBtnZoomIn := MakeFlatBtn(AParent, '+', 0, 0, 10, 10, ZoomInClick); 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; end;
procedure TPanafallPanel.PositionPanZoomBar(BottomY, RW, H: Integer; procedure TPanafallPanel.PositionPanZoomBar(BottomY, RW, H: Integer;
AVisible: Boolean); AVisible: Boolean);
var BtnW, Gap, StripW, X: Integer; var BtnW, Gap, StripW, X, NBtns: Integer; WantAdd: Boolean;
begin begin
if (FPbPanZoom = nil) or (FBtnZoomIn = nil) then Exit; if (FPbPanZoom = nil) or (FBtnZoomIn = nil) then Exit;
// «⊞» добавления пана живёт в этом же ряду; хозяин включает его флагом
// AddPanEnabled (Visible кнопки управляется здесь вместе с рядом).
WantAdd := FAddPanWanted and (FBtnAddPan <> nil);
FPbPanZoom.Visible := AVisible; FPbPanZoom.Visible := AVisible;
FBtnZoomIn.Visible := AVisible; FBtnZoomIn.Visible := AVisible;
FBtnZoomDef.Visible := AVisible; FBtnZoomDef.Visible := AVisible;
FBtnZoomOut.Visible := AVisible; FBtnZoomOut.Visible := AVisible;
if FBtnAddPan <> nil then FBtnAddPan.Visible := AVisible and WantAdd;
if not AVisible then Exit; if not AVisible then Exit;
Gap := MulDiv(2, Screen.PixelsPerInch, 96); Gap := MulDiv(2, Screen.PixelsPerInch, 96);
BtnW := H; // квадратные кнопки в высоту строки 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; if StripW < 20 then StripW := 20;
FPbPanZoom.SetBounds(0, BottomY, StripW, H); FPbPanZoom.SetBounds(0, BottomY, StripW, H);
X := StripW + Gap; X := StripW + Gap;
FBtnZoomOut.SetBounds(X, BottomY, BtnW, H); Inc(X, BtnW + Gap); FBtnZoomOut.SetBounds(X, BottomY, BtnW, H); Inc(X, BtnW + Gap);
FBtnZoomDef.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); FBtnZoomIn.SetBounds(X, BottomY, BtnW, H);
if WantAdd then
begin
Inc(X, BtnW + Gap);
FBtnAddPan.SetBounds(X, BottomY, BtnW, H);
end;
FPbPanZoom.Invalidate; FPbPanZoom.Invalidate;
end; end;
@@ -327,20 +450,38 @@ var
AvailH: Integer; AvailH: Integer;
RULER_H: Integer; RULER_H: Integer;
PANZOOM_H: Integer; PANZOOM_H: Integer;
HEADER_H: Integer;
ShowPZ: Boolean; ShowPZ: Boolean;
begin begin
if (FParent = nil) or (FPbSpectrum = nil) then Exit; if (FParent = nil) or (FPbSpectrum = nil) then Exit;
FShowSpectrum := AShowSpectrum; FShowSpectrum := AShowSpectrum;
FShowWaterfall := AShowWaterfall; FShowWaterfall := AShowWaterfall;
FSplitAvailH := 0; // валидно только в режиме «спектр+водопад» (ниже)
RULER_H := MulDiv(18, Screen.PixelsPerInch, 96); RULER_H := MulDiv(18, Screen.PixelsPerInch, 96);
RW := FParent.ClientWidth; 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); 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); PositionPanZoomBar(RH - PANZOOM_H, RW, PANZOOM_H, ShowPZ);
if ShowPZ then RH := RH - PANZOOM_H; 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 if (not AShowSpectrum) and (not AShowWaterfall) then
@@ -417,6 +558,9 @@ begin
AvailH := RH - TopOff - RULER_H - SPLITTER_H; AvailH := RH - TopOff - RULER_H - SPLITTER_H;
if AvailH < (MIN_SH + MIN_WH) then Exit; if AvailH < (MIN_SH + MIN_WH) then Exit;
// Запоминаем геометрию для drag-математики сплиттера (регион пана в стеке).
FSplitTopOff := TopOff;
FSplitAvailH := AvailH;
// Ограничиваем соотношение, чтобы каждая зона имела минимальный размер // Ограничиваем соотношение, чтобы каждая зона имела минимальный размер
if FSplitterRatio < MIN_SH / AvailH then if FSplitterRatio < MIN_SH / AvailH then
@@ -494,15 +638,14 @@ const
begin begin
RULER_H := MulDiv(18, Screen.PixelsPerInch, 96); RULER_H := MulDiv(18, Screen.PixelsPerInch, 96);
if not FSplitterDrag then Exit; if not FSplitterDrag then Exit;
// Геометрия региона ЭТОГО пана из последнего Layout (стек панов!),
// а не от всего родителя.
TopOff := FSplitTopOff;
AvailH := FSplitAvailH;
if AvailH <= 0 then Exit;
P := FParent.ScreenToClient(FSplitter.ClientToScreen(Point(X, Y))); P := FParent.ScreenToClient(FSplitter.ClientToScreen(Point(X, Y)));
DeltaY := P.Y - FSplitterDragY0; 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; NewSH := FSplitterSH0 + DeltaY;
if NewSH < MIN_SH then NewSH := MIN_SH; if NewSH < MIN_SH then NewSH := MIN_SH;
if NewSH > AvailH - MIN_WH then NewSH := AvailH - MIN_WH; if NewSH > AvailH - MIN_WH then NewSH := AvailH - MIN_WH;
@@ -519,14 +662,11 @@ var
P: TPoint; P: TPoint;
DeltaY: Integer; DeltaY: Integer;
NewSH: Integer; NewSH: Integer;
TopOff, AvailH: Integer; AvailH: Integer;
RULER_H: Integer;
const const
SPLITTER_H = 5; MIN_SH = 60;
MIN_SH = 60; MIN_WH = 40;
MIN_WH = 40;
begin begin
RULER_H := MulDiv(18, Screen.PixelsPerInch, 96);
if not FSplitterDrag then Exit; if not FSplitterDrag then Exit;
FSplitterDrag := False; FSplitterDrag := False;
{$IFDEF WINDOWS} {$IFDEF WINDOWS}
@@ -537,10 +677,8 @@ begin
P := FParent.ScreenToClient(FSplitter.ClientToScreen(Point(X, Y))); P := FParent.ScreenToClient(FSplitter.ClientToScreen(Point(X, Y)));
DeltaY := P.Y - FSplitterDragY0; DeltaY := P.Y - FSplitterDragY0;
TopOff := 0; // Высота спектр+водопад региона ЭТОГО пана из последнего Layout (стек панов!).
// Низ зарезервирован под панель пана/зума (она видна, пока виден водопад). AvailH := FSplitAvailH;
AvailH := FParent.ClientHeight - TopOff - RULER_H - SPLITTER_H
- MulDiv(22, Screen.PixelsPerInch, 96);
if AvailH <= 0 then Exit; if AvailH <= 0 then Exit;
NewSH := FSplitterSH0 + DeltaY; NewSH := FSplitterSH0 + DeltaY;
@@ -595,6 +733,14 @@ begin
if Assigned(FActiveFlag) then Result := FActiveFlag.SliceId else Result := 0; if Assigned(FActiveFlag) then Result := FActiveFlag.SliceId else Result := 0;
end; 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; function TPanafallPanel.FindSliceFlag(SliceId: Integer): TVfoOverlay;
var i: Integer; var i: Integer;
begin begin
@@ -647,7 +793,7 @@ begin
O.Free; O.Free;
if Assigned(FOnSliceClosed) then FOnSliceClosed(SliceId); if Assigned(FOnSliceClosed) then FOnSliceClosed(SliceId);
UpdateMainFlagVisibility; // закрыт последний слайс при открытой панели → скрыть флаг A UpdateMainFlagVisibility; // закрыт последний слайс при открытой панели → скрыть флаг A
if Assigned(FOnFlagsInvalidate) then FOnFlagsInvalidate(nil); if Assigned(FOnFlagsInvalidate) then FOnFlagsInvalidate(Self); // Self = панель (роутинг по пану)
end; end;
procedure TPanafallPanel.RequestSliceClose(SliceId: Integer); procedure TPanafallPanel.RequestSliceClose(SliceId: Integer);
@@ -675,7 +821,7 @@ var
begin begin
Result := 0; Result := 0;
if (FPbSpectrum = nil) or (FPbSpectrum.Width <= 0) or (FController = nil) then Exit; if (FPbSpectrum = nil) or (FPbSpectrum.Width <= 0) or (FController = nil) then Exit;
FController.GetViewWindow(VC, VS); ViewWindow(VC, VS);
if VS <= 0 then Exit; if VS <= 0 then Exit;
for i := 0 to High(FSliceFlags) do for i := 0 to High(FSliceFlags) do
begin begin
@@ -738,7 +884,7 @@ var
begin begin
if (FPbSpectrum = nil) or (FController = nil) then Exit; if (FPbSpectrum = nil) or (FController = nil) then Exit;
W := FPbSpectrum.Width; W := FPbSpectrum.Width;
FController.GetViewWindow(VC, VS); ViewWindow(VC, VS);
if (W <= 0) or (VS <= 0) then Exit; if (W <= 0) or (VS <= 0) then Exit;
// 1. Собрать видимые флаги + их идеальный Left рядом со своей полосой. // 1. Собрать видимые флаги + их идеальный Left рядом со своей полосой.
@@ -809,7 +955,7 @@ begin
Moved := True; Moved := True;
end; end;
end; end;
if Moved and Assigned(FOnFlagsInvalidate) then FOnFlagsInvalidate(nil); if Moved and Assigned(FOnFlagsInvalidate) then FOnFlagsInvalidate(Self);
end; end;
// Флаг главного приёмника (слайс A) виден, когда СКРЫТА левая панель хозяина // Флаг главного приёмника (слайс A) виден, когда СКРЫТА левая панель хозяина
+99 -8
View File
@@ -89,7 +89,8 @@ SmartSDR НЕ умеет — наш pop-out будет улучшением по
один слайс в его центре (поведение Flex). один слайс в его центре (поведение Flex).
3. ADC-источник — свойство **панадаптера**, не слайса (бейдж `A1`/`A2` в шапке). 3. ADC-источник — свойство **панадаптера**, не слайса (бейдж `A1`/`A2` в шапке).
В отличие от Flex показываем именно АЦП, а не антенну: на HPSDR-платах В отличие от Flex показываем именно АЦП, а не антенну: на HPSDR-платах
маппинг «вход→АЦП» жёсткий, антенная абстракция не нужна. маппинг «вход→АЦП» жёсткий. Антенны ADC0 (Alex ANT1/2/3) — отдельная
ГЛОБАЛЬНАЯ ручка, общая для всех панов этого АЦП (детали в 3.4).
4. Буквы слайсов — глобальный пул A..G через все паны (как во Flex A-H): 4. Буквы слайсов — глобальный пул A..G через все паны (как во Flex A-H):
буква = личность слайса, пан = место жительства. буква = личность слайса, пан = место жительства.
5. Возможности бэкенда описывает запись capabilities (см. таблицу) — UI по ней 5. Возможности бэкенда описывает запись capabilities (см. таблицу) — UI по ней
@@ -342,14 +343,99 @@ ChangeSampleRate).
независим от DDC, TX-частота = частота слайса. (Полный мультислайс-TX — независим от 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, оба АЦП когерентно в один канал) — НЕ здесь; протокол - Diversity (sync-DDC, оба АЦП когерентно в один канал) — НЕ здесь; протокол
умеет, дизайну панов не мешает, отдельная будущая фича. умеет, дизайну панов не мешает, отдельная будущая фича.
@@ -437,6 +523,11 @@ ChangeSampleRate).
осознанно: только явное создание пана (решение «сначала статика» остаётся). осознанно: только явное создание пана (решение «сначала статика» остаётся).
- Настройки водопада (палитра/AGC/avg) per-pan или глобально — стартуем с - Настройки водопада (палитра/AGC/avg) per-pan или глобально — стартуем с
наследования глобальных, разделим по запросу. наследования глобальных, разделим по запросу.
- **Компоновка панов сеткой** (запрос юзера 2026-07-11): не только вертикальный
стек — например, пан 0 сверху во всю ширину, паны B и C ниже бок о бок
(пол-ширины каждый), и т.п. Идея на после 3.3/3.5: обобщить `LayoutPanStack`
до грид-раскладки (регион пана = прямоугольник, а не полоса; `StackTop/Height`
`Rect`), сплиттеры по обеим осям. Не блокер текущего этапа.
## Отвергнутые альтернативы (для истории) ## Отвергнутые альтернативы (для истории)
+4 -2
View File
@@ -17,9 +17,9 @@
<UseVersionInfo Value="True"/> <UseVersionInfo Value="True"/>
<AutoIncrementBuild Value="True"/> <AutoIncrementBuild Value="True"/>
<MinorVersionNr Value="9"/> <MinorVersionNr Value="9"/>
<BuildNr Value="256"/> <BuildNr Value="257"/>
</VersionInfo> </VersionInfo>
<MacroValues Count="74"> <MacroValues Count="76">
<Macro1 Name="LCLWidgetType" Value="qt6"/> <Macro1 Name="LCLWidgetType" Value="qt6"/>
<Macro2 Name="LCLWidgetType" Value="qt6"/> <Macro2 Name="LCLWidgetType" Value="qt6"/>
<Macro3 Name="LCLWidgetType" Value="qt6"/> <Macro3 Name="LCLWidgetType" Value="qt6"/>
@@ -94,6 +94,8 @@
<Macro72 Name="LCLWidgetType" Value="qt6"/> <Macro72 Name="LCLWidgetType" Value="qt6"/>
<Macro73 Name="LCLWidgetType" Value="qt6"/> <Macro73 Name="LCLWidgetType" Value="qt6"/>
<Macro74 Name="LCLWidgetType" Value="qt6"/> <Macro74 Name="LCLWidgetType" Value="qt6"/>
<Macro75 Name="LCLWidgetType" Value="qt6"/>
<Macro76 Name="LCLWidgetType" Value="qt6"/>
</MacroValues> </MacroValues>
<BuildModes> <BuildModes>
<Item Name="Debug" Default="True"/> <Item Name="Debug" Default="True"/>