refactor(slices): этап 3.0B1 — флаги слайсов переехали в TPanafallPanel

Панель владеет коллекцией флагов B+ (создание/закрытие/заливка состояния из
контроллера), раскладкой LayoutFlags, диспетчером мыши флагов, тиком S-метров
(TickFlags) и видимостью флага A (UpdateMainFlagVisibility). Главный флаг A
создаёт MainForm (проводка семантических событий у него) и отдаёт через
AttachMainFlag; новые флаги проводятся через OnWireFlag. Роутинг по слайсу —
FPan.CurSliceId; закрытие «×» — RequestSliceClose/OnSliceClosed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 22:51:47 +03:00
co-authored by Claude Fable 5
parent c125e5978e
commit cc9919c35a
2 changed files with 539 additions and 449 deletions
+493
View File
@@ -16,6 +16,12 @@ unit PanafallPanel;
Хозяин по-прежнему сам синхронизирует состояние view (частоты, палитры,
wf-настройки) и решает, когда звать Layout (resize, show/hide, сплиттер).
Фаза B1: панель владеет коллекцией флагов слайсов (главный флаг A создаёт
хозяин и отдаёт через AttachMainFlag): раскладка LayoutFlags, диспетчер мыши
флагов, создание/закрытие слайс-флагов, заливка их состояния из контроллера,
тик S-метров (TickFlags). Семантические обработчики событий флага (MODE/DSP/
AUD/TX…) остаются у хозяина — он проводит их через OnWireFlag.
}
{$IFDEF FPC}
@@ -28,9 +34,13 @@ uses
Classes, SysUtils, Math, Controls, ExtCtrls, Graphics, Forms,
LCLIntf, LCLType,
OpenGLContextEx,
AppTheme, WDSPEngine, RadioController, VfoOverlay,
SpectrumView, SpectrumViewOpengl, PanZoomBar, FlatButton;
type
TWireFlagEvent = procedure(O: TVfoOverlay) of object;
TSliceClosedEvent = procedure(SliceId: Integer) of object;
TPanafallPanel = class(TComponent)
private
FParent: TWinControl;
@@ -63,6 +73,18 @@ type
FOnSplitterMoved: TNotifyEvent;
// ---- Флаги слайсов (фаза B1) ----
FController: TRadioController; // не владеем
FMainFlag: TVfoOverlay; // флаг A (главный) — создаёт хозяин
FSliceFlags: array of TVfoOverlay; // флаги B+ (Owner=Self)
FActiveFlag: TVfoOverlay; // чьё событие сейчас обрабатывается
FPendingSliceClose: Integer; // SliceId к удалению после мыши (0=нет)
FMainFlagPinned: Boolean; // True = левая панель хозяина скрыта
FLastSliceMeterMs: QWord; // троттлинг S-метра слайсов (~10 Гц)
FOnWireFlag: TWireFlagEvent;
FOnSliceClosed: TSliceClosedEvent;
FOnFlagsInvalidate: TNotifyEvent;
procedure SplitterMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure SplitterMouseMove(Sender: TObject; Shift: TShiftState;
@@ -119,6 +141,49 @@ type
// Пользователь отпустил сплиттер (ratio уже обновлён) — хозяин делает
// полный пересчёт (у него wideband/S-метр/оверлеи).
property OnSplitterMoved: TNotifyEvent read FOnSplitterMoved write FOnSplitterMoved;
// ---- Флаги слайсов ----
// Главный флаг (слайс A) создаёт хозяин (проводка событий у него) и отдаёт
// сюда; панель подключает его к view и включает в раскладку/диспетчер.
procedure AttachMainFlag(O: TVfoOverlay);
// SliceId флага, чьё событие сейчас обрабатывается (0 = главный).
function CurSliceId: Integer;
function FindSliceFlag(SliceId: Integer): TVfoOverlay;
procedure CreateSliceFlag(SliceId: Integer);
procedure DestroySliceFlag(SliceId: Integer);
// «×» на флаге: нельзя free внутри его же обработчика мыши — пометить...
procedure RequestSliceClose(SliceId: Integer);
// …и снять после возврата из диспетчера.
procedure ProcessPendingSliceClose;
// SliceId, чья несущая под пикселем X спектра (0 = нет).
function SliceAtPixel(X: Integer): Integer;
// SliceId флага под курсором мыши (прямоугольник оверлея; 0 = нет).
function SliceUnderCursor: Integer;
// Единый packing всех флагов (A+B+) в один ряд, без наездов.
procedure LayoutFlags;
// Флаг A виден: панель хозяина скрыта (MainFlagPinned) ИЛИ есть слайсы B+.
procedure UpdateMainFlagVisibility;
procedure PushSliceFlagState(SliceId: Integer);
procedure PushMainFlagExtState;
procedure PushMainFlagAudioDevices;
// Тик: S-метр/частота главного флага + позиции + троттленный S-метр слайсов.
procedure TickFlags;
procedure SetFlagsTheme(const T: TAppTheme);
function DispatchFlagsMouseDown(Button: TMouseButton; X, Y: Integer): Boolean;
procedure DispatchFlagsMouseMove(X, Y: Integer);
function DispatchFlagsMouseUp(X, Y: Integer): Boolean;
procedure DispatchFlagsMouseLeave;
property Controller: TRadioController read FController write FController;
property MainFlag: TVfoOverlay read FMainFlag;
property MainFlagPinned: Boolean read FMainFlagPinned write FMainFlagPinned;
// Проводка семантических событий флага (MODE/DSP/AUD/…) — хозяин.
property OnWireFlag: TWireFlagEvent read FOnWireFlag write FOnWireFlag;
// Слайс закрыт «×» — хозяину сбросить активный слайс и т.п.
property OnSliceClosed: TSliceClosedEvent read FOnSliceClosed write FOnSliceClosed;
// Флаги сдвинулись/пересозданы — хозяину пометить кадр грязным
// (эквивалент прежнего OnVfoOverlayInvalidate(nil)).
property OnFlagsInvalidate: TNotifyEvent read FOnFlagsInvalidate write FOnFlagsInvalidate;
end;
implementation
@@ -515,4 +580,432 @@ begin
if FZoomBar <> nil then FZoomBar.HandleDblClick;
end;
// ===========================================================================
// Флаги слайсов: коллекция, раскладка, диспетчер мыши, тик
// ===========================================================================
procedure TPanafallPanel.AttachMainFlag(O: TVfoOverlay);
begin
FMainFlag := O;
FView.VfoOverlay := O;
end;
function TPanafallPanel.CurSliceId: Integer;
begin
if Assigned(FActiveFlag) then Result := FActiveFlag.SliceId else Result := 0;
end;
function TPanafallPanel.FindSliceFlag(SliceId: Integer): TVfoOverlay;
var i: Integer;
begin
Result := nil;
for i := 0 to High(FSliceFlags) do
if (FSliceFlags[i] <> nil) and (FSliceFlags[i].SliceId = SliceId) then
Exit(FSliceFlags[i]);
end;
procedure TPanafallPanel.CreateSliceFlag(SliceId: Integer);
var
O: TVfoOverlay;
S: TCtrlSlice;
begin
if FindSliceFlag(SliceId) <> nil then Exit;
if (FController = nil) or not FController.GetSlice(SliceId, S) then Exit;
O := TVfoOverlay.Create(Self);
if Assigned(FOnWireFlag) then FOnWireFlag(O);
O.SetSliceInfo(SliceId, S.Letter, False);
O.SMeterThreshold := 1.5; // слайсы: грубее метр → меньше дорогих ре-рендеров флага
O.Width := OVL_W;
O.Height := O.BaseHeight;
O.Visible := True; // флаги слайсов видны всегда (не зависят от скрытия панели)
SetLength(FSliceFlags, Length(FSliceFlags) + 1);
FSliceFlags[High(FSliceFlags)] := O;
FView.AddSliceOverlay(O);
PushSliceFlagState(SliceId);
UpdateMainFlagVisibility; // появился слайс B+ → показать и флаг A (если панель открыта)
LayoutFlags;
end;
procedure TPanafallPanel.DestroySliceFlag(SliceId: Integer);
var
i, j: Integer;
O: TVfoOverlay;
begin
O := FindSliceFlag(SliceId);
if O = nil then Exit;
if FController <> nil then FController.RemoveSlice(SliceId);
FView.RemoveSliceOverlay(O);
// Убираем из массива со сжатием.
j := 0;
for i := 0 to High(FSliceFlags) do
if FSliceFlags[i] <> O then
begin
FSliceFlags[j] := FSliceFlags[i];
Inc(j);
end;
SetLength(FSliceFlags, j);
O.Free;
if Assigned(FOnSliceClosed) then FOnSliceClosed(SliceId);
UpdateMainFlagVisibility; // закрыт последний слайс при открытой панели → скрыть флаг A
if Assigned(FOnFlagsInvalidate) then FOnFlagsInvalidate(nil);
end;
procedure TPanafallPanel.RequestSliceClose(SliceId: Integer);
begin
FPendingSliceClose := SliceId;
end;
procedure TPanafallPanel.ProcessPendingSliceClose;
var Id: Integer;
begin
if FPendingSliceClose = 0 then Exit;
Id := FPendingSliceClose;
FPendingSliceClose := 0;
DestroySliceFlag(Id);
end;
// SliceId, чья несущая под пикселем X (в пределах допуска), 0 если нет.
function TPanafallPanel.SliceAtPixel(X: Integer): Integer;
const TOL = 6;
var
i, CarrierX: Integer;
VC, VS: Double;
S: TCtrlSlice;
O: TVfoOverlay;
begin
Result := 0;
if (FPbSpectrum = nil) or (FPbSpectrum.Width <= 0) or (FController = nil) then Exit;
FController.GetViewWindow(VC, VS);
if VS <= 0 then Exit;
for i := 0 to High(FSliceFlags) do
begin
O := FSliceFlags[i];
if (O = nil) or (not O.Visible) then Continue;
if not FController.GetSlice(O.SliceId, S) then Continue;
CarrierX := Round((S.TargetHz - VC + VS / 2) / VS * FPbSpectrum.Width);
if Abs(X - CarrierX) <= TOL then Exit(O.SliceId);
end;
end;
// Флаг слайса под курсором мыши (в клиентских координатах спектра)?
function TPanafallPanel.SliceUnderCursor: Integer;
var P: TPoint; i: Integer; O: TVfoOverlay;
begin
Result := 0;
if (FPbSpectrum = nil) or (not FPbSpectrum.Visible) then Exit;
P := FPbSpectrum.ScreenToClient(Mouse.CursorPos);
for i := 0 to High(FSliceFlags) do
begin
O := FSliceFlags[i];
if (O = nil) or (not O.Visible) then Continue;
if (P.X >= O.Left) and (P.X < O.Left + O.Width) and
(P.Y >= O.Top) and (P.Y < O.Top + O.Height) then
Exit(O.SliceId);
end;
end;
// Позиционирование всех флагов (главный A + слайсы B+) единым проходом.
// Идеал каждого флага — рядом со СВОЕЙ полосой фильтра (справа, флип влево
// если не влезает). Флаги не наезжают друг на друга: сосед слева «толкает»
// правого; если ряд вылез за правый край — обратный проход поджимает влево.
procedure TPanafallPanel.LayoutFlags;
const
FLAG_TOP = 35; // все флаги на одном уровне
GAPX = 4; // минимальный зазор между соседними флагами
var
Ovls: array of TVfoOverlay;
Ideal, OWs, Lefts: array of Integer;
n, i, j, W: Integer;
VC, VS: Double;
S: TCtrlSlice;
LoHz, HiHz: Double;
TmpO: TVfoOverlay;
TmpI: Integer;
Moved: Boolean;
// Идеальный Left рядом с полосой фильтра [loHz..hiHz] вокруг несущей Carrier.
function IdealLeftFor(Carrier, loHz, hiHz: Double; OW: Integer): Integer;
var vfoX, sx1, sx2, nl: Integer;
begin
vfoX := Round((Carrier - VC + VS / 2) / VS * W);
sx1 := Max(0, Min(W - 1, vfoX + Round(loHz / VS * W)));
sx2 := Max(0, Min(W - 1, vfoX + Round(hiHz / VS * W)));
nl := sx2 + 6; // по умолчанию справа от полосы
if nl + OW > W - 4 then nl := sx1 - 6 - OW; // не влезает → слева
Result := nl;
end;
begin
if (FPbSpectrum = nil) or (FController = nil) then Exit;
W := FPbSpectrum.Width;
FController.GetViewWindow(VC, VS);
if (W <= 0) or (VS <= 0) then Exit;
// 1. Собрать видимые флаги + их идеальный Left рядом со своей полосой.
n := 0;
SetLength(Ovls, 1 + Length(FSliceFlags));
SetLength(Ideal, 1 + Length(FSliceFlags));
SetLength(OWs, 1 + Length(FSliceFlags));
if Assigned(FMainFlag) and FMainFlag.Visible then
begin
case FController.FMode of
MODE_LSB: begin LoHz := -FController.FFilterBW; HiHz := -100; end;
MODE_USB: begin LoHz := 100; HiHz := FController.FFilterBW; end;
MODE_DIGL: begin LoHz := -FController.FFilterBW; HiHz := 0; end;
MODE_DIGU: begin LoHz := 0; HiHz := FController.FFilterBW; end;
else begin LoHz := -FController.FFilterBW / 2; HiHz := FController.FFilterBW / 2; end;
end;
Ovls[n] := FMainFlag;
OWs[n] := FMainFlag.Width;
Ideal[n] := IdealLeftFor(FController.FVfoA, LoHz, HiHz, OWs[n]);
Inc(n);
end;
for i := 0 to High(FSliceFlags) do
begin
if (FSliceFlags[i] = nil) or (not FSliceFlags[i].Visible) then Continue;
if not FController.GetSlice(FSliceFlags[i].SliceId, S) then Continue;
Ovls[n] := FSliceFlags[i];
OWs[n] := FSliceFlags[i].Width;
Ideal[n] := IdealLeftFor(S.TargetHz, S.FilterLo, S.FilterHi, OWs[n]);
Inc(n);
end;
if n = 0 then Exit;
// 2. Сортировка по идеальному Left (≈ по частоте) — пузырьком, n мал.
for i := 0 to n - 2 do
for j := 0 to n - 2 - i do
if Ideal[j] > Ideal[j + 1] then
begin
TmpI := Ideal[j]; Ideal[j] := Ideal[j + 1]; Ideal[j + 1] := TmpI;
TmpI := OWs[j]; OWs[j] := OWs[j + 1]; OWs[j + 1] := TmpI;
TmpO := Ovls[j]; Ovls[j] := Ovls[j + 1]; Ovls[j + 1] := TmpO;
end;
// 3. Вперёд-проход: сосед слева толкает правого (тот не пересекает).
SetLength(Lefts, n);
for i := 0 to n - 1 do
begin
Lefts[i] := Ideal[i];
if (i > 0) and (Lefts[i] < Lefts[i - 1] + OWs[i - 1] + GAPX) then
Lefts[i] := Lefts[i - 1] + OWs[i - 1] + GAPX;
end;
// 4. Если ряд вылез за правый край — обратный проход поджимает влево.
if Lefts[n - 1] + OWs[n - 1] > W - 4 then
for i := n - 1 downto 0 do
if i = n - 1 then
Lefts[i] := Min(Lefts[i], W - 4 - OWs[i])
else
Lefts[i] := Min(Lefts[i], Lefts[i + 1] - GAPX - OWs[i]);
// 5. Применить (зажать в поле), инвалидировать при реальном сдвиге.
Moved := False;
for i := 0 to n - 1 do
begin
Lefts[i] := Max(4, Lefts[i]);
if (Ovls[i].Left <> Lefts[i]) or (Ovls[i].Top <> FLAG_TOP) then
begin
Ovls[i].Left := Lefts[i];
Ovls[i].Top := FLAG_TOP;
Moved := True;
end;
end;
if Moved and Assigned(FOnFlagsInvalidate) then FOnFlagsInvalidate(nil);
end;
// Флаг главного приёмника (слайс A) виден, когда СКРЫТА левая панель хозяина
// (MainFlagPinned) ЛИБО есть доп. слайсы (B+): тогда A всегда представлен
// флагом рядом с B/C…, а сворачивание/раскрытие панели при живых слайсах не
// дёргает его появление.
procedure TPanafallPanel.UpdateMainFlagVisibility;
var
ShouldShow: Boolean;
begin
if (FMainFlag = nil) or (FController = nil) then Exit;
ShouldShow := FMainFlagPinned or (Length(FSliceFlags) > 0);
if ShouldShow = FMainFlag.Visible then Exit;
if ShouldShow then
begin
FMainFlag.Width := OVL_W;
FMainFlag.Height := FMainFlag.BaseHeight; // растёт сам при открытии fly-out
FMainFlag.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FController.FLastSMeter);
FMainFlag.SetDSPState(FController.FNRMode, FController.FNBMode, FController.FSNB, FController.FANF, FController.FAGCMode);
FMainFlag.SetSquelchState(FController.FFMSQOn, FController.FFMSQLevel);
FMainFlag.Visible := True;
PushMainFlagExtState;
PushMainFlagAudioDevices;
LayoutFlags;
end
else
begin
FMainFlag.HandleMouseLeave;
FMainFlag.Visible := False;
end;
end;
// Заливает во флаг слайса его текущее состояние из контроллера.
procedure TPanafallPanel.PushSliceFlagState(SliceId: Integer);
var
O: TVfoOverlay;
S: TCtrlSlice;
BW, Cnt, I: Integer;
Names: TStringList;
Indices: array[0..63] of Integer;
Idx: array of Integer;
begin
O := FindSliceFlag(SliceId);
if (O = nil) or (FController = nil) or not FController.GetSlice(SliceId, S) then Exit;
case S.Mode of
MODE_LSB, MODE_DIGL: BW := -S.FilterLo;
MODE_USB, MODE_DIGU: BW := S.FilterHi;
else BW := S.FilterHi - S.FilterLo;
end;
O.SetState(S.Mode, BW, S.TargetHz, FController.SliceSMeter(SliceId));
O.SetDSPState(0, 0, False, False, FController.FAGCMode);
O.SetSquelchState(S.FMSQOn, S.FMSQLevel);
O.SetExtState(Round(S.Volume * 100), S.Muted, False, False, False);
// Список аудио-устройств (общий PA-список) + текущее устройство слайса.
if Assigned(FController.FAudioOut) then
begin
Names := TStringList.Create;
try
Cnt := 0;
FController.FAudioOut.EnumOutputDevices(Names, Indices, Cnt);
SetLength(Idx, Cnt);
for I := 0 to Cnt - 1 do Idx[I] := Indices[I];
O.SetAudioDevices(Names, Idx, S.DevName);
finally
Names.Free;
end;
end;
end;
procedure TPanafallPanel.PushMainFlagExtState;
begin
if not (Assigned(FMainFlag) and FMainFlag.Visible) or (FController = nil) then Exit;
// TxSel: этот слайс выбран для передачи. Пока TX только главный → всегда True.
FMainFlag.SetExtState(FController.ActiveVolume, FController.FMuted,
FController.FSplitTxB, FController.FTransmitting, True);
end;
procedure TPanafallPanel.PushMainFlagAudioDevices;
var
Names: TStringList;
Indices: array[0..63] of Integer;
Cnt, I: Integer;
Idx: array of Integer;
begin
if (FMainFlag = nil) or (FController = nil) or
not Assigned(FController.FAudioOut) then Exit;
Names := TStringList.Create;
try
Cnt := 0;
FController.FAudioOut.EnumOutputDevices(Names, Indices, Cnt);
SetLength(Idx, Cnt);
for I := 0 to Cnt - 1 do Idx[I] := Indices[I];
FMainFlag.SetAudioDevices(Names, Idx, FController.FAudioOutDevName);
finally
Names.Free;
end;
end;
// Тик (~частота FSpectrumTimer хозяина): метр/частота главного флага, позиции
// всех флагов, троттленный S-метр слайсов.
procedure TPanafallPanel.TickFlags;
var I: Integer;
begin
if FController = nil then Exit;
if Assigned(FMainFlag) and FMainFlag.Visible then
begin
FMainFlag.UpdateSMeter(FController.FLastSMeter);
FMainFlag.UpdateVfo(FController.FVfoA);
PushMainFlagExtState;
LayoutFlags;
end;
if Length(FSliceFlags) > 0 then
begin
LayoutFlags; // держим слайс-флаги на частоте
// S-метр слайсов троттлим до ~10 Гц: каждое изменение метра = софтовый
// ре-рендер флага (дорого), 60 Гц там не нужно.
if GetTickCount64 - FLastSliceMeterMs >= 100 then
begin
FLastSliceMeterMs := GetTickCount64;
for I := 0 to High(FSliceFlags) do // S-метр каждого слайса из его канала
if FSliceFlags[I] <> nil then
FSliceFlags[I].UpdateSMeter(FController.SliceSMeter(FSliceFlags[I].SliceId));
end;
end;
end;
procedure TPanafallPanel.SetFlagsTheme(const T: TAppTheme);
var i: Integer;
begin
if Assigned(FMainFlag) then FMainFlag.SetTheme(T);
for i := 0 to High(FSliceFlags) do
if Assigned(FSliceFlags[i]) then FSliceFlags[i].SetTheme(T);
end;
// ---- Диспетчер мыши флагов: FActiveFlag выставляется на время Handle*, чтобы
// события флага (синхронные) роутились по CurSliceId. ----
function TPanafallPanel.DispatchFlagsMouseDown(Button: TMouseButton; X, Y: Integer): Boolean;
var i: Integer;
begin
Result := False;
if Assigned(FMainFlag) and FMainFlag.Visible then
begin
FActiveFlag := FMainFlag;
if FMainFlag.HandleMouseDown(Button, X, Y) then Exit(True);
end;
for i := 0 to High(FSliceFlags) do
if (FSliceFlags[i] <> nil) and FSliceFlags[i].Visible then
begin
FActiveFlag := FSliceFlags[i];
if FSliceFlags[i].HandleMouseDown(Button, X, Y) then Exit(True);
end;
FActiveFlag := nil;
end;
procedure TPanafallPanel.DispatchFlagsMouseMove(X, Y: Integer);
var i: Integer;
begin
if Assigned(FMainFlag) then
begin
FActiveFlag := FMainFlag;
FMainFlag.HandleMouseMove(X, Y);
end;
for i := 0 to High(FSliceFlags) do
if FSliceFlags[i] <> nil then
begin
FActiveFlag := FSliceFlags[i];
FSliceFlags[i].HandleMouseMove(X, Y);
end;
FActiveFlag := nil;
end;
function TPanafallPanel.DispatchFlagsMouseUp(X, Y: Integer): Boolean;
var i: Integer;
begin
Result := False;
if Assigned(FMainFlag) then
begin
FActiveFlag := FMainFlag;
if FMainFlag.HandleMouseUp(X, Y) then Result := True;
end;
for i := 0 to High(FSliceFlags) do
if FSliceFlags[i] <> nil then
begin
FActiveFlag := FSliceFlags[i];
if FSliceFlags[i].HandleMouseUp(X, Y) then Result := True;
end;
FActiveFlag := nil;
end;
procedure TPanafallPanel.DispatchFlagsMouseLeave;
var i: Integer;
begin
if Assigned(FMainFlag) then FMainFlag.HandleMouseLeave;
for i := 0 to High(FSliceFlags) do
if FSliceFlags[i] <> nil then FSliceFlags[i].HandleMouseLeave;
end;
end.