feat(slices): персист панов+слайсов, раздельно КВ/трансвертеры

Сохранение и восстановление панадаптеров, слайсов и раскладки per-device,
раздельно по контексту: КВ ('hf') и каждый трансвертер ('xvtr_N') — паны
одного контекста не имеют смысла в другом.

Settings: секция "pans"/<ctx> под MAC (grid-режим/доли, на пан —
freq/rate/adc/zoom/pan/доли стека+колонки+сплиттер спектр/водопад, на слайс —
freq/mode/фильтр/agc/vol/mute/squelch/аудиоустройство). TSliceCfg/TPanCfg/
TPansConfig + Default/Save/LoadPans(Ctx).

MainForm: CreatePanCore (вынос ядра из OnAddPanClick), SavePanWorkspaceCtx/
RestorePanWorkspaceCtx/TearDownWorkspace, PanContextKey. Хук контроллера
OnBeforeStop (снимок до гашения DDC в StopRunning). Переходы ActivateXvtrBand/
DeactivateXvtr обёрнуты save-старого/teardown/restore-нового (гейт FRunning);
restore XVTR-панов после ActivateXvtr (FCurrentXvtr целевой ⇒ XvtrTranslate
верный). PanafallPanel.DestroyAllSliceFlags. Смена КВ-банда контекст не меняет.

Round-trip сериализации и разделения контекстов проверены автономно; на
экране не проверено.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 23:06:12 +03:00
co-authored by Claude Opus 4.8
parent b3edf3a4ad
commit 543453007e
4 changed files with 427 additions and 22 deletions
+235 -22
View File
@@ -189,6 +189,7 @@ type
FPanGridRowShare: Double; // доля высоты верхнего ряда (пан 0), 0.15..0.85
FPanColShare: array[1..MAX_PANS-1] of Double; // доли ширины колонок
FPanVSplitters: array[1..MAX_PANS-1] of TPanel; // слева от колонки пана i
FPansRestored: Boolean; // персист панов восстановлен в этой Run-сессии
// Drag вертикального сплиттера (индекс пана справа; 0 = нет).
FPanVSplitDrag: Integer;
FPanVSplitX0: Integer;
@@ -583,10 +584,20 @@ type
function PanOfOverlay(Sender: TObject): TPanafallPanel;
procedure PushFlagStateAllPans(SliceId: Integer);
procedure LayoutFlagsAllPans;
function CreatePanCore(PanId: Integer; Freq: Double;
RateKHz: Word; ADC: Byte): TPanafallPanel; // DDC+панель (без слайса)
procedure OnAddPanClick(Sender: TObject); // «⊞» пана 0
procedure OnPanHeaderClose(Sender: TObject); // «×» шапки пана
procedure ClosePanadapter(PanId: Integer);
procedure DestroyAllExtraPans; // stop/выход
function PanContextKey: string; // 'hf' | 'xvtr_N'
procedure TearDownWorkspace; // снос панов+слайсов контекста
procedure SavePanWorkspace; // OnBeforeStop: текущий контекст
procedure SavePanWorkspaceCtx(const Ctx: string); // снимок панов+слайсов контекста
procedure RestorePanWorkspace; // восстановление при Run (once)
procedure RestorePanWorkspaceCtx(const Ctx: string); // восстановление контекста Ctx
procedure RestoreSliceInto(P: TPanafallPanel; PanId: Integer;
const SC: TSliceCfg);
procedure UpdatePanHeaders; // текст+видимость шапок
procedure ApplyPanHeaderTheme(P: TPanafallPanel); // цвета шапки пана
procedure UpdateAddPanButton;
@@ -879,6 +890,8 @@ begin
FChannelController.OnActiveChanged := OnChannelActiveChanged;
// Канал-оркестрация фаером из контроллера в конце SetVfoA (любой путь тюнинга).
FController.OnAfterTune := FChannelController.OnVfoTuned;
// Снимок панов+слайсов перед гашением DDC в StopRunning (персист).
FController.OnBeforeStop := SavePanWorkspace;
FController.FVfoA := 14200000;
FController.FVfoB := 7100000;
FController.FActiveVfo := 0;
@@ -1337,6 +1350,7 @@ begin
BtnMOX.Enabled := True;
UpdateAddPanButton; // «⊞» доступна только в Run
ResizeSpectrumPanels;
RestorePanWorkspace; // паны+слайсы прошлой сессии
end
else
begin
@@ -1344,6 +1358,10 @@ begin
BtnStartStop.Caption := 'START';
StyleButton(BtnStartStop, False);
BtnMOX.Enabled := False;
// Снимок панов уже сделан хуком OnBeforeStop=SavePanWorkspace (до
// гашения DDC в StopRunning). Здесь только разрешаем повторный
// restore на следующем старте.
FPansRestored := False;
// Доп. паны живут только в Run-сессии (их DDC уже погашены
// контроллером в StopRunning) — убираем и UI.
DestroyAllExtraPans;
@@ -5841,31 +5859,18 @@ begin
FController.FRunning and HasSlot;
end;
procedure TMainForm.OnAddPanClick(Sender: TObject);
// «⊞»: новый пан на своём DDC. Центр = частота активного VFO, rate 384к,
// ADC 0; в центре автоматически создаётся первый слайс.
function TMainForm.CreatePanCore(PanId: Integer; Freq: Double;
RateKHz: Word; ADC: Byte): TPanafallPanel;
// Ядро создания доп. пана: DDC + панель + проводка + сплиттеры + регистрация
// в FPans. НЕ создаёт слайс, НЕ трогает раскладку/шапки — это делают
// вызывающие (OnAddPanClick — новый пан; RestorePanWorkspace — восстановление).
// Возвращает nil, если DDC не создался.
var
PanId, i: Integer;
RateKHz: Word;
Freq: Double;
P: TPanafallPanel;
Split: TPanel;
begin
if not FController.FRunning then Exit;
PanId := -1;
for i := 1 to MAX_PANS - 1 do
if FPans[i] = nil then begin PanId := i; Break; end;
if PanId < 0 then Exit;
// Rate наследуется от главного пана с клампом сверху (768к — потолок доп.
// пана по Ethernet/CPU; 48/96/192к наследуются как есть). Не из сетки
// (напр. Pluto-подобные) — контроллер откатит на 384к. Дальше rate пана
// независим: селектор «NNN kHz» в шапке, за сменой rate главного НЕ следует.
RateKHz := FController.FSampleRate div 1000;
if RateKHz > 768 then RateKHz := 768;
Freq := ActiveVfoFreq;
if not FController.AddPanDDC(PanId, Freq, RateKHz, 0) then Exit;
Result := nil;
if not FController.AddPanDDC(PanId, Freq, RateKHz, ADC) then Exit;
P := TPanafallPanel.Create(nil, FUseOpenGLSpectrum);
P.PanId := PanId;
@@ -5942,13 +5947,42 @@ begin
FPanVSplitters[PanId] := Split;
FPanColShare[PanId] := 1.0;
// Публикуем пан для пиксельных колбэков (display-поток) и делим высоту поровну.
// Публикуем пан для пиксельных колбэков (display-поток).
FPanCbLock.Enter;
try
FPans[PanId] := P;
finally
FPanCbLock.Leave;
end;
Result := P;
end;
procedure TMainForm.OnAddPanClick(Sender: TObject);
// «⊞»: новый пан на своём DDC. Центр = частота активного VFO, rate наследуется
// от главного (кламп 768к); ADC 0; в центре автоматически создаётся слайс.
var
PanId, i: Integer;
RateKHz: Word;
Freq: Double;
P: TPanafallPanel;
begin
if not FController.FRunning then Exit;
PanId := -1;
for i := 1 to MAX_PANS - 1 do
if FPans[i] = nil then begin PanId := i; Break; end;
if PanId < 0 then Exit;
// Rate наследуется от главного пана с клампом сверху (768к — потолок доп.
// пана по Ethernet/CPU; 48/96/192к наследуются как есть). Не из сетки
// (напр. Pluto-подобные) — контроллер откатит на 384к. Дальше rate пана
// независим: селектор «NNN kHz» в шапке, за сменой rate главного НЕ следует.
RateKHz := FController.FSampleRate div 1000;
if RateKHz > 768 then RateKHz := 768;
Freq := ActiveVfoFreq;
P := CreatePanCore(PanId, Freq, RateKHz, 0);
if P = nil then Exit;
for i := 0 to MAX_PANS - 1 do
if FPans[i] <> nil then FPanShare[i] := 1.0 / PanCount;
@@ -6011,6 +6045,163 @@ begin
if FPans[i] <> nil then ClosePanadapter(i);
end;
function TMainForm.PanContextKey: string;
// Ключ контекста персиста панов: КВ и каждый трансвертер — раздельно
// (паны/слайсы одного контекста не имеют смысла в другом).
begin
if FController.FCurrentXvtr < 0 then
Result := 'hf'
else
Result := 'xvtr_' + IntToStr(FController.FCurrentXvtr);
end;
procedure TMainForm.TearDownWorkspace;
// Полный снос панов+слайсов текущего контекста (доп. паны + слайсы пана 0).
begin
DestroyAllExtraPans;
FPan.DestroyAllSliceFlags;
FActiveSliceId := 0;
end;
procedure TMainForm.SavePanWorkspace;
// OnBeforeStop-хук: снимок текущего контекста перед гашением панов.
begin
SavePanWorkspaceCtx(PanContextKey);
end;
procedure TMainForm.SavePanWorkspaceCtx(const Ctx: string);
// Снимок панов+слайсов+раскладки в секцию "pans"/Ctx под MAC. Зовётся при STOP/
// закрытии/смене контекста (пока железо ещё подключено), до разрушения панов.
var
Cfg: TPansConfig;
i, s, n: Integer;
begin
if not FController.FNetwork.Connected then Exit; // MAC валиден только при девайсе
TSettingsManager.DefaultPans(Cfg);
Cfg.GridMode := FPanGridMode;
Cfg.GridRowShare := FPanGridRowShare;
for i := 0 to MAX_PANS - 1 do
begin
Cfg.Pans[i].StackShare := FPanShare[i];
if FPans[i] <> nil then Cfg.Pans[i].SplitRatio := FPans[i].SplitterRatio;
if i > 0 then
begin
if not FController.PanDDCActive(i) then Continue; // слайсы мёртвого пана не пишем
Cfg.Pans[i].Active := True;
Cfg.Pans[i].FreqHz := FController.PanDDCFreq(i);
Cfg.Pans[i].RateKHz := FController.PanDDCRateKHz(i);
Cfg.Pans[i].ADC := FController.PanADC(i);
Cfg.Pans[i].Zoom := FController.PanZoomFactor(i);
Cfg.Pans[i].PanSlider := FController.PanPanSlider(i);
Cfg.Pans[i].ColShare := FPanColShare[i];
end;
// Слайсы этого пана (главный VFO пана 0 — не слайс, его тут нет).
n := 0;
for s := 0 to High(FController.FSlices) do
if FController.FSlices[s].Active and (FController.FSlices[s].PanId = i) then
Inc(n);
SetLength(Cfg.Pans[i].Slices, n);
n := 0;
for s := 0 to High(FController.FSlices) do
if FController.FSlices[s].Active and (FController.FSlices[s].PanId = i) then
begin
Cfg.Pans[i].Slices[n].TargetHz := FController.FSlices[s].TargetHz;
Cfg.Pans[i].Slices[n].Mode := FController.FSlices[s].Mode;
Cfg.Pans[i].Slices[n].FilterLo := FController.FSlices[s].FilterLo;
Cfg.Pans[i].Slices[n].FilterHi := FController.FSlices[s].FilterHi;
Cfg.Pans[i].Slices[n].AGC := Ord(FController.FSlices[s].AGC);
Cfg.Pans[i].Slices[n].Volume := FController.FSlices[s].Volume;
Cfg.Pans[i].Slices[n].Muted := FController.FSlices[s].Muted;
Cfg.Pans[i].Slices[n].FMSQOn := FController.FSlices[s].FMSQOn;
Cfg.Pans[i].Slices[n].FMSQLevel := FController.FSlices[s].FMSQLevel;
Cfg.Pans[i].Slices[n].DevName := FController.FSlices[s].DevName;
Inc(n);
end;
end;
FController.FSettings.SavePans(FController.FDevMAC, Ctx, Cfg);
FController.FSettings.Save;
end;
procedure TMainForm.RestoreSliceInto(P: TPanafallPanel; PanId: Integer;
const SC: TSliceCfg);
// Один слайс в пан P (PanId): создаёт WDSP-канал + флаг, применяет mute/squelch.
var Id, DevIdx: Integer;
begin
if not FController.SliceFitsCapture(SC.TargetHz, PanId) then Exit; // вне полосы захвата
DevIdx := -1;
if SC.DevName <> '' then
DevIdx := FController.FAudioOut.FindDeviceByName(SC.DevName);
Id := FController.AddSlice(SC.TargetHz, SC.Mode, SC.FilterLo, SC.FilterHi,
TWDSPAGCMode(EnsureRange(SC.AGC, 0, Ord(High(TWDSPAGCMode)))),
EnsureRange(SC.Volume, 0.0, 1.0), DevIdx, SC.DevName, PanId);
if Id < 0 then Exit;
if SC.Muted then FController.SetSliceMute(Id, True);
if SC.FMSQOn then FController.SetSliceFMSquelch(Id, True, SC.FMSQLevel);
P.CreateSliceFlag(Id);
FActiveSliceId := Id;
end;
procedure TMainForm.RestorePanWorkspace;
// Восстановление контекста при выходе в Run (один раз за сессию). Контекст
// (КВ|трансвертер) уже финальный — ConnectAndRun зовёт StartRunning последним.
begin
if not FController.FRunning then Exit;
if FPansRestored then Exit;
FPansRestored := True;
// Слайсы пана 0 переживают STOP (софт-fan-out) — снести выживших, иначе дубли.
TearDownWorkspace;
RestorePanWorkspaceCtx(PanContextKey);
end;
procedure TMainForm.RestorePanWorkspaceCtx(const Ctx: string);
// Восстановление панов+слайсов+раскладки контекста Ctx. Вызывающий обязан
// СНАЧАЛА снести текущий воркспейс (TearDownWorkspace). При отсутствии
// сохранёнки применяются дефолты (чистый стек, без панов).
var
Cfg: TPansConfig;
i, k: Integer;
P: TPanafallPanel;
begin
if not FController.FRunning then Exit;
// LoadPans заполняет Cfg дефолтами и при отсутствии секции — применяем как есть
// (грид off, панов нет) ради чистого целевого контекста.
FController.FSettings.LoadPans(FController.FDevMAC, Ctx, Cfg);
FPanGridMode := Cfg.GridMode;
FPanGridRowShare := Cfg.GridRowShare;
// Пан 0: восстановить его доп. слайсы (главный VFO — не слайс) + доли.
FPanShare[0] := Cfg.Pans[0].StackShare;
FPan.SplitterRatio := Cfg.Pans[0].SplitRatio;
for k := 0 to High(Cfg.Pans[0].Slices) do
RestoreSliceInto(FPan, 0, Cfg.Pans[0].Slices[k]);
// Доп. паны на своих DDC.
for i := 1 to MAX_PANS - 1 do
begin
if not Cfg.Pans[i].Active then Continue;
if FPans[i] <> nil then Continue;
P := CreatePanCore(i, Cfg.Pans[i].FreqHz,
Word(EnsureRange(Cfg.Pans[i].RateKHz, 48, 1536)),
Byte(EnsureRange(Cfg.Pans[i].ADC, 0, 3)));
if P = nil then Continue;
FPanShare[i] := Cfg.Pans[i].StackShare;
FPanColShare[i] := Cfg.Pans[i].ColShare;
P.SplitterRatio := Cfg.Pans[i].SplitRatio;
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]);
end;
ApplyDarkTheme;
UpdatePanHeaders;
UpdateAddPanButton;
ResizeSpectrumPanels;
// Зум-бары/флаги — после раскладки (размеры вьюх уже верны).
for i := 1 to MAX_PANS - 1 do
if FPans[i] <> nil then RefreshPanZoom(FPans[i]);
end;
procedure TMainForm.SyncPanViews;
// Центр/span вьюх доп. панов (аналог SyncSpecViewFreq главного, но без
// VFO/TX-маркеров — они уведены за окно при создании).
@@ -6860,20 +7051,42 @@ end;
// Активирует XVTR-band: устанавливает видимую частоту по LastFreq или
// середине [FreqBegin..FreqEnd], применяет network XVTR-режим.
procedure TMainForm.ActivateXvtrBand(Idx: Integer);
var Running: Boolean;
begin
// Смена контекста персиста панов: сохранить старый (КВ|прежний XVTR), снести,
// войти в слот, восстановить целевой. Паны живут только в Run.
Running := FController.FRunning;
if Running then
begin
SavePanWorkspaceCtx(PanContextKey); // текущий контекст (до смены FCurrentXvtr)
TearDownWorkspace;
end;
// Тонкий делегат: вход в XVTR-слот + рендер кнопок/частот/спектра/wideband/web —
// всё в команде/событиях (rfXvtr/rfBand/rfActiveVfo).
FController.ActivateXvtr(Idx);
if Running then
RestorePanWorkspaceCtx(PanContextKey); // новый контекст (xvtr_Idx)
end;
// Деактивирует XVTR — возвращает к HF-режиму. FController.FCurrentBand указывает
// на HF-диапазон, с которого пользователь зашёл в трансвертер. RestoreBand вернёт
// VFO/Mode/Filter/AGC/CTUN/Span этого диапазона и обновит подсветку HF band-кнопки.
procedure TMainForm.DeactivateXvtr;
var Running: Boolean;
begin
// Выход из XVTR в КВ = смена контекста персиста панов: сохранить XVTR-контекст,
// снести, вернуться в HF, восстановить 'hf'.
Running := FController.FRunning;
if Running then
begin
SavePanWorkspaceCtx(PanContextKey); // текущий XVTR-контекст (до сброса FCurrentXvtr)
TearDownWorkspace;
end;
// Тонкий делегат: выход в HF (SaveCurrentBand + HF-возврат RestoreBand + рендер
// кнопок/wideband/web) — всё в команде/событиях.
FController.DeactivateXvtr;
if Running then
RestorePanWorkspaceCtx(PanContextKey); // контекст 'hf'
end;
procedure TMainForm.OnXvtrSettingsChange(const X: TXvtrSettings);