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
+247 -137
View File
@@ -22,7 +22,8 @@ unit MainForm;
interface
uses
Classes, SysUtils, StrUtils, FreqDisplay, DeviceForm, VfoOverlay, SampleRateOverlay, BandPlanOverlay,
Classes, SysUtils, StrUtils, FreqDisplay, DeviceForm, FilterPopup,
VfoOverlay, SampleRateOverlay, BandPlanOverlay,
FlatButton, FlatSlider, FlatDropDown, AppTheme, Forms, Controls, Graphics, Dialogs,
StdCtrls, ExtCtrls, Buttons, Menus, Math, Types, SyncObjs,
OpenGLContextEx,
@@ -68,23 +69,12 @@ const
WDSP_WISDOM_FILE = 'wdspWisdom00';
BAND_COUNT = 12; // макс. слотов среди планов (Pluto VHF/UHF = 12, HF = 11)
// Полосы/дефолты фильтров (FILT_COUNT, FILT_*_BW, FILT_*_DEF, FILT_FM_*)
// перенесены в Settings.pas — нужны TRadioController. Здесь остаются только
// UI-подписи кнопок (NAMES).
FILT_SSB_NAMES: array[0..FILT_COUNT-1] of string =
('5.0k','4.4k','3.8k','3.3k','2.9k','2.7k','2.4k','2.1k','1.8k','1.0k');
FILT_CW_NAMES: array[0..FILT_COUNT-1] of string =
('1.0k','800','600','500','400','250','150','100','50','25');
FILT_DSB_NAMES: array[0..FILT_COUNT-1] of string =
('16k','12k','10k','8.0k','6.6k','5.2k','4.0k','3.1k','2.9k','2.4k');
FILT_AM_NAMES: array[0..FILT_COUNT-1] of string =
('20k','18k','16k','12k','10k','9.0k','8.0k','7.0k','6.0k','5.0k');
FILT_DIGI_NAMES: array[0..FILT_COUNT-1] of string =
('3.0k','2.7k','2.4k','2.1k','1.8k','1.5k','1.2k','900','600','300');
FILT_FM_NAMES: array[0..1] of string = ('NFM', 'FM');
FILT_WFM_NAMES: array[0..FILT_WFM_COUNT-1] of string = ('200k', '160k', '120k');
FILT_RAW_NAMES: array[0..FILT_RAW_COUNT-1] of string =
('6.25k', '10k', '12.5k', '20k', '25k');
// Полосы/дефолты/подписи фильтров (FILT_COUNT, FILT_*_BW, FILT_*_DEF,
// FILT_*_NAMES) живут в Settings.pas: из них строится заводская таблица
// фильтров, а подписи кнопок теперь берутся из живой таблицы контроллера.
FILT_GRID_COLS = 6; // сетка кнопок фильтра: 2 ряда по 6 (12 = 10 + VAR1/2)
EDGE_GRAB_PX = 5; // зона захвата края полосы фильтра мышью, px
EDGE_SNAP_HZ = 10; // снап края при перетаскивании
// CTCSS tones (38 standard tones, no None — toggle via CTCSS button)
// CTCSS_COUNT и CTCSS_TONES перенесены в FMRepeater (нужны TRadioController).
@@ -132,11 +122,17 @@ type
// ---- Network ----
// Список устройств (saved+discovered) → FController.FDeviceStore (общий с web).
FDeviceDialog: TDeviceDialog;
FFilterPop: TFilterPopup; // поповер редактора фильтров (ПКМ по кнопке FILTER)
FPendingIP: string; // IP устройства для подключения
// Флаг главного (слайс A): создаётся здесь (проводка событий), живёт в
// раскладке/диспетчере FPan (AttachMainFlag). Флаги B+ — целиком в FPan.
FVfoOverlay: TVfoOverlay;
FActiveSliceId: Integer; // выбранный активный слайс (0 = главный)
// Перетаскивание края полосы фильтра: -1 левый, +1 правый, 0 не тянем.
// Кромки на момент нажатия — чтобы неподвижный край не «плыл» от снапа.
FEdgeDrag: Integer;
FEdgeDragLo: Integer;
FEdgeDragHi: Integer;
FSliceDrag: Boolean; // тащим маркер несущей слайса по спектру
FSliceDragId: Integer; // какой слайс тащим
FSampleRateOverlay: TSampleRateOverlay; // оверлей span/hide слева вверху спектра
@@ -298,7 +294,7 @@ type
BtnMode: array[0..MODE_COUNT-1] of TFlatButton;
PanelFilter: TPanel;
BtnFilter: array[0..FILT_COUNT-1] of TFlatButton;
BtnFilter: array[0..FILT_SLOTS-1] of TFlatButton; // 10 пресетов + VAR1/VAR2
BtnCTun: TFlatButton;
BtnBeacon: TFlatButton; // QO-100 beacon lock (видим только на Pluto в трансвертере)
BtnDUP: TFlatButton;
@@ -467,6 +463,16 @@ type
procedure BtnBandClick(Sender: TObject);
procedure BtnModeClick(Sender: TObject);
procedure BtnFilterClick(Sender: TObject);
procedure BtnFilterMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
procedure LayoutFilterButtons(PresetCount: Integer);
procedure PushFilterEdgesToView; // кромки RX/TX в SpectrumView
procedure RefreshFilterCaptions; // подписи кнопок без сброса выбора
function FilterEdgeAtPixel(X: Integer): Integer; // -1 левый край, +1 правый, 0 мимо
procedure DoFilterEdgeDrag(X: Integer; Shift: TShiftState);
procedure OpenFilterEditor(SlotIdx: Integer);
procedure OnFilterSlotEdited(Mode, Idx: Integer; const S: TFilterSlot);
procedure OnFilterSlotReset(Mode, Idx: Integer); // Idx < 0 = весь режим
procedure BtnCTunClick(Sender: TObject);
procedure BtnBeaconClick(Sender: TObject);
procedure BtnBeaconMouseDown(Sender: TObject; Button: TMouseButton;
@@ -980,7 +986,9 @@ begin
FController.FAGCTop := 90; // 90 dBm
// default: зависит от режима, будет сброшен в UpdateFilterButtons
FController.FCTun := False;
FController.FFilterBW := 2700;
// Фильтр (индекс + кромки + производная ширина) — одной точкой, чтобы
// состояние не расползалось: ширину отдельно больше не выставляем.
FController.SelectFilterSlotState(FController.FFilter);
FSpecDrag := False;
FController.FDriveLevel := 0;
FController.FPAMaxPower := 100.0;
@@ -1426,6 +1434,7 @@ begin
// TX со слайса краснеет полоса ТОГО слайса (DrawSliceFilterMarkers).
FSpecView.TXOverlay := FController.FTransmitting and
(FController.FTxSliceId = 0);
PushFilterEdgesToView; // на передаче полоса рисуется по TX-фильтру
ApplySpecViewGridFromState; // сетка спектра под активный тракт
RefreshTxIndicators; // TX-бейджи флагов (какой слайс передаёт)
UpdateTXControlAvailability;
@@ -1526,17 +1535,29 @@ begin
SavedFilter := FController.FFilter;
SavedFilterBW := FController.FFilterBW;
UpdateFilterButtons; // подписи + FM-панели под режим
FController.FFilter := SavedFilter;
FController.FFilterBW := SavedFilterBW;
for i := 0 to FILT_COUNT - 1 do StyleButton(BtnFilter[i], i = FController.FFilter);
// Возвращаем СЛОТ целиком: кромки — истина, одной ширины мало.
FController.SelectFilterSlotState(SavedFilter);
for i := 0 to FILT_SLOTS - 1 do StyleButton(BtnFilter[i], i = FController.FFilter);
PushFilterEdgesToView;
// Редактор всегда показывает фильтры ТЕКУЩЕГО режима — иначе правка
// ушла бы в чужую таблицу.
if (FFilterPop <> nil) and FFilterPop.Visible then
begin
if FController.FMode = MODE_DMR then FFilterPop.Hide
else FFilterPop.LoadFrom(FController, FController.FMode,
FController.FFilter);
end;
SyncSpecViewFreq; // иначе полоса фильтра рисуется под старый режим
UpdateTXControlAvailability;
UpdateDMRStatus;
end;
rfFilter:
begin
for i := 0 to FILT_COUNT - 1 do StyleButton(BtnFilter[i], i = FController.FFilter);
for i := 0 to FILT_SLOTS - 1 do StyleButton(BtnFilter[i], i = FController.FFilter);
PushFilterEdgesToView;
SyncSpecViewFreq;
if (FFilterPop <> nil) and FFilterPop.Visible then
FFilterPop.SyncSelection(FController.FMode, FController.FFilter);
end;
rfFMDeviation:
if (TrkRawDev <> nil) and (LblRawDev <> nil) then
@@ -1775,6 +1796,7 @@ begin
rfFMSQ, rfFMSQLevel]) then
begin
FVfoOverlay.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FController.FLastSMeter);
FVfoOverlay.SetFilterEdges(FController.FilterLo, FController.FilterHi);
FVfoOverlay.SetDSPState(FController.FNRMode, FController.FNBMode, FController.FSNB, FController.FANF, FController.FAGCMode);
FVfoOverlay.SetSquelchState(FController.FFMSQOn, FController.FFMSQLevel);
end;
@@ -2315,20 +2337,22 @@ begin
Inc(Y, MODE_PANEL_H + 2);
// Filter selector (10 кнопок в 2 ряда по 5)
// Filter selector (12 кнопок в 2 ряда по 6: 10 пресетов + VAR1/VAR2).
// ПКМ по любой из них открывает редактор фильтров.
PanelFilter := TPanel.Create(Self);
PanelFilter.Parent := PanelLeft;
PanelFilter.SetBounds(0, Y, LEFT_W, 74);
PanelFilter.BevelOuter := bvNone;
MakeLbl(PanelFilter, 'FILTER', 4, 2);
for i := 0 to FILT_COUNT - 1 do
for i := 0 to FILT_SLOTS - 1 do
begin
B := MakeBtn(PanelFilter, '---',
LeftPanelButtonLeft(LEFT_W, 5, i mod 5),
16 + (i div 5) * 27,
LeftPanelButtonWidth(LEFT_W, 5, i mod 5),
LeftPanelButtonLeft(LEFT_W, FILT_GRID_COLS, i mod FILT_GRID_COLS),
16 + (i div FILT_GRID_COLS) * 27,
LeftPanelButtonWidth(LEFT_W, FILT_GRID_COLS, i mod FILT_GRID_COLS),
BTN_SM, BtnFilterClick);
B.Tag := i;
B.OnMouseDown := BtnFilterMouseDown;
BtnFilter[i] := B;
end;
@@ -3121,7 +3145,7 @@ begin
// Обычные кнопки — все массивы
for i := 0 to BAND_COUNT-1 do StyleButton(BtnBand[i], BtnBand[i].Active);
for i := 0 to MODE_COUNT-1 do StyleButton(BtnMode[i], BtnMode[i].Active);
for i := 0 to FILT_COUNT-1 do StyleButton(BtnFilter[i], BtnFilter[i].Active);
for i := 0 to FILT_SLOTS-1 do StyleButton(BtnFilter[i], BtnFilter[i].Active);
for i := 0 to 4 do StyleButton(BtnAGCMode[i],BtnAGCMode[i].Active);
StyleButton(BtnCTun, BtnCTun.Active);
StyleButton(BtnDUP, BtnDUP.Active);
@@ -4454,10 +4478,38 @@ end;
// ---------------------------------------------------------------------------
// UpdateFilterButtons — обновляет подписи кнопок фильтра под текущий режим
// ---------------------------------------------------------------------------
// Раскладывает N кнопок фильтра: пресеты режима + VAR1/VAR2 следом. Ряды по
// FILT_GRID_COLS, но узкие режимы (FM=2+2, WFM=3+2) занимают один ряд целиком,
// растягивая кнопки — как было до появления VAR.
procedure TMainForm.LayoutFilterButtons(PresetCount: Integer);
var
i, Total, Cols, Rows, RowOf, ColOf, InRow: Integer;
begin
Total := PresetCount + 2; // + VAR1/VAR2
if Total <= FILT_GRID_COLS then Cols := Total // FM/WFM: всё в один ряд
else Cols := FILT_GRID_COLS;
Rows := (Total + Cols - 1) div Cols;
for i := 0 to FILT_SLOTS - 1 do
begin
if i < PresetCount then InRow := i // пресет на своём месте
else if i = FILT_VAR1 then InRow := PresetCount // VAR1 сразу за пресетами
else if i = FILT_VAR2 then InRow := PresetCount + 1
else begin BtnFilter[i].Visible := False; Continue; end;
RowOf := InRow div Cols;
ColOf := InRow mod Cols;
BtnFilter[i].Left := LeftPanelButtonLeft(PanelFilter.Width, Cols, ColOf);
BtnFilter[i].Width := LeftPanelButtonWidth(PanelFilter.Width, Cols, ColOf);
BtnFilter[i].Top := 16 + RowOf * 27;
BtnFilter[i].Caption := FController.FilterSlot(FController.FMode, i).Name;
BtnFilter[i].Visible := True;
StyleButton(BtnFilter[i], i = FController.FFilter);
end;
PanelFilter.Height := 16 + Rows * 27 + 4;
end;
procedure TMainForm.UpdateFilterButtons;
var
i: Integer;
Names: array[0..FILT_COUNT-1] of string;
DefIdx: Integer;
begin
// DMR has a mandatory 12.5 kHz channel. There is nothing for the operator
@@ -4465,26 +4517,11 @@ begin
PanelFilter.Visible := FController.FMode <> MODE_DMR;
if FController.FMode = MODE_FM then
begin
// FM: show only NFM and WFM buttons, resize them to fill the row
// FM: NFM/FM + VAR1/VAR2 в один ряд (панель остаётся низкой, SQL/CTCSS
// садятся сразу под ней).
CloseCTCSSPopup;
if FController.FFilter > 1 then FController.FFilter := FILT_FM_DEF; // sanitize, keep NFM/WFM as-is
FController.FFilterBW := FILT_FM_BW[FController.FFilter];
FController.FFMDeviation := FILT_FM_DEV[FController.FFilter];
BtnFilter[0].Caption := FILT_FM_NAMES[0];
BtnFilter[0].Left := LeftPanelButtonLeft(PanelFilter.Width, 2, 0);
BtnFilter[0].Width := LeftPanelButtonWidth(PanelFilter.Width, 2, 0);
BtnFilter[0].Top := 16;
BtnFilter[0].Visible := True;
StyleButton(BtnFilter[0], FController.FFilter = 0);
BtnFilter[1].Caption := FILT_FM_NAMES[1];
BtnFilter[1].Left := LeftPanelButtonLeft(PanelFilter.Width, 2, 1);
BtnFilter[1].Width := LeftPanelButtonWidth(PanelFilter.Width, 2, 1);
BtnFilter[1].Top := 16;
BtnFilter[1].Visible := True;
StyleButton(BtnFilter[1], FController.FFilter = 1);
for i := 2 to FILT_COUNT - 1 do BtnFilter[i].Visible := False;
// Shrink panel to fit label + 1 button row so SQL/CTCSS sit right below
PanelFilter.Height := BtnFilter[0].Top + BtnFilter[0].Height + 4;
FController.SanitizeFilterState;
LayoutFilterButtons(FILT_FM_COUNT);
if PanelFMSQ <> nil then
begin
PanelFMSQ.Visible := True;
@@ -4536,21 +4573,10 @@ begin
if FController.FMode = MODE_WFM then
begin
// WFM: полосы вещательного канала — три кнопки в один ряд.
// WFM: полосы вещательного канала — три кнопки + VAR1/VAR2 в один ряд.
CloseCTCSSPopup;
if FController.FFilter > FILT_WFM_COUNT - 1 then FController.FFilter := FILT_WFM_DEF;
FController.FFilterBW := FILT_WFM_BW[FController.FFilter];
for i := 0 to FILT_WFM_COUNT - 1 do
begin
BtnFilter[i].Caption := FILT_WFM_NAMES[i];
BtnFilter[i].Left := LeftPanelButtonLeft(PanelFilter.Width, FILT_WFM_COUNT, i);
BtnFilter[i].Width := LeftPanelButtonWidth(PanelFilter.Width, FILT_WFM_COUNT, i);
BtnFilter[i].Top := 16;
BtnFilter[i].Visible := True;
StyleButton(BtnFilter[i], i = FController.FFilter);
end;
for i := FILT_WFM_COUNT to FILT_COUNT - 1 do BtnFilter[i].Visible := False;
PanelFilter.Height := BtnFilter[0].Top + BtnFilter[0].Height + 4;
FController.SanitizeFilterState;
LayoutFilterButtons(FILT_WFM_COUNT);
RelayoutBelowBands;
Exit;
end;
@@ -4558,20 +4584,8 @@ begin
if FController.FMode = MODE_FMRAW then
begin
CloseCTCSSPopup;
if FController.FFilter > FILT_RAW_COUNT - 1 then
FController.FFilter := FILT_RAW_DEF;
FController.FFilterBW := FILT_RAW_BW[FController.FFilter];
for i := 0 to FILT_RAW_COUNT - 1 do
begin
BtnFilter[i].Caption := FILT_RAW_NAMES[i];
BtnFilter[i].Left := LeftPanelButtonLeft(PanelFilter.Width, FILT_RAW_COUNT, i);
BtnFilter[i].Width := LeftPanelButtonWidth(PanelFilter.Width, FILT_RAW_COUNT, i);
BtnFilter[i].Top := 16;
BtnFilter[i].Visible := True;
StyleButton(BtnFilter[i], i = FController.FFilter);
end;
for i := FILT_RAW_COUNT to FILT_COUNT - 1 do BtnFilter[i].Visible := False;
PanelFilter.Height := BtnFilter[0].Top + BtnFilter[0].Height + 4;
FController.SanitizeFilterState;
LayoutFilterButtons(FILT_RAW_COUNT);
if PanelRawDev <> nil then
begin
TrkRawDev.Position := Round(FController.FRawFMDeviation) div 50;
@@ -4585,67 +4599,18 @@ begin
if FController.FMode = MODE_DMR then
begin
CloseCTCSSPopup;
FController.FFilter := 0;
FController.FFilterBW := FILT_DMR_BW;
FController.FFMDeviation := DMR_FM_DEV;
for i := 0 to FILT_COUNT - 1 do BtnFilter[i].Visible := False;
FController.SanitizeFilterState;
for i := 0 to FILT_SLOTS - 1 do BtnFilter[i].Visible := False;
RelayoutBelowBands;
Exit;
end;
for i := 0 to FILT_COUNT - 1 do
begin
BtnFilter[i].Left := LeftPanelButtonLeft(PanelFilter.Width, 5, i mod 5);
BtnFilter[i].Top := 16 + (i div 5) * 27;
BtnFilter[i].Width := LeftPanelButtonWidth(PanelFilter.Width, 5, i mod 5);
BtnFilter[i].Visible := True;
end;
case FController.FMode of
MODE_LSB, MODE_USB:
begin
for i := 0 to FILT_COUNT-1 do Names[i] := FILT_SSB_NAMES[i];
DefIdx := FILT_SSB_DEF;
end;
MODE_DIGU, MODE_DIGL:
begin
for i := 0 to FILT_COUNT-1 do Names[i] := FILT_DIGI_NAMES[i];
DefIdx := FILT_DIGI_DEF;
end;
MODE_DSB:
begin
for i := 0 to FILT_COUNT-1 do Names[i] := FILT_DSB_NAMES[i];
DefIdx := FILT_DSB_DEF;
end;
MODE_CWL, MODE_CWU:
begin
for i := 0 to FILT_COUNT-1 do Names[i] := FILT_CW_NAMES[i];
DefIdx := FILT_CW_DEF;
end;
else begin
for i := 0 to FILT_COUNT-1 do Names[i] := FILT_AM_NAMES[i];
DefIdx := FILT_AM_DEF;
end;
end;
FController.FFilter := DefIdx;
case FController.FMode of
MODE_LSB, MODE_USB:
FController.FFilterBW := FILT_SSB_BW[DefIdx];
MODE_DIGU, MODE_DIGL:
FController.FFilterBW := FILT_DIGI_BW[DefIdx];
MODE_DSB:
FController.FFilterBW := FILT_DSB_BW[DefIdx];
MODE_CWL, MODE_CWU:
FController.FFilterBW := FILT_CW_BW[DefIdx];
else FController.FFilterBW := FILT_AM_BW[DefIdx];
end;
for i := 0 to FILT_COUNT-1 do
begin
BtnFilter[i].Caption := Names[i];
StyleButton(BtnFilter[i], i = FController.FFilter);
end;
// Прочие режимы: смена режима возвращает заводской индекс фильтра (как было
// до таблицы), кромки и подписи приезжают из таблицы контроллера.
PanelFilter.Height := 74;
DefIdx := FilterDefIdx(FController.FMode);
FController.SelectFilterSlotState(DefIdx);
LayoutFilterButtons(FILT_COUNT);
RelayoutBelowBands;
end;
@@ -5105,6 +5070,17 @@ begin
UpdateBeaconButton;
Exit;
end;
// ЛКМ по краю полосы фильтра — тянем именно этот край (левый/правый
// независимо). Проверяется РАНЬШЕ слайс-маркера и панорамирования, иначе
// край у самой несущей (узкий CW) было бы не поймать.
if (PbSpectrum.Width > 0) and (FilterEdgeAtPixel(X) <> 0) then
begin
FEdgeDrag := FilterEdgeAtPixel(X);
FEdgeDragLo := FController.FilterLo;
FEdgeDragHi := FController.FilterHi;
PbSpectrum.Cursor := crSizeWE;
Exit;
end;
// ЛКМ по маркеру несущей слайса — начинаем его перетаскивание (перетюн).
if (PbSpectrum.Width > 0) then
begin
@@ -5164,6 +5140,13 @@ procedure TMainForm.PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState;
var
VC, VS, NewHz, LoHz, HiHz: Double;
begin
// Перетаскивание края полосы фильтра.
if FEdgeDrag <> 0 then
begin
if ssLeft in Shift then DoFilterEdgeDrag(X, Shift);
Exit;
end;
// Перетаскивание маркера несущей слайса — перетюн в пределах захваченной полосы.
if FSliceDrag then
begin
@@ -5192,6 +5175,8 @@ begin
if (Assigned(FVfoOverlay) and (FVfoOverlay.HotIdx >= 0)) or
(Assigned(FSampleRateOverlay) and (FSampleRateOverlay.HotIdx >= 0)) then
PbSpectrum.Cursor := crHandPoint
else if FilterEdgeAtPixel(X) <> 0 then
PbSpectrum.Cursor := crSizeWE // над краем фильтра — край можно тянуть
else if FPan.SliceAtPixel(X) <> 0 then
PbSpectrum.Cursor := crSizeWE // над маркером слайса — его можно тащить
else
@@ -5211,6 +5196,12 @@ end;
procedure TMainForm.PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
begin
if FEdgeDrag <> 0 then
begin
FEdgeDrag := 0;
PbSpectrum.Cursor := crDefault;
Exit;
end;
if FSliceDrag then
begin
FSliceDrag := False;
@@ -5335,11 +5326,130 @@ end;
procedure TMainForm.BtnFilterClick(Sender: TObject);
begin
// Логика (FFilter/FFilterBW по таблицам режима, ApplyModeFilter, band cache)
// — в контроллере; рендер — в OnControllerState(rfFilter).
// Логика (слот → кромки, ApplyModeFilter, band cache) — в контроллере;
// рендер — в OnControllerState(rfFilter).
FController.SetFilterIdx((Sender as TFlatButton).Tag);
end;
function TMainForm.FilterEdgeAtPixel(X: Integer): Integer;
// -1 = курсор у левого края полосы фильтра, +1 = у правого, 0 = мимо.
// Работает по тем же кромкам, что нарисованы (главный VFO, не слайсы).
var
VC, VS, Lo, Hi: Double;
W, X1, X2: Integer;
begin
Result := 0;
W := PbSpectrum.Width;
if (W <= 0) or (FController.FMode = MODE_DMR) then Exit; // DMR: полоса фиксирована
if FController.FTransmitting then Exit; // на передаче фильтр мышью не крутим
FController.GetViewWindow(VC, VS);
if VS <= 0 then Exit;
Lo := ActiveVfoFreq + FController.FilterLo;
Hi := ActiveVfoFreq + FController.FilterHi;
X1 := Round((Lo - VC + VS / 2) / VS * W);
X2 := Round((Hi - VC + VS / 2) / VS * W);
// Слишком узкая на экране полоса (CW на полном спане) — края слиплись с
// несущей, и захват съел бы обычный клик-тюн. Тогда фильтр правится только
// в редакторе; чтобы потянуть мышью, достаточно зазумиться.
if X2 - X1 < 2 * EDGE_GRAB_PX + 2 then Exit;
// Приоритет правому краю: при узкой полосе левый иначе «съедает» оба.
if Abs(X - X2) <= EDGE_GRAB_PX then Result := 1
else if Abs(X - X1) <= EDGE_GRAB_PX then Result := -1;
end;
procedure TMainForm.DoFilterEdgeDrag(X: Integer; Shift: TShiftState);
// Тянем ОДИН край; второй стоит. Alt — симметрично (второй край зеркалит).
// Именно Alt, а не Ctrl: Ctrl+ЛКМ по спектру уже создаёт слайс. Модификатор
// читаем из Shift обработчика, а не GetKeyState — надёжнее на Wayland.
var
VC, VS, Hz: Double;
W, Lo, Hi, D: Integer;
begin
W := PbSpectrum.Width;
if W <= 0 then Exit;
FController.GetViewWindow(VC, VS);
if VS <= 0 then Exit;
Hz := PixelToFreq(X, W, VC, VS) - ActiveVfoFreq;
Lo := FEdgeDragLo;
Hi := FEdgeDragHi;
if FEdgeDrag > 0 then Hi := Round(Hz / EDGE_SNAP_HZ) * EDGE_SNAP_HZ
else Lo := Round(Hz / EDGE_SNAP_HZ) * EDGE_SNAP_HZ;
if ssAlt in Shift then
begin
// Симметрия относительно центра исходной полосы.
D := (FEdgeDragLo + FEdgeDragHi) div 2;
if FEdgeDrag > 0 then Lo := 2 * D - Hi
else Hi := 2 * D - Lo;
end;
if Hi - Lo < FILT_MIN_WIDTH then Exit; // схлопывать полосу не даём
FController.SetFilterEdges(Lo, Hi); // уходит в VAR — пресеты целы
FSpectrumDirty := True;
end;
procedure TMainForm.PushFilterEdgesToView;
// Кромки в спектр: RX — то, что реально стоит в приёмнике (фильтр может быть
// произвольным), TX — из TXSignedEdges движка. Полоса на экране обязана
// совпадать с тем, что слышно и что уходит в эфир.
var Lo, Hi: Integer;
begin
if FSpecView = nil then Exit;
FSpecView.SetRXEdges(FController.FilterLo, FController.FilterHi);
FController.TXFilterEdges(Lo, Hi);
FSpecView.SetTXEdges(Lo, Hi);
end;
procedure TMainForm.BtnFilterMouseDown(Sender: TObject; Button: TMouseButton;
Shift: TShiftState; X, Y: Integer);
// ПКМ по кнопке фильтра открывает редактор на этом слоте (эталон Thetis:
// у них это пункт контекстного меню «RX1 Filter Configure»). ЛКМ не трогаем —
// иначе окно вылезало бы при каждом обычном переключении фильтра.
begin
if Button = mbRight then OpenFilterEditor((Sender as TFlatButton).Tag);
end;
procedure TMainForm.OpenFilterEditor(SlotIdx: Integer);
var Anchor: TFlatButton;
begin
if FController.FMode = MODE_DMR then Exit; // у DMR полоса жёстко задана
if FFilterPop = nil then
begin
FFilterPop := TFilterPopup.Create(Self);
FFilterPop.OnSlotEdited := OnFilterSlotEdited;
FFilterPop.OnSlotReset := OnFilterSlotReset;
end;
if FFilterPop.Visible then begin FFilterPop.Hide; Exit; end; // повторный ПКМ закрывает
Anchor := BtnFilter[EnsureRange(SlotIdx, 0, FILT_SLOTS - 1)];
if (Anchor = nil) or not Anchor.Visible then Anchor := BtnFilter[0];
FFilterPop.PopupBelow(FController, Anchor, FController.FMode, SlotIdx);
end;
// Обновляет ТОЛЬКО подписи/раскладку кнопок фильтра. Полный
// UpdateFilterButtons для не-FM режимов сбрасывает выбранный фильтр на
// заводской — из редактора это отобрало бы у оператора текущий фильтр на
// каждом нажатии клавиши.
procedure TMainForm.RefreshFilterCaptions;
begin
if FController.FMode = MODE_DMR then Exit;
LayoutFilterButtons(FilterPresetCount(FController.FMode));
end;
procedure TMainForm.OnFilterSlotEdited(Mode, Idx: Integer; const S: TFilterSlot);
begin
FController.SetFilterSlot(Mode, Idx, S);
RefreshFilterCaptions; // подпись слота могла измениться
OnControllerState(FController, rfFilter);
end;
procedure TMainForm.OnFilterSlotReset(Mode, Idx: Integer);
begin
if Idx < 0 then FController.ResetFilterSet(Mode)
else FController.ResetFilterSlot(Mode, Idx);
if FFilterPop <> nil then
FFilterPop.LoadFrom(FController, Mode, FFilterPop.SlotIdx);
RefreshFilterCaptions;
OnControllerState(FController, rfFilter);
end;
// ---------------------------------------------------------------------------
// FM CTCSS helpers
// ---------------------------------------------------------------------------