feat(filter): произвольные кромки фильтра — таблица по режиму, редактор, drag, TX-полоска

Фильтр главного приёмника перестал быть одним числом. Теперь это таблица
(Name, Low, High) на режим: 10 пресетов + VAR1/VAR2, знаковые кромки (у LSB
своя строка, у USB своя), паритет с Thetis FilterForm. Слайсы независимые
кромки умели и раньше — главный приёмник настраивался только пресетами.

Модель (Settings/RadioController):
- TFilterSlot/TFilterSet/TFilterTable + заводские дефолты из прежних FILT_*_BW
  и FILT_*_NAMES (переехали в Settings — из них строится таблица).
- FFilterLo/FFilterHi = истина, FFilterBW производная (FilterBWFromEdges),
  поэтому все прежние читатели ширины (web, зум, band-cache, CAT) не тронуты.
- ApplyFilterSlot — единственная точка «слот → состояние тракта».
- SetFilterEdges: произвольные кромки уходят в VAR1 с автопереключением,
  пресеты нельзя испортить случайным движением мыши или энкодера.
- Персист: секция "filters" под MAC, пишутся только режимы, отличные от
  заводских; секция режима сносится целиком, иначе возврат слота к
  заводскому оставлял бы старый ключ.
- Диапазон помнит только выбранный слот: кромки живут в таблице, второго
  источника истины у VAR нет.

UI:
- 12 кнопок фильтра (2 ряда по 6), подписи из живой таблицы.
- FilterPopup — поповер редактора по ПКМ на кнопке фильтра, по образцу
  PureSignalPopup (дочерний контрол формы, не top-level: Wayland). Слева
  слоты, справа Name/Low/High/Width, внизу форма полосы на знаковой оси.
- Перетаскивание края полосы на спектре: ±5px, снап 10 Гц, Alt = симметрично
  (не Ctrl — он создаёт слайс). Отказ на передаче и когда полоса на экране
  уже 12px, иначе захват съедал бы клик-тюн.

Отрисовка:
- Полоса главного VFO рисуется по РЕАЛЬНЫМ кромкам приёмника, слайса — по его
  кромкам; вывод из режима и ширины остался фолбэком. Пара→ширина→пара было
  преобразованием с потерями: несимметричный фильтр из слайс-CAT (ZZFL/ZZFH)
  рисовался не там, где звучал.
- На передаче полоса считается по TX-фильтру (TXSignedEdges), а не по
  приёмной ширине — TX-полоса от RX не зависит (модель Thetis).
- CalcFilterBandXFor больше не держит свою копию таблицы знака боковой:
  единственная таблица теперь FilterEdgesFromBW.

Управление:
- CAT ZZFL/ZZFH заработали на основном порту (были заглушками).
- Андромеда: энкодеры #4/#5 — независимые Filter High/Low, как на железе G2
  (было: #4 ширина, #5 не реализован).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 13:15:33 +03:00
co-authored by Claude Opus 5
parent a820182716
commit 25521a2d10
13 changed files with 1329 additions and 308 deletions
+514
View File
@@ -0,0 +1,514 @@
unit FilterPopup;
{
TFilterPopup — поповер редактора фильтров приёмника, открывается ПКМ по
любой кнопке фильтра на левой панели. Слева список слотов режима (10
пресетов + VAR1/VAR2), справа Name/Low/High/Width, внизу форма полосы в
масштабе на ЗНАКОВОЙ оси частот.
Знак и есть главный смысл нижней картинки: у LSB полоса лежит в минусе, и
это видно глазами — ровно то место, где в проекте дважды заводились баги с
боковой. Скат нарисован схематично: реальная крутизна у WDSP определяется
длиной FIR (fircore) и никаким пользовательским параметром не управляется.
Эталон содержимого — Thetis FilterForm.cs (12 слотов + Name/Low/High/Width);
графика полосы — наша добавка, у них её нет.
Реализован как ДОЧЕРНИЙ контрол верхнеуровневой формы (не отдельное окно) —
как TPureSignalPopup/TPanDisplayPopup: Wayland не даёт клиенту
позиционировать top-level окна.
}
{$IFDEF FPC}
{$MODE Delphi}
{$ENDIF}
interface
uses
Classes, SysUtils, Math, Types, Controls, ExtCtrls, StdCtrls, Graphics, Forms,
RadioController, RadioModes, Settings,
FlatButton, FlatEdit, FlatSpinEdit, FlatListBox;
type
TFilterSlotEvent = procedure(Mode, Idx: Integer; const S: TFilterSlot) of object;
TFilterResetEvent = procedure(Mode, Idx: Integer) of object;
TFilterPopup = class(TCustomControl)
private
FController: TRadioController; // не владеем
FMode: Integer;
FSlotIdx: Integer;
FSet: TFilterSet;
FPresets: Integer; // пресетов у режима (VAR идут сверх)
FLoading: Boolean; // подавляет Apply при программной заливке
FBg: TPanel;
FTitle: TLabel;
FBtnClose: TFlatButton;
FList: TFlatListBox;
FEdName: TFlatEdit;
FEdLow: TFlatSpinEdit;
FEdHigh: TFlatSpinEdit;
FEdWidth: TFlatSpinEdit;
FBtnReset: TFlatButton;
FBtnResetAll: TFlatButton;
FShape: TPaintBox;
FOnSlotEdited: TFilterSlotEvent;
FOnSlotReset: TFilterResetEvent;
function MakeLbl(const Cap: string; ALeft, ATop, AW: Integer): TLabel;
procedure FillList;
procedure ShowSlot;
procedure Commit;
procedure ListClick(Sender: TObject);
procedure EdgeChange(Sender: TObject);
procedure WidthChange(Sender: TObject);
procedure NameChange(Sender: TObject);
procedure ResetClick(Sender: TObject);
procedure ResetAllClick(Sender: TObject);
procedure CloseClick(Sender: TObject);
procedure ShapePaint(Sender: TObject);
function SlotLabel(Idx: Integer): string;
protected
procedure Paint; override;
public
constructor Create(AOwner: TComponent); override;
// Перечитывает таблицу контроллера и показывает слот SlotIdx режима Mode.
procedure LoadFrom(C: TRadioController; Mode, SlotIdx: Integer);
// Подсветить выбор, когда фильтр переключили снаружи (кнопка/CAT/drag).
procedure SyncSelection(Mode, Idx: Integer);
// Показать под контролом-якорем (кнопкой фильтра) как дочерний контрол формы.
procedure PopupBelow(C: TRadioController; Anchor: TControl;
Mode, SlotIdx: Integer);
property SlotIdx: Integer read FSlotIdx;
property OnSlotEdited: TFilterSlotEvent read FOnSlotEdited write FOnSlotEdited;
property OnSlotReset: TFilterResetEvent read FOnSlotReset write FOnSlotReset;
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);
CLR_GRID = TColor($00303030);
CLR_BAND = TColor($0040FF80);
POP_W = 300;
PAD = 10;
LISTW = 78;
LBLW = 40;
ROWH = 24;
ITEMH = 22; // = BASE_ITEM_H в FlatListBox
SHAPE_H = 96;
RULER_H = 13;
EDGE_LIM = 20000;
FE_MODE_NAMES: array[0..MODE_MAX] of string = (
'LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM','DMR','FMRAW');
constructor TFilterPopup.Create(AOwner: TComponent);
var
Y, CtlX, CtlW, BW: Integer;
function MakeSpin(ATop: Integer; H: TNotifyEvent): TFlatSpinEdit;
begin
Result := TFlatSpinEdit.Create(Self);
Result.Parent := FBg;
Result.SetBounds(CtlX, ATop, CtlW, ROWH - 2);
Result.MinValue := -EDGE_LIM;
Result.MaxValue := EDGE_LIM;
Result.Increment := 10;
Result.Color := CLR_INPUT;
Result.Font.Color := CLR_INPUT_TEXT;
Result.Font.Name := UI_FONT;
Result.Font.Size := 9;
Result.OnChange := H;
end;
begin
inherited Create(AOwner);
ControlStyle := ControlStyle + [csOpaque];
Visible := False;
Color := CLR_BG;
Font.Name := UI_FONT;
Font.Size := 9;
Font.Color := CLR_TEXT;
FMode := MODE_USB;
FSlotIdx := 0;
FPresets := FILT_COUNT;
// ★Гард на ВСЁ время постройки: сеттеры Flat*-контролов (напр.
// TFlatSpinEdit.SetMinValue → SetValue → DoChange) стреляют OnChange прямо
// из конструктора, а обработчики трогают контролы, созданные ниже.
FLoading := True;
FBg := TPanel.Create(Self);
FBg.Parent := Self;
FBg.BevelOuter := bvNone;
FBg.Color := CLR_PANEL;
FBg.SetBounds(1, 1, POP_W - 2, 10);
Y := PAD;
FTitle := MakeLbl('Filters', PAD, Y + 2, POP_W - 2 * PAD - 18);
FTitle.Font.Color := CLR_ACCENT;
FTitle.Font.Style := [fsBold];
FBtnClose := MakeFlatBtn(FBg, '×', POP_W - PAD - 16, Y, 16, 16, CloseClick);
FBtnClose.ClrText := CLR_TEXTDIM;
FBtnClose.ClrTextAct := CLR_TEXT;
Inc(Y, ROWH);
// ---- Слева список слотов, справа поля ----
FList := TFlatListBox.Create(Self);
FList.Parent := FBg;
FList.SetBounds(PAD, Y, LISTW, FILT_SLOTS * ITEMH + 4);
FList.Font.Name := UI_FONT;
FList.Font.Size := 9;
FList.OnClick := ListClick; // выбор строки = клик (у TFlatListBox нет OnSelect)
CtlX := PAD + LISTW + 10 + LBLW + 6;
CtlW := POP_W - PAD - CtlX;
MakeLbl('Name', PAD + LISTW + 10, Y + 3, LBLW);
FEdName := TFlatEdit.Create(Self);
FEdName.Parent := FBg;
FEdName.SetBounds(CtlX, Y, CtlW, ROWH - 2);
FEdName.MaxLength := 6;
FEdName.Color := CLR_INPUT;
FEdName.Font.Color := CLR_INPUT_TEXT;
FEdName.Font.Name := UI_FONT;
FEdName.Font.Size := 9;
FEdName.OnChange := NameChange;
Inc(Y, ROWH + 4);
MakeLbl('Low', PAD + LISTW + 10, Y + 3, LBLW);
FEdLow := MakeSpin(Y, EdgeChange);
Inc(Y, ROWH + 4);
MakeLbl('High', PAD + LISTW + 10, Y + 3, LBLW);
FEdHigh := MakeSpin(Y, EdgeChange);
Inc(Y, ROWH + 4);
MakeLbl('Width', PAD + LISTW + 10, Y + 3, LBLW);
FEdWidth := MakeSpin(Y, WidthChange);
FEdWidth.MinValue := FILT_MIN_WIDTH;
Inc(Y, ROWH + 8);
BW := (POP_W - PAD - (PAD + LISTW + 10) - 6) div 2;
FBtnReset := MakeFlatBtn(FBg, 'Reset', PAD + LISTW + 10, Y, BW, ROWH - 2,
ResetClick);
FBtnResetAll := MakeFlatBtn(FBg, 'Reset all', PAD + LISTW + 10 + BW + 6, Y,
BW, ROWH - 2, ResetAllClick);
// Список выше полей — берём максимум по высоте.
Y := Max(Y + ROWH - 2, FList.Top + FList.Height) + 8;
MakeLbl('— Passband —', PAD, Y, POP_W - 2 * PAD).Font.Color := CLR_TEXTDIM;
Inc(Y, 18);
FShape := TPaintBox.Create(Self);
FShape.Parent := FBg;
FShape.SetBounds(PAD, Y, POP_W - 2 * PAD, SHAPE_H);
FShape.OnPaint := ShapePaint;
Inc(Y, SHAPE_H);
FBg.Height := Y - 1 + PAD;
Width := POP_W;
Height := Y + 1 + PAD;
FLoading := False;
end;
procedure TFilterPopup.Paint;
begin
Canvas.Brush.Style := bsSolid;
Canvas.Brush.Color := CLR_BG;
Canvas.Pen.Style := psClear;
Canvas.FillRect(0, 0, Width, Height);
end;
function TFilterPopup.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;
function TFilterPopup.SlotLabel(Idx: Integer): string;
begin
Result := FSet[Idx].Name;
if Result = '' then Result := '—';
end;
procedure TFilterPopup.FillList;
var i: Integer;
begin
FList.Items.BeginUpdate;
try
FList.Items.Clear;
for i := 0 to FPresets - 1 do FList.Items.Add(SlotLabel(i));
FList.Items.Add(SlotLabel(FILT_VAR1));
FList.Items.Add(SlotLabel(FILT_VAR2));
finally
FList.Items.EndUpdate;
end;
end;
procedure TFilterPopup.LoadFrom(C: TRadioController; Mode, SlotIdx: Integer);
var i: Integer;
begin
if C = nil then Exit;
FController := C;
FMode := EnsureRange(Mode, 0, CFG_MODE_MAX);
FPresets := FilterPresetCount(FMode);
for i := 0 to FILT_SLOTS - 1 do FSet[i] := C.FilterSlot(FMode, i);
FSlotIdx := EnsureRange(SlotIdx, 0, FILT_SLOTS - 1);
// Пресет вне таблицы режима (FM/WFM короче) — показываем VAR1.
if (FSlotIdx >= FPresets) and (FSlotIdx < FILT_VAR1) then FSlotIdx := FILT_VAR1;
FTitle.Caption := 'Filters — ' + FE_MODE_NAMES[EnsureRange(FMode, 0, MODE_MAX)];
FLoading := True;
try
FillList;
ShowSlot;
finally
FLoading := False;
end;
end;
procedure TFilterPopup.SyncSelection(Mode, Idx: Integer);
begin
if (Mode <> FMode) or (Idx = FSlotIdx) then Exit;
FSlotIdx := EnsureRange(Idx, 0, FILT_SLOTS - 1);
FLoading := True;
try ShowSlot; finally FLoading := False; end;
end;
procedure TFilterPopup.ShowSlot;
var Row: Integer;
begin
if FSlotIdx >= FILT_VAR1 then Row := FPresets + (FSlotIdx - FILT_VAR1)
else Row := FSlotIdx;
FList.ItemIndex := Row;
FEdName.Text := FSet[FSlotIdx].Name;
FEdLow.Value := FSet[FSlotIdx].Lo;
FEdHigh.Value := FSet[FSlotIdx].Hi;
FEdWidth.Value := FSet[FSlotIdx].Hi - FSet[FSlotIdx].Lo;
FShape.Invalidate;
end;
procedure TFilterPopup.Commit;
begin
if FLoading or (FShape = nil) then Exit;
FShape.Invalidate;
if Assigned(FOnSlotEdited) then FOnSlotEdited(FMode, FSlotIdx, FSet[FSlotIdx]);
end;
procedure TFilterPopup.ListClick(Sender: TObject);
var Row: Integer;
begin
if FLoading then Exit;
Row := FList.ItemIndex;
if Row < 0 then Exit;
if Row >= FPresets then FSlotIdx := FILT_VAR1 + (Row - FPresets)
else FSlotIdx := Row;
FLoading := True;
try ShowSlot; finally FLoading := False; end;
// Клик по строке = выбрать этот фильтр в приёмнике (как кнопка на панели).
if Assigned(FController) then FController.SetFilterIdx(FSlotIdx);
end;
procedure TFilterPopup.EdgeChange(Sender: TObject);
var Lo, Hi: Integer;
begin
if FLoading then Exit;
Lo := FEdLow.Value;
Hi := FEdHigh.Value;
if Hi - Lo < FILT_MIN_WIDTH then
begin
// Края не пускаем друг через друга: двигаем тот, который НЕ трогали.
if Sender = FEdLow then Hi := Lo + FILT_MIN_WIDTH
else Lo := Hi - FILT_MIN_WIDTH;
FLoading := True;
try
FEdLow.Value := Lo;
FEdHigh.Value := Hi;
finally
FLoading := False;
end;
end;
FSet[FSlotIdx].Lo := Lo;
FSet[FSlotIdx].Hi := Hi;
FLoading := True;
try FEdWidth.Value := Hi - Lo; finally FLoading := False; end;
Commit;
end;
procedure TFilterPopup.WidthChange(Sender: TObject);
// Width производная: держим центр полосы, разъезжаются оба края (как Thetis).
var W, C, Lo, Hi: Integer;
begin
if FLoading then Exit;
W := Max(FILT_MIN_WIDTH, FEdWidth.Value);
C := (FSet[FSlotIdx].Lo + FSet[FSlotIdx].Hi) div 2;
Lo := C - W div 2;
Hi := Lo + W;
FSet[FSlotIdx].Lo := Lo;
FSet[FSlotIdx].Hi := Hi;
FLoading := True;
try
FEdLow.Value := Lo;
FEdHigh.Value := Hi;
finally
FLoading := False;
end;
Commit;
end;
procedure TFilterPopup.NameChange(Sender: TObject);
var Row: Integer;
begin
if FLoading then Exit;
FSet[FSlotIdx].Name := FEdName.Text;
Row := FList.ItemIndex;
FLoading := True;
try
FillList;
FList.ItemIndex := Row;
finally
FLoading := False;
end;
Commit;
end;
procedure TFilterPopup.ResetClick(Sender: TObject);
begin
if Assigned(FOnSlotReset) then FOnSlotReset(FMode, FSlotIdx);
end;
procedure TFilterPopup.ResetAllClick(Sender: TObject);
begin
if Assigned(FOnSlotReset) then FOnSlotReset(FMode, -1);
end;
procedure TFilterPopup.CloseClick(Sender: TObject);
begin
Hide;
end;
procedure TFilterPopup.ShapePaint(Sender: TObject);
// Трапеция полосы в масштабе на знаковой оси. Окно оси симметрично нулю и
// шире полосы, поэтому сразу видно, по какую сторону от несущей она лежит.
var
W, H, GH, ZeroX, i, X, Skirt, Lo, Hi, X1, X2: Integer;
Span, Step: Double;
S: string;
function FreqToX(F: Double): Integer;
begin
Result := Round((F + Span) / (2 * Span) * W);
end;
begin
W := FShape.Width;
H := FShape.Height;
if (W <= 0) or (H <= 0) then Exit;
GH := H - RULER_H;
Lo := FSet[FSlotIdx].Lo;
Hi := FSet[FSlotIdx].Hi;
with FShape.Canvas do
begin
Brush.Style := bsSolid;
Brush.Color := CLR_PANEL;
FillRect(0, 0, W, H);
Span := Max(Abs(Lo), Abs(Hi)) * 1.6;
if Span < 500 then Span := 500;
// Сетка: шаг кратен 1 кГц, подстраивается под окно.
Step := 1000;
while Span / Step > 12 do Step := Step * 2;
Pen.Style := psSolid;
Pen.Color := CLR_GRID;
i := -Trunc(Span / Step);
while i <= Trunc(Span / Step) do
begin
X := FreqToX(i * Step);
Line(X, 0, X, GH);
Inc(i);
end;
// Ноль = несущая, ярче сетки.
ZeroX := FreqToX(0);
Pen.Color := CLR_TEXTDIM;
Line(ZeroX, 0, ZeroX, GH);
// Полоса: плоская вершина + схематичные скаты.
X1 := FreqToX(Lo);
X2 := FreqToX(Hi);
Skirt := Max(2, Round(0.06 * (X2 - X1)));
Brush.Color := CLR_BAND;
Pen.Color := CLR_BAND;
Polygon([Point(X1, GH - 2),
Point(X1 + Skirt, Round(GH * 0.2)),
Point(X2 - Skirt, Round(GH * 0.2)),
Point(X2, GH - 2)]);
// Линейка: края полосы + ноль.
Brush.Style := bsClear;
Font.Name := UI_FONT;
Font.Size := 7;
Font.Color := CLR_TEXTDIM;
S := '0';
TextOut(ZeroX - TextWidth(S) div 2, GH, S);
Font.Color := CLR_BAND;
S := IntToStr(Lo);
TextOut(Max(0, Min(W - TextWidth(S), X1 - TextWidth(S) div 2)), GH, S);
S := IntToStr(Hi);
TextOut(Max(0, Min(W - TextWidth(S), X2 - TextWidth(S) div 2)), GH, S);
end;
end;
procedure TFilterPopup.PopupBelow(C: TRadioController; Anchor: TControl;
Mode, SlotIdx: Integer);
var
Root: TCustomForm;
L, T: Integer;
Cl: TPoint;
begin
if Anchor = nil then Exit;
Root := GetParentForm(Anchor);
if Root = nil then Exit;
// ★Родителя назначаем ДО заливки данных. TFlatListBox при заполнении и при
// установке ItemIndex считает ItemHeight, а тот трогает Canvas
// (Canvas.Font.Assign / TextHeight) — у контрола без родительского окна это
// AV. У TPureSignalPopup списка нет, поэтому там порядок был обратный и
// проблемы не возникало.
Parent := Root;
LoadFrom(C, Mode, SlotIdx);
// Позиция под якорем в КЛИЕНТСКИХ координатах формы (не top-level окно!).
Cl := Root.ScreenToClient(Anchor.ClientToScreen(Point(0, Anchor.Height + 2)));
L := Cl.X;
T := Cl.Y;
if L + Width > Root.ClientWidth then L := Root.ClientWidth - Width;
if L < 0 then L := 0;
if T + Height > Root.ClientHeight then
T := Root.ScreenToClient(Anchor.ClientToScreen(Point(0, 0))).Y - Height;
if T < 0 then T := 0;
SetBounds(L, T, Width, Height);
Visible := True;
BringToFront;
end;
end.