From c125e5978e0773e742d8f05d00f46bf26f7635d7 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Fri, 10 Jul 2026 22:41:32 +0300 Subject: [PATCH] =?UTF-8?q?refactor(slices):=20=D1=8D=D1=82=D0=B0=D0=BF=20?= =?UTF-8?q?3.0A=20=E2=80=94=20=D0=BF=D0=B0=D0=BD=D0=B0=D0=B4=D0=B0=D0=BF?= =?UTF-8?q?=D1=82=D0=B5=D1=80=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=20=D0=B2=20TPanafallPanel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PanafallPanel.pas: view (CPU/GL), контролы стека (спектр/линейка/сплиттер/ водопад/пан-зум/кнопки зума), геометрия Layout, drag сплиттера и мышь пан/зум-полосы. MainForm работает через алиасы (FSpecView/PbSpectrum/… = FPan.*), размеры областей — FPan.SpectrumWidth/SpectrumHeight/WaterfallHeight. Семантика мыши/флаги/тик пока в MainForm (фаза B). GL-аудит per-instance пройден (глобальных GL-ресурсов нет). План: doc/SLICES_PLAN.md. Co-Authored-By: Claude Fable 5 --- MainForm.pas | 464 ++++++--------------------------------- PanafallPanel.pas | 518 +++++++++++++++++++++++++++++++++++++++++++ doc/SLICES_PLAN.md | 531 +++++++++++++++++++++++++++------------------ 3 files changed, 905 insertions(+), 608 deletions(-) create mode 100644 PanafallPanel.pas diff --git a/MainForm.pas b/MainForm.pas index 8a86a47..bb92cb4 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -33,7 +33,7 @@ uses WebServer, WebAdapter, CATAdapter, SpectrumView, SpectrumViewOpengl, - PanZoomBar, + PanZoomBar, PanafallPanel, WidebandView, StatusBar, PlatformUtils, @@ -156,12 +156,14 @@ type // живут в TRadioController. // ---- Spectrum / Waterfall view ---- + // Панадаптер (спектр+линейка+сплиттер+водопад+пан/зум) — composite-объект + // (Этап 3.0 мультислайсов). FSpecView и Pb*-поля ниже — алиасы его членов + // (исторические имена; постепенная миграция на FPan.*). Размеры областей — + // FPan.SpectrumWidth/SpectrumHeight/WaterfallHeight. + FPan: TPanafallPanel; FSpecView: TSpectrumView; FWidebandView: TWidebandView; FUseOpenGLSpectrum: Boolean; - FSpectrumWidth: Integer; // актуальная ширина для FDSPEngine и resize-детектора - FSpectrumHeight: Integer; - FWaterfallHeight:Integer; FDisplayFPS: Integer; FWaterfallDirty: Boolean; FWidebandDirty: Boolean; @@ -170,11 +172,6 @@ type // Снимок последнего кадра спектра/водопада для web переехал в TRadioController // (FSpectrumBuf/FWaterfallBuf) — нужен headless-демону. FAgcLineCounter: Integer; - // ---- Splitter ---- - FSplitterDrag: Boolean; - FSplitterDragY0: Integer; - FSplitterSH0: Integer; - FSplitterRatio: Double; // --- Settings --- FWebServer: TWebServer; FWebAdapter: TWebAdapter; // мост web-сервер ↔ контроллер (вынесенные WebOnXxx) @@ -301,6 +298,7 @@ type // ---- Right panel ---- PanelRight: TPanel; + // Контролы панадаптера — алиасы членов FPan (владелец FPan; см. выше). PbSpectrum: TControl; PbWideband: TControl; PbWidebandRuler: TPaintBox; @@ -454,18 +452,11 @@ type procedure DoSpectrumClick(PixelX: Integer; PanelWidth: Integer); procedure DoSpectrumDrag(PixelX: Integer; PanelWidth: Integer); procedure DoWidebandClick(PixelX: Integer; PanelWidth: Integer); - // ---- Пан/зум ---- - procedure PbPanZoomMouseDown(Sender: TObject; Button: TMouseButton; - Shift: TShiftState; X, Y: Integer); - procedure PbPanZoomMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer); - procedure PbPanZoomMouseUp(Sender: TObject; Button: TMouseButton; - Shift: TShiftState; X, Y: Integer); - procedure PbPanZoomDblClick(Sender: TObject); + // ---- Пан/зум (мышь полосы/сплиттера — внутри TPanafallPanel) ---- procedure OnPanZoomWindow(AZoom, APan: Double); procedure BtnZoomInClick(Sender: TObject); procedure BtnZoomDefClick(Sender: TObject); procedure BtnZoomOutClick(Sender: TObject); - procedure PositionPanZoomBar(BottomY, RW, H: Integer; AVisible: Boolean); procedure UpdatePanZoomBar; procedure BtnVfoSwapClick(Sender: TObject); procedure BtnVfoACopyBClick(Sender: TObject); @@ -577,13 +568,6 @@ type procedure SpectrumTimerTick(Sender: TObject); procedure RightPanelResize(Sender: TObject); procedure AfterShowTick(Sender: TObject); - // Splitter handlers - procedure SplitterMouseDown(Sender: TObject; Button: TMouseButton; - Shift: TShiftState; X, Y: Integer); - procedure SplitterMouseMove(Sender: TObject; Shift: TShiftState; - X, Y: Integer); - procedure SplitterMouseUp(Sender: TObject; Button: TMouseButton; - Shift: TShiftState; X, Y: Integer); public // Методы для SettingsForm — немедленное применение настроек @@ -1008,16 +992,15 @@ begin FController.FSpectrumFill := True; FDisplayFPS := 60; FFreqMhzDigits := 3; - FSplitterRatio := 0.40; - FSplitterDrag := False; // OpenGLContextEx (QOpenGLWidget-бэкенд) работает и в нативном Wayland, // старая X11-заглушка больше не нужна FUseOpenGLSpectrum := HasOpenGLSpectrumSwitch; - if FUseOpenGLSpectrum then - FSpecView := TSpectrumViewOpenGL.Create - else - FSpecView := TSpectrumView.Create; + // Панадаптер: владеет view/зум-баром/контролами спектра; контролы создаст + // FPan.Build в BuildUI. Owner=nil — освобождаем сами в FormDestroy. + FPan := TPanafallPanel.Create(nil, FUseOpenGLSpectrum); + FSpecView := FPan.View; // алиас (историческое имя) + FPanZoomBar := FPan.ZoomBar; // алиас FSpecView.VfoOverlay := FVfoOverlay; FSpecView.VfoA := FController.FVfoA; FSpecView.VfoB := FController.FVfoB; @@ -1114,9 +1097,16 @@ begin if FController.FNetwork.Running then FController.FNetwork.SetRunAndFreq(False, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), 0); FController.FNetwork.Disconnect; - FreeAndNil(FSpecView); + // Панадаптер освобождает view, зум-бар и свои контролы; алиасы обнуляем. + FSpecView := nil; + FPanZoomBar := nil; + PbSpectrum := nil; + PbRuler := nil; + PanelSplitter := nil; + PbWaterfall := nil; + PbPanZoom := nil; + FreeAndNil(FPan); FreeAndNil(FWidebandView); - FreeAndNil(FPanZoomBar); // Сохраняем настройки при закрытии if FController.FDevConnected then @@ -1416,7 +1406,7 @@ begin // ChangeSampleRate пересоздал WDSP-канал — пересчёт AGC-линий под // актуальную ширину спектра (UI-зависимая величина). if FController.FWDSPReady then - FController.FDSPEngine.UpdateAGCLines(FSpectrumWidth); + FController.FDSPEngine.UpdateAGCLines(FPan.SpectrumWidth); UpdatePanZoomBar; end; rfZoom: @@ -2215,190 +2205,42 @@ begin PbWidebandRuler.Cursor := crDefault; PbWidebandRuler.Visible := False; - if FUseOpenGLSpectrum then - begin - PbSpectrum := TOpenGLControl.Create(Self); - TOpenGLControl(PbSpectrum).AutoResizeViewport := False; - TOpenGLControl(PbSpectrum).DoubleBuffered := True; - TOpenGLControl(PbSpectrum).MultiSampling := Max(1, FController.FSpecMSAA); - TOpenGLControl(PbSpectrum).OnPaint := FSpecView.PaintSpectrum; - TOpenGLControl(PbSpectrum).OnMouseDown := PbSpectrumMouseDown; - TOpenGLControl(PbSpectrum).OnDblClick := PbSpectrumDblClick; - TOpenGLControl(PbSpectrum).OnMouseMove := PbSpectrumMouseMove; - TOpenGLControl(PbSpectrum).OnMouseUp := PbSpectrumMouseUp; - TOpenGLControl(PbSpectrum).OnMouseLeave := PbSpectrumMouseLeave; - TSpectrumViewOpenGL(FSpecView).AttachControl(TOpenGLControl(PbSpectrum)); - end - else - begin - PbSpectrum := TPaintBox.Create(Self); - TPaintBox(PbSpectrum).OnPaint := FSpecView.PaintSpectrum; - TPaintBox(PbSpectrum).OnMouseDown := PbSpectrumMouseDown; - TPaintBox(PbSpectrum).OnDblClick := PbSpectrumDblClick; - TPaintBox(PbSpectrum).OnMouseMove := PbSpectrumMouseMove; - TPaintBox(PbSpectrum).OnMouseUp := PbSpectrumMouseUp; - TPaintBox(PbSpectrum).OnMouseLeave := PbSpectrumMouseLeave; - end; - PbSpectrum.Parent := PanelRight; - - PbRuler := TPaintBox.Create(Self); - PbRuler.Parent := PanelRight; - PbRuler.OnPaint := FSpecView.PaintRuler; - PbRuler.Cursor := crDefault; - FSpecView.PbRuler := PbRuler; - - // ---- Splitter между спектром+линейкой и водопадом ---- - PanelSplitter := TPanel.Create(Self); - PanelSplitter.Parent := PanelRight; - PanelSplitter.BevelOuter := bvNone; - PanelSplitter.Color := TColor($00303030); - PanelSplitter.Cursor := crVSplit; - PanelSplitter.Height := 5; - PanelSplitter.OnMouseDown := SplitterMouseDown; - PanelSplitter.OnMouseMove := SplitterMouseMove; - PanelSplitter.OnMouseUp := SplitterMouseUp; - - if FUseOpenGLSpectrum then - begin - PbWaterfall := TOpenGLControl.Create(Self); - TOpenGLControl(PbWaterfall).AutoResizeViewport := False; - TOpenGLControl(PbWaterfall).DoubleBuffered := True; - TOpenGLControl(PbWaterfall).OnPaint := FSpecView.PaintWaterfall; - TOpenGLControl(PbWaterfall).OnMouseDown := PbWaterfallMouseDown; - TOpenGLControl(PbWaterfall).OnMouseMove := PbWaterfallMouseMove; - TOpenGLControl(PbWaterfall).OnMouseUp := PbWaterfallMouseUp; - TSpectrumViewOpenGL(FSpecView).AttachWaterfallControl(TOpenGLControl(PbWaterfall)); - end - else - begin - PbWaterfall := TPaintBox.Create(Self); - TPaintBox(PbWaterfall).OnPaint := FSpecView.PaintWaterfall; - TPaintBox(PbWaterfall).OnMouseDown := PbWaterfallMouseDown; - TPaintBox(PbWaterfall).OnMouseMove := PbWaterfallMouseMove; - TPaintBox(PbWaterfall).OnMouseUp := PbWaterfallMouseUp; - end; - PbWaterfall.Parent := PanelRight; - - // ---- Панель пана/зума под водопадом ---- - // Кнопки создаём с дефолтными цветами; финальная тема — в ApplyDarkTheme. - FPanZoomBar := TPanZoomBar.Create; + // ---- Панадаптер: контролы спектра/линейки/сплиттера/водопада/пан-зума ---- + // Создаёт и раскладывает TPanafallPanel; семантика мыши остаётся здесь — + // обработчики подвешиваются ДО Build. + FPan.SpectrumMouseDown := PbSpectrumMouseDown; + FPan.SpectrumMouseMove := PbSpectrumMouseMove; + FPan.SpectrumMouseUp := PbSpectrumMouseUp; + FPan.SpectrumMouseLeave := PbSpectrumMouseLeave; + FPan.SpectrumDblClick := PbSpectrumDblClick; + FPan.WaterfallMouseDown := PbWaterfallMouseDown; + FPan.WaterfallMouseMove := PbWaterfallMouseMove; + FPan.WaterfallMouseUp := PbWaterfallMouseUp; + FPan.ZoomInClick := BtnZoomInClick; + FPan.ZoomDefClick := BtnZoomDefClick; + FPan.ZoomOutClick := BtnZoomOutClick; + FPan.OnSplitterMoved := RightPanelResize; + FPan.Build(PanelRight, Max(1, FController.FSpecMSAA)); + // Алиасы контролов (исторические имена; постепенная миграция на FPan.*) + PbSpectrum := FPan.PbSpectrum; + PbRuler := FPan.PbRuler; + PanelSplitter := FPan.Splitter; + PbWaterfall := FPan.PbWaterfall; + PbPanZoom := FPan.PbPanZoom; + BtnZoomIn := FPan.BtnZoomIn; + BtnZoomDef := FPan.BtnZoomDef; + BtnZoomOut := FPan.BtnZoomOut; FPanZoomBar.OnZoomPan := OnPanZoomWindow; - PbPanZoom := TPaintBox.Create(Self); - PbPanZoom.Parent := PanelRight; - PbPanZoom.OnPaint := FPanZoomBar.Paint; - PbPanZoom.OnMouseDown := PbPanZoomMouseDown; - PbPanZoom.OnMouseMove := PbPanZoomMouseMove; - PbPanZoom.OnMouseUp := PbPanZoomMouseUp; - PbPanZoom.OnDblClick := PbPanZoomDblClick; - FPanZoomBar.PaintBox := PbPanZoom; - BtnZoomOut := MakeFlatBtn(PanelRight, '−', 0, 0, 10, 10, BtnZoomOutClick); - BtnZoomDef := MakeFlatBtn(PanelRight, '⌂', 0, 0, 10, 10, BtnZoomDefClick); - BtnZoomIn := MakeFlatBtn(PanelRight, '+', 0, 0, 10, 10, BtnZoomInClick); // Initial layout ResizeSpectrumPanels; UpdatePanZoomBar; end; -// =========================================================================== -// Splitter — перетаскивание границы спектр/водопад -// Обновление размеров происходит только при отпускании мыши (MouseUp), -// чтобы исключить фризы во время перетаскивания. -// Во время перетаскивания рисуем только призрак-линию на PanelSplitter. -// =========================================================================== - -procedure TMainForm.SplitterMouseDown(Sender: TObject; Button: TMouseButton; - Shift: TShiftState; X, Y: Integer); -var - P: TPoint; -begin - if Button <> mbLeft then Exit; - FSplitterDrag := True; - FSplitterSH0 := FSpectrumHeight; - // Y в координатах PanelRight - P := PanelRight.ScreenToClient(PanelSplitter.ClientToScreen(Point(X, Y))); - FSplitterDragY0 := P.Y; - PanelSplitter.Color := TColor($005050A0); // подсветка при захвате - {$IFDEF WINDOWS} - SetCapture(PanelSplitter.Handle); - {$ENDIF} -end; - -procedure TMainForm.SplitterMouseMove(Sender: TObject; Shift: TShiftState; - X, Y: Integer); -var - P: TPoint; - DeltaY: Integer; - NewSH: Integer; - TopOff, AvailH: Integer; - RULER_H: Integer; -const - SPLITTER_H = 5; - MIN_SH = 60; - MIN_WH = 40; -begin - RULER_H := MulDiv(18, Screen.PixelsPerInch, 96); - if not FSplitterDrag then Exit; - P := PanelRight.ScreenToClient(PanelSplitter.ClientToScreen(Point(X, Y))); - DeltaY := P.Y - FSplitterDragY0; - - TopOff := 0; - // Низ зарезервирован под панель пана/зума (она видна, пока виден водопад). - AvailH := PanelRight.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; - - // Только двигаем сплиттер визуально — без пересчёта битмапов - PanelSplitter.Top := TopOff + NewSH + RULER_H; - PbWaterfall.Top := TopOff + NewSH + RULER_H + SPLITTER_H; - PbWaterfall.Height := AvailH - NewSH; -end; - -procedure TMainForm.SplitterMouseUp(Sender: TObject; Button: TMouseButton; - Shift: TShiftState; X, Y: Integer); -var - P: TPoint; - DeltaY: Integer; - NewSH: Integer; - TopOff, AvailH: Integer; - RULER_H: Integer; -const - SPLITTER_H = 5; - MIN_SH = 60; - MIN_WH = 40; -begin - RULER_H := MulDiv(18, Screen.PixelsPerInch, 96); - if not FSplitterDrag then Exit; - FSplitterDrag := False; - {$IFDEF WINDOWS} - ReleaseCapture; - {$ENDIF} - PanelSplitter.Color := TColor($00303030); // обычный цвет - - P := PanelRight.ScreenToClient(PanelSplitter.ClientToScreen(Point(X, Y))); - DeltaY := P.Y - FSplitterDragY0; - - TopOff := 0; - // Низ зарезервирован под панель пана/зума (она видна, пока виден водопад). - AvailH := PanelRight.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; - - // Сохраняем новое соотношение и делаем полный пересчёт с перерисовкой - FSplitterRatio := NewSH / AvailH; - ResizeSpectrumPanels; -end; - // =========================================================================== // Resize handler (normal method instead of anonymous procedure) +// Сплиттер спектр/водопад живёт в TPanafallPanel; по OnSplitterMoved панель +// зовёт RightPanelResize → полный пересчёт. // =========================================================================== procedure TMainForm.RightPanelResize(Sender: TObject); @@ -2581,27 +2423,22 @@ end; procedure TMainForm.ResizeSpectrumPanels; const - SPLITTER_H = 5; - WB_H = 118; - MIN_SH = 60; // минимальная высота спектра - MIN_WH = 40; // минимальная высота водопада + WB_H = 118; var - RW, RH, SH, WH, TopOff, WideH, WideSpecH: Integer; - AvailH: Integer; + RW, RH, WideH, WideSpecH: Integer; RULER_H: Integer; PANZOOM_H: Integer; - ShowPZ: Boolean; begin RULER_H := MulDiv(18, Screen.PixelsPerInch, 96); if PanelRight = nil then Exit; if Assigned(FSpecView) then SyncSpecViewFreq; RW := PanelRight.ClientWidth; RH := PanelRight.ClientHeight; - // Резервируем низ под панель пана/зума (видна, если виден спектр или водопад). + // Низ занят панелью пана/зума (позиционирует FPan.Layout той же формулой) — + // wideband-блок делит оставшуюся высоту. PANZOOM_H := MulDiv(22, Screen.PixelsPerInch, 96); - ShowPZ := FController.FShowSpectrum or FController.FShowWaterfall; - PositionPanZoomBar(RH - PANZOOM_H, RW, PANZOOM_H, ShowPZ); - if ShowPZ then RH := RH - PANZOOM_H; + if FController.FShowSpectrum or FController.FShowWaterfall then + RH := RH - PANZOOM_H; UpdateWidebandFrequencyView; WideH := 0; WideSpecH := 0; @@ -2609,7 +2446,6 @@ begin WideH := Min(MulDiv(WB_H, Screen.PixelsPerInch, 96), Max(40, RH div 2)); if WideH > 0 then WideSpecH := Max(1, WideH - RULER_H); - TopOff := WideH; if PbWideband <> nil then begin PbWideband.SetBounds(0, 0, RW, WideSpecH); @@ -2633,124 +2469,9 @@ begin // S-метр позиционируется отдельно в ResizeSMeter ResizeSMeter; - // ---- Оба скрыты ---- - if (not FController.FShowSpectrum) and (not FController.FShowWaterfall) then - begin - PbSpectrum.Visible := False; - PbRuler.Visible := False; - PanelSplitter.Visible := False; - PbWaterfall.Visible := False; - FSpectrumWidth := RW; - FSpectrumHeight := 0; - FWaterfallHeight := 0; - PositionSampleRateOverlay; - Exit; - end; - - // ---- Только спектр (водопад скрыт) ---- - if FController.FShowSpectrum and (not FController.FShowWaterfall) then - begin - AvailH := RH - TopOff - RULER_H; - if AvailH < MIN_SH then AvailH := MIN_SH; - SH := AvailH; - WH := 0; - PbSpectrum.SetBounds(0, TopOff, RW, SH); - PbSpectrum.Visible := True; - PbRuler.SetBounds(0, TopOff + SH, RW, RULER_H); - PbRuler.Visible := True; - PanelSplitter.Visible := False; - PbWaterfall.Visible := False; - PositionSampleRateOverlay; - FSpectrumWidth := RW; - FSpectrumHeight := SH; - FWaterfallHeight := 0; - if RW > 0 then - begin - FSpecView.SetSpectrumBitmapSize(RW, SH); - FSpecView.SetWaterfallBitmapSize(1, 1); - FSpecView.SetRulerSize(RW, RULER_H); - FSpecView.DrawSpectrum; - PbSpectrum.Invalidate; - PbRuler.Invalidate; - end; - Exit; - end; - - // ---- Только водопад (спектр скрыт): линейка под водопадом ---- - if (not FController.FShowSpectrum) and FController.FShowWaterfall then - begin - AvailH := RH - TopOff - RULER_H; - if AvailH < MIN_WH then AvailH := MIN_WH; - SH := 0; - WH := AvailH; - PbSpectrum.Visible := False; - PanelSplitter.Visible := False; - PbWaterfall.SetBounds(0, TopOff, RW, WH); - PbWaterfall.Visible := True; - PbRuler.SetBounds(0, TopOff + WH, RW, RULER_H); - PbRuler.Visible := True; - PositionSampleRateOverlay; - FSpectrumWidth := RW; - FSpectrumHeight := 0; - FWaterfallHeight := WH; - if RW > 0 then - begin - FSpecView.SetSpectrumBitmapSize(1, 1); - FSpecView.SetWaterfallBitmapSize(RW, WH); - FSpecView.SetRulerSize(RW, RULER_H); - FSpecView.DrawWaterfall; - PbWaterfall.Invalidate; - PbRuler.Invalidate; - end; - Exit; - end; - - // ---- Оба видимы: стандартный режим со сплиттером ---- - PbSpectrum.Visible := True; - PbRuler.Visible := True; - PanelSplitter.Visible := True; - PbWaterfall.Visible := True; - - AvailH := RH - TopOff - RULER_H - SPLITTER_H; - if AvailH < (MIN_SH + MIN_WH) then Exit; - - // Ограничиваем соотношение, чтобы каждая зона имела минимальный размер - if FSplitterRatio < MIN_SH / AvailH then - FSplitterRatio := MIN_SH / AvailH; - if FSplitterRatio > 1.0 - MIN_WH / AvailH then - FSplitterRatio := 1.0 - MIN_WH / AvailH; - - SH := Round(AvailH * FSplitterRatio); - WH := AvailH - SH; - if WH < MIN_WH then WH := MIN_WH; - - // Спектр - PbSpectrum.SetBounds(0, TopOff, RW, SH); - // Линейка частот — всегда прижата к низу спектра - PbRuler.SetBounds(0, TopOff + SH, RW, RULER_H); - // Сплиттер — между линейкой и водопадом - PanelSplitter.SetBounds(0, TopOff + SH + RULER_H, RW, SPLITTER_H); - // Водопад — под сплиттером - PbWaterfall.SetBounds(0, TopOff + SH + RULER_H + SPLITTER_H, RW, WH); - + // Стек спектр/линейка/сплиттер/водопад/пан-зум раскладывает панадаптер. + FPan.Layout(WideH, FController.FShowSpectrum, FController.FShowWaterfall); PositionSampleRateOverlay; - - // Обновляем переменные размеров - FSpectrumWidth := RW; - FSpectrumHeight := SH; - FWaterfallHeight := WH; - - // Пересоздаём bitmap точно под новый размер - if RW > 0 then - begin - if SH > 0 then FSpecView.SetSpectrumBitmapSize(RW, SH); - if WH > 0 then FSpecView.SetWaterfallBitmapSize(RW, WH); - FSpecView.SetRulerSize(RW, RULER_H); - FSpecView.DrawSpectrum; - PbSpectrum.Invalidate; - if PbRuler <> nil then PbRuler.Invalidate; - PbWaterfall.Invalidate; - end; end; // =========================================================================== @@ -3234,17 +2955,17 @@ var begin // Синхронизируем размеры если размер панели изменился (после ресайза). // Важно: проверяем только видимые панели — когда спектр скрыт, - // PbSpectrum.Height может быть ненулевым при FSpectrumHeight=0, + // PbSpectrum.Height может быть ненулевым при FPan.SpectrumHeight=0, // что вызвало бы сброс буфера водопада на каждом тике. - if (FSpectrumWidth = 0) or - (FController.FShowWideband and (PbWideband.Width <> FSpectrumWidth)) or - (FController.FShowSpectrum and ((PbSpectrum.Width <> FSpectrumWidth) or - (PbSpectrum.Height <> FSpectrumHeight))) or + if (FPan.SpectrumWidth = 0) or + (FController.FShowWideband and (PbWideband.Width <> FPan.SpectrumWidth)) or + (FController.FShowSpectrum and ((PbSpectrum.Width <> FPan.SpectrumWidth) or + (PbSpectrum.Height <> FPan.SpectrumHeight))) or (FController.FShowWaterfall and not FController.FShowSpectrum and - ((PbWaterfall.Width <> FSpectrumWidth) or - (PbWaterfall.Height <> FWaterfallHeight))) then + ((PbWaterfall.Width <> FPan.SpectrumWidth) or + (PbWaterfall.Height <> FPan.WaterfallHeight))) then ResizeSpectrumPanels; - if FSpectrumWidth <= 0 then Exit; + if FPan.SpectrumWidth <= 0 then Exit; SyncSpecViewFreq; // Проверка таймаута — трансивер не отвечает @@ -3290,11 +3011,11 @@ begin if FController.FWDSPReady then begin FController.FLastSMeter := FController.FDSPEngine.GetSMeterDBm; - FController.FDSPEngine.SetSpectrumWidth(FSpectrumWidth); + FController.FDSPEngine.SetSpectrumWidth(FPan.SpectrumWidth); Inc(FAgcLineCounter); if FAgcLineCounter >= 6 then begin - FController.FDSPEngine.UpdateAGCLines(FSpectrumWidth); + FController.FDSPEngine.UpdateAGCLines(FPan.SpectrumWidth); FSpecView.AGCThresh := FController.FDSPEngine.AGCThresh; FSpecView.AGCHangLevel := FController.FDSPEngine.AGCHangLevel; FAgcLineCounter := 0; @@ -3350,9 +3071,9 @@ begin if not FController.FNetwork.Connected then begin // При первом старте показываем демо-спектр - if FSpecView.SpectrumBitmapWidth <> FSpectrumWidth then + if FSpecView.SpectrumBitmapWidth <> FPan.SpectrumWidth then begin - FSpecView.SetSpectrumBitmapSize(FSpectrumWidth, FSpectrumHeight); + FSpecView.SetSpectrumBitmapSize(FPan.SpectrumWidth, FPan.SpectrumHeight); FSpecView.ResetSpectrumBuf; FSpecView.DrawSpectrum; PbSpectrum.Invalidate; @@ -3891,7 +3612,7 @@ begin // вся оркестрация в контроллере (ConnectAndRun); рендер device/band/run- // виджетов идёт через события (rfDevice/rfBandRestore/rfXvtr/rfActiveVfo/ // rfRunning). Здесь — только UI-extras (статус-строки/AGCTop/resize). - if not FController.ConnectAndRun(Dev, FSpectrumWidth) then + if not FController.ConnectAndRun(Dev, FPan.SpectrumWidth) then begin ShowMessage('Failed to connect to ' + Dev.IPAddress + IfThen(FController.FNetwork.LastError <> '', ': ' + FController.FNetwork.LastError, '')); @@ -4437,27 +4158,6 @@ end; // Панель пана / зума // --------------------------------------------------------------------------- -procedure TMainForm.PositionPanZoomBar(BottomY, RW, H: Integer; AVisible: Boolean); -var BtnW, Gap, StripW, X: Integer; -begin - if (PbPanZoom = nil) or (BtnZoomIn = nil) then Exit; - PbPanZoom.Visible := AVisible; - BtnZoomIn.Visible := AVisible; - BtnZoomDef.Visible := AVisible; - BtnZoomOut.Visible := AVisible; - if not AVisible then Exit; - Gap := MulDiv(2, Screen.PixelsPerInch, 96); - BtnW := H; // квадратные кнопки в высоту строки - StripW := RW - 3 * BtnW - 4 * Gap; - if StripW < 20 then StripW := 20; - PbPanZoom.SetBounds(0, BottomY, StripW, H); - X := StripW + Gap; - BtnZoomOut.SetBounds(X, BottomY, BtnW, H); Inc(X, BtnW + Gap); - BtnZoomDef.SetBounds(X, BottomY, BtnW, H); Inc(X, BtnW + Gap); - BtnZoomIn.SetBounds(X, BottomY, BtnW, H); - PbPanZoom.Invalidate; -end; - procedure TMainForm.UpdatePanZoomBar; begin if FPanZoomBar <> nil then @@ -4475,28 +4175,6 @@ begin FController.ApplyZoom(AZoom, APan); end; -procedure TMainForm.PbPanZoomDblClick(Sender: TObject); -begin - if FPanZoomBar <> nil then FPanZoomBar.HandleDblClick; -end; - -procedure TMainForm.PbPanZoomMouseDown(Sender: TObject; Button: TMouseButton; - Shift: TShiftState; X, Y: Integer); -begin - if FPanZoomBar <> nil then FPanZoomBar.HandleMouseDown(Button, X, Y); -end; - -procedure TMainForm.PbPanZoomMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer); -begin - if FPanZoomBar <> nil then FPanZoomBar.HandleMouseMove(X, Y); -end; - -procedure TMainForm.PbPanZoomMouseUp(Sender: TObject; Button: TMouseButton; - Shift: TShiftState; X, Y: Integer); -begin - if FPanZoomBar <> nil then FPanZoomBar.HandleMouseUp; -end; - procedure TMainForm.BtnZoomInClick(Sender: TObject); begin FController.ZoomIn; diff --git a/PanafallPanel.pas b/PanafallPanel.pas new file mode 100644 index 0000000..68f4166 --- /dev/null +++ b/PanafallPanel.pas @@ -0,0 +1,518 @@ +unit PanafallPanel; + +{ + TPanafallPanel — панадаптер (спектр + линейка частот + сплиттер + водопад + + полоса пана/зума с кнопками) как единый composite-объект. + + Этап 3.0 плана мультислайсов (doc/SLICES_PLAN.md): вынос панадаптера из + MainForm без изменения поведения. Панель владеет: + • конструированием контролов (Build) и SpectrumView (CPU или OpenGL); + • геометрией стека спектр/линейка/сплиттер/водопад/пан-зум (Layout); + • механикой сплиттера (drag с призраком, пересчёт по MouseUp); + • прокидкой мыши пан/зум-полосы в TPanZoomBar. + Семантика мыши спектра/водопада (тюн, слайсы, маркер, оверлеи) остаётся у + хозяина — он подвешивает свои обработчики через публичные поля Spectrum*/ + Waterfall*/Zoom*Click ДО вызова Build. + + Хозяин по-прежнему сам синхронизирует состояние view (частоты, палитры, + wf-настройки) и решает, когда звать Layout (resize, show/hide, сплиттер). +} + +{$IFDEF FPC} + {$MODE Delphi} +{$ENDIF} + +interface + +uses + Classes, SysUtils, Math, Controls, ExtCtrls, Graphics, Forms, + LCLIntf, LCLType, + OpenGLContextEx, + SpectrumView, SpectrumViewOpengl, PanZoomBar, FlatButton; + +type + TPanafallPanel = class(TComponent) + private + FParent: TWinControl; + FUseOpenGL: Boolean; + FView: TSpectrumView; + FPbSpectrum: TControl; + FPbRuler: TPaintBox; + FSplitter: TPanel; + FPbWaterfall: TControl; + FPbPanZoom: TPaintBox; + FZoomBar: TPanZoomBar; + FBtnZoomIn: TFlatButton; + FBtnZoomDef: TFlatButton; + FBtnZoomOut: TFlatButton; + + // Актуальные размеры областей после Layout (для DSP-движка и + // resize-детектора хозяина). + FSpectrumWidth: Integer; + FSpectrumHeight: Integer; + FWaterfallHeight: Integer; + FShowSpectrum: Boolean; + FShowWaterfall: Boolean; + FSplitterRatio: Double; + + // Сплиттер-драг: обновление размеров только при отпускании мыши (MouseUp), + // чтобы исключить фризы; во время перетаскивания двигаем контролы визуально. + FSplitterDrag: Boolean; + FSplitterDragY0: Integer; + FSplitterSH0: Integer; + + FOnSplitterMoved: TNotifyEvent; + + procedure SplitterMouseDown(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); + procedure SplitterMouseMove(Sender: TObject; Shift: TShiftState; + X, Y: Integer); + procedure SplitterMouseUp(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); + procedure PanZoomMouseDown(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); + procedure PanZoomMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer); + procedure PanZoomMouseUp(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); + procedure PanZoomDblClick(Sender: TObject); + procedure PositionPanZoomBar(BottomY, RW, H: Integer; AVisible: Boolean); + public + // Обработчики мыши спектра/водопада и кликов зум-кнопок хозяина — + // задать ДО вызова Build (Build подвешивает их на создаваемые контролы). + SpectrumMouseDown: TMouseEvent; + SpectrumMouseMove: TMouseMoveEvent; + SpectrumMouseUp: TMouseEvent; + SpectrumMouseLeave: TNotifyEvent; + SpectrumDblClick: TNotifyEvent; + WaterfallMouseDown: TMouseEvent; + WaterfallMouseMove: TMouseMoveEvent; + WaterfallMouseUp: TMouseEvent; + ZoomInClick: TNotifyEvent; + ZoomDefClick: TNotifyEvent; + ZoomOutClick: TNotifyEvent; + + constructor Create(AOwner: TComponent; AUseOpenGL: Boolean); reintroduce; + destructor Destroy; override; + + // Создаёт контролы в AParent и подвешивает обработчики (см. поля выше). + procedure Build(AParent: TWinControl; AMSAA: Integer); + // Полная раскладка стека. ATopOffset — высота wideband-блока хозяина + // над спектром. Show-флаги приходят от контроллера (FShowSpectrum/ + // FShowWaterfall). + procedure Layout(ATopOffset: Integer; AShowSpectrum, AShowWaterfall: Boolean); + + property View: TSpectrumView read FView; + property PbSpectrum: TControl read FPbSpectrum; + property PbRuler: TPaintBox read FPbRuler; + property Splitter: TPanel read FSplitter; + property PbWaterfall: TControl read FPbWaterfall; + property PbPanZoom: TPaintBox read FPbPanZoom; + property ZoomBar: TPanZoomBar read FZoomBar; + property BtnZoomIn: TFlatButton read FBtnZoomIn; + property BtnZoomDef: TFlatButton read FBtnZoomDef; + property BtnZoomOut: TFlatButton read FBtnZoomOut; + + property SpectrumWidth: Integer read FSpectrumWidth; + property SpectrumHeight: Integer read FSpectrumHeight; + property WaterfallHeight: Integer read FWaterfallHeight; + property SplitterRatio: Double read FSplitterRatio write FSplitterRatio; + // Пользователь отпустил сплиттер (ratio уже обновлён) — хозяин делает + // полный пересчёт (у него wideband/S-метр/оверлеи). + property OnSplitterMoved: TNotifyEvent read FOnSplitterMoved write FOnSplitterMoved; + end; + +implementation + +constructor TPanafallPanel.Create(AOwner: TComponent; AUseOpenGL: Boolean); +begin + inherited Create(AOwner); + FUseOpenGL := AUseOpenGL; + if FUseOpenGL then + FView := TSpectrumViewOpenGL.Create + else + FView := TSpectrumView.Create; + FZoomBar := TPanZoomBar.Create; + FSplitterRatio := 0.40; + FSplitterDrag := False; +end; + +destructor TPanafallPanel.Destroy; +begin + // Порядок как в прежнем FormDestroy: view/зум-бар первыми, контролы + // (Owner=Self) освободит inherited. + FreeAndNil(FZoomBar); + FreeAndNil(FView); + inherited Destroy; +end; + +procedure TPanafallPanel.Build(AParent: TWinControl; AMSAA: Integer); +begin + FParent := AParent; + + if FUseOpenGL then + begin + FPbSpectrum := TOpenGLControl.Create(Self); + TOpenGLControl(FPbSpectrum).AutoResizeViewport := False; + TOpenGLControl(FPbSpectrum).DoubleBuffered := True; + TOpenGLControl(FPbSpectrum).MultiSampling := Max(1, AMSAA); + TOpenGLControl(FPbSpectrum).OnPaint := FView.PaintSpectrum; + TOpenGLControl(FPbSpectrum).OnMouseDown := SpectrumMouseDown; + TOpenGLControl(FPbSpectrum).OnDblClick := SpectrumDblClick; + TOpenGLControl(FPbSpectrum).OnMouseMove := SpectrumMouseMove; + TOpenGLControl(FPbSpectrum).OnMouseUp := SpectrumMouseUp; + TOpenGLControl(FPbSpectrum).OnMouseLeave := SpectrumMouseLeave; + TSpectrumViewOpenGL(FView).AttachControl(TOpenGLControl(FPbSpectrum)); + end + else + begin + FPbSpectrum := TPaintBox.Create(Self); + TPaintBox(FPbSpectrum).OnPaint := FView.PaintSpectrum; + TPaintBox(FPbSpectrum).OnMouseDown := SpectrumMouseDown; + TPaintBox(FPbSpectrum).OnDblClick := SpectrumDblClick; + TPaintBox(FPbSpectrum).OnMouseMove := SpectrumMouseMove; + TPaintBox(FPbSpectrum).OnMouseUp := SpectrumMouseUp; + TPaintBox(FPbSpectrum).OnMouseLeave := SpectrumMouseLeave; + end; + FPbSpectrum.Parent := AParent; + + FPbRuler := TPaintBox.Create(Self); + FPbRuler.Parent := AParent; + FPbRuler.OnPaint := FView.PaintRuler; + FPbRuler.Cursor := crDefault; + FView.PbRuler := FPbRuler; + + // ---- Сплиттер между спектром+линейкой и водопадом ---- + FSplitter := TPanel.Create(Self); + FSplitter.Parent := AParent; + FSplitter.BevelOuter := bvNone; + FSplitter.Color := TColor($00303030); + FSplitter.Cursor := crVSplit; + FSplitter.Height := 5; + FSplitter.OnMouseDown := SplitterMouseDown; + FSplitter.OnMouseMove := SplitterMouseMove; + FSplitter.OnMouseUp := SplitterMouseUp; + + if FUseOpenGL then + begin + FPbWaterfall := TOpenGLControl.Create(Self); + TOpenGLControl(FPbWaterfall).AutoResizeViewport := False; + TOpenGLControl(FPbWaterfall).DoubleBuffered := True; + TOpenGLControl(FPbWaterfall).OnPaint := FView.PaintWaterfall; + TOpenGLControl(FPbWaterfall).OnMouseDown := WaterfallMouseDown; + TOpenGLControl(FPbWaterfall).OnMouseMove := WaterfallMouseMove; + TOpenGLControl(FPbWaterfall).OnMouseUp := WaterfallMouseUp; + TSpectrumViewOpenGL(FView).AttachWaterfallControl(TOpenGLControl(FPbWaterfall)); + end + else + begin + FPbWaterfall := TPaintBox.Create(Self); + TPaintBox(FPbWaterfall).OnPaint := FView.PaintWaterfall; + TPaintBox(FPbWaterfall).OnMouseDown := WaterfallMouseDown; + TPaintBox(FPbWaterfall).OnMouseMove := WaterfallMouseMove; + TPaintBox(FPbWaterfall).OnMouseUp := WaterfallMouseUp; + end; + FPbWaterfall.Parent := AParent; + + // ---- Панель пана/зума под водопадом ---- + // Кнопки создаём с дефолтными цветами; финальная тема — у хозяина + // (ApplyDarkTheme → StyleButton/SetTheme через алиасы). + FPbPanZoom := TPaintBox.Create(Self); + FPbPanZoom.Parent := AParent; + FPbPanZoom.OnPaint := FZoomBar.Paint; + FPbPanZoom.OnMouseDown := PanZoomMouseDown; + FPbPanZoom.OnMouseMove := PanZoomMouseMove; + FPbPanZoom.OnMouseUp := PanZoomMouseUp; + FPbPanZoom.OnDblClick := PanZoomDblClick; + FZoomBar.PaintBox := FPbPanZoom; + FBtnZoomOut := MakeFlatBtn(AParent, '−', 0, 0, 10, 10, ZoomOutClick); + FBtnZoomDef := MakeFlatBtn(AParent, '⌂', 0, 0, 10, 10, ZoomDefClick); + FBtnZoomIn := MakeFlatBtn(AParent, '+', 0, 0, 10, 10, ZoomInClick); +end; + +procedure TPanafallPanel.PositionPanZoomBar(BottomY, RW, H: Integer; + AVisible: Boolean); +var BtnW, Gap, StripW, X: Integer; +begin + if (FPbPanZoom = nil) or (FBtnZoomIn = nil) then Exit; + FPbPanZoom.Visible := AVisible; + FBtnZoomIn.Visible := AVisible; + FBtnZoomDef.Visible := AVisible; + FBtnZoomOut.Visible := AVisible; + if not AVisible then Exit; + Gap := MulDiv(2, Screen.PixelsPerInch, 96); + BtnW := H; // квадратные кнопки в высоту строки + StripW := RW - 3 * BtnW - 4 * 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); + FPbPanZoom.Invalidate; +end; + +procedure TPanafallPanel.Layout(ATopOffset: Integer; + AShowSpectrum, AShowWaterfall: Boolean); +const + SPLITTER_H = 5; + MIN_SH = 60; // минимальная высота спектра + MIN_WH = 40; // минимальная высота водопада +var + RW, RH, SH, WH, TopOff: Integer; + AvailH: Integer; + RULER_H: Integer; + PANZOOM_H: Integer; + ShowPZ: Boolean; +begin + if (FParent = nil) or (FPbSpectrum = nil) then Exit; + FShowSpectrum := AShowSpectrum; + FShowWaterfall := AShowWaterfall; + RULER_H := MulDiv(18, Screen.PixelsPerInch, 96); + RW := FParent.ClientWidth; + RH := FParent.ClientHeight; + // Резервируем низ под панель пана/зума (видна, если виден спектр или водопад). + PANZOOM_H := MulDiv(22, Screen.PixelsPerInch, 96); + ShowPZ := AShowSpectrum or AShowWaterfall; + PositionPanZoomBar(RH - PANZOOM_H, RW, PANZOOM_H, ShowPZ); + if ShowPZ then RH := RH - PANZOOM_H; + TopOff := ATopOffset; + + // ---- Оба скрыты ---- + if (not AShowSpectrum) and (not AShowWaterfall) then + begin + FPbSpectrum.Visible := False; + FPbRuler.Visible := False; + FSplitter.Visible := False; + FPbWaterfall.Visible := False; + FSpectrumWidth := RW; + FSpectrumHeight := 0; + FWaterfallHeight := 0; + Exit; + end; + + // ---- Только спектр (водопад скрыт) ---- + if AShowSpectrum and (not AShowWaterfall) then + begin + AvailH := RH - TopOff - RULER_H; + if AvailH < MIN_SH then AvailH := MIN_SH; + SH := AvailH; + FPbSpectrum.SetBounds(0, TopOff, RW, SH); + FPbSpectrum.Visible := True; + FPbRuler.SetBounds(0, TopOff + SH, RW, RULER_H); + FPbRuler.Visible := True; + FSplitter.Visible := False; + FPbWaterfall.Visible := False; + FSpectrumWidth := RW; + FSpectrumHeight := SH; + FWaterfallHeight := 0; + if RW > 0 then + begin + FView.SetSpectrumBitmapSize(RW, SH); + FView.SetWaterfallBitmapSize(1, 1); + FView.SetRulerSize(RW, RULER_H); + FView.DrawSpectrum; + FPbSpectrum.Invalidate; + FPbRuler.Invalidate; + end; + Exit; + end; + + // ---- Только водопад (спектр скрыт): линейка под водопадом ---- + if (not AShowSpectrum) and AShowWaterfall then + begin + AvailH := RH - TopOff - RULER_H; + if AvailH < MIN_WH then AvailH := MIN_WH; + WH := AvailH; + FPbSpectrum.Visible := False; + FSplitter.Visible := False; + FPbWaterfall.SetBounds(0, TopOff, RW, WH); + FPbWaterfall.Visible := True; + FPbRuler.SetBounds(0, TopOff + WH, RW, RULER_H); + FPbRuler.Visible := True; + FSpectrumWidth := RW; + FSpectrumHeight := 0; + FWaterfallHeight := WH; + if RW > 0 then + begin + FView.SetSpectrumBitmapSize(1, 1); + FView.SetWaterfallBitmapSize(RW, WH); + FView.SetRulerSize(RW, RULER_H); + FView.DrawWaterfall; + FPbWaterfall.Invalidate; + FPbRuler.Invalidate; + end; + Exit; + end; + + // ---- Оба видимы: стандартный режим со сплиттером ---- + FPbSpectrum.Visible := True; + FPbRuler.Visible := True; + FSplitter.Visible := True; + FPbWaterfall.Visible := True; + + AvailH := RH - TopOff - RULER_H - SPLITTER_H; + if AvailH < (MIN_SH + MIN_WH) then Exit; + + // Ограничиваем соотношение, чтобы каждая зона имела минимальный размер + if FSplitterRatio < MIN_SH / AvailH then + FSplitterRatio := MIN_SH / AvailH; + if FSplitterRatio > 1.0 - MIN_WH / AvailH then + FSplitterRatio := 1.0 - MIN_WH / AvailH; + + SH := Round(AvailH * FSplitterRatio); + WH := AvailH - SH; + if WH < MIN_WH then WH := MIN_WH; + + // Спектр + FPbSpectrum.SetBounds(0, TopOff, RW, SH); + // Линейка частот — всегда прижата к низу спектра + FPbRuler.SetBounds(0, TopOff + SH, RW, RULER_H); + // Сплиттер — между линейкой и водопадом + FSplitter.SetBounds(0, TopOff + SH + RULER_H, RW, SPLITTER_H); + // Водопад — под сплиттером + FPbWaterfall.SetBounds(0, TopOff + SH + RULER_H + SPLITTER_H, RW, WH); + + // Обновляем переменные размеров + FSpectrumWidth := RW; + FSpectrumHeight := SH; + FWaterfallHeight := WH; + + // Пересоздаём bitmap точно под новый размер + if RW > 0 then + begin + if SH > 0 then FView.SetSpectrumBitmapSize(RW, SH); + if WH > 0 then FView.SetWaterfallBitmapSize(RW, WH); + FView.SetRulerSize(RW, RULER_H); + FView.DrawSpectrum; + FPbSpectrum.Invalidate; + if FPbRuler <> nil then FPbRuler.Invalidate; + FPbWaterfall.Invalidate; + end; +end; + +// =========================================================================== +// Splitter — перетаскивание границы спектр/водопад +// Обновление размеров происходит только при отпускании мыши (MouseUp), +// чтобы исключить фризы во время перетаскивания. +// Во время перетаскивания рисуем только призрак-линию на сплиттере. +// =========================================================================== + +procedure TPanafallPanel.SplitterMouseDown(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); +var + P: TPoint; +begin + if Button <> mbLeft then Exit; + FSplitterDrag := True; + FSplitterSH0 := FSpectrumHeight; + // Y в координатах родителя (области панадаптера) + P := FParent.ScreenToClient(FSplitter.ClientToScreen(Point(X, Y))); + FSplitterDragY0 := P.Y; + FSplitter.Color := TColor($005050A0); // подсветка при захвате + {$IFDEF WINDOWS} + SetCapture(FSplitter.Handle); + {$ENDIF} +end; + +procedure TPanafallPanel.SplitterMouseMove(Sender: TObject; Shift: TShiftState; + X, Y: Integer); +var + P: TPoint; + DeltaY: Integer; + NewSH: Integer; + TopOff, AvailH: Integer; + RULER_H: Integer; +const + SPLITTER_H = 5; + MIN_SH = 60; + MIN_WH = 40; +begin + RULER_H := MulDiv(18, Screen.PixelsPerInch, 96); + if not FSplitterDrag 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; + + // Только двигаем сплиттер визуально — без пересчёта битмапов + FSplitter.Top := TopOff + NewSH + RULER_H; + FPbWaterfall.Top := TopOff + NewSH + RULER_H + SPLITTER_H; + FPbWaterfall.Height := AvailH - NewSH; +end; + +procedure TPanafallPanel.SplitterMouseUp(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); +var + P: TPoint; + DeltaY: Integer; + NewSH: Integer; + TopOff, AvailH: Integer; + RULER_H: Integer; +const + SPLITTER_H = 5; + MIN_SH = 60; + MIN_WH = 40; +begin + RULER_H := MulDiv(18, Screen.PixelsPerInch, 96); + if not FSplitterDrag then Exit; + FSplitterDrag := False; + {$IFDEF WINDOWS} + ReleaseCapture; + {$ENDIF} + FSplitter.Color := TColor($00303030); // обычный цвет + + 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; + + // Сохраняем новое соотношение; полный пересчёт с перерисовкой — у хозяина + // (ему виднее: wideband, S-метр, оверлеи). + FSplitterRatio := NewSH / AvailH; + if Assigned(FOnSplitterMoved) then FOnSplitterMoved(Self); +end; + +// =========================================================================== +// Пан/зум-полоса — прокидка мыши в TPanZoomBar +// =========================================================================== + +procedure TPanafallPanel.PanZoomMouseDown(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); +begin + if FZoomBar <> nil then FZoomBar.HandleMouseDown(Button, X, Y); +end; + +procedure TPanafallPanel.PanZoomMouseMove(Sender: TObject; Shift: TShiftState; + X, Y: Integer); +begin + if FZoomBar <> nil then FZoomBar.HandleMouseMove(X, Y); +end; + +procedure TPanafallPanel.PanZoomMouseUp(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); +begin + if FZoomBar <> nil then FZoomBar.HandleMouseUp; +end; + +procedure TPanafallPanel.PanZoomDblClick(Sender: TObject); +begin + if FZoomBar <> nil then FZoomBar.HandleDblClick; +end; + +end. diff --git a/doc/SLICES_PLAN.md b/doc/SLICES_PLAN.md index 0bb5f3c..836490a 100644 --- a/doc/SLICES_PLAN.md +++ b/doc/SLICES_PLAN.md @@ -1,9 +1,16 @@ -# Мультислайсы (несколько приёмных фильтров, как во FlexRadio) +# Мультислайсы и панадаптеры (как во FlexRadio) Статус: **Этапы 1 + 2 РЕАЛИЗОВАНЫ и ЗАКОММИЧЕНЫ** (ветка `feature/multislice`, коммит `1527b0f`; проверено на эфире) + UI-полировка флагов (`30a6616`, -`3049f91`, `a3ac459`). Этап 3 (аппаратные DDC для слайса вне захваченной -полосы) — не начат. Ниже: что вошло по факту + дизайн/остаток. +`3049f91`, `a3ac459`). + +**2026-07-10: Этап 3 ПЕРЕПРОЕКТИРОВАН.** Вместо старой идеи «off-span слайс с +edge-маркером» принята Flex-модель: **аппаратный DDC = отдельный панадаптер** +(свой спектр+водопад), а слайсы ВСЕГДА живут внутри какого-то панадаптера. +Старый черновик (edge-маркеры + slices-рейл) перенесён в «Отвергнутые +альтернативы» в конце файла. Реализация Этапа 3 не начата. + +--- ## Что реализовано (Этапы 1 + 2) @@ -22,260 +29,354 @@ Ctrl+ЛКМ по спектру = добавить слайс. Флаг главного (A) виден когда скрыта левая панель ЛИБО есть слайсы B+ (синхронизируется с правками с левой панели). - **UI-полировка флагов** (`30a6616`, `3049f91`, `a3ac459`): - - `MainForm.LayoutFlags` — единый packing всех флагов (A+B+) в один ряд: каждый - рядом со своей полосой фильтра, но флаги НЕ наезжают (сосед слева толкает - правого; обратный поджим при выходе за правый край). `PositionVfoOverlay`/ - `PositionSliceFlags` — тонкие обёртки над ним. - - Идентификация уехавшего флага — буквой слайса по центру его полосы фильтра - сверху (`DrawBandLetter` CPU + `DrawBandLetterGL` GL с кэшем текстур букв); - у главного A буква только когда есть слайсы. - - Палитра слайсов `VfoOverlay.SliceColor(L)` (A зелёный … F фиолетовый, не - красный — не путать с TX): один цвет для бейджа-буквы во флаге и для буквы - на полосе. - - Кнопки флага — общий `FlatButton.PaintFlatButton` (плоские, как левая - панель; без «стеклянного» градиента), центровка бейджа через `TextRect`. - - Флаг полностью тематизирован: структурные `CLR_*` выводятся из `TAppTheme` - (`ApplyThemeColors`), тема раздаётся из `ApplyDarkTheme` + `WireOverlayEvents`. - Статус-акценты (TX/SPLIT) фиксированы намеренно. + - `MainForm.LayoutFlags` — единый packing всех флагов (A+B+) в один ряд; + идентификация уехавшего флага буквой по центру полосы (`DrawBandLetter` + CPU + `DrawBandLetterGL` GL с кэшем текстур букв); палитра + `VfoOverlay.SliceColor(L)`; кнопки через `FlatButton.PaintFlatButton`; + полная тематизация из `TAppTheme`. - **Перф** — S-метр развязан с кэшем «хрома» флага (`FMeterDirty` + meter-only `RefreshMeter` через `EnsureRendered`); `InvalidateVfoOverlay` не дёргает глобальный перезалив оверлеев на тик метра. -Остаток/риски (не сделано): персист слайсов в `Settings` (список + устройство); -per-slice AGC-Top (сейчас общий с главным); полноценный мультислайс-TX (пока TX -только главный); Этап 3 (HW DDC). Исходный план/дизайн — ниже без изменений. +Остаток Этапов 1-2 (не сделано): персист слайсов в `Settings` (список + +устройство); per-slice AGC-Top (сейчас общий с главным); полноценный +мультислайс-TX (пока TX только главный). -## Предыстория (UI-каркас до мультислайса — исторично) +--- -> Ниже — состояние ДО реализации Этапа 2 (одиночный флаг, захардкоженная `'A'`). -> Актуальное состояние см. в «Что реализовано» выше. Оставлено как контекст. +## Модель FlexRadio (итог разбора 2026-07-10) -`VfoOverlay.pas` переделан в **«флаг слайса»** в стиле SmartSDR (6 коммитов -`4a92256..a04c592`, уже в `main`). Это ровно UI-управление одним слайсом из -Этапа 2, только пока в единственном экземпляре: +Во Flex **две независимые сущности**, и это ключ ко всему дизайну: -- Флаг-накладка поверх спектра с буквой слайса (`FSlice`, сейчас захардкожено `'A'`), - fly-out пикерами **MODE / DSP / AUD**, слайдером громкости, mute, SPLIT, - кликабельным **TX-бейджем** (выбор TX-слайса) и S-метром. -- **Per-flag выбор аудио-устройства** уже есть: `SetAudioDevices` + `OnAudioDevice` - (`VfoOverlay.pas:31,125,136`) — прокручиваемый список в AUD-fly-out. Это готовый - UI под «своё звуковое устройство на слайс» из абстракции `TSlice.DeviceIndex`. -- События наружу: `OnSelect/OnDSPChange/OnAGCChange/OnVolume/OnMute/OnAudioDevice/` - `OnSplit/OnTxSelect/OnInvalidate` (`VfoOverlay.pas:131-139`). -- Автопозиционирование: флаг флипается влево, если не влезает справа (`6ad5f29`). +- **Панадаптер (panafall)** — спектр+водопад. Самостоятельный объект: свой + широкополосный захват в железе, свой центр/span/зум, свои настройки водопада. +- **Слайс** — узкополосный приёмник, ВСЕГДА прикреплён к какому-то панадаптеру + и рисуется на нём флагом. Слайс никогда не имеет собственного спектра. + Тянешь слайс за край — панорама скроллится за ним. -Привязка в приложении **пока одиночная**: один `FVfoOverlay` в `MainForm` -(`MainForm.pas:125`), созданный/подключённый на `MainForm.pas:962-972`, привязан к -VFO A (`UpdateVfo(FController.FVfoA)`, `MainForm.pas:3265`); хендлеры -`OnVfoOverlay*` шлют изменения в главный тракт. `SpectrumView` держит один -`FVfoOverlay` и рисует его в `DrawOverlay` (`SpectrumView.pas:72,220,1010`). +Хочешь слушать другой участок — открываешь **второй панадаптер** (радио выделяет +ещё один захват) и создаёшь слайсы в нём. Лимиты: 6400 — 2 пана/2 слайса, +6600 — 4/4, 6700 — 8/8. -Чтобы превратить это в мультислайс — нужно из одиночного флага сделать **коллекцию -флагов (по одному на слайс)** и развести их события на конкретный `SliceId` вместо -глобального VFO A. Детали — в обновлённом Этапе 2. +**Второй приёмник (второй АЦП)** у Flex называется SCU (Spectral Capture Unit): +полный тракт «антенный вход → АЦП → захват». 6600/6700 — два SCU, 6400 — один. +У панадаптера НЕТ переключателя «RX1/RX2» — есть селектор **антенны** +(ANT1/ANT2/RXA/RXB/XVTR), и радио само сажает панадаптер на SCU под выбранную +антенну. Слайсы наследуют антенну своего пана. Diversity (когерентное сложение +двух SCU) — отдельный режим поверх. -## Цель +**Окна:** все панафоллы тайлятся ВНУТРИ одного главного окна (сплиты с +перетаскиваемыми разделителями). Вынос панафолла в отдельное OS-окно штатный +SmartSDR НЕ умеет — наш pop-out будет улучшением поверх Flex-модели. -Несколько независимых приёмных «слайсов» на одном ADC/одном IQ-потоке: -у каждого слайса своя частота, режим, полоса, AGC и **своё звуковое -устройство** (как DAX/наушники/динамик во Flex). Панадаптер (спектр+водопад) -остаётся один, слайсы рисуются маркерами поверх. +--- -## Как это устроено у Flex и что из этого берём - -Слайс во Flex — это **не «ещё один фильтр» на одном приёмнике**, а полноценный -независимый узкополосный приёмник (свой DDC в FPGA + демодулятор + фильтр + AGC + -аудио). Общий у слайсов — только панадаптер (широкий FFT-захват). - -Один WDSP-канал = один демодулятор = один фильтр = один аудиопоток. Значит для -«разный звук/режим/полоса на каждый слайс» нужен **отдельный WDSP-канал на слайс**. -Способ получить узкополосный IQ для канала различается по железу — см. ниже. - -## Ключевое различие бэкендов +## Итоговая модель EWSDR ``` -openHPSDR (Protocol 2, V4.3, MAX_DDCS=80): - ADC0 ─┬─ DDC (FPGA-тюнер) → IQ-поток порт 1035+n → WDSP-канал → фильтр/AGC → AudioOut[dev] - ├─ DDC → ... - └─ DDC → ... - • Аппаратные DDC: слайс тюнится КУДА УГОДНО в полосе ADC (весь КВ), дёшево по CPU. - • На Angelia/Orion/Saturn DDC0/DDC1 заняты PureSignal → слайсы с DDC2+ (в коде DDCBase=2). - • RX2 = ВТОРОЙ ADC (ADC1), отдельная антенна/diversity — ОРТОГОНАЛЬНО слайсам, не нужно. - -Pluto (AD9363, libiio): - один RX-тракт: один LO, один rf_bandwidth, один IQ-поток (притворяется одним DDC, - пушится как TDDCIQPacket в тот же конвейер). - • Аппаратных DDC НЕТ. Слайсы = СОФТОВЫЙ fan-out одного IQ-блока на N WDSP-каналов, - каждый со своим SetRXAShiftFreq в пределах захваченной полосы вокруг LO. - • Для QO-100 идеально: весь транспондер (~500 кГц) в захвате, LO залочен на маяк. +Панадаптер (спектр+водопад; свой захват IQ; свой ADC-источник; свой span/зум/ +│ палитра/wf-AGC; свой WDSP-анализатор) +└─ Слайсы (флаги внутри панорамы; софт-fan-out IQ ЭТОГО панадаптера; + свой WDSP-канал/режим/фильтр/AGC/DSP/звуковуха — уже реализовано) ``` -## Единая абстракция +Правила: +1. Слайс всегда принадлежит панадаптеру (`PanId`). Понятия «off-span слайс» + больше нет: хочешь слайс вне захвата — создай панадаптер там. +2. Пан 0 (главный) существует всегда; его «слайс A» — текущий главный тракт + (WDSP-канал 0). Панадаптеры N>0 живут целиком на слайс-механике Этапа 1 + (каналы `SLICE_CHAN_BASE+`), при создании пана автоматически создаётся + один слайс в его центре (поведение Flex). +3. ADC-источник — свойство **панадаптера**, не слайса (бейдж `A1`/`A2` в шапке). + В отличие от Flex показываем именно АЦП, а не антенну: на HPSDR-платах + маппинг «вход→АЦП» жёсткий, антенная абстракция не нужна. +4. Буквы слайсов — глобальный пул A..G через все паны (как во Flex A-H): + буква = личность слайса, пан = место жительства. +5. Возможности бэкенда описывает запись capabilities (см. таблицу) — UI по ней + прячет/блокирует кнопки, а не хардкодит «if Pluto». -```pascal -TSlice = record - Id: Integer; - TargetHz: Double; // абсолютная частота слайса - Mode: TRxMode; // USB/LSB/CW/FM/... - FilterLo, - FilterHi: Integer; - AGCMode: TWDSPAGCMode; - Volume: Double; - WDSPChannel: Integer; // канал WDSP (0..N); 0=главный, 1=TX заняты - Source: (srcSoftShift, srcHardDDC); // как получаем узкий IQ - DDCIndex: Integer; // если srcHardDDC — какой аппаратный DDC - ShiftHz: Double; // если srcSoftShift — сдвиг = TargetHz - CapturedCenterHz - AudioOut: TAudioOutput; // свой инстанс со своим DeviceIndex - DeviceIndex: Integer; // выбранная звуковуха (или -1 = default) - Enabled: Boolean; -end; +Матрица бэкендов: + +| Бэкенд | Панадаптеров | Незав. частоты панов | АЦП | Примечание | +|-------------------------|--------------|----------------------|-----|------------| +| HPSDR 1 ADC (Hermes) | до 4 (DDC) | да | 1 | ADC-селектор скрыт | +| HPSDR 2 ADC (Angelia/Orion/Saturn) | до 4 (DDC, DDCBase=2) | да | 2 | селектор A1/A2 на пан | +| Pluto (обычный) | 1 | — | 1 | как сейчас, ничего не меняется | +| Pluto+ (2r2t) | 2 | **нет — общий LO** | 2 | пан RX2 частотно связан с RX1 | + +``` +openHPSDR (Protocol 2, MAX_DDCS=80): + ADC0 ─┬─ DDC2 (широкий) → Пан 0 → анализатор + слайсы A,B… + └─ DDC3 (узкий) → Пан 1 → анализатор + слайсы C,D… + ADC1 ──── DDC4 → Пан 2 (другая антенна) → … + • Каждый DDC тюнится куда угодно, свой rate. DDC0/1 заняты PureSignal + на Angelia/Orion/Saturn (DDCBase=2, HPSDRNetwork.pas:1485). + +Pluto+ (AD9363 2r2t, libiio): + RX LO ─┬─ RX1 → псевдо-DDC0 → Пан 0 + (один!)└─ RX2 → псевдо-DDC1 → Пан 1 (та же частота/rate, другая антенна) + • Оба канала на ОДНОМ LO/rate, фазокогерентны (база под diversity/пеленгацию). + • Обычный Pluto: один RX разведён на плате — Пан 1 недоступен. ``` -Правило маршрутизации по частоте (одно на оба бэкенда): -- слайс **внутри** текущего захваченного span → `srcSoftShift` (Pluto и HPSDR); -- слайс **вне** захваченного span → `srcHardDDC` (только HPSDR; Pluto так не умеет). +--- -## Порядок работ +## Этап 3 (переработан): «DDC = панадаптер» -### Этап 1 — Софтовый fan-out в WDSPEngine (backend-agnostic) ✅ РЕАЛИЗОВАН +Подэтапы упорядочены так, чтобы каждый собирался и работал сам по себе. +3.0 — чистый рефакторинг без новых фич, дальше функциональность наращивается. -Даёт слайсы сразу на Pluto/QO-100 (единственный режим) и на HPSDR для слайсов -внутри видимой полосы. Pluto уже пушит IQ через тот же DDC-конвейер, поэтому -fan-out живёт в движке и не зависит от бэкенда. +### 3.0 — TPanafallPanel: выделить панадаптер из MainForm -- `WDSPEngine.pas`: сейчас `RXA_CHAN=0` один канал, один `FRXOut`, один аудиовыход - (`WDSPEngine.pas:96`, alloc буферов ~`:159`, OpenChannel ~`:1081`). - - Добавить массив слайс-каналов: для каждого активного слайса `OpenChannel(chN, ...)` - с той же геометрией, что RXA_CHAN, + `SetRXAShiftRun/SetRXAShiftFreq(chN, ShiftHz)` - (механизм уже используется: `WDSPEngine.pas:1099-1100`), `SetRXAMode`, - `RXASetPassband`, AGC, `SetRXAPanelGain1`. - - В DSP-потоке после накопления IQ-блока (`FRXAccI/Q`, `FRXAccPos`) прогонять - `fexchange0` по КАЖДОМУ активному каналу из ОДНОГО и того же входного буфера - (входной буфер не портить in-place между каналами — держать копию, как `FSpecBuf`). - - Выход каждого канала → свой callback `OnSliceAudio(SliceId, L, R, Count)`. -- `AudioOutput.pas`: уже готов к мульти-устройству — `DeviceIndex`, `EnumOutputDevices`, - `FindDeviceByName` (`AudioOutput.pas:137-151`). Создавать **отдельный `TAudioOutput` - на слайс**, каждый со своим `DeviceIndex`. -- `RadioController.pas`: `TSlice[]` (add/remove/retune/setmode/setdevice), роутинг - `OnSliceAudio` → нужный `TAudioOutput`. +**Статус 2026-07-10: фаза A СДЕЛАНА (собирается, стартует; на экране не +проверено).** Создан `PanafallPanel.pas`: панель владеет view (CPU/GL), +контролами (спектр/линейка/сплиттер/водопад/пан-зум/кнопки зума), полной +геометрией стека (`Layout`), механикой сплиттера и мышью пан/зум-полосы. +MainForm держит алиасы (`FSpecView`, `PbSpectrum`, …) на члены `FPan` — весь +остальной код не тронут; размеры областей читаются из +`FPan.SpectrumWidth/SpectrumHeight/WaterfallHeight`. Семантика мыши +спектра/водопада, флаги слайсов, оверлеи, тик — ПОКА в MainForm (фаза B). +**GL-аудит per-instance ПРОЙДЕН**: в `SpectrumViewOpengl`/`WaterfallViewOpengl` +все текстуры (`FGridLabelTex`…`FLetterTex`, `FSliceTex`, `FTexture`, +`FMarkerTex`) и LUT водопада — поля экземпляра, глобальных/class var GL-ресурсов +нет; палитры — read-only const (шарить безопасно). -Проверка Этапа 1: Pluto/QO-100 — 2 слайса (VFO + маяк-монитор), звук в две разные -звуковухи, независимые режимы/полосы. Сборка: `lazbuild --ws=qt6 ewsdr.lpr`. +Самый большой и самый важный кусок. Сейчас панадаптер размазан по MainForm: +`FSpecView: TSpectrumView` создаётся и прошивается ~20 свойствами напрямую +(`MainForm.pas:1018-1035`), канва `PbSpectrum: TControl` (TOpenGLControl, +`MainForm.pas:2220`), `FPanZoomBar` (`MainForm.pas:312`), мышиные хендлеры +`PbSpectrumMouse*` (`MainForm.pas:439-445`), `LayoutFlags`, тик обновления. -### Этап 2 — UI слайсов на водопаде + выбор устройства ✅ РЕАЛИЗОВАН (кроме персиста) +Сделать composite `TPanafallPanel` (новый модуль `PanafallPanel.pas`, +наследник TCustomControl/TPanel), который владеет: -Каркас флага слайса уже сделан (см. «Что уже готово»): один `TVfoOverlay` со всеми -пикерами и per-flag выбором устройства. Остаётся размножить его на N слайсов и -привязать к позиции по частоте. Выпадашка устройства и dropdown-паттерн **уже есть в -самом флаге** (AUD fly-out) — отдельный `DeviceForm` не нужен. +- канвой (`TOpenGLControl` или `TPaintBox` по `USE_OPENGL` — как сейчас); +- своим `TSpectrumView`/`TSpectrumViewOpenGL`; +- своим `TPanZoomBar` и линейкой; +- коллекцией флагов слайсов этого пана (`FVfoOverlay` пана + `FSliceFlags`) + и `LayoutFlags` (packing переезжает сюда); +- мышиной логикой канвы (тюн/drag/колесо/Ctrl+ЛКМ/drag маркера слайса); +- шапкой пана (см. 3.3) — в пане 0 шапка скрыта до появления пана 1. -- **Коллекция флагов вместо одного.** Из `MainForm.FVfoOverlay` (одиночный, - `MainForm.pas:125`) сделать `array of TVfoOverlay` / список, по флагу на слайс; - `SpectrumView.FVfoOverlay` (`SpectrumView.pas:72`) — тоже коллекция, рисовать все в - `DrawOverlay` (`SpectrumView.pas:1010`). Разместить: `SetSlice(Id, Letter)` в оверлей, - события `OnVfoOverlay*` должны нести `SliceId` (сейчас бьют по VFO A напрямую — - завести маппинг оверлей→слайс, роутить в нужный `TSlice`/WDSP-канал из Этапа 1). -- **Позиционирование по частоте.** `PositionVfoOverlay` (`MainForm.pas:518`) сейчас - ставит один флаг; расширить на маппинг `TargetHz → X` через `GetViewWindow`/ - `SyncSpecViewFreq` (зум-работа). Слайс за краем видимой полосы — маркер у края - (флип влево/вправо уже есть, `6ad5f29`). -- **ЛКМ по водопаду** = поставить/двинуть активный слайс; выделение активного - (подсветка буквы/рамки флага). Хит-тест мыши флага уже разведён - (`HandleMouseDown/Move/Up`, `MainForm.pas:4424`) — добавить «клик мимо флага по - спектру = ретюн активного слайса». -- **TX-select** уже кликабелен (`OnTxSelect`, `08c71c4`) — при мультислайсе выбирать, - какой слайс идёт в TX; снимать флаг с остальных. -- Персист: список слайсов в `Settings.pas` (per-band или глобально — решить), включая - `DeviceIndex`/имя устройства каждого флага. +Наружу: события `OnTune(PanId, Hz)`, `OnAddSlice(PanId, Hz)`, `OnSlice*` +(существующие слайс-события + PanId), `OnPanClose/OnPanPopOut/OnPanADC`; +входные сеттеры состояния (центр/span/mode/wf-настройки) — то, что сейчас +раздаётся FSpecView напрямую. -### Этап 3 — Аппаратные DDC для openHPSDR (слайс куда угодно по КВ) ⏳ НЕ НАЧАТ +MainForm после рефакторинга держит `FPans: array of TPanafallPanel` +(пан 0 всегда), общий тик раздаёт кадры по панам, весь остальной код +(левая панель, FreqDisplay, контроллер) работает с «активным слайсом» — +механика `FActiveFlag/CurSliceId` уже есть, добавляется лишь `ActivePan`. -Апгрейд поверх общего каркаса; нужен только чтобы ставить слайс ВНЕ захваченной -полосы. Плита ~на 70% есть: -- `ConfigureDDCs(NumDDCs, ...)` уже включает N DDC с per-DDC конфигом/ADC-source - (`HPSDRNetwork.pas:220`, `DDCConfig[ddc*6+0]=ADCSource`). -- Приём разведён по DDC: порты `1035+n` → `HandleDDCIQ(Buf,Len,DDCIdx)` - (`HPSDRNetwork.pas:392`). -- High Priority уже держит частоту на КАЖДЫЙ DDC (bytes 9-12=DDC0, 13-16=DDC1, …) - `HPSDRProtocol.pas:271`. +Критерий готовности 3.0: поведение приложения ИДЕНТИЧНО текущему +(один пан, все фичи слайсов работают), diff в MainForm — только вынос. -Чего не хватает: -1. Per-DDC частота: сейчас `SetRunAndFreq(...; DDC0FreqHz; ...)` знает только DDC0 - (`HPSDRNetwork.pas:224`) — писать частотное слово каждого DDC в High Priority. -2. Роутинг `HandleDDCIQ(DDCIdx)` → соответствующий WDSP-канал слайса. -3. Учёт DDCBase=2 (PureSignal) и Ethernet-бюджета (реально 4–8 слайсов, не 80). +Отдельная задача внутри 3.0 — **аудит GL-ресурсов** `SpectrumViewOpengl` на +per-instance: каждое окно/канва = свой GL-контекст, текстуры между контекстами +НЕ шарятся. Проверить: текстуры водопада, LUT палитры, кэш текстур букв +(`DrawBandLetterGL`), текстуры флагов (`FSliceTex`), шейдеры/списки — ничего +static/глобального. (CPU-LUT палитры общий — это ок.) -`feature/rx2-support` — это про ВТОРОЙ ADC, не про слайсы; но её каркас -(доп. WDSP-канал + доп. аудиовыход + роутинг по индексу) частично переиспользуем. +### 3.1 — Бэкенд HPSDR: per-DDC частота/rate/ADC + роутинг -### Этап 3 — рабочие заметки (черновик, обсуждение НЕ закончено) +Плита готова ~на 70 %, дельты точечные: -Разобрали фактический data-path и набросали дизайн; вернёмся позже. +1. **Per-DDC частота в High Priority.** `SendFullHP` + (`HPSDRNetwork.pas:1552`) пишет частотное слово только активного DDC + (`Buf[9 + DDCBase*4]`). Сделать цикл по активным DDC: + `Buf[9+ddc*4] := FreqToPhaseWord(FDDCFreq[ddc])`. Контроллер держит + `FDDCFreq: array[0..MAX_DDCS-1] of Double`, обновляет при ретюне пана. +2. **Per-DDC rate/ADC/enable в `ConfigureDDCs`** (`HPSDRNetwork.pas:1460`): + сейчас один rate/ADCSource на все N. Принимать массив записей + `(Enabled, RateKHz, ADCSource)` — протокол это позволяет + (`DDCConfig[ddc*6+0]=ADCSource`, rate там же per-DDC). +3. **Роутинг IQ по панам.** `RadioController.OnDDCIQ` + (`RadioController.pas:828`) сейчас гейтит `DDCIndex = FActiveDDC` + (`:875`), остальное выбрасывает. Заменить на маппинг + `DDCIndex → PanId`: пакет пана 0 — в существующий путь, пакеты + панов N>0 — в `WDSPEngine.PushPanIQ(PanId, Data)` (см. 3.2). +4. **Аллокатор DDC**: пул hw-индексов от `DDCBase+1` (DDCBase=2 на + PureSignal-платах, `HPSDRNetwork.pas:1485`), захват при создании пана, + освобождение при закрытии, слот-стабильность как у слайсов. +5. **Rate слайс-панов**: выбор из 192/384/768 кГц (дефолт 384). Бюджет + Ethernet: поток DDC ≈ rate × 6 байт (24-бит I+Q) → 384 кГц ≈ 18 Мбит/с; + 3 доп. пана + главный на 1.536 МГц укладываются даже в 100 Мбит с запасом, + но кап «до 4 панов» держим жёстко (и по CPU тоже). -**Что есть сейчас (по коду):** -- Один аппаратный DDC: `ConfigureDDCs(1, rate, ADCSource=0)` (`RadioController.pas:3314`), - IQ на порт `1035+n` → `HandleDDCIQ(DDCIdx)` → `OnDDCIQ(DDCIndex)`. В WDSP подаётся - **только `FActiveDDC`** (`RadioController.pas:843`), остальные DDC отбрасываются. -- Слайсы = софт fan-out этого одного IQ: `SetSliceShift(Id, TargetHz-FCenterFreq)` - (`RefreshSliceShifts:1063`). Гейт `SliceFitsCapture` ±47.5% rate вокруг центра - (`:1081`); вне полосы `AddSlice` отказывает (`:1097`). -- HP-фрейм шлёт частотное слово **только DDC0**: `Buf[9 + DDCBase*4]` - (`SendFullHP:1567`); слова остальных DDC = нули. -- `ConfigureDDCs` умеет N DDC (per-DDC enable), но пишет **один** rate/ADCSource на все; - `DDCBase=2` на Orion/Angelia/Saturn (DDC0/1=PureSignal), `NumADCs=2` на 2-АЦП платах. +Ретюн пана N>0 = запись `FDDCFreq[ddc]` + `SendFullHP` (без реконфига DDC); +смена rate/ADC = `ConfigureDDCs` (как сейчас при смене sample rate). -**Что нужно (дельты по точкам):** -1. Per-DDC частота в HP: `SendFullHP` — цикл `Buf[9+ddc*4]=FreqToPhaseWord(FDDCFreq[ddc])`; - контроллер держит `FDDCFreq[]`, обновляет при ретюне слайса. -2. Per-DDC rate + ADCSource в `ConfigureDDCs` (принимать массив): главный DDC широкий - (панорама), слайс-DDC узкие ~48–96 кГц (дёшево по линку). Протокол это позволяет. -3. Аллокатор DDC: пул hw-индексов от `DDCBase+1`, захват/освобождение, кап (см. решения). -4. **Ключевое — два типа слайс-канала в WDSPEngine:** - - *soft* (Этап 1): вход = копия главного IQ, rate главного, NCO shift = `Target-Center`, - общий аккумулятор. - - *hard* (Этап 3): вход = **свой** DDC-пакет, rate его DDC, NCO shift = 0, **свой** - аккумулятор `FSliceAccI/Q`. Нужен `PushSlicePacket(chan, ...)` + роутинг - `OnDDCIQ(DDCIndex)→WDSP-канал привязанного слайса`. -5. RX2 (второй ADC): `TSlice.ADCSource (0/1)` → слайс-DDC на нужный ADC. Даёт двух-антенные - слайсы (главный RX на ANT1, слайс на ANT2). Ортогонально «внутри/вне полосы». +### 3.2 — WDSPEngine: анализатор и IQ-тракт на панадаптер -**Принятые решения (предварительно):** -- Лимит: **до 4–6 hard-DDC слайсов**, со строгим контролем суммарного rate (бюджет Ethernet). -- **Сначала статика**: источник (soft/hard) выбирается при добавлении/явном ретюне; - авто-promotion/demotion (soft↔hard при смене центра/зума) — отдельным этапом позже. -- RX2/ADCSource — не решено (нужно знать, как заведены антенны у пользователя). +Сейчас: анализаторы `RX_DISP_ID=0` / `TX_DISP_ID=1` (`WDSPEngine.pas:133`), +создание `XCreateAnalyzer` (`:1140`), настройки+span-clip +`ApplyRXAnalyzerSettings` (`:1163-1211`), снапшот спектра +`Spectrum0(1, RX_DISP_ID, …)` из DSP-потока (`:1597`), выдача пикселей +через `FOnSpectrum` (`:2850`). Слайсы едят копию ГЛАВНОГО аккумулятора +в `ProcessSlices`. -**UI-концепция (черновик, не финал — выбор представления off-span НЕ сделан):** -Принцип: **цвет+буква слайса = его личность во всех местах** (флаг, полоса, edge-маркер, -чип рейла, ADC-бейдж) — переиспользуем палитру `SliceColor`, тему, `PaintFlatButton`. -- Слайс в полосе — как сейчас (флаг сбоку + буква на полосе, packing). -- Слайс **вне** видимой панорамы (hard) — приклеенный к краю спектра edge-маркер: - цветная буква + стрелка (◄/►) + абсолютная частота. Клик = выбрать, двойной = перевести - главную панораму на него. Почти бесплатно (флип/клэмп к краю уже есть). -- **Slices-рейл** — тонкая полоса-менеджер (под панорамой или в правой панели): чип на слайс - (цветная точка+буква · частота · режим · мини-S-метр · ADC-бейдж), сортировка по частоте, - `[+]` = добавить с вводом частоты (для off-span, где Ctrl-клик не поможет). Единственное - место, где off-span слайсы всегда видны/управляемы. -- **ADC/антенна** — бейдж `A1`/`A2` в шапке флага и в чипе (только на 2-АЦП платах). -- **soft/hard** — тонкий признак (плотная vs полая точка, или значок `⧉`); не кричать. -- Позже «вау»: мини-панадаптер (свой спектр/водопад) на каждый hard-слайс в чипе рейла. +Дельты: -Развилка для следующего захода: как показывать off-span слайсы — -(a) edge-маркеры + рейл [рекомендация], (b) только edge-маркеры, (c) рейл с мини-панадаптерами. +1. **Пан-анализаторы**: `PAN_DISP_BASE = 8` (не 4/5 — они заняты в ветке + `feature/qo100-zoom-panel`, при будущем merge не столкнёмся); + `XCreateAnalyzer(PAN_DISP_BASE+PanId)` при создании пана, + `ApplyPanAnalyzerSettings(PanId)` — параметризованная копия + `ApplyRXAnalyzerSettings` (свой rate, свой span-clip/zoom, свои + FFT/avg/detector — можно стартовать с общих настроек, разделить позже). +2. **Per-pan аккумулятор**: `TPanChan = record` (`FPanAccI/Q`, `FPanAccPos`, + rate, disp id, активные слайсы). `PushPanIQ(PanId, Data)` из сетевого + потока кладёт в аккумулятор пана; по накоплению блока в DSP-потоке: + копия → `Spectrum0(1, PAN_DISP_BASE+PanId, …)` + `ProcessSlices` по + слайсам ЭТОГО пана. +3. **Слайс знает пана**: в `TSliceChan` добавляется `PanId`; NCO shift + слайса = `TargetHz − PanCenterHz`; rate канала слайса = rate его пана + (для панов N>0 это 192-768 кГц — WDSP-каналу дешевле, чем полный rate!). + `RefreshSliceShifts` обобщается на «центр пана», гейт `SliceFitsCapture` — + на «span пана». +4. **Выдача пикселей**: `FOnPanSpectrum(PanId, Pixels, N)` (или существующий + колбэк + PanId), MainForm-тик раздаёт по `FPans[PanId]`. +5. **Пул WDSP-каналов** общий: 0=RXA (слайс A пана 0), 1=TX, слайсы 2..7 + (`SLICE_CHAN_BASE`, `MAX_SLICES=6`) — на все паны суммарно. Этого хватает: + 6 доп. слайсов + главный = 7 приёмников. + +Блокировки: `FSliceLock` уже сериализует слайсы vs DSP; добавить такую же +дисциплину для add/remove пана (сетевой поток пишет в аккумулятор пана — +создание/удаление пана только под локом, как переоткрытие слайсов при +ChangeSampleRate). + +### 3.3 — UI: второй панадаптер в главном окне + +- **Стек панов**: область спектра = вертикальный стек `TPanafallPanel` с + `TSplitter` между ними (Flex-стиль). Пан 0 сверху. Высоты в персист. +- **Шапка пана** (тонкая полоска над спектром, темизирована как флаги): + частота центра · диапазон · rate · бейдж ADC `A1/A2` (3.4) · + кнопки `⧉` (pop-out, 3.5) и `×` (закрыть; у пана 0 нет). Шапка пана 0 + появляется только когда панов ≥ 2. +- **Добавить пан**: кнопка `+` в шапке/тулбаре (только если + `caps.MaxPans > 1`): диалог/дефолт — частота (ввод или «текущая ±»), + rate, ADC. Создание пана автоматически создаёт его первый слайс в центре. +- **Активный пан** = пан активного слайса. Клик по канве пана делает его + слайс активным (существующая механика `FActiveFlag`), левая панель / + FreqDisplay / CAT правят активный слайс — как сейчас. +- **Ctrl+ЛКМ** на любом пане = добавить слайс в этот пан (буква из + глобального пула). +- **Тик/FPS**: общий таймер, кадры раздаются каждому пану; FPS-гейт из + перф-работ применяется per-pan. Свёрнутый/скрытый пан кадры не рендерит. +- **TX**: TX-бейдж на флаге работает с любого слайса любого пана — DUC + независим от DDC, TX-частота = частота слайса. (Полный мультислайс-TX — + отдельная старая задача, тут ничего нового не ломаем.) + +### 3.4 — ADC-селектор панадаптера + +- `TPanafall.ADCSource: 0/1`; в шапке пана селектор/бейдж `A1`/`A2`. + Показывается только при `NumADCs=2` (детект платы уже есть — `NumADCs` + в ConfigureDDCs-пути). +- Переключение = `ConfigureDDCs` с новым ADCSource для DDC этого пана. +- Пан 0 тоже можно перекинуть на ADC1 — это закрывает сценарий старой ветки + `feature/rx2-support` (вторая антенна) бесплатно, как частный случай. +- Diversity (sync-DDC, оба АЦП когерентно в один канал) — НЕ здесь; протокол + умеет, дизайну панов не мешает, отдельная будущая фича. + +### 3.5 — Pop-out: панадаптер в отдельном OS-окне + +Улучшение поверх Flex (SmartSDR так не умеет). Кнопка `⧉` в шапке. + +- `TPanFloatForm` — простая TForm-обёртка, в которую переезжает + `TPanafallPanel`; `×` окна возвращает пан в стек главного окна. +- **Риск №1 — GL + reparent**: перенос живого `TOpenGLControl` между + окнами на Qt6/Wayland может убить контекст. Безопасный путь: НЕ + репарентить канву, а пересоздать вьюху в новом окне (панель отдаёт + state: центр/span/wf-историю можно не сохранять — водопад начнётся + заново; слайсы/флаги живут в контроллере и переподключаются). + Сначала попробовать честный reparent, fallback — пересоздание. +- **Риск №2 — общие GL-ресурсы**: закрывается аудитом из 3.0. +- Персист: флаг «пан N в отдельном окне» + геометрия окна. + +### 3.6 — Pluto+ (2r2t): второй пан на втором антенном входе + +Опционально, после 3.0-3.3. Обычного Pluto НЕ касается (там один RX разведён). + +- **Детект**: число RX-каналов в iio-контексте (`voltage0/1` + `voltage2/3` + у `cf-ad9361-lpc`); прошивка должна быть с `adi,2rx-2tx-mode-enable` + (на Pluto+ обычно так). Если каналов 2 → `caps.MaxPans=1`, как сейчас. +- **PlutoBackend** (`PlutoBackend.pas:222-236`): сейчас RX-поток пушит + `FOnDDCIQ(0, pkt)`. В 2r2t буфер отдаёт 4 канала интерливом — + деинтерлив на два пакета → `FOnDDCIQ(0, …)` и `FOnDDCIQ(1, …)`. + Дальше это обычные псевдо-DDC, роутинг из 3.1/3.2 работает без изменений. +- **Частотная связка (главное отличие)**: оба RX на одном LO и одном rate. + `caps.IndependentPanFreq = False` → пан 1 создаётся ТОЛЬКО с центром/rate + пана 0; ретюн любого пана ретюнит общий LO (двигает оба); в шапке пана 1 — + признак связки (`🔗 LO`), контролы центра либо скрыты, либо честно тюнят + общий LO. Зум/span-clip у каждого пана свой (анализаторы независимы). +- **Пропускная способность**: ×2 потока. Через гигабитный Ethernet Pluto+ + (наш libiio network context) проходит свободно; на USB2 пришлось бы резать + rate — не наш кейс. +- **QO-100 beacon-lock**: лок правит общий LO — оба пана едут вместе, + инвариант «всё стоит на месте» сохраняется, конфликта нет. +- **Бонус на потом**: каналы фазокогерентны — готовая база под честный + diversity (сложение с фазовращателем) и пеленгацию. Не в этом этапе. +- TX2 (второй выход, общий TX LO) — вне этого плана. + +--- + +## Персист (собрать в один заход после 3.3) + +В `Settings.pas`: +- список панов: центр, rate, ADC, span/зум, высота в стеке, pop-out+геометрия; +- список слайсов: PanId, TargetHz, mode/filter/AGC/DSP, звуковуха + (DeviceIndex + имя для повторного матчинга), громкость/mute; +- восстановление на старте: сначала паны (ConfigureDDCs), потом слайсы. +Это закрывает и старый долг «персист слайсов» из Этапа 2. + +--- ## Оговорки / риски -- CPU софтовых слайсов ~линейно от их числа: каждый WDSP-канал жуёт полный rate и - децимирует сам (аппаратный DDC дешевле — децимирует железо). Для QO-100 span мал, - несколько слайсов — норма. -- Не портить входной IQ-буфер между каналами (fexchange0 пишет in-place). -- Pluto: слайсы только в пределах захваченной полосы; выход за край = ретюн LO - (двигает всё, при QO-100 LO залочен на маяк — не трогать). -- Пороговая аккуратность: старт/стоп WDSP-каналов на лету (см. заметку про задержки - RXA стоп/старт в `WDSPEngine.pas:~230`). +- **CPU**: слайс на пане N>0 дешевле слайса на пане 0 (WDSP-канал жуёт + 192-768 кГц вместо полного rate). Дорогое место — рендер: каждый пан = + свой спектр+водопад; FPS-гейт и перф-дисциплина из [[project_perf_profiling]] + обязательны per-pan. +- **Не портить входной IQ-буфер** между каналами (fexchange0 in-place) — + правило Этапа 1, действует и для пан-аккумуляторов (копия до Spectrum0, + как `FSpecBuf`, см. `WDSPEngine.pas:235`). +- **Старт/стоп WDSP-каналов на лету** — задержки RXA стоп/старт + (`WDSPEngine.pas:~230`), создание/закрытие пана держать вне аудио-глитчей. +- **Ethernet**: кап 4 пана суммарно; следить за суммарным rate при выборе + rate панов. +- **DDCBase=2** на PureSignal-платах: доп. панам достаются DDC3+ — при + 4 панах на Angelia/Orion/Saturn это DDC2..5, ок. +- **Qt6/Wayland + GL**: reparent канвы (3.5) и per-instance ресурсы (3.0) — + два главных технических риска всего этапа; аудит 3.0 делать первым. +- Смена sample rate главного пана переоткрывает его слайсы (уже реализовано) — + панов N>0 это касаться не должно (их rate независим). + +## Открытые вопросы (решить по ходу, не блокеры) + +- CAT: что репортить как VFO A/B при активном слайсе на пане N>0 + (сейчас CAT смотрит на главный тракт; вероятно «активный слайс = VFO A»). +- Перетаскивание слайса МЕЖДУ панами (drag флага на другой пан): вкусно, + но не в первую итерацию — сначала слайс живёт в своём пане (кламп по + span пана, как сейчас по захвату). +- Авто-promotion (слайс уехал за край пана → предлагать новый пан) — нет, + осознанно: только явное создание пана (решение «сначала статика» остаётся). +- Настройки водопада (палитра/AGC/avg) per-pan или глобально — стартуем с + наследования глобальных, разделим по запросу. + +## Отвергнутые альтернативы (для истории) + +Старый черновик Этапа 3 предлагал «off-span слайсы»: слайс вне видимой +панорамы на своём hard-DDC, представленный edge-маркером у края спектра +(цветная буква + ◄/► + частота) и/или «slices-рейлом» (полоса-менеджер с +чипами слайсов). Отвергнуто 2026-07-10 в пользу Flex-модели «DDC = +панадаптер»: off-span слайс без своего спектра слепой (не видно обстановку +вокруг), сущности расползаются (слайс с DDC ≠ слайс без DDC), а рейл дублирует +флаги. Идея рейла может вернуться позже как компактный обзор всех слайсов — +но не как замена панадаптеру. Палитра «цвет+буква = личность слайса во всех +местах» из черновика — остаётся в силе. ## Связанные заметки памяти -- project_pluto_integration, project_qo100_zoom_panel, project_qo100_decoder_lock -- project_spectrum_zoom (GetViewWindow / оверлеи / маппинг мыши) -- project_channels_feature (dropdown/форма-редактор как паттерн UI) +- project_slices_plan (статус), project_pluto_integration, project_perf_profiling +- project_spectrum_zoom (GetViewWindow / span-clip / маппинг мыши) +- project_qo100_zoom_panel (прецедент доп. анализаторов WDSP, disp id 4/5!) +- project_lazopengl_component (QOpenGLWidget/контексты на Qt6 — база для 3.5)