feat(slices): грид-раскладка панов + фиксы минимумов стека, утечки кнопок, CPU

Грид: пан 0 сверху во всю ширину, доп. паны колонками снизу; тумблер
«▦/▤» в ряду зума пана 0, вертикальные сплиттеры-призраки колонок
(FPanColShare), доля ряда FPanGridRowShare; автоткат в стек при <2 доп.
панах. Регион пана обобщён до прямоугольника (StackLeft/StackWidth).

Фиксы по экранному тесту:
- наезд панов: раздача высот и драг сплиттеров клампятся минимумом
  содержимого пана (MinLayoutHeight/PanMinHeight, пан 0 += wideband);
- «блуждающие» кнопки после STOP: MakeFlatBtn даёт Owner=контейнер,
  TPanafallPanel.Destroy теперь явно освобождает −/⌂/+/⊞/▦;
- жор CPU ~40%/пан: убран безусловный Invalidate зум-бара из тика
  (SyncPanZoomUI), инвалидация только при реальной смене значений;
- пропорция спектр/водопад пана 0 не восстанавливалась после закрытия
  панов: кламп ratio минимумами больше не пишется в FSplitterRatio.

Подтверждено юзером на экране: грид/стек/STOP/pop-out работают,
CPU со всеми панами 17-20%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 15:51:11 +03:00
co-authored by Claude Fable 5
parent 64edf9e289
commit eac66100a8
3 changed files with 501 additions and 66 deletions
+104 -28
View File
@@ -110,6 +110,10 @@ type
FOnPanClose: TNotifyEvent;
FOnAddPan: TNotifyEvent;
FAddPanWanted: Boolean; // хозяин: показывать «⊞» в ряду пан/зума
FBtnGrid: TFlatButton; // «▦/▤» тумблер грид-раскладки (пан 0)
FGridBtnWanted: Boolean; // хозяин: показывать тумблер (>=2 доп. панов)
FStackLeft: Integer; // регион пана: X (0 при полной ширине)
FStackWidth: Integer; // ширина региона (0 = вся ширина родителя)
procedure BtnClosePanClick(Sender: TObject);
procedure BtnAddPanClick(Sender: TObject);
@@ -135,7 +139,7 @@ type
procedure PanZoomMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure PanZoomDblClick(Sender: TObject);
procedure PositionPanZoomBar(BottomY, RW, H: Integer; AVisible: Boolean);
procedure PositionPanZoomBar(X0, BottomY, RW, H: Integer; AVisible: Boolean);
public
// Обработчики мыши спектра/водопада и кликов зум-кнопок хозяина —
// задать ДО вызова Build (Build подвешивает их на создаваемые контролы).
@@ -150,6 +154,7 @@ type
ZoomInClick: TNotifyEvent;
ZoomDefClick: TNotifyEvent;
ZoomOutClick: TNotifyEvent;
GridClick: TNotifyEvent; // «▦/▤» тумблер грид-раскладки (пан 0)
constructor Create(AOwner: TComponent; AUseOpenGL: Boolean); reintroduce;
destructor Destroy; override;
@@ -164,6 +169,10 @@ type
// над спектром. Show-флаги приходят от контроллера (FShowSpectrum/
// FShowWaterfall).
procedure Layout(ATopOffset: Integer; AShowSpectrum, AShowWaterfall: Boolean);
// Мин. высота региона, при которой Layout не вылезает за него (те же
// константы/DPI, что в Layout; учитывает текущую HeaderVisible).
function MinLayoutHeight(ATopOffset: Integer;
AShowSpectrum, AShowWaterfall: Boolean): Integer;
property View: TSpectrumView read FView;
property PbSpectrum: TControl read FPbSpectrum;
@@ -186,6 +195,9 @@ type
property PanId: Integer read FPanId write FPanId;
property StackTop: Integer read FStackTop write FStackTop;
property StackHeight: Integer read FStackHeight write FStackHeight;
// Грид-раскладка: регион пана может быть колонкой (StackWidth=0 = вся ширина).
property StackLeft: Integer read FStackLeft write FStackLeft;
property StackWidth: Integer read FStackWidth write FStackWidth;
// Шапка пана: текст ставит хозяин (частота), rate — отдельным лейблом
// (SetHeaderRate); у панов N>0 хозяин делает его кликабельным (селектор
// rate, RateClickable + OnRateClick). «×» → OnPanClose.
@@ -212,6 +224,9 @@ type
property ShowZoomRow: Boolean read FShowZoomRow write FShowZoomRow;
// «⊞» в ряду пан/зума (пан 0): показать/спрятать решает хозяин.
property AddPanEnabled: Boolean read FAddPanWanted write FAddPanWanted;
// «▦/▤» тумблер грид-раскладки (пан 0, виден при >=2 доп. панах).
property GridBtnEnabled: Boolean read FGridBtnWanted write FGridBtnWanted;
property BtnGrid: TFlatButton read FBtnGrid;
property OnPanClose: TNotifyEvent read FOnPanClose write FOnPanClose;
property OnAddPan: TNotifyEvent read FOnAddPan write FOnAddPan;
// Пользователь отпустил сплиттер (ratio уже обновлён) — хозяин делает
@@ -288,6 +303,14 @@ begin
// (Owner=Self) освободит inherited.
FreeAndNil(FZoomBar);
FreeAndNil(FView);
// Кнопки ряда зума создаются MakeFlatBtn с Owner=КОНТЕЙНЕР (не Self) —
// inherited их не освободит, и после гибели пана они оставались жить
// на родителе (STOP → «блуждающие» −/⌂/+ поверх окна).
FreeAndNil(FBtnZoomOut);
FreeAndNil(FBtnZoomDef);
FreeAndNil(FBtnZoomIn);
FreeAndNil(FBtnAddPan);
FreeAndNil(FBtnGrid);
inherited Destroy;
end;
@@ -385,6 +408,11 @@ begin
FBtnAddPan.Hint := 'Add panadapter';
FBtnAddPan.ShowHint := True;
FBtnAddPan.Visible := False;
// Тумблер грид-раскладки (пан 0; глифом рулит хозяин: ▦ = в грид, ▤ = в стек).
FBtnGrid := MakeFlatBtn(AParent, '▦', 0, 0, 10, 10, GridClick);
FBtnGrid.Hint := 'Grid layout';
FBtnGrid.ShowHint := True;
FBtnGrid.Visible := False;
// ---- Шапка пана (тонкая полоска над спектром; видна при панах >= 2) ----
FHeaderPanel := TPanel.Create(Self);
@@ -427,6 +455,7 @@ begin
FBtnZoomDef.Parent := NewParent;
FBtnZoomIn.Parent := NewParent;
FBtnAddPan.Parent := NewParent;
FBtnGrid.Parent := NewParent;
FHeaderPanel.Parent := NewParent; // дети шапки едут вместе с ней
end;
@@ -538,31 +567,39 @@ begin
FBtnPopPan.SetBounds(RW - 2 * BtnS - 6, 2, BtnS, BtnS);
end;
procedure TPanafallPanel.PositionPanZoomBar(BottomY, RW, H: Integer;
procedure TPanafallPanel.PositionPanZoomBar(X0, BottomY, RW, H: Integer;
AVisible: Boolean);
var BtnW, Gap, StripW, X, NBtns: Integer; WantAdd: Boolean;
var BtnW, Gap, StripW, X, NBtns: Integer; WantAdd, WantGrid: Boolean;
begin
if (FPbPanZoom = nil) or (FBtnZoomIn = nil) then Exit;
// «⊞» добавления пана живёт в этом же ряду; хозяин включает его флагом
// AddPanEnabled (Visible кнопки управляется здесь вместе с рядом).
WantAdd := FAddPanWanted and (FBtnAddPan <> nil);
// «⊞» добавления пана и «▦/▤» грид-тумблер живут в этом же ряду; хозяин
// включает их флагами AddPanEnabled/GridBtnEnabled (Visible — здесь).
WantAdd := FAddPanWanted and (FBtnAddPan <> nil);
WantGrid := FGridBtnWanted and (FBtnGrid <> nil);
FPbPanZoom.Visible := AVisible;
FBtnZoomIn.Visible := AVisible;
FBtnZoomDef.Visible := AVisible;
FBtnZoomOut.Visible := AVisible;
if FBtnAddPan <> nil then FBtnAddPan.Visible := AVisible and WantAdd;
if FBtnGrid <> nil then FBtnGrid.Visible := AVisible and WantGrid;
if not AVisible then Exit;
Gap := MulDiv(2, Screen.PixelsPerInch, 96);
BtnW := H; // квадратные кнопки в высоту строки
NBtns := 3;
if WantAdd then NBtns := 4;
if WantAdd then Inc(NBtns);
if WantGrid then Inc(NBtns);
StripW := RW - NBtns * BtnW - (NBtns + 1) * Gap;
if StripW < 20 then StripW := 20;
FPbPanZoom.SetBounds(0, BottomY, StripW, H);
X := StripW + Gap;
FPbPanZoom.SetBounds(X0, BottomY, StripW, H);
X := X0 + StripW + Gap;
FBtnZoomOut.SetBounds(X, BottomY, BtnW, H); Inc(X, BtnW + Gap);
FBtnZoomDef.SetBounds(X, BottomY, BtnW, H); Inc(X, BtnW + Gap);
FBtnZoomIn.SetBounds(X, BottomY, BtnW, H);
if WantGrid then
begin
Inc(X, BtnW + Gap);
FBtnGrid.SetBounds(X, BottomY, BtnW, H);
end;
if WantAdd then
begin
Inc(X, BtnW + Gap);
@@ -571,6 +608,31 @@ begin
FPbPanZoom.Invalidate;
end;
function TPanafallPanel.MinLayoutHeight(ATopOffset: Integer;
AShowSpectrum, AShowWaterfall: Boolean): Integer;
const
SPLITTER_H = 5;
MIN_SH = 60; // = Layout
MIN_WH = 40; // = Layout
var
RULER_H, PANZOOM_H, HEADER_H: Integer;
begin
RULER_H := MulDiv(18, Screen.PixelsPerInch, 96);
PANZOOM_H := MulDiv(22, Screen.PixelsPerInch, 96);
HEADER_H := MulDiv(20, Screen.PixelsPerInch, 96);
Result := ATopOffset;
if FHeaderVisible then Inc(Result, HEADER_H);
if (AShowSpectrum or AShowWaterfall) and FShowZoomRow then
Inc(Result, PANZOOM_H);
if AShowSpectrum then Inc(Result, MIN_SH + RULER_H);
if AShowWaterfall then
begin
Inc(Result, MIN_WH);
if AShowSpectrum then Inc(Result, SPLITTER_H)
else Inc(Result, RULER_H); // водопад-only тоже рисует линейку
end;
end;
procedure TPanafallPanel.Layout(ATopOffset: Integer;
AShowSpectrum, AShowWaterfall: Boolean);
const
@@ -578,20 +640,29 @@ const
MIN_SH = 60; // минимальная высота спектра
MIN_WH = 40; // минимальная высота водопада
var
RW, RH, SH, WH, TopOff: Integer;
RW, RH, SH, WH, TopOff, X0: Integer;
AvailH: Integer;
RULER_H: Integer;
PANZOOM_H: Integer;
HEADER_H: Integer;
ShowPZ: Boolean;
EffRatio: Double;
begin
if (FParent = nil) or (FPbSpectrum = nil) then Exit;
FShowSpectrum := AShowSpectrum;
FShowWaterfall := AShowWaterfall;
FSplitAvailH := 0; // валидно только в режиме «спектр+водопад» (ниже)
RULER_H := MulDiv(18, Screen.PixelsPerInch, 96);
RW := FParent.ClientWidth;
// Регион пана в стеке: [FStackTop .. RH). 0/0 = весь родитель (один пан).
// Регион пана — прямоугольник [X0..X0+RW) × [FStackTop..RH): грид-раскладка
// ставит паны колонками; 0/0 по каждой оси = весь родитель.
X0 := FStackLeft;
if FStackWidth > 0 then
RW := FStackWidth
else
begin
X0 := 0;
RW := FParent.ClientWidth;
end;
if FStackHeight > 0 then
RH := FStackTop + FStackHeight
else
@@ -603,14 +674,14 @@ begin
FHeaderPanel.Visible := FHeaderVisible;
if FHeaderVisible then
begin
FHeaderPanel.SetBounds(0, FStackTop, RW, HEADER_H);
FHeaderPanel.SetBounds(X0, FStackTop, RW, HEADER_H);
PositionHeaderChildren(RW, HEADER_H);
end;
end;
// Резервируем низ под панель пана/зума (видна, если виден спектр или водопад).
PANZOOM_H := MulDiv(22, Screen.PixelsPerInch, 96);
ShowPZ := (AShowSpectrum or AShowWaterfall) and FShowZoomRow;
PositionPanZoomBar(RH - PANZOOM_H, RW, PANZOOM_H, ShowPZ);
PositionPanZoomBar(X0, RH - PANZOOM_H, RW, PANZOOM_H, ShowPZ);
if ShowPZ then RH := RH - PANZOOM_H;
TopOff := ATopOffset + FStackTop;
if FHeaderVisible then Inc(TopOff, HEADER_H);
@@ -634,9 +705,9 @@ begin
AvailH := RH - TopOff - RULER_H;
if AvailH < MIN_SH then AvailH := MIN_SH;
SH := AvailH;
FPbSpectrum.SetBounds(0, TopOff, RW, SH);
FPbSpectrum.SetBounds(X0, TopOff, RW, SH);
FPbSpectrum.Visible := True;
FPbRuler.SetBounds(0, TopOff + SH, RW, RULER_H);
FPbRuler.SetBounds(X0, TopOff + SH, RW, RULER_H);
FPbRuler.Visible := True;
FSplitter.Visible := False;
FPbWaterfall.Visible := False;
@@ -663,9 +734,9 @@ begin
WH := AvailH;
FPbSpectrum.Visible := False;
FSplitter.Visible := False;
FPbWaterfall.SetBounds(0, TopOff, RW, WH);
FPbWaterfall.SetBounds(X0, TopOff, RW, WH);
FPbWaterfall.Visible := True;
FPbRuler.SetBounds(0, TopOff + WH, RW, RULER_H);
FPbRuler.SetBounds(X0, TopOff + WH, RW, RULER_H);
FPbRuler.Visible := True;
FSpectrumWidth := RW;
FSpectrumHeight := 0;
@@ -694,24 +765,29 @@ begin
FSplitTopOff := TopOff;
FSplitAvailH := AvailH;
// Ограничиваем соотношение, чтобы каждая зона имела минимальный размер
if FSplitterRatio < MIN_SH / AvailH then
FSplitterRatio := MIN_SH / AvailH;
if FSplitterRatio > 1.0 - MIN_WH / AvailH then
FSplitterRatio := 1.0 - MIN_WH / AvailH;
// Ограничиваем соотношение, чтобы каждая зона имела минимальный размер.
// Кламп — в ЛОКАЛЬНУЮ переменную: FSplitterRatio правит только юзер
// (drag сплиттера). Иначе в маленьком регионе (стек панов) кламп
// перезаписывал ratio (60/AvailH → ~0.7) и после закрытия панов
// пропорция «50/50» не восстанавливалась.
EffRatio := FSplitterRatio;
if EffRatio < MIN_SH / AvailH then
EffRatio := MIN_SH / AvailH;
if EffRatio > 1.0 - MIN_WH / AvailH then
EffRatio := 1.0 - MIN_WH / AvailH;
SH := Round(AvailH * FSplitterRatio);
SH := Round(AvailH * EffRatio);
WH := AvailH - SH;
if WH < MIN_WH then WH := MIN_WH;
// Спектр
FPbSpectrum.SetBounds(0, TopOff, RW, SH);
FPbSpectrum.SetBounds(X0, TopOff, RW, SH);
// Линейка частот — всегда прижата к низу спектра
FPbRuler.SetBounds(0, TopOff + SH, RW, RULER_H);
FPbRuler.SetBounds(X0, TopOff + SH, RW, RULER_H);
// Сплиттер — между линейкой и водопадом
FSplitter.SetBounds(0, TopOff + SH + RULER_H, RW, SPLITTER_H);
FSplitter.SetBounds(X0, TopOff + SH + RULER_H, RW, SPLITTER_H);
// Водопад — под сплиттером
FPbWaterfall.SetBounds(0, TopOff + SH + RULER_H + SPLITTER_H, RW, WH);
FPbWaterfall.SetBounds(X0, TopOff + SH + RULER_H + SPLITTER_H, RW, WH);
// Обновляем переменные размеров
FSpectrumWidth := RW;