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