From 3566f10191aa8e91bcbe14cbbec344b6d10da20f Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 13 Jul 2026 13:49:19 +0300 Subject: [PATCH] =?UTF-8?q?feat(slices):=20per-pan=20=D0=B4=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D0=BB=D0=B5=D0=B9=20=E2=80=94=20=D0=BF=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D1=80=D0=B0/gamma/=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D0=B8?= =?UTF-8?q?=20=D0=BD=D0=B0=20=D0=BF=D0=B0=D0=BD=20(=C2=AB=E2=97=91=C2=BB-?= =?UTF-8?q?=D0=BF=D0=BE=D0=BF=D0=BE=D0=B2=D0=B5=D1=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кнопка «◑» в шапке каждого пана открывает компактный поповер настроек отображения (палитра, gamma, ref/range спектра, авто-уровень водопада + offset/ручные min-max). Модель «глобальный дефолт + override на пан»: пан 0 правит глобальные поля рендера (зеркалятся в контроллер), паны N ставят DisplayOverride и персистятся в воркспейс раздельно КВ/трансвертер. - PanDisplayPopup.pas (new): borderless fsStayOnTop форма, гаснет по OnDeactivate; Flat*-контролы; крестик «×» закрытия; дим-хинт «правки применяются и сохраняются сами»; кнопки «⟳ Reset»/«⇄ Copy to all». - TPanafallPanel: FBtnDisplay + OnDisplayClick + DisplayOverride; PositionHeaderChildren каскадит [×][⧉][◑] справа по Visible. - MainForm: OnPanDisplayClick/Applied/Reset/ApplyAll + ApplyGlobalRenderToView + MarkPanDisplayDirty. При смене палитры/сбросе чистим историю GL-водопада (TSpectrumView.ClearWaterfall) — GL хранит уже раскрашенные пиксели. - Settings/TPanCfg: под-объект 'display' (палитра/gamma/ref/range/wf-уровни), пишется только при override. Глобальными осознанно оставлены разрешение анализатора, MSAA, скорость водопада, детектор/усреднение (движок/перф, не косметика). Co-Authored-By: Claude Opus 4.8 --- MainForm.pas | 171 ++++++++++++++++++++- PanDisplayPopup.pas | 357 ++++++++++++++++++++++++++++++++++++++++++++ PanafallPanel.pas | 39 ++++- Settings.pas | 55 ++++++- SpectrumView.pas | 9 ++ doc/SLICES_PLAN.md | 45 +++++- 6 files changed, 666 insertions(+), 10 deletions(-) create mode 100644 PanDisplayPopup.pas diff --git a/MainForm.pas b/MainForm.pas index b57a483..6de6366 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -33,7 +33,7 @@ uses WebServer, WebAdapter, CATAdapter, SpectrumView, SpectrumViewOpengl, - PanZoomBar, PanafallPanel, + PanZoomBar, PanafallPanel, PanDisplayPopup, WidebandView, StatusBar, PlatformUtils, @@ -167,6 +167,7 @@ type FPanSpecDirty: array[0..MAX_PANS-1] of Boolean; // кадр пана готов к рендеру FPanWfDirty: array[0..MAX_PANS-1] of Boolean; FPanCbLock: SyncObjs.TCriticalSection; // жизнь FPans[i] vs пиксельные колбэки + FPanDisplayPopup: TObject; // TPanDisplayPopup (3.6), лениво создаётся // Мышь на пане N>0: drag слайса или drag центра пана (ретюн DDC). FPanSliceDrag: Boolean; FPanSliceDragId: Integer; @@ -604,6 +605,13 @@ type procedure OnPanRateClick(Sender: TObject); // «NNN kHz» шапки пана N procedure PanRateMenuItemClick(Sender: TObject); procedure OnPanADCClick(Sender: TObject); // бейдж «RX1/RX2» (3.4) + // Per-pan дисплей (3.6): «◑» → поповер палитры/gamma/уровней. + procedure OnPanDisplayClick(Sender: TObject); + procedure OnPanDisplayApplied(P: TPanafallPanel; FullRefresh: Boolean); + procedure OnPanDisplayReset(P: TPanafallPanel); // сброс к глоб. дефолту + procedure OnPanDisplayApplyAll(P: TPanafallPanel); // раскатать на все паны + procedure ApplyGlobalRenderToView(V: TSpectrumView); // глоб. рендер → вьюха + procedure MarkPanDisplayDirty(P: TPanafallPanel; ClearWf: Boolean); // репейнт (+сброс истории водопада) // Pop-out (3.5): «⧉» выносит пан в отдельное окно, [×] окна возвращает в стек. procedure OnPanPopOutClick(Sender: TObject); procedure PopOutPan(PanId: Integer); @@ -1111,6 +1119,7 @@ begin FPan.PanId := 0; FPan.OnAddPan := OnAddPanClick; FPan.OnADCClick := OnPanADCClick; // бейдж RX1/RX2 (3.4, платы с 2 АЦП) + FPan.OnDisplayClick := OnPanDisplayClick; // «◑» дисплей-поповер (3.6) FPans[0] := FPan; FPanShare[0] := 1.0; FPanGridMode := False; @@ -2881,6 +2890,7 @@ begin if FPans[i].BtnGrid <> nil then StyleButton(FPans[i].BtnGrid, False); if FPans[i].BtnPopPan <> nil then StyleButton(FPans[i].BtnPopPan, False); if FPans[i].BtnClosePan <> nil then StyleButton(FPans[i].BtnClosePan, False); + if FPans[i].BtnDisplay <> nil then StyleButton(FPans[i].BtnDisplay, False); ApplyPanHeaderTheme(FPans[i]); if i > 0 then begin @@ -5643,6 +5653,132 @@ begin UpdatePanHeaders; end; +// ---- Per-pan дисплей (3.6): «◑» палитра/gamma/уровни отдельно на пан ---- + +procedure TMainForm.ApplyGlobalRenderToView(V: TSpectrumView); +// Глобальный дефолт рендера (контроллер) → вьюха пана. Используется при +// создании пана (дефолт) и «Сбросе к дефолту». +begin + if V = nil then Exit; + V.WfPalette := FController.FWfPalette; + V.WfGamma := FController.FWfGamma; + V.SpecRefLevel := FController.FSpecRefLevel; + V.SpecRange := FController.FSpecRange; + V.SpecGridStep := FController.FSpecGridStep; + V.WfAGCEnabled := FController.FWfAGCEnabled; + V.WfAGCOffset := FController.FWfAGCOffset; + V.WfManualHigh := FController.FWfManualHigh; + V.WfManualLow := FController.FWfManualLow; +end; + +procedure TMainForm.MarkPanDisplayDirty(P: TPanafallPanel; ClearWf: Boolean); +// Полный репейнт пана после смены рендера: сетка спектра + водопад +// (LUT/палитра пересоберётся в SpectrumView по своим no-op-гардам). +// ClearWf=True (смена палитры/gamma/авто) чистит историю водопада — GL хранит +// уже раскрашенные пиксели, иначе старые строки остаются в прежней палитре. +begin + if P = nil then Exit; + P.View.InvalidateGridCache; + if ClearWf then P.View.ClearWaterfall; + if P = FPan then + begin + FSpectrumDirty := True; + FWaterfallDirty := True; + if Assigned(PbSpectrum) then PbSpectrum.Invalidate; + if Assigned(PbWaterfall) then PbWaterfall.Invalidate; + Exit; + end; + P.View.SpectrumDirty := True; + FPanSpecDirty[P.PanId] := True; + FPanWfDirty[P.PanId] := True; + if P.PbSpectrum <> nil then P.PbSpectrum.Invalidate; + if P.PbWaterfall <> nil then P.PbWaterfall.Invalidate; +end; + +procedure TMainForm.OnPanDisplayClick(Sender: TObject); +// «◑» в шапке пана: показать поповер под кнопкой. +var + P: TPanafallPanel; + Pop: TPanDisplayPopup; + Pt: TPoint; +begin + if not (Sender is TPanafallPanel) then Exit; + P := TPanafallPanel(Sender); + if P.BtnDisplay = nil then Exit; + if FPanDisplayPopup = nil then + begin + Pop := TPanDisplayPopup.CreateNew(Self); + Pop.OnApplied := OnPanDisplayApplied; + Pop.OnResetDefault := OnPanDisplayReset; + Pop.OnApplyAll := OnPanDisplayApplyAll; + FPanDisplayPopup := Pop; + end + else + Pop := TPanDisplayPopup(FPanDisplayPopup); + // Экранная позиция левого-нижнего угла кнопки. + Pt := P.BtnDisplay.ClientToScreen(Point(0, P.BtnDisplay.Height + 2)); + Pop.ShowFor(P, Pt.X, Pt.Y); +end; + +procedure TMainForm.OnPanDisplayApplied(P: TPanafallPanel; FullRefresh: Boolean); +// Поповер применил правку к P.View. Пан 0 = глобальный дефолт (зеркалим в +// контроллер, чтобы персистилось и наследовалось новыми панами); паны N — +// ставим override-флаг (персист в воркспейс). +begin + if P = nil then Exit; + if P.PanId = 0 then + begin + FController.FWfPalette := P.View.WfPalette; + FController.FWfGamma := P.View.WfGamma; + FController.FSpecRefLevel := P.View.SpecRefLevel; + FController.FSpecRange := P.View.SpecRange; + FController.FWfAGCEnabled := P.View.WfAGCEnabled; + FController.FWfAGCOffset := P.View.WfAGCOffset; + FController.FWfManualHigh := P.View.WfManualHigh; + FController.FWfManualLow := P.View.WfManualLow; + // На диск не пишем на каждый тик — глобальные сохранятся при закрытии + // (FormDestroy → SaveGlobalSettings), как и для главного диалога настроек. + end + else + P.DisplayOverride := True; + MarkPanDisplayDirty(P, FullRefresh); +end; + +procedure TMainForm.OnPanDisplayReset(P: TPanafallPanel); +// «Сброс к дефолту»: пан снова наследует глобальный рендер. +begin + if (P = nil) or (P.PanId = 0) then Exit; + ApplyGlobalRenderToView(P.View); + P.DisplayOverride := False; + MarkPanDisplayDirty(P, True); // палитра могла смениться → чистим водопад +end; + +procedure TMainForm.OnPanDisplayApplyAll(P: TPanafallPanel); +// «Применить ко всем»: рендер пана P раскатывается на все паны + становится +// глобальным дефолтом (для пана 0 и новых панов). +var i: Integer; Q: TPanafallPanel; +begin + if P = nil then Exit; + // Глобальный дефолт ← P. + FController.FWfPalette := P.View.WfPalette; + FController.FWfGamma := P.View.WfGamma; + FController.FSpecRefLevel := P.View.SpecRefLevel; + FController.FSpecRange := P.View.SpecRange; + FController.FWfAGCEnabled := P.View.WfAGCEnabled; + FController.FWfAGCOffset := P.View.WfAGCOffset; + FController.FWfManualHigh := P.View.WfManualHigh; + FController.FWfManualLow := P.View.WfManualLow; + FController.SaveGlobalSettings; + for i := 0 to MAX_PANS - 1 do + begin + Q := FPans[i]; + if Q = nil then Continue; + if Q <> P then ApplyGlobalRenderToView(Q.View); + Q.DisplayOverride := False; // все синхронны с дефолтом ⇒ не override + MarkPanDisplayDirty(Q, True); // палитра раскатана → чистим водопад + end; +end; + // ---- Pop-out (3.5): пан N в отдельном OS-окне ---- function TMainForm.PanFloating(PanId: Integer): Boolean; @@ -5822,6 +5958,8 @@ begin if P.BtnClosePan <> nil then P.BtnClosePan.Visible := (N > 1) and (i > 0); if P.BtnPopPan <> nil then P.BtnPopPan.Visible := (N > 1) and (i > 0) and not PanFloating(i); + // «◑» дисплей-поповер — на всех панах с видимой шапкой (вкл. пан 0). + if P.BtnDisplay <> nil then P.BtnDisplay.Visible := P.HeaderVisible; if i = 0 then begin P.SetHeaderText(Format('PAN 0 · %.6f MHz ·', @@ -5888,6 +6026,7 @@ begin P.RateClickable := True; P.OnADCClick := OnPanADCClick; // «RX1/RX2» тумблер АЦП (3.4) P.OnPanPopOut := OnPanPopOutClick; // «⧉» в отдельное окно (3.5) + P.OnDisplayClick := OnPanDisplayClick; // «◑» дисплей-поповер (3.6) P.SpectrumMouseDown := PanNSpectrumMouseDown; P.SpectrumMouseMove := PanNSpectrumMouseMove; P.SpectrumMouseUp := PanNSpectrumMouseUp; @@ -6010,6 +6149,9 @@ begin if (PanId < 1) or (PanId >= MAX_PANS) then Exit; P := FPans[PanId]; if P = nil then Exit; + // Дисплей-поповер мог быть привязан к этому пану — спрятать (FPan станет + // висячим; поповер перепривяжется при следующем показе через LoadFrom). + if FPanDisplayPopup <> nil then TPanDisplayPopup(FPanDisplayPopup).Hide; // Отписываем от пиксельных колбэков ДО удаления DDC/панели. FPanCbLock.Enter; try @@ -6094,6 +6236,19 @@ begin Cfg.Pans[i].Zoom := FController.PanZoomFactor(i); Cfg.Pans[i].PanSlider := FController.PanPanSlider(i); Cfg.Pans[i].ColShare := FPanColShare[i]; + // Per-pan дисплей (3.6): пишем override только если пан его задал. + if (FPans[i] <> nil) and FPans[i].DisplayOverride then + begin + Cfg.Pans[i].HasDisplay := True; + Cfg.Pans[i].DPalette := FPans[i].View.WfPalette; + Cfg.Pans[i].DGamma := FPans[i].View.WfGamma; + Cfg.Pans[i].DSpecRef := FPans[i].View.SpecRefLevel; + Cfg.Pans[i].DSpecRange := FPans[i].View.SpecRange; + Cfg.Pans[i].DWfAGC := FPans[i].View.WfAGCEnabled; + Cfg.Pans[i].DWfOffset := FPans[i].View.WfAGCOffset; + Cfg.Pans[i].DWfHigh := FPans[i].View.WfManualHigh; + Cfg.Pans[i].DWfLow := FPans[i].View.WfManualLow; + end; end; // Слайсы этого пана (главный VFO пана 0 — не слайс, его тут нет). n := 0; @@ -6188,6 +6343,20 @@ begin FPanShare[i] := Cfg.Pans[i].StackShare; FPanColShare[i] := Cfg.Pans[i].ColShare; P.SplitterRatio := Cfg.Pans[i].SplitRatio; + // Per-pan дисплей (3.6): CreatePanCore уже залил глоб. дефолт — при наличии + // override перекрываем сохранёнкой. + if Cfg.Pans[i].HasDisplay then + begin + P.View.WfPalette := EnsureRange(Cfg.Pans[i].DPalette, 0, 2); + P.View.WfGamma := EnsureRange(Cfg.Pans[i].DGamma, 0.30, 3.00); + P.View.SpecRefLevel := Cfg.Pans[i].DSpecRef; + P.View.SpecRange := Cfg.Pans[i].DSpecRange; + P.View.WfAGCEnabled := Cfg.Pans[i].DWfAGC; + P.View.WfAGCOffset := Cfg.Pans[i].DWfOffset; + P.View.WfManualHigh := Cfg.Pans[i].DWfHigh; + P.View.WfManualLow := Cfg.Pans[i].DWfLow; + P.DisplayOverride := True; + end; FController.SetPanZoomPan(i, Cfg.Pans[i].Zoom, Cfg.Pans[i].PanSlider); for k := 0 to High(Cfg.Pans[i].Slices) do RestoreSliceInto(P, i, Cfg.Pans[i].Slices[k]); diff --git a/PanDisplayPopup.pas b/PanDisplayPopup.pas new file mode 100644 index 0000000..1aa772b --- /dev/null +++ b/PanDisplayPopup.pas @@ -0,0 +1,357 @@ +unit PanDisplayPopup; + +{ + TPanDisplayPopup — компактный поповер per-pan настроек отображения + (палитра / gamma / уровни спектра и водопада), открывается кнопкой «◑» в + шапке пана. Правки применяются к P.View живьём; после каждой изменения зовётся + OnApplied(P) — хозяин (MainForm) инвалидирует пан, ставит override-флаг и + зеркалит глобальные (для пана 0). Кнопки «Сброс к дефолту» / «Применить ко + всем» — через OnResetDefault / OnApplyAll. + + Форма без рамки, поверх всех, гаснет по потере фокуса (OnDeactivate). + Этап 3.6 плана мультислайсов (doc/SLICES_PLAN.md), per-pan дисплей. +} + +{$IFDEF FPC} + {$MODE Delphi} +{$ENDIF} + +interface + +uses + Classes, SysUtils, Math, Controls, ExtCtrls, StdCtrls, Graphics, Forms, + AppTheme, PanafallPanel, + FlatButton, FlatComboBox, FlatCheckBox, FlatSpinEdit, FlatFloatSpinEdit; + +type + TPanDisplayEvent = procedure(P: TPanafallPanel) of object; + // FullRefresh=True ⇒ смена палитры/gamma/авто: чистим историю водопада. + TPanDisplayApplyEvent = procedure(P: TPanafallPanel; FullRefresh: Boolean) of object; + + TPanDisplayPopup = class(TForm) + private + FPan: TPanafallPanel; // пан, чьи настройки правим (не владеем) + FLoading: Boolean; // подавляет OnApplied при программной заливке + FBg: TPanel; + FTitle: TLabel; + FCbPalette: TFlatComboBox; + FSeGamma: TFlatFloatSpinEdit; + FSeRef: TFlatSpinEdit; + FSeRange: TFlatSpinEdit; + FChkAuto: TFlatCheckBox; + FSeOffset: TFlatSpinEdit; + FSeMin: TFlatSpinEdit; + FSeMax: TFlatSpinEdit; + FLbOffset: TLabel; + FLbMin: TLabel; + FLbMax: TLabel; + FHint: TLabel; + FBtnClose: TFlatButton; + FBtnReset: TFlatButton; + FBtnAll: TFlatButton; + FOnApplied: TPanDisplayApplyEvent; + FOnResetDefault: TPanDisplayEvent; + FOnApplyAll: TPanDisplayEvent; + + function MakeLbl(const Cap: string; ALeft, ATop, AW: Integer): TLabel; + procedure Apply(FullRefresh: Boolean); + procedure CtlChange(Sender: TObject); + procedure PaletteChange(Sender: TObject); + procedure AutoChange(Sender: TObject); + procedure CloseClick(Sender: TObject); + procedure ResetClick(Sender: TObject); + procedure AllClick(Sender: TObject); + procedure DoDeactivate(Sender: TObject); + procedure UpdateWfRowEnable; + public + constructor CreateNew(AOwner: TComponent; Num: Integer = 0); override; + procedure LoadFrom(P: TPanafallPanel); + // Показать под кнопкой (экранные координаты левого-нижнего угла кнопки). + procedure ShowFor(P: TPanafallPanel; ScreenX, ScreenY: Integer); + property OnApplied: TPanDisplayApplyEvent read FOnApplied write FOnApplied; + property OnResetDefault: TPanDisplayEvent read FOnResetDefault write FOnResetDefault; + property OnApplyAll: TPanDisplayEvent read FOnApplyAll write FOnApplyAll; + end; + +implementation + +const +{$IFDEF WINDOWS} + UI_FONT = 'Segoe UI'; +{$ELSE} + UI_FONT = 'Sans'; +{$ENDIF} + CLR_BG = TColor($00303030); // рамка (1px кайма формы) + CLR_PANEL = TColor($001A1A1A); + CLR_TEXT = TColor($00E0E0E0); + CLR_TEXTDIM = TColor($00888888); + CLR_ACCENT = TColor($0040FF80); + CLR_INPUT = TColor($00FFFFFF); + CLR_INPUT_TEXT = TColor($00202020); + + POP_W = 244; + PAD = 10; + LBLW = 66; + CTLX = 84; + ROWH = 24; + + WF_PALETTE_NAMES: array[0..2] of string = ('Classic', 'Inferno', 'Turbo'); + +constructor TPanDisplayPopup.CreateNew(AOwner: TComponent; Num: Integer); +var + Y: Integer; + + function NextRow: Integer; + begin + Result := Y; + Inc(Y, ROWH + 4); + end; + + function MakeIntSpin(ATop, AMin, AMax, AInc: Integer): TFlatSpinEdit; + begin + Result := TFlatSpinEdit.Create(Self); + Result.Parent := FBg; + Result.SetBounds(CTLX, ATop, POP_W - CTLX - PAD, ROWH - 2); + Result.MinValue := AMin; + Result.MaxValue := AMax; + Result.Increment := AInc; + Result.Color := CLR_INPUT; + Result.Font.Color := CLR_INPUT_TEXT; + Result.Font.Name := UI_FONT; + Result.Font.Size := 9; + Result.OnChange := CtlChange; + end; + +begin + inherited CreateNew(AOwner, Num); + BorderStyle := bsNone; + FormStyle := fsStayOnTop; + Color := CLR_BG; + Font.Name := UI_FONT; + Font.Size := 9; + Font.Color := CLR_TEXT; + OnDeactivate := DoDeactivate; + FLoading := False; + + FBg := TPanel.Create(Self); + FBg.Parent := Self; + FBg.BevelOuter := bvNone; + FBg.Color := CLR_PANEL; + // 1px рамка = зазор до края формы (цвет формы = CLR_BG). + FBg.SetBounds(1, 1, POP_W - 2, 10); + + Y := PAD; + + FTitle := MakeLbl('Display', PAD, NextRow + 2, POP_W - 2 * PAD - 18); + FTitle.Font.Color := CLR_ACCENT; + FTitle.Font.Style := [fsBold]; + // Крестик закрытия в правом верхнем углу («готово» — правки сохраняются сами). + FBtnClose := MakeFlatBtn(FBg, '×', POP_W - PAD - 16, PAD, 16, 16, CloseClick); + FBtnClose.ClrText := CLR_TEXTDIM; + FBtnClose.ClrTextAct := CLR_TEXT; + // Хинты на кнопках поповера отключены: THintWindow в этой borderless-форме + // позиционирует подсказку не у элемента (у левой панели). Лейблы и так ясны. + + // ---- Палитра ---- + MakeLbl('Palette', PAD, Y + 3, LBLW); + FCbPalette := TFlatComboBox.Create(Self); + FCbPalette.Parent := FBg; + FCbPalette.SetBounds(CTLX, Y, POP_W - CTLX - PAD, ROWH - 2); + FCbPalette.Items.Add(WF_PALETTE_NAMES[0]); + FCbPalette.Items.Add(WF_PALETTE_NAMES[1]); + FCbPalette.Items.Add(WF_PALETTE_NAMES[2]); + FCbPalette.Color := CLR_INPUT; + FCbPalette.Font.Color := CLR_INPUT_TEXT; + FCbPalette.Font.Name := UI_FONT; + FCbPalette.Font.Size := 9; + FCbPalette.OnChange := PaletteChange; // смена палитры чистит историю водопада + NextRow; + + // ---- Gamma ---- + MakeLbl('Gamma', PAD, Y + 3, LBLW); + FSeGamma := TFlatFloatSpinEdit.Create(Self); + FSeGamma.Parent := FBg; + FSeGamma.SetBounds(CTLX, Y, POP_W - CTLX - PAD, ROWH - 2); + FSeGamma.MinValue := 0.30; + FSeGamma.MaxValue := 3.00; + FSeGamma.Increment := 0.05; + FSeGamma.DecimalPlaces := 2; + FSeGamma.Color := CLR_INPUT; + FSeGamma.Font.Color := CLR_INPUT_TEXT; + FSeGamma.Font.Name := UI_FONT; + FSeGamma.Font.Size := 9; + // gamma — непрерывный спин: чистить историю на каждый тик = мерцание; + // применяем к новым строкам (как ref/range), без сброса водопада. + FSeGamma.OnChange := CtlChange; + NextRow; + + MakeLbl('— Spectrum —', PAD, Y + 3, POP_W - 2 * PAD).Font.Color := CLR_TEXTDIM; + NextRow; + + // ---- Ref level / Range ---- + MakeLbl('Ref, dBm', PAD, Y + 3, LBLW); + FSeRef := MakeIntSpin(Y, -200, 30, 5); + NextRow; + MakeLbl('Range, dB', PAD, Y + 3, LBLW); + FSeRange := MakeIntSpin(Y, 20, 200, 5); + NextRow; + + MakeLbl('— Waterfall —', PAD, Y + 3, POP_W - 2 * PAD).Font.Color := CLR_TEXTDIM; + NextRow; + + // ---- Авто-уровень водопада ---- + FChkAuto := TFlatCheckBox.Create(Self); + FChkAuto.Parent := FBg; + FChkAuto.SetBounds(PAD, Y + 2, POP_W - 2 * PAD, ROWH - 4); + FChkAuto.Caption := 'Auto level'; + FChkAuto.Font.Color := CLR_TEXT; + FChkAuto.Font.Name := UI_FONT; + FChkAuto.Font.Size := 9; + FChkAuto.OnChange := AutoChange; + NextRow; + + FLbOffset := MakeLbl('Offset, dB', PAD, Y + 3, LBLW); + FSeOffset := MakeIntSpin(Y, -60, 60, 1); + NextRow; + FLbMin := MakeLbl('Min, dBm', PAD, Y + 3, LBLW); + FSeMin := MakeIntSpin(Y, -200, 0, 5); + NextRow; + FLbMax := MakeLbl('Max, dBm', PAD, Y + 3, LBLW); + FSeMax := MakeIntSpin(Y, -160, 30, 5); + NextRow; + + // ---- Подсказка: правки применяются и сохраняются сами (не нужно «сохранять») ---- + FHint := MakeLbl('Changes apply & save automatically', PAD, Y + 2, POP_W - 2 * PAD); + FHint.Font.Color := CLR_TEXTDIM; + Inc(Y, 18); + + // ---- Кнопки действий (обе — вторичные, не «сохранение») ---- + FBtnReset := MakeFlatBtn(FBg, '⟳ Reset', PAD, Y, (POP_W - 3 * PAD) div 2, ROWH, ResetClick); + FBtnReset.ClrText := CLR_TEXT; + FBtnReset.ClrTextAct := CLR_ACCENT; + FBtnAll := MakeFlatBtn(FBg, '⇄ Copy to all', + PAD + (POP_W - 3 * PAD) div 2 + PAD, Y, (POP_W - 3 * PAD) div 2, ROWH, AllClick); + FBtnAll.ClrText := CLR_TEXT; + FBtnAll.ClrTextAct := CLR_ACCENT; + Inc(Y, ROWH + PAD); + + FBg.Height := Y - 1; + ClientWidth := POP_W; + ClientHeight := Y + 1; +end; + +function TPanDisplayPopup.MakeLbl(const Cap: string; ALeft, ATop, AW: Integer): TLabel; +begin + Result := TLabel.Create(Self); + Result.Parent := FBg; + Result.Caption := Cap; + Result.Left := ALeft; + Result.Top := ATop; + if AW > 0 then Result.Width := AW; + Result.Font.Color := CLR_TEXT; + Result.Font.Name := UI_FONT; + Result.Font.Size := 9; +end; + +procedure TPanDisplayPopup.UpdateWfRowEnable; +var Man: Boolean; +begin + Man := not FChkAuto.Checked; + FLbOffset.Enabled := not Man; FSeOffset.Enabled := not Man; + FLbMin.Enabled := Man; FSeMin.Enabled := Man; + FLbMax.Enabled := Man; FSeMax.Enabled := Man; +end; + +procedure TPanDisplayPopup.LoadFrom(P: TPanafallPanel); +begin + FPan := P; + if P = nil then Exit; + FLoading := True; + try + if P.PanId = 0 then FTitle.Caption := 'Display · PAN 0' + else FTitle.Caption := Format('Display · PAN %d', [P.PanId]); + FCbPalette.ItemIndex := EnsureRange(P.View.WfPalette, 0, 2); + FSeGamma.Value := EnsureRange(P.View.WfGamma, 0.30, 3.00); + FSeRef.Value := Round(P.View.SpecRefLevel); + FSeRange.Value := Round(P.View.SpecRange); + FChkAuto.Checked := P.View.WfAGCEnabled; + FSeOffset.Value := Round(P.View.WfAGCOffset); + FSeMin.Value := Round(P.View.WfManualLow); + FSeMax.Value := Round(P.View.WfManualHigh); + // Сброс к дефолту осмыслен только для панов N (пан 0 = сам дефолт). + FBtnReset.Visible := P.PanId > 0; + UpdateWfRowEnable; + finally + FLoading := False; + end; +end; + +procedure TPanDisplayPopup.Apply(FullRefresh: Boolean); +begin + if FLoading or (FPan = nil) then Exit; + FPan.View.WfPalette := FCbPalette.ItemIndex; + FPan.View.WfGamma := FSeGamma.Value; + FPan.View.SpecRefLevel := FSeRef.Value; + FPan.View.SpecRange := FSeRange.Value; + FPan.View.WfAGCEnabled := FChkAuto.Checked; + FPan.View.WfAGCOffset := FSeOffset.Value; + FPan.View.WfManualLow := FSeMin.Value; + FPan.View.WfManualHigh := FSeMax.Value; + if Assigned(FOnApplied) then FOnApplied(FPan, FullRefresh); +end; + +procedure TPanDisplayPopup.CtlChange(Sender: TObject); +begin + Apply(False); // ref/range/offset/min/max: применяются к новым строкам +end; + +procedure TPanDisplayPopup.PaletteChange(Sender: TObject); +begin + Apply(True); // палитра (дискретный выбор): перекрасить весь водопад +end; + +procedure TPanDisplayPopup.AutoChange(Sender: TObject); +begin + UpdateWfRowEnable; + Apply(True); // смена режима уровня меняет отображение всей истории +end; + +procedure TPanDisplayPopup.CloseClick(Sender: TObject); +begin + Hide; +end; + +procedure TPanDisplayPopup.ResetClick(Sender: TObject); +begin + if (FPan = nil) or not Assigned(FOnResetDefault) then Exit; + FOnResetDefault(FPan); + LoadFrom(FPan); // перезалить контролы значениями глобального дефолта +end; + +procedure TPanDisplayPopup.AllClick(Sender: TObject); +begin + if (FPan = nil) or not Assigned(FOnApplyAll) then Exit; + FOnApplyAll(FPan); +end; + +procedure TPanDisplayPopup.DoDeactivate(Sender: TObject); +begin + Hide; +end; + +procedure TPanDisplayPopup.ShowFor(P: TPanafallPanel; ScreenX, ScreenY: Integer); +var L, T: Integer; +begin + LoadFrom(P); + L := ScreenX; + T := ScreenY; + // Кламп в экран. + if L + Width > Screen.Width then L := Screen.Width - Width - 4; + if L < 0 then L := 4; + if T + Height > Screen.Height then T := ScreenY - Height - 4; // раскрыть вверх + if T < 0 then T := 4; + SetBounds(L, T, Width, Height); + Show; +end; + +end. diff --git a/PanafallPanel.pas b/PanafallPanel.pas index d980dd2..0fd6fce 100644 --- a/PanafallPanel.pas +++ b/PanafallPanel.pas @@ -103,6 +103,9 @@ type FOnADCClick: TNotifyEvent; FBtnPopPan: TFlatButton; // «⧉» pop-out в отдельное окно (паны N>0, 3.5) FOnPanPopOut: TNotifyEvent; + FBtnDisplay: TFlatButton; // «◑» per-pan настройки дисплея (3.6) + FOnDisplayClick: TNotifyEvent; + FDisplayOverride: Boolean; // True = пан переопределил глобальный дефолт (persist) FBtnClosePan: TFlatButton; // «×» в шапке (только паны N>0) FBtnAddPan: TFlatButton; // «⊞» в ряду пан/зума (только пан 0) FHeaderVisible: Boolean; @@ -122,6 +125,7 @@ type procedure HeaderADCClick(Sender: TObject); procedure SetADCClickable(AValue: Boolean); procedure BtnPopPanClick(Sender: TObject); + procedure BtnDisplayClick(Sender: TObject); procedure PositionHeaderChildren(RW, H: Integer); // Видимое частотное окно ЭТОГО пана: пан 0 — окно главного (зум/пан), // паны N — центр их DDC ± rate/2 (пан-зум — этап 3.3+). @@ -213,6 +217,11 @@ type property OnADCClick: TNotifyEvent read FOnADCClick write FOnADCClick; // «⧉» (3.5): вынести пан в отдельное окно (хозяин). property OnPanPopOut: TNotifyEvent read FOnPanPopOut write FOnPanPopOut; + // «◑» (3.6): открыть поповер per-pan настроек дисплея (хозяин). + property OnDisplayClick: TNotifyEvent read FOnDisplayClick write FOnDisplayClick; + property BtnDisplay: TFlatButton read FBtnDisplay; + // Пан переопределил глобальный дефолт дисплея (для персиста панов N). + property DisplayOverride: Boolean read FDisplayOverride write FDisplayOverride; property HeaderPanel: TPanel read FHeaderPanel; property HeaderLabel: TLabel read FHeaderLabel; property HeaderRateLabel: TLabel read FHeaderRateLabel; @@ -435,6 +444,10 @@ begin FHeaderADCLabel.Parent := FHeaderPanel; FHeaderADCLabel.AutoSize := True; FHeaderADCLabel.OnClick := HeaderADCClick; + FBtnDisplay := MakeFlatBtn(FHeaderPanel, '◑', 0, 0, 10, 10, BtnDisplayClick); + FBtnDisplay.Hint := 'Display settings'; + FBtnDisplay.ShowHint := True; + FBtnDisplay.Visible := False; // хозяин включает при видимой шапке FBtnPopPan := MakeFlatBtn(FHeaderPanel, '⧉', 0, 0, 10, 10, BtnPopPanClick); FBtnPopPan.Hint := 'Pop out'; FBtnPopPan.ShowHint := True; @@ -538,6 +551,11 @@ begin if Assigned(FOnPanPopOut) then FOnPanPopOut(Self); end; +procedure TPanafallPanel.BtnDisplayClick(Sender: TObject); +begin + if Assigned(FOnDisplayClick) then FOnDisplayClick(Self); +end; + procedure TPanafallPanel.ViewWindow(out VC, VS: Double); begin VC := 0; VS := 0; @@ -547,7 +565,7 @@ begin end; procedure TPanafallPanel.PositionHeaderChildren(RW, H: Integer); -var BtnS: Integer; +var BtnS, X: Integer; begin if FHeaderLabel <> nil then FHeaderLabel.Top := (H - FHeaderLabel.Height) div 2; @@ -561,11 +579,22 @@ begin FHeaderADCLabel.Top := (H - FHeaderADCLabel.Height) div 2; FHeaderADCLabel.Left := FHeaderRateLabel.Left + FHeaderRateLabel.Width + 10; end; + // Кнопки шапки прижаты вправо, справа налево: [×] [⧉] [◑]. Позиция каждой + // зависит от видимости соседей (у пана 0 нет × и ⧉ — ◑ уезжает к краю). BtnS := H - 4; - if FBtnClosePan <> nil then - FBtnClosePan.SetBounds(RW - BtnS - 2, 2, BtnS, BtnS); - if FBtnPopPan <> nil then - FBtnPopPan.SetBounds(RW - 2 * BtnS - 6, 2, BtnS, BtnS); + X := RW - 2; + if (FBtnClosePan <> nil) and FBtnClosePan.Visible then + begin + Dec(X, BtnS); FBtnClosePan.SetBounds(X, 2, BtnS, BtnS); Dec(X, 4); + end; + if (FBtnPopPan <> nil) and FBtnPopPan.Visible then + begin + Dec(X, BtnS); FBtnPopPan.SetBounds(X, 2, BtnS, BtnS); Dec(X, 4); + end; + if (FBtnDisplay <> nil) and FBtnDisplay.Visible then + begin + Dec(X, BtnS); FBtnDisplay.SetBounds(X, 2, BtnS, BtnS); Dec(X, 4); + end; end; procedure TPanafallPanel.PositionPanZoomBar(X0, BottomY, RW, H: Integer; diff --git a/Settings.pas b/Settings.pas index 2ce699e..044937a 100644 --- a/Settings.pas +++ b/Settings.pas @@ -110,6 +110,17 @@ type StackShare: Double; // доля высоты в стеке панов ColShare: Double; // доля ширины в грид-раскладке SplitRatio: Double; // доля высоты спектра (сплиттер спектр/водопад) + // Per-pan дисплей (3.6): override глобального рендера. HasDisplay=False ⇒ + // пан наследует глобальный дефолт (поля игнорируются). + HasDisplay: Boolean; + DPalette: Integer; // 0=Classic/1=Inferno/2=Turbo + DGamma: Double; + DSpecRef: Double; // верх шкалы спектра, dBm + DSpecRange: Double; // диапазон шкалы, dB + DWfAGC: Boolean; // авто-уровень водопада + DWfOffset: Double; // яркость водопада при AGC, dB + DWfHigh: Double; // ручной верх водопада, dBm + DWfLow: Double; // ручной низ водопада, dBm Slices: array of TSliceCfg; end; @@ -1608,6 +1619,15 @@ begin P.Pans[i].StackShare := 1.0; P.Pans[i].ColShare := 1.0; P.Pans[i].SplitRatio := 0.40; // = TPanafallPanel дефолт + P.Pans[i].HasDisplay := False; + P.Pans[i].DPalette := 0; + P.Pans[i].DGamma := 1.0; + P.Pans[i].DSpecRef := -20.0; + P.Pans[i].DSpecRange := 110.0; + P.Pans[i].DWfAGC := True; + P.Pans[i].DWfOffset := 0.0; + P.Pans[i].DWfHigh := -60.0; + P.Pans[i].DWfLow := -120.0; SetLength(P.Pans[i].Slices, 0); end; end; @@ -1615,7 +1635,7 @@ end; procedure TSettingsManager.SavePans(const MAC: array of Byte; const Ctx: string; const P: TPansConfig); var - Root, PanObj, SliceObj: TJSONObject; + Root, PanObj, SliceObj, DispObj: TJSONObject; PanArr, SliceArr: TJSONArray; i, j, Idx: Integer; begin @@ -1643,6 +1663,20 @@ begin PanObj.Add('stack_share', P.Pans[i].StackShare); PanObj.Add('col_share', P.Pans[i].ColShare); PanObj.Add('split_ratio', P.Pans[i].SplitRatio); + // Per-pan дисплей: пишем блок только при override (иначе пан = глоб. дефолт). + if P.Pans[i].HasDisplay then + begin + DispObj := TJSONObject.Create; + PanObj.Add('display', DispObj); + DispObj.Add('palette', P.Pans[i].DPalette); + DispObj.Add('gamma', P.Pans[i].DGamma); + DispObj.Add('spec_ref', P.Pans[i].DSpecRef); + DispObj.Add('spec_range', P.Pans[i].DSpecRange); + DispObj.Add('wf_agc', P.Pans[i].DWfAGC); + DispObj.Add('wf_offset', P.Pans[i].DWfOffset); + DispObj.Add('wf_high', P.Pans[i].DWfHigh); + DispObj.Add('wf_low', P.Pans[i].DWfLow); + end; SliceArr := TJSONArray.Create; PanObj.Add('slices', SliceArr); for j := 0 to High(P.Pans[i].Slices) do @@ -1667,8 +1701,8 @@ function TSettingsManager.LoadPans(const MAC: array of Byte; const Ctx: string; out P: TPansConfig): Boolean; var MacStr: string; - DevObj, PansObj, Root, PanObj, SliceObj: TJSONObject; - ListData, SlData: TJSONData; + DevObj, PansObj, Root, PanObj, SliceObj, DispObj: TJSONObject; + ListData, SlData, DispData: TJSONData; Arr, SArr: TJSONArray; i, id, k, n: Integer; begin @@ -1701,6 +1735,21 @@ begin P.Pans[id].StackShare := JD(PanObj, 'stack_share', 1.0); P.Pans[id].ColShare := JD(PanObj, 'col_share', 1.0); P.Pans[id].SplitRatio := EnsureRange(JD(PanObj, 'split_ratio', 0.40), 0.05, 0.95); + // Per-pan дисплей: присутствие блока 'display' = override. + DispData := PanObj.Find('display'); + if (DispData <> nil) and (DispData is TJSONObject) then + begin + DispObj := TJSONObject(DispData); + P.Pans[id].HasDisplay := True; + P.Pans[id].DPalette := EnsureRange(JI(DispObj, 'palette', 0), 0, 2); + P.Pans[id].DGamma := EnsureRange(JD(DispObj, 'gamma', 1.0), 0.30, 3.00); + P.Pans[id].DSpecRef := JD(DispObj, 'spec_ref', -20.0); + P.Pans[id].DSpecRange := JD(DispObj, 'spec_range', 110.0); + P.Pans[id].DWfAGC := JB(DispObj, 'wf_agc', True); + P.Pans[id].DWfOffset := JD(DispObj, 'wf_offset', 0.0); + P.Pans[id].DWfHigh := JD(DispObj, 'wf_high', -60.0); + P.Pans[id].DWfLow := JD(DispObj, 'wf_low', -120.0); + end; SlData := PanObj.Find('slices'); if (SlData = nil) or not (SlData is TJSONArray) then Continue; SArr := TJSONArray(SlData); diff --git a/SpectrumView.pas b/SpectrumView.pas index 7c7d301..88320cb 100644 --- a/SpectrumView.pas +++ b/SpectrumView.pas @@ -305,6 +305,7 @@ type procedure InvalidateRulerCache; virtual; procedure ResetSpectrumBuf; virtual; procedure ResetWfAvgBuf; virtual; + procedure ClearWaterfall; // сброс истории водопада (смена палитры/gamma) procedure FillDemoSpectrum; virtual; function NeedsRulerRedraw: Boolean; virtual; end; @@ -1250,6 +1251,14 @@ begin FWaterfall.ResetWfAvgBuf; end; +procedure TSpectrumView.ClearWaterfall; +// Сброс истории водопада (GL: пересоздать текстуру). Нужен при смене палитры/ +// gamma: GL-водопад хранит УЖЕ раскрашенные пиксели, старые строки иначе +// остаются в прежней палитре, и смена/сброс выглядят как «не сработало». +begin + FWaterfall.ResetWfBuf; +end; + procedure TSpectrumView.FillDemoSpectrum; var i: Integer; FreqOff, Noise, Sig: Double; W: Integer; begin diff --git a/doc/SLICES_PLAN.md b/doc/SLICES_PLAN.md index cee03d3..f5502d0 100644 --- a/doc/SLICES_PLAN.md +++ b/doc/SLICES_PLAN.md @@ -712,13 +712,56 @@ RATEPROBE удалены.** Наблюдение юзера: смена rate Г - Авто-promotion (слайс уехал за край пана → предлагать новый пан) — нет, осознанно: только явное создание пана (решение «сначала статика» остаётся). - Настройки водопада (палитра/AGC/avg) per-pan или глобально — стартуем с - наследования глобальных, разделим по запросу. + наследования глобальных, разделим по запросу. ★РЕШЕНО/РЕАЛИЗОВАНО 2026-07-13 + (см. блок «PER-PAN ДИСПЛЕЙ» ниже). - **Компоновка панов сеткой** (запрос юзера 2026-07-11): не только вертикальный стек — например, пан 0 сверху во всю ширину, паны B и C ниже бок о бок (пол-ширины каждый), и т.п. Идея на после 3.3/3.5: обобщить `LayoutPanStack` до грид-раскладки (регион пана = прямоугольник, а не полоса; `StackTop/Height` → `Rect`), сплиттеры по обеим осям. Не блокер текущего этапа. +**PER-PAN ДИСПЛЕЙ (2026-07-13, собирается, dispcfgtest ALL PASS, НЕ закоммичен, +НА ЭКРАНЕ НЕ ПРОВЕРЕН)** + +UX залочен с юзером: кнопка «◑» в шапке каждого пана открывает компактный +поповер настроек отображения. Модель — **глобальный дефолт + override на пан**: +- Пан 0 «◑» правит ГЛОБАЛЬНЫЕ поля рендера (`FController.FWf*`/`FSpec*`), + зеркалятся в контроллер, persist глобальных при закрытии (FormDestroy → + SaveGlobalSettings). Solo пан 0 (нет доп. панов ⇒ шапка скрыта) правится + штатным диалогом настроек — «◑» виден только при видимой шапке (>=2 панов). +- Паны N при первой правке ставят `DisplayOverride:=True`; их рендер + персистится в воркспейс раздельно по контексту (КВ/xvtr). +- «Reset» (только паны N) — снять override, вернуть глоб. дефолт. + «Apply all» — раскатать текущий рендер пана на все паны + сделать глоб. + дефолтом. + +Что per-pan (view-side, дёшево — всё уже в `Pan.View`): палитра, gamma, +ref level, range, водопад авто-уровень + offset + ручные min/max. Что осталось +ГЛОБАЛЬНЫМ (движок/перф, не косметика): DisplayPixels (разрешение анализатора), +MSAA, WfSpeedDiv, детектор/усреднение спектра и водопада. + +Реализация: +- Новый юнит **PanDisplayPopup.pas**: `TPanDisplayPopup` = borderless + `fsStayOnTop` форма, гаснет по `OnDeactivate`; Flat*-контролы (combo палитры, + float-spin gamma, int-spin уровней, чекбокс авто). Combo-дропдаун парентится + к самой форме (child) ⇒ не деактивирует поповер. Правки применяются к + `P.View` живьём → `OnApplied(P)`. +- **TPanafallPanel**: `FBtnDisplay` («◑», Owner=FHeaderPanel ⇒ освобождается + авто с шапкой, в Destroy не нужен), `OnDisplayClick`, `DisplayOverride`. + `PositionHeaderChildren` каскадит `[×][⧉][◑]` справа налево по `Visible`. +- **MainForm**: `OnPanDisplayClick` (лениво создаёт `FPanDisplayPopup`, показ + под кнопкой), `OnPanDisplayApplied/Reset/ApplyAll`, `ApplyGlobalRenderToView`, + `MarkPanDisplayDirty` (инвалидция сетки спектра + водопада; `SetWfPalette/ + Gamma` сами no-op-гардят и метят LUT к пересбору). Поповер прячется в + `ClosePanadapter` (FPan мог висеть → перепривязка на след. показе). +- **Settings/TPanCfg**: `HasDisplay` + `DPalette/DGamma/DSpecRef/DSpecRange/ + DWfAGC/DWfOffset/DWfHigh/DWfLow`; под-объект `'display'` пишется ТОЛЬКО при + override; чтение = присутствие блока. Save/RestorePanWorkspaceCtx прокинуты. + +Файлы: `PanDisplayPopup.pas` (new), `PanafallPanel.pas`, `MainForm.pas`, +`Settings.pas`. NEXT: экранная проверка (палитра/gamma/уровни на пан, Reset/ +ApplyAll, тёплый рестарт восстанавливает override панов N раздельно КВ/xvtr). + ## Отвергнутые альтернативы (для истории) Старый черновик Этапа 3 предлагал «off-span слайсы»: слайс вне видимой