mirror of
https://git.vladimir.cc/vladimir/ewsdr.git
synced 2026-08-25 18:43:51 +00:00
Format(', tone %+d Hz', ...) печатал ', tone d Hz' — без значения. Флага '+' в
Format Object Pascal нет, синтаксис здесь %[индекс:][-][ширина][.точность]тип;
встретив '%+', FPC съедает процент с плюсом и копирует остаток спецификатора
как обычный текст. Знак ставим руками, отрицательные %d печатает со своим
минусом сам.
Найдено попутно — тот же промах был в новом коде лупы маяка.
Сборка ewsdr (--ws=qt6) — ОК.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9485 lines
404 KiB
ObjectPascal
9485 lines
404 KiB
ObjectPascal
unit MainForm;
|
||
|
||
{
|
||
OpenHPSDR Transceiver - Main Form
|
||
Lazarus / FPC 3.2+ (нет inline var, нет анонимных процедур)
|
||
|
||
ИСПРАВЛЕНИЯ:
|
||
- (Web NR/NB/ANF fix) BtnNRClick, BtnNBClick, BtnANFClick переписаны
|
||
без использования «Sender as TFlatButton» — теперь обращаются напрямую
|
||
к BtnNR / BtnNB / BtnANF. Это устраняет EAccessViolation / EInvalidCast
|
||
при вызове из SyncWebNR/SyncWebNB/SyncWebANF через TThread.Synchronize,
|
||
где Sender передавался как nil. Исключение в UI-потоке приводило к
|
||
разрыву WebSocket-соединения и «отключению» веб-клиента.
|
||
- SyncWebNR / SyncWebNB / SyncWebANF: передают реальную кнопку (BtnNR /
|
||
BtnNB / BtnANF) вместо nil — защита на случай изменения обработчиков.
|
||
}
|
||
|
||
{$IFDEF FPC}
|
||
{$MODE Delphi}
|
||
{$ENDIF}
|
||
|
||
interface
|
||
|
||
uses
|
||
Classes, SysUtils, StrUtils, FreqDisplay, DeviceForm, FilterPopup,
|
||
VfoOverlay, SampleRateOverlay, BandPlanOverlay,
|
||
FlatButton, FlatSlider, FlatDropDown, OverlayScrollBar, AppTheme,
|
||
Forms, Controls, Graphics, Dialogs,
|
||
StdCtrls, ExtCtrls, Buttons, Menus, Math, Types, SyncObjs,
|
||
OpenGLContextEx,
|
||
LCLIntf, LCLType, GraphType,
|
||
HPSDRProtocol, HPSDRNetwork,
|
||
WDSP, WDSPEngine, AudioOutput, AudioInput,
|
||
Settings,
|
||
WebServer, WebAdapter,
|
||
CATAdapter,
|
||
SpectrumView, SpectrumViewOpengl,
|
||
PanZoomBar, PanafallPanel, PanDisplayPopup, PureSignalPopup, FlatPopupMenu,
|
||
WidebandView,
|
||
StatusBar,
|
||
PlatformUtils,
|
||
RadioModes,
|
||
WinFirewall,
|
||
BoardUtils, WisdomBuilder, UISync,
|
||
FlatEdit, FMRepeater,
|
||
ChannelStore, ChannelsForm, CWMessagesForm, CWTerminalForm, BeaconScopeForm, DMRDecoder,
|
||
PowerInhibit,
|
||
DeviceStore,
|
||
RadioBackend, PlutoBackend,
|
||
RadioController, ChannelController, DpiUtils;
|
||
|
||
const
|
||
// Меню шапки пана: теги ниже — rate в кГц, от DDC_MENU_TAG — выбор hw-DDC.
|
||
DDC_MENU_TAG = 1000;
|
||
|
||
CLR_BG = TColor($00101010);
|
||
CLR_PANEL = TColor($00181818);
|
||
CLR_BORDER = TColor($00303030);
|
||
CLR_FREQ = TColor($0000FF00);
|
||
CLR_FREQ_DIM = TColor($00007800);
|
||
CLR_AMBER = TColor($0000AAFF);
|
||
CLR_ACTIVE = TColor($000C3010);
|
||
CLR_ACTIVE_BDR = TColor($00387838);
|
||
CLR_INACTIVE = TColor($001A1A1A);
|
||
CLR_TEXT = TColor($00CCCCCC);
|
||
CLR_TEXTDIM = TColor($00666666);
|
||
CLR_METER_ON = TColor($0000CC44);
|
||
CLR_METER_OVR = TColor($000000CC);
|
||
CLR_SPECTRUM = TColor($0044FF44);
|
||
WDSP_WISDOM_FILE = 'wdspWisdom00';
|
||
|
||
BAND_COUNT = 12; // макс. слотов среди планов (Pluto VHF/UHF = 12, HF = 11)
|
||
// Полосы/дефолты/подписи фильтров (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).
|
||
CTCSS_NAMES: array[0..37] of string = (
|
||
'67.0', '71.9', '74.4', '77.0', '79.7', '82.5', '85.4', '88.5',
|
||
'91.5', '94.8', '97.4', '100.0', '103.5', '107.2', '110.9', '114.8',
|
||
'118.8', '123.0', '127.3', '131.8', '136.5', '141.3', '146.2', '151.4',
|
||
'156.7', '162.2', '167.9', '173.8', '179.9', '186.2', '192.8', '203.5',
|
||
'210.7', '218.1', '225.7', '233.6', '241.8', '250.3');
|
||
|
||
// FM Step tuning
|
||
FM_STEP_COUNT = 4;
|
||
FM_STEP_HZ: array[0..3] of Integer = (6250, 12500, 20000, 25000);
|
||
FM_STEP_NAMES: array[0..3] of string = ('6.25k', '12.5k', '20k', '25k');
|
||
FM_STEP_DEF = 3; // 25 kHz
|
||
|
||
// Стартовые подписи band-кнопок (HF-план); RelabelBands переписывает под
|
||
// активный бэкенд. 12-й слот пуст — в HF-плане кнопка прячется.
|
||
BAND_NAMES: array[0..BAND_COUNT-1] of string = (
|
||
'160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','');
|
||
|
||
MODE_NAMES: array[0..MODE_COUNT-1] of string = (
|
||
'LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM','DMR','FMRAW');
|
||
MODE_ROWS = 4; // 4 колонки; последний ряд неполный
|
||
|
||
SMETER_WIDTH_RATIO = 0.32;
|
||
SMETER_MARGIN = 3;
|
||
TOP_SMETER_GAP = 8;
|
||
TOP_VFO_FONT = 25;
|
||
LEFT_SCROLL_GUTTER_W = 8;
|
||
LEFT_PANEL_CONTENT_W = 232;
|
||
LEFT_PANEL_BTN_PAD = 3;
|
||
LEFT_PANEL_BTN_GAP = 2;
|
||
|
||
type
|
||
{ TMainForm }
|
||
TMainForm = class(TForm)
|
||
private
|
||
// ---- Headless ядро (состояние + движки). Фаза 1 миграции: см.
|
||
// RadioController.pas. Поля состояния/движков теперь живут в
|
||
// FController; обращения идут как FController.FXxx. ----
|
||
FController: TRadioController;
|
||
// True пока OnControllerState обновляет виджеты — чтобы программное
|
||
// изменение слайдера/кнопки не вызывало повторно команду из OnChange.
|
||
FSyncingFromController: Boolean;
|
||
|
||
// ---- 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 слева вверху спектра
|
||
FBandPlanOverlay: TBandPlanOverlay; // полоска бэндплана QO-100 внизу спектра
|
||
FPanelHidden: Boolean; // True = левая панель скрыта
|
||
FLeftPanelW: Integer; // ширина панели до скрытия (DPI-safe restore)
|
||
FPendingBoardType: Integer; // BoardType устройства для подключения
|
||
|
||
// ---- UI-local state (мышь на спектре/водопаде) ----
|
||
FSpecDrag: Boolean;
|
||
FSpecDragX0: Integer; // X при нажатии
|
||
FSpecDragFreq: Double; // FCenterFreq при нажатии
|
||
FBeaconArming: Boolean; // ждём клик по маяку (после включения BEACON)
|
||
FSpectrumDirty: Boolean; // таймер должен перерисовать спектр/водопад
|
||
// TX DUC-очередь (FDUCPending*) переехала в TRadioController (OnTXIQ).
|
||
FSMeterPeak: Double; // верхняя граница светлой зоны
|
||
FSMeterMin: Double; // нижняя граница светлой зоны
|
||
FSMeterAvg: Double; // сглаженное среднее (EMA)
|
||
// RX/seq + HP-телеметрия (FController.FLastSMeter/FwdW/SWR/SupplyV/SupplyA/PLLLock)
|
||
// живут в TRadioController.
|
||
|
||
// ---- Spectrum / Waterfall view ----
|
||
// Панадаптер (спектр+линейка+сплиттер+водопад+пан/зум) — composite-объект
|
||
// (Этап 3.0 мультислайсов). FSpecView и Pb*-поля ниже — алиасы его членов
|
||
// (исторические имена; постепенная миграция на FPan.*). Размеры областей —
|
||
// FPan.SpectrumWidth/SpectrumHeight/WaterfallHeight.
|
||
FPan: TPanafallPanel;
|
||
// ---- Стек панов (этап 3.3): FPans[0] = FPan, FPans[N>0] — доп. паны на
|
||
// своих DDC (nil = не создан). Сплиттер FPanSplitters[i] стоит НАД паном i.
|
||
FPans: array[0..MAX_PANS-1] of TPanafallPanel;
|
||
FPanSplitters: array[1..MAX_PANS-1] of TPanel;
|
||
FPanShare: array[0..MAX_PANS-1] of Double; // доля высоты стека
|
||
FPanSpecDirty: array[0..MAX_PANS-1] of Boolean; // кадр пана готов к рендеру
|
||
FPanWfDirty: array[0..MAX_PANS-1] of Boolean;
|
||
FPanCbLock: SyncObjs.TCriticalSection; // жизнь FPans[i] vs пиксельные колбэки
|
||
FPanDisplayPopup: TObject; // TPanDisplayPopup (3.6), лениво создаётся
|
||
// Мышь на пане N>0: drag слайса или drag центра пана (ретюн DDC).
|
||
FPanSliceDrag: Boolean;
|
||
FPanSliceDragId: Integer;
|
||
FPanCenterDrag: Boolean;
|
||
FPanCenterMoved: Boolean; // был реальный сдвиг (иначе up = клик-тюн)
|
||
FPanCenterDragX0: Integer;
|
||
FPanCenterDragF0: Double;
|
||
FPanMousePan: TPanafallPanel; // пан, в котором идёт drag
|
||
// Сплиттер между панами (индекс пана, чей верхний сплиттер тащим; 0 = нет).
|
||
// Во время drag сплиттер двигается «призраком» (клампы в FPanSplitMin/MaxTop),
|
||
// пересчёт долей — по отпусканию.
|
||
FPanSplitDrag: Integer;
|
||
FPanSplitY0: Integer;
|
||
FPanSplitTop0: Integer;
|
||
FPanSplitMinTop: Integer;
|
||
FPanSplitMaxTop: Integer;
|
||
// Грид-раскладка: при >=2 доп. панах — пан 0 сверху во всю ширину, доп.
|
||
// паны колонками снизу; при ОДНОМ доп. пане — две колонки бок о бок
|
||
// (пан 0 слева, доп. справа). Активна при FPanGridMode и >=1 доп. пане.
|
||
FPanGridMode: Boolean;
|
||
FPanGridRowShare: Double; // доля высоты верхнего ряда (пан 0), 0.15..0.85
|
||
FPanColShare: array[0..MAX_PANS-1] of Double; // доли ширины колонок
|
||
FPanVSplitters: array[1..MAX_PANS-1] of TPanel; // слева от колонки пана i
|
||
FPansRestored: Boolean; // персист панов восстановлен в этой Run-сессии
|
||
// Drag вертикального сплиттера (индекс пана справа; 0 = нет).
|
||
FPanVSplitDrag: Integer;
|
||
FPanVSplitX0: Integer;
|
||
FPanVSplitLeft0: Integer;
|
||
FPanVSplitMinLeft: Integer;
|
||
FPanVSplitMaxLeft: Integer;
|
||
FPanWideH: Integer; // wideband-высота последнего LayoutPanStack (для PanMinHeight)
|
||
FPendingPanClose: Integer; // «×» шапки: PanId к закрытию в тике (0 = нет)
|
||
// Меню rate пана («NNN kHz» в шапке панов N; создаётся лениво).
|
||
FPanRateMenu: TFlatPopupMenu; // тематизированное меню (не нативное)
|
||
FPanRateMenuPanId: Integer;
|
||
FPanBandMenu: TFlatPopupMenu; // селектор диапазона пана N (3.6, КВ)
|
||
FPanBandMenuPanId: Integer;
|
||
// Pop-out (3.5): пан N в отдельном OS-окне. nil = пан в стеке.
|
||
FPanFloatForm: array[1..MAX_PANS-1] of TForm;
|
||
FSpecView: TSpectrumView;
|
||
FWidebandView: TWidebandView;
|
||
FUseOpenGLSpectrum: Boolean;
|
||
FDisplayFPS: Integer;
|
||
FWaterfallDirty: Boolean;
|
||
FWidebandDirty: Boolean;
|
||
// Display state (FShow*/FSpec*/FWf*/FTXSpec*/FController.FWidebandFill/FController.FSpectrumFill)
|
||
// переехало в TRadioController (нужно headless-load в DoConnectDevice).
|
||
// Снимок последнего кадра спектра/водопада для web переехал в TRadioController
|
||
// (FSpectrumBuf/FWaterfallBuf) — нужен headless-демону.
|
||
FAgcLineCounter: Integer;
|
||
// --- Settings ---
|
||
FWebServer: TWebServer;
|
||
FWebAdapter: TWebAdapter; // мост web-сервер ↔ контроллер (вынесенные WebOnXxx)
|
||
FChannelController: TChannelController; // адаптер каналов памяти поверх контроллера
|
||
FWebEnabled: Boolean;
|
||
FWebPort: Integer;
|
||
FWebBindAddr: string;
|
||
FWebUser: string;
|
||
FWebPass: string;
|
||
FWebSpecPixels: Integer; // потолок точек кадра спектра/водопада для web
|
||
// --- CAT ---
|
||
FCATAdapter: TCATAdapter; // адаптер CAT поверх контроллера (движок+транспорты+контекст)
|
||
FCATLastGlobal: TGlobalSettings; // текущие CAT-настройки (UI/persist; ApplySettings → адаптер)
|
||
// Виджеты построены (конец FormCreate). До этого рендер состояния запрещён:
|
||
// адаптеры (CAT/web), создаваемые по ходу FormCreate, могут дёрнуть
|
||
// Changed() раньше, чем появятся кнопки/панадаптер.
|
||
FUIReady: Boolean;
|
||
// Временные поля для передачи параметров в Synchronize-методы
|
||
FWebSyncFreq: Double;
|
||
FWebSyncInt: Integer;
|
||
FWebSyncBool: Boolean;
|
||
FWebSyncStr1: string; // для web-команд с текстом (IP/имя)
|
||
FWebSyncStr2: string;
|
||
FWebSyncM: TThreadMethod;
|
||
FSettingsForm: TObject; // TSettingsForm (cast при использовании)
|
||
FPendingDev: THPSDRDevice; // устройство ожидающее открытия WDSP
|
||
FLightTheme: Boolean; // True = светлая тема
|
||
FFreqMhzDigits: Integer; // 3=999MHz, 4=9.999GHz, 5=99.999GHz
|
||
|
||
// ---- Timers ----
|
||
FMeterTimer: TTimer;
|
||
FSpectrumTimer: TTimer;
|
||
FAfterShowTimer:TTimer; // однократный таймер для пост-инициализации
|
||
FRestoreL, FRestoreT: Integer; // позиция для отложенного восстановления
|
||
FHasPendingRestore: Boolean;
|
||
|
||
// ---- Top panel (toolbar + VFO group + S-meter) ----
|
||
PanelToolbar: TPanel;
|
||
BtnDiscover: TFlatButton;
|
||
BtnStartStop: TFlatButton;
|
||
BtnSettings: TFlatButton;
|
||
|
||
// ---- Left panel ----
|
||
PanelLeft: TPanel;
|
||
FLeftScrollBar: TOverlayScrollBar;
|
||
FLeftScrollOffset: Integer;
|
||
|
||
PanelVfoA: TPanel;
|
||
LblVfoALabel: TLabel;
|
||
FreqDispA: TFreqDisplay;
|
||
BtnTopVfoASelect: TFlatButton;
|
||
BtnTopVfoATX: TFlatButton;
|
||
|
||
PanelVfoB: TPanel;
|
||
LblVfoBLabel: TLabel;
|
||
FreqDispB: TFreqDisplay;
|
||
BtnTopVfoBSelect: TFlatButton;
|
||
BtnTopVfoBTX: TFlatButton;
|
||
|
||
PanelVfoButtons: TPanel;
|
||
BtnVfoSwap: TFlatButton;
|
||
BtnVfoACopyB: TFlatButton;
|
||
BtnVfoBCopyA: TFlatButton;
|
||
PanelTopVfoGroup: TPanel;
|
||
PanelTopVfoA: TPanel;
|
||
PanelTopVfoB: TPanel;
|
||
|
||
PanelBands: TPanel;
|
||
BtnBand: array[0..BAND_COUNT-1] of TFlatButton;
|
||
// Динамические кнопки XVTR (создаются/удаляются по мере того,
|
||
// как пользователь enabled/disabled слоты в Settings → Transverter).
|
||
BtnXvtrBand: array[0..CFG_XVTR_COUNT-1] of TFlatButton;
|
||
|
||
PanelMode: TPanel;
|
||
BtnMode: array[0..MODE_COUNT-1] of TFlatButton;
|
||
|
||
PanelFilter: TPanel;
|
||
BtnFilter: array[0..FILT_SLOTS-1] of TFlatButton; // 10 пресетов + VAR1/VAR2
|
||
BtnCTun: TFlatButton;
|
||
BtnBeacon: TFlatButton; // QO-100 beacon lock (видим только на Pluto в трансвертере)
|
||
BtnDUP: TFlatButton;
|
||
BtnRxMute: TFlatButton; // QO-100 self-monitor: RX-аудио на TX (видим только Pluto+full-duplex)
|
||
PanelFMSQ: TPanel;
|
||
BtnFMSQ: TFlatButton;
|
||
TrkFMSQ: TFlatSlider;
|
||
LblFMSQ: TLabel;
|
||
PanelRawDev: TPanel;
|
||
TrkRawDev: TFlatSlider;
|
||
LblRawDev: TLabel;
|
||
PanelFMCTCSS: TPanel;
|
||
BtnFMCTCSS: TFlatButton;
|
||
BtnFMCTCSSTone: TFlatButton;
|
||
FCTCSSDropDown: TFlatDropDown;
|
||
PanelFMStep: TPanel;
|
||
BtnFMStep: TFlatButton;
|
||
BtnFMStepSel: TFlatButton;
|
||
FStepDropDown: TFlatDropDown;
|
||
PanelFMRpt: TPanel;
|
||
BtnFMRptMinus: TFlatButton;
|
||
BtnFMRptPlus: TFlatButton;
|
||
EdFMRptOffset: TFlatEdit;
|
||
|
||
PanelAGC: TPanel;
|
||
BtnAGCMode: array[0..4] of TFlatButton; // FAST MED SLOW LONG OFF
|
||
LblAGCTop: TLabel; // показывает значение уровня
|
||
TrkAGC: TFlatSlider;
|
||
// RX hw-gain (Pluto) — ОТДЕЛЬНЫЙ блок под AGC (WDSP). Виден только для Pluto.
|
||
PanelPlutoAGC: TPanel;
|
||
BtnPlutoGain: array[0..3] of TFlatButton; // FAST/SLOW/HYB/MAN (Tag=режим)
|
||
TrkRxGain: TFlatSlider; // manual gain 0..73 dB
|
||
LblRxGain: TLabel; // значение усиления
|
||
TrkVolume: TFlatSlider;
|
||
LblVolume: TLabel;
|
||
BtnNR: TFlatButton;
|
||
BtnNB: TFlatButton;
|
||
BtnSNB: TFlatButton;
|
||
BtnANF: TFlatButton;
|
||
BtnMute: TFlatButton;
|
||
|
||
PanelSMeterRight: TPanel;
|
||
PbSMeterRight: TPaintBox;
|
||
|
||
PanelTX: TPanel;
|
||
LblDrv: TLabel;
|
||
LblDrvVal: TLabel; // значение drive: % (openHPSDR) или дБ (Pluto)
|
||
TrkDrive: TFlatSlider;
|
||
BtnMOX: TFlatButton;
|
||
BtnTUN: TFlatButton;
|
||
BtnPS: TFlatButton; // PureSignal: ЛКМ = вкл/выкл, ПКМ = поповер настроек
|
||
Btn2Ton: TFlatButton; // двухтональник (IMD/проверка PS)
|
||
FPSPopup: TObject; // TPureSignalPopup (cast при использовании)
|
||
|
||
PanelRXBlock: TPanel;
|
||
// Шаговый аттенюатор ADC (openHPSDR): плавные 0..31 дБ. На Pluto ряд
|
||
// скрыт — там усиление тракта живёт в блоке RF AGC.
|
||
LblAtt: TLabel;
|
||
LblAttVal: TLabel;
|
||
TrkAtt: TFlatSlider;
|
||
BtnChannels: TFlatButton;
|
||
FChannelsDropDown: TFlatDropDown;
|
||
// TX-профиль: кнопка с именем активного (блок TX, ряд DUP) + список.
|
||
// ЛКМ — список, ПКМ — вкладка Transmit в настройках.
|
||
BtnTXProfile: TFlatButton;
|
||
FTXProfileDropDown: TFlatDropDown;
|
||
FChannelsForm: TObject; // TChannelsForm (cast при использовании)
|
||
FCWMsgForm: TObject; // TCWMessagesForm (память телеграфа)
|
||
FCWTermForm: TObject; // TCWTerminalForm (лента + набор с клавиатуры)
|
||
FBeaconScopeForm: TBeaconScopeForm; // окно констелляции маяка (ПКМ по BEACON)
|
||
|
||
// ---- Right panel ----
|
||
PanelRight: TPanel;
|
||
// Контролы панадаптера — алиасы членов FPan (владелец FPan; см. выше).
|
||
PbSpectrum: TControl;
|
||
PbWideband: TControl;
|
||
PbWidebandRuler: TPaintBox;
|
||
PbRuler: TPaintBox; // полоса частотных меток между спектром и водопадом
|
||
PanelSplitter: TPanel; // перетаскиваемый разделитель спектр/водопад
|
||
PbWaterfall: TControl;
|
||
// ---- Панель пана/зума под водопадом ----
|
||
PbPanZoom: TPaintBox; // полоса-ползунок пана зума
|
||
FPanZoomBar: TPanZoomBar;
|
||
BtnZoomIn: TFlatButton;
|
||
BtnZoomDef: TFlatButton;
|
||
BtnZoomOut: TFlatButton;
|
||
|
||
// ---- Status bar ----
|
||
StatusPanel: TMainStatusBar;
|
||
|
||
// ---- Helpers ----
|
||
procedure DoInvoke(M: TThreadMethod); // маршалинг команд контроллера в UI-поток
|
||
procedure OnControllerState(Sender: TObject; Field: TRadioField); // контроллер → UI
|
||
procedure BuildUI;
|
||
function LeftPanelButtonLeft(PanelWidth, ColCount, ColIndex: Integer): Integer;
|
||
function LeftPanelButtonWidth(PanelWidth, ColCount, ColIndex: Integer): Integer;
|
||
procedure ApplyDarkTheme;
|
||
procedure SetLightTheme(V: Boolean);
|
||
procedure StyleButton(B: TFlatButton; Active: Boolean = False);
|
||
procedure StyleSpanButton(B: TFlatButton; Active: Boolean = False);
|
||
procedure SetStatusText(Index: Integer; const Text: string);
|
||
procedure SetRadioOfflineStatus(const ConnText: string; ClearDevice: Boolean = False);
|
||
procedure ApplyStatusTheme(const T: TAppTheme);
|
||
procedure PositionSampleRateOverlay;
|
||
procedure OnSampleRateOverlayInvalidate(Sender: TObject);
|
||
procedure OnSampleRateSelect(SampleRate: Integer);
|
||
procedure OnSampleRateHidePanel;
|
||
procedure UpdateVfoDisplay;
|
||
function FormatFreq(Hz: Double): string;
|
||
procedure LayoutTopVfoBlock;
|
||
|
||
procedure ResizeSMeter;
|
||
procedure ResizeSpectrumPanels;
|
||
function UpdateWidebandFrequencyView: Boolean;
|
||
procedure InvalidateGridCache;
|
||
procedure SyncSpecViewFreq;
|
||
procedure RecreateDSPEngine(ASampleRate: Integer);
|
||
function EnsureWDSPWisdom: Boolean;
|
||
|
||
// Network callbacks
|
||
procedure OnWidebandCB(ADCIndex: Integer; const Samples: array of SmallInt;
|
||
Count: Integer);
|
||
procedure OnWDSPOpenDone(Success: Boolean);
|
||
function ResolveDevice(const IP: string): THPSDRDevice;
|
||
procedure RelabelBands; // подписи band-кнопок под план бэкенда
|
||
procedure ApplyBackendCapsToUI; // sample-rate пресеты + гейтинг полей по Caps
|
||
procedure UpdateRxGainUI; // рендер RX hw-gain (Pluto)
|
||
procedure LayoutLeftPanel(Pluto: Boolean); // показать/скрыть Pluto-блок + reflow
|
||
procedure BtnPlutoGainClick(Sender: TObject);
|
||
procedure DoConnectDevice(const Dev: THPSDRDevice);
|
||
// Старт с уже выбранным устройством (preload rate + WDSP open + connect).
|
||
// Переиспользуется десктоп-диалогом и web-оверлеем — без модального диалога.
|
||
procedure StartWithDevice(const Dev: THPSDRDevice);
|
||
// STOP-teardown: persistence + graceful Run=0 + Disconnect + offline-статус.
|
||
// Общий для десктоп-STOP и web-disconnect.
|
||
procedure DoStopAndDisconnect;
|
||
|
||
// DSP/Audio callbacks (вызываются из рабочих потоков)
|
||
procedure OnSpectrumReady(const Pixels: array of Single; Count: Integer);
|
||
procedure OnWaterfallReady(const Pixels: array of Single; Count: Integer);
|
||
|
||
// Public UI update (called from sync objects)
|
||
procedure UpdateTXMeters;
|
||
procedure UpdateTXControlAvailability;
|
||
procedure UpdateStatusField7; // поле 7: PLL (HPSDR) или beacon (Pluto)
|
||
procedure UpdateDMRStatus; // поле 8: только при активном DMR
|
||
procedure UpdateStatusField3; // поле 3: Supply (HPSDR) или Temp/RSSI (Pluto)
|
||
function BeaconStatusText: string; // компактный статус QO-100 beacon lock
|
||
function DMRStatusText: string; // sync/type/polarity DMR front-end
|
||
procedure DoUpdateDDCSeq(DDCIdx: Integer; Seq: LongWord);
|
||
|
||
// Event handlers
|
||
procedure BtnDiscoverClick(Sender: TObject);
|
||
procedure BtnDiscoverFromDialog(Sender: TObject);
|
||
procedure BtnStartStopClick(Sender: TObject);
|
||
procedure FreqDispAChanged(Sender: TObject; NewFreq: Int64);
|
||
procedure ApplyVfoA(NewFreq: Int64);
|
||
procedure FreqDispAClick(Sender: TObject);
|
||
procedure FreqDispBClick(Sender: TObject);
|
||
procedure BtnTopVfoASelectClick(Sender: TObject);
|
||
procedure BtnTopVfoATXClick(Sender: TObject);
|
||
procedure BtnTopVfoBSelectClick(Sender: TObject);
|
||
procedure BtnTopVfoBTXClick(Sender: TObject);
|
||
procedure ActivateVfo(Idx: Integer);
|
||
function ActiveVfoFreq: Int64;
|
||
// Настройки
|
||
procedure SaveCurrentBand;
|
||
procedure RestoreBand(BandIdx: Integer);
|
||
procedure SaveAllAndExit;
|
||
procedure RestoreWindowBounds;
|
||
procedure SaveWindowBounds;
|
||
procedure FreqDispBChanged(Sender: TObject; NewFreq: Int64);
|
||
procedure BtnBandClick(Sender: TObject);
|
||
procedure BtnModeClick(Sender: TObject);
|
||
procedure BtnModeMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
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;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
procedure UpdateBeaconButton;
|
||
procedure BtnDUPClick(Sender: TObject);
|
||
procedure ApplyDUP(Active: Boolean);
|
||
procedure BtnRxMuteClick(Sender: TObject);
|
||
procedure UpdateRxMuteButton; // видимость (Pluto+full-duplex) + стиль кнопки RX MUTE
|
||
procedure UpdateBandPlanOverlay; // вкл/выкл полоски бэндплана QO-100 по InQO100
|
||
procedure BtnTUNClick(Sender: TObject);
|
||
procedure ApplyTUN(Active: Boolean);
|
||
// PureSignal: ЛКМ по PS = вкл/выкл, ПКМ = поповер настроек.
|
||
procedure BtnPSClick(Sender: TObject);
|
||
procedure BtnPSMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
procedure Btn2TonClick(Sender: TObject);
|
||
procedure OnPSPopupChanged(Sender: TObject); // персист PS-параметров
|
||
procedure UpdatePSButtons; // стиль/доступность PS/2TON
|
||
procedure UpdateIMDMarkers; // 2TON: маркеры пиков/IMD3 на спектре
|
||
procedure UpdateFilterButtons;
|
||
procedure BtnFMSQClick(Sender: TObject);
|
||
procedure TrkFMSQChange(Sender: TObject);
|
||
procedure TrkRawDevChange(Sender: TObject);
|
||
procedure ApplyFMSquelch;
|
||
procedure BtnFMCTCSSClick(Sender: TObject);
|
||
procedure BtnFMCTCSSToneClick(Sender: TObject);
|
||
procedure SetFMCTCSSTone(Idx: Integer);
|
||
procedure CloseCTCSSPopup;
|
||
procedure BtnFMRptMinusClick(Sender: TObject);
|
||
procedure BtnFMRptPlusClick(Sender: TObject);
|
||
procedure EdFMRptOffsetChange(Sender: TObject);
|
||
procedure UpdateRptAutoState;
|
||
procedure SetFMStep(Idx: Integer);
|
||
procedure CloseFMStepPopup;
|
||
procedure BtnFMStepClick(Sender: TObject);
|
||
procedure BtnFMStepSelClick(Sender: TObject);
|
||
procedure OnCTCSSDropDownSelect(Sender: TObject; Idx: Integer);
|
||
procedure OnStepDropDownSelect(Sender: TObject; Idx: Integer);
|
||
procedure BtnChannelsClick(Sender: TObject);
|
||
procedure BtnChannelsMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
procedure OnChannelsDropDownSelect(Sender: TObject; Idx: Integer);
|
||
procedure RefreshChannelsDropDown;
|
||
// TX-профили: рендер кнопки/списка + обработчики блока TX и вкладки Transmit.
|
||
procedure RenderTXProfile;
|
||
procedure PushTXProfilesToSettings;
|
||
procedure BtnTXProfileClick(Sender: TObject);
|
||
procedure BtnTXProfileMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
procedure OnTXProfileDropDownSelect(Sender: TObject; Idx: Integer);
|
||
procedure OnTXProfileSelectFromSettings(Idx: Integer);
|
||
procedure OnTXProfileAddFromSettings(const AName: string);
|
||
procedure OnTXProfileRenameFromSettings(Idx: Integer; const AName: string);
|
||
procedure OnTXProfileDeleteFromSettings(Idx: Integer);
|
||
procedure OnTXProfileFactoryFromSettings(Sender: TObject);
|
||
procedure OnVfoOverlayTXProfile(Idx: Integer);
|
||
procedure OnChannelStoreChanged(Sender: TObject);
|
||
procedure OnChannelActiveChanged(Idx: Integer; const Name: string; Active: Boolean);
|
||
procedure PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
procedure PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState;
|
||
X, Y: Integer);
|
||
procedure PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
procedure PbSpectrumMouseLeave(Sender: TObject);
|
||
procedure PbWidebandMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
procedure PbWaterfallMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
procedure PbWaterfallMouseMove(Sender: TObject; Shift: TShiftState;
|
||
X, Y: Integer);
|
||
procedure PbWaterfallMouseUp(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
procedure DoSpectrumClick(PixelX: Integer; PanelWidth: Integer);
|
||
procedure DoSpectrumDrag(PixelX: Integer; PanelWidth: Integer);
|
||
procedure DoWidebandClick(PixelX: Integer; PanelWidth: Integer);
|
||
// ---- Пан/зум (мышь полосы/сплиттера — внутри TPanafallPanel) ----
|
||
function PanOfZoomButton(Sender: TObject): TPanafallPanel;
|
||
procedure OnPanZoomWindow(Sender: TObject; AZoom, APan: Double);
|
||
procedure BtnZoomInClick(Sender: TObject);
|
||
procedure BtnZoomDefClick(Sender: TObject);
|
||
procedure BtnZoomOutClick(Sender: TObject);
|
||
procedure UpdatePanZoomBar;
|
||
procedure BtnVfoSwapClick(Sender: TObject);
|
||
procedure BtnVfoACopyBClick(Sender: TObject);
|
||
procedure BtnVfoBCopyAClick(Sender: TObject);
|
||
procedure BtnMOXClick(Sender: TObject);
|
||
procedure BtnMuteClick(Sender: TObject);
|
||
procedure UpdateNRButton;
|
||
procedure UpdateNBButton;
|
||
procedure UpdateSNBButton;
|
||
procedure UpdateANFButton;
|
||
procedure BtnNRClick(Sender: TObject);
|
||
procedure BtnNBClick(Sender: TObject);
|
||
procedure BtnSNBClick(Sender: TObject);
|
||
procedure BtnANFClick(Sender: TObject);
|
||
// Веб-интерфейс: host-зависимые callbacks от TWebServer (UI-обёртки).
|
||
// Чистые-контроллерные перенесены в TWebAdapter (Mode/Filter/AGC/AGCTop/Volume/
|
||
// WfAGC/WfNF/Mute/NR/NB/SNB/ANF/FreqB/Center/Attn/ActiveVfo/Ctun/MOX/Tun/FMStep/
|
||
// ClientActive). Здесь только то, что дёргает UI-обёртки MainForm.
|
||
procedure WebOnRun(On_: Boolean);
|
||
// Discovery / connect / saved-CRUD из web (маршалятся в UI)
|
||
procedure WebOnConnect(const IP: string);
|
||
procedure WebOnDiscover;
|
||
procedure WebOnDisconnect;
|
||
procedure WebOnDevAdd(const AName, IP: string);
|
||
procedure WebOnDevRemove(Idx: Integer);
|
||
procedure WebOnDevAutoStart(Idx: Integer);
|
||
procedure SyncWebConnect;
|
||
procedure SyncWebDiscover;
|
||
procedure SyncWebDisconnect;
|
||
procedure SyncWebDevAdd;
|
||
procedure SyncWebDevRemove;
|
||
procedure SyncWebDevAutoStart;
|
||
procedure PushDeviceListToWeb; // store → FWebServer.SetDeviceList
|
||
// Synchronize-обёртки (выполняются в UI-потоке) — только host-зависимые
|
||
procedure SyncWebRun;
|
||
// --- CAT settings (UI/persist; контекст+движок — в TCATAdapter) ----------
|
||
procedure OnCATSettingsChange(
|
||
const SerEnabled: array of Boolean;
|
||
const SerPort: array of string;
|
||
const SerBaud, SerDataBits, SerStopBits, SerParity: array of Integer;
|
||
const SerAndromeda: array of Boolean;
|
||
TcpEnabled: Boolean; TcpPort: Integer);
|
||
procedure OnSliceCATSettingsChange(
|
||
const Enabled: array of Boolean;
|
||
const Port: array of Integer;
|
||
const AutoTX: array of Boolean;
|
||
const OutDev: array of string;
|
||
const InDev: array of string);
|
||
// Выбор устройства во флаге слайса = устройство его слота: зеркалим в
|
||
// конфиг, чтобы вкладка Slices показывала то же самое.
|
||
procedure StoreSliceSlotAudio(SliceId: Integer);
|
||
// Аудио слотов из конфига → контроллер (старт и смена устройства): слайсы
|
||
// восстанавливаются позже и должны встать на свои звуковухи.
|
||
procedure ApplySliceSlotAudio;
|
||
procedure OnAlexSettingsChange(const A: TAlexSettings);
|
||
procedure OnOCSettingsChange(const O: TOCSettings);
|
||
procedure OnXvtrSettingsChange(const X: TXvtrSettings);
|
||
procedure PushXvtrToWeb;
|
||
procedure PushRatePresetsToWeb; // backend sample-rate пресеты → web
|
||
procedure PushBandsToWeb; // backend band-план → web
|
||
// XVTR helpers
|
||
function FindXvtrIdxForFreq(FreqHz: Double): Integer;
|
||
function XvtrTranslate(VisibleHz: Double): Double;
|
||
function XvtrTranslateTX(VisibleHz: Double): Double;
|
||
procedure ApplyXvtrToNetwork;
|
||
procedure RebuildXvtrButtons;
|
||
procedure RelayoutBelowBands;
|
||
procedure UpdateLeftPanelScroll;
|
||
function CursorOverLeftPanel: Boolean;
|
||
procedure LeftScrollChanged(Sender: TObject);
|
||
procedure BtnXvtrBandClick(Sender: TObject);
|
||
procedure ActivateXvtrBand(Idx: Integer);
|
||
procedure DeactivateXvtr;
|
||
// -------------------------------------------------------------------------
|
||
procedure BtnSettingsClick(Sender: TObject);
|
||
// Семантические обработчики событий флагов (роутинг по FPan.CurSliceId).
|
||
// Коллекция/раскладка/диспетчер флагов — в TPanafallPanel.
|
||
procedure OnModeFilterSelect(Mode: Integer; FilterBW: Integer);
|
||
procedure OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
|
||
procedure OnVfoOverlaySquelchChange(SQLOn: Boolean; SQLLevel: Integer);
|
||
procedure OnVfoOverlayAGCChange(AGCMode: Integer);
|
||
procedure OnVfoOverlayVolume(Vol: Integer);
|
||
procedure OnVfoOverlayMute(Sender: TObject);
|
||
procedure OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string);
|
||
procedure OnVfoOverlayAudioInDevice(DevIndex: Integer; const DevName: string);
|
||
procedure OnVfoOverlaySplit(Sender: TObject);
|
||
procedure OnVfoOverlayTxSelect(Sender: TObject);
|
||
procedure OnVfoOverlayRxMuteTx(Sender: TObject); // RXM — приём слайса на TX
|
||
procedure RefreshTxIndicators; // TX-бейджи всех флагов
|
||
procedure OnVfoOverlaySliceSelect(Sender: TObject); // выбрать слайс активным
|
||
procedure OnVfoOverlayClose(Sender: TObject); // × — удалить слайс
|
||
procedure OnVfoOverlayInvalidate(Sender: TObject);
|
||
procedure OnPanSliceClosed(SliceId: Integer); // FPan закрыл слайс
|
||
// --- Мультислайсы (UI) ---
|
||
function CurSliceId: Integer; // SliceId обрабатываемого сейчас флага
|
||
procedure WireOverlayEvents(O: TVfoOverlay);
|
||
procedure AddSliceAtFreq(Hz: Double);
|
||
// --- Стек панов (этап 3.3) ---
|
||
function PanCount: Integer; // активных панов (>=1)
|
||
function PanFromSender(Sender: TObject): TPanafallPanel;
|
||
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;
|
||
procedure OnPanRateClick(Sender: TObject); // «NNN kHz» шапки пана N
|
||
procedure OnPanRateSelect(Tag: Integer); // выбор rate из flat-меню
|
||
procedure OnPanBandClick(Sender: TObject); // «20m» шапки пана N (3.6)
|
||
procedure OnPanBandSelect(Tag: Integer); // выбор диапазона из flat-меню
|
||
function CurrentAppTheme: TAppTheme; // тек. тема (light/dark)
|
||
procedure ApplyPanBand(P: TPanafallPanel; BandIdx: Integer); // Flex-style смена банда пана
|
||
function PanBandSelectorVisible: Boolean; // КВ (не XVTR, не Pluto)
|
||
procedure OnPanADCClick(Sender: TObject); // бейдж «RX1/RX2» (3.4)
|
||
// Per-pan дисплей (3.6): «◑» → поповер палитры/gamma/уровней.
|
||
procedure OnPanDisplayClick(Sender: TObject);
|
||
procedure OnPanDisplayApplied(P: TPanafallPanel; FullRefresh: Boolean);
|
||
procedure OnPanDisplayReset(P: TPanafallPanel); // сброс к глоб. дефолту
|
||
procedure OnPanDisplayApplyAll(P: TPanafallPanel); // раскатать на все паны
|
||
procedure ApplyGlobalRenderToView(V: TSpectrumView); // глоб. рендер → вьюха
|
||
procedure MarkPanDisplayDirty(P: TPanafallPanel; ClearWf: Boolean); // репейнт (+сброс истории водопада)
|
||
// Pop-out (3.5): «⧉» выносит пан в отдельное окно, [×] окна возвращает в стек.
|
||
procedure OnPanPopOutClick(Sender: TObject);
|
||
procedure PopOutPan(PanId: Integer);
|
||
procedure DockBackPan(PanId: Integer);
|
||
procedure PanFloatClose(Sender: TObject; var CloseAction: TCloseAction);
|
||
procedure PanFloatResize(Sender: TObject);
|
||
function PanFloating(PanId: Integer): Boolean;
|
||
procedure ResetPanGLCaches(P: TPanafallPanel); // после смены родителя
|
||
procedure AddSliceAtFreqPan(P: TPanafallPanel; Hz: Double);
|
||
procedure PanClickTune(P: TPanafallPanel; X, W: Integer); // клик по фону пана N
|
||
procedure PanViewWindow(P: TPanafallPanel; out VC, VS: Double);
|
||
procedure SyncPanZoomUI(P: TPanafallPanel); // вьюха+зум-бар ← контроллер
|
||
procedure RefreshPanZoom(P: TPanafallPanel); // + флаги/инвалидции
|
||
procedure SyncPanViews; // центр/span вьюх панов
|
||
procedure MarkPanDirty(P: TPanafallPanel); // репейнт пана N
|
||
// Мышь панов N>0 (у пана 0 семантика ниже, PbSpectrum*).
|
||
procedure PanNSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
procedure PanNSpectrumMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
|
||
procedure PanNSpectrumMouseUp(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
procedure PanNSpectrumMouseLeave(Sender: TObject);
|
||
procedure PanNSpectrumDblClick(Sender: TObject);
|
||
// Пиксели панов из display-потока движка.
|
||
procedure OnPanSpectrumReady(PanId: Integer; const Pixels: array of Single;
|
||
Count: Integer);
|
||
procedure OnPanWaterfallReady(PanId: Integer; const Pixels: array of Single;
|
||
Count: Integer);
|
||
// Сплиттеры между панами.
|
||
procedure PanSplitMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
procedure PanSplitMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
|
||
procedure OnPanGridToggle(Sender: TObject); // «▦/▤» в ряду зума пана 0
|
||
function PanGridActive: Boolean; // грид реально применён
|
||
function PanGridTwoCol: Boolean; // грид из 2 колонок (пан 0 + 1)
|
||
function ExtraPanCount: Integer; // доп. паны в раскладке
|
||
procedure PanVSplitMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
procedure PanVSplitMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
|
||
procedure PanVSplitMouseUp(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
procedure PanSplitMouseUp(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
procedure LayoutPanStack(AWideH: Integer); // регионы панов + Layout каждого
|
||
function PanMinHeight(PanId: Integer): Integer; // мин. высота региона пана
|
||
function Pan0HeaderVisible: Boolean; // шапка пана 0: стек ИЛИ 2 входа
|
||
function Pan0HeaderH: Integer; // высота шапки пана 0 (0 без стека)
|
||
procedure PbSpectrumDblClick(Sender: TObject);
|
||
procedure TrkDriveChange(Sender: TObject);
|
||
procedure UpdateDriveLabel; // % (openHPSDR) / дБ (Pluto) рядом с ползунком drive
|
||
procedure TrkVolumeChange(Sender: TObject);
|
||
procedure UpdateVolumeLabel;
|
||
function CalcDriveByte: Byte;
|
||
function ActiveTXFreqHz: Double;
|
||
function DefaultMicSource: TTXMicSource;
|
||
procedure ApplyMOX(Active: Boolean);
|
||
procedure OnPASettingsChange(MaxPower: Double; const BandCal: array of Double);
|
||
procedure OnCalibrationSettingsChange(const C: TCalibration);
|
||
procedure OnPlutoTxMaxAttChange(MaxAttDb: Double);
|
||
procedure OnAnt936xChange(const A: TAD936xAnt); // разъёмы AD936x (Antenna)
|
||
procedure OnVHFCalSettingsChange(const VHFCal: array of Double);
|
||
procedure OnTXSettingsChange(const T: TTXSettings);
|
||
procedure OnCWSettingsChange(const C: TCWSettings);
|
||
procedure ShowCWMessages;
|
||
procedure ShowCWTerminal;
|
||
procedure ServiceCWTerminal; // таймер: разбор + обновление окна
|
||
procedure OnCWLogAppend(const S: string; IsTX: Boolean);
|
||
procedure OnCWTermBackspace(Sender: TObject);
|
||
procedure OnCWTermDecoder(Value: Boolean);
|
||
procedure OnCWTermKbdKey(Dot, Dash: Boolean);
|
||
procedure OnCWTermSpeed(WPM: Integer);
|
||
procedure OnCWTermMacro(Sender: TObject);
|
||
procedure OnCWMessageSend(const Text: string);
|
||
procedure OnCWMessageAbort(Sender: TObject);
|
||
procedure FormKeyDownCW(Sender: TObject; var Key: Word; Shift: TShiftState);
|
||
procedure BtnAGCModeClick(Sender: TObject);
|
||
procedure TrkAGCChange(Sender: TObject);
|
||
procedure TrkRxGainChange(Sender: TObject);
|
||
procedure TrkAttChange(Sender: TObject);
|
||
procedure UpdateAttLabel;
|
||
// (BtnPlutoGainClick объявлен выше)
|
||
procedure MeterTimerTick(Sender: TObject);
|
||
procedure SpectrumTimerTick(Sender: TObject);
|
||
procedure RightPanelResize(Sender: TObject);
|
||
procedure AfterShowTick(Sender: TObject);
|
||
|
||
public
|
||
// Методы для SettingsForm — немедленное применение настроек
|
||
procedure ApplyDisplayParams(FFTSize, WinType, SpecDet, SpecAvgMode: Integer;
|
||
SpecAvgTimeMS: Double);
|
||
procedure ApplyWaterfallParams(WfDet, WfAvgMode: Integer;
|
||
WfAvgTimeMS, WfHigh, WfLow, WfAGCOffset: Double);
|
||
procedure ApplyWfRenderParams(Palette: Integer; Gamma: Double;
|
||
ResLimit, SpeedDiv: Integer);
|
||
procedure ApplySpecMSAA(Samples: Integer);
|
||
function EffectiveWfInterval: Integer;
|
||
procedure ApplyWfAGCNF(WfAGC, WfNF: Boolean);
|
||
procedure ApplyADCSettings(Dither, Random: Boolean);
|
||
procedure ApplyWebSettings(Enabled: Boolean; Port: Integer;
|
||
const BindAddr, User, Pass: string; SpecPixels: Integer);
|
||
procedure ApplyGridParams(RefLevel, Range, GridStep: Double);
|
||
// Пушит активные grid-параметры (RX или TX в зависимости от FTransmitting)
|
||
// в FSpecView и сбрасывает кэш сетки. Вызывается при смене RX↔TX и при
|
||
// правке любого из соответствующих полей (FSpec*, FTXSpec*).
|
||
procedure ApplySpecViewGridFromState;
|
||
procedure ApplyAudioDevice(DevIndex: Integer; const DevName: string);
|
||
procedure ApplyAudioInputDevice(DevIndex: Integer; const DevName: string);
|
||
procedure ApplyAudioBufferSize(BufferSize: Integer);
|
||
procedure ApplySendAudioToRadio(Enabled: Boolean);
|
||
// TX settings (ApplyTXSettingsToDSP/SendDUCSpecificFromSettings/
|
||
// BuildMicLineSelectByte) переехали в TRadioController.
|
||
procedure ApplyVisibility(ShowSpectrum, ShowWaterfall, ShowWideband,
|
||
WidebandFill, SpectrumFill: Boolean);
|
||
procedure ApplyFPS(FPS: Integer);
|
||
procedure ApplyFreqMhzDigits(Digits: Integer);
|
||
procedure UpdateFreqDisplayMax; // верхняя граница FreqDisplay (XVTR/Pluto/HF)
|
||
|
||
published
|
||
// Обработчики событий формы — должны быть в published для RTTI/LFM
|
||
procedure FormCreate(Sender: TObject);
|
||
procedure FormDestroy(Sender: TObject);
|
||
procedure FormClose(Sender: TObject; var CloseAction: TCloseAction);
|
||
procedure FormMouseWheel(Sender: TObject; Shift: TShiftState;
|
||
WheelDelta: Integer; MousePos: TPoint;
|
||
var Handled: Boolean);
|
||
end;
|
||
|
||
var
|
||
MainForm: TMainForm;
|
||
|
||
implementation
|
||
|
||
uses SettingsForm;
|
||
|
||
{$R *.lfm}
|
||
|
||
const
|
||
// Высота RX-блока левой панели: с рядом ATT (openHPSDR) и без него (Pluto —
|
||
// там усиление тракта в блоке RF AGC). Переключает LayoutLeftPanel, он же
|
||
// двигает панели ниже.
|
||
RX_BLOCK_H = 100;
|
||
RX_BLOCK_H_ATT = 124;
|
||
|
||
function TMainForm.LeftPanelButtonWidth(PanelWidth, ColCount,
|
||
ColIndex: Integer): Integer;
|
||
var
|
||
InnerW, BaseW, Extra, Pad, Gap: Integer;
|
||
begin
|
||
if ColCount <= 0 then Exit(0);
|
||
Pad := MulDiv(LEFT_PANEL_BTN_PAD, PanelWidth, LEFT_PANEL_CONTENT_W);
|
||
Gap := MulDiv(LEFT_PANEL_BTN_GAP, PanelWidth, LEFT_PANEL_CONTENT_W);
|
||
InnerW := PanelWidth - 2 * Pad - (ColCount - 1) * Gap;
|
||
BaseW := InnerW div ColCount;
|
||
Extra := InnerW mod ColCount;
|
||
Result := BaseW;
|
||
if ColIndex < Extra then Inc(Result);
|
||
end;
|
||
|
||
function TMainForm.LeftPanelButtonLeft(PanelWidth, ColCount,
|
||
ColIndex: Integer): Integer;
|
||
var
|
||
i, Pad, Gap: Integer;
|
||
begin
|
||
Pad := MulDiv(LEFT_PANEL_BTN_PAD, PanelWidth, LEFT_PANEL_CONTENT_W);
|
||
Gap := MulDiv(LEFT_PANEL_BTN_GAP, PanelWidth, LEFT_PANEL_CONTENT_W);
|
||
Result := Pad;
|
||
for i := 0 to ColIndex - 1 do
|
||
Inc(Result, LeftPanelButtonWidth(PanelWidth, ColCount, i) +
|
||
Gap);
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// Settings helpers
|
||
// ===========================================================================
|
||
|
||
procedure TMainForm.RestoreWindowBounds;
|
||
var
|
||
L, T, Wd, Ht, SavedDPI, CurDPI: Integer;
|
||
Maximized: Boolean;
|
||
R, WA: TRect;
|
||
Mon: TMonitor;
|
||
begin
|
||
FController.FSettings.LoadWindowBounds(L, T, Wd, Ht, SavedDPI, Maximized);
|
||
if (Wd > 400) and (Ht > 300) then
|
||
begin
|
||
CurDPI := CurrentScreenDPI;
|
||
if SavedDPI <= 0 then SavedDPI := CurDPI;
|
||
|
||
if SavedDPI <> CurDPI then
|
||
begin
|
||
// Позицию не масштабируем: на LCLWin32 координаты физические и не зависят
|
||
// от DPI-фактора; на Qt6/GTK/Cocoa — логические (device-independent), тоже
|
||
// инвариантны. Выход за рабочую область поймает EnsureRange ниже.
|
||
//
|
||
// Размер масштабируем, чтобы окно выглядело одинаково при смене DPI:
|
||
// LCLWin32: физические пиксели → Wd × CurDPI/SavedDPI
|
||
// Qt6/GTK/Cocoa: логические пиксели → Wd × SavedDPI/CurDPI (обратное)
|
||
{$IFDEF LCLWin32}
|
||
Wd := MulDiv(Wd, CurDPI, SavedDPI);
|
||
Ht := MulDiv(Ht, CurDPI, SavedDPI);
|
||
{$ELSE}
|
||
Wd := MulDiv(Wd, SavedDPI, CurDPI);
|
||
Ht := MulDiv(Ht, SavedDPI, CurDPI);
|
||
{$ENDIF}
|
||
end;
|
||
|
||
// Минимальный размер:
|
||
// LCLWin32 — в физических пикселях, масштабируется с DPI.
|
||
// Qt6/GTK/Cocoa — в логических пикселях, device-independent, фиксированный.
|
||
{$IFDEF LCLWin32}
|
||
Wd := Max(MulDiv(900, CurDPI, 96), Wd);
|
||
Ht := Max(MulDiv(560, CurDPI, 96), Ht);
|
||
{$ELSE}
|
||
Wd := Max(900, Wd);
|
||
Ht := Max(560, Ht);
|
||
{$ENDIF}
|
||
R := Bounds(L, T, Wd, Ht);
|
||
Mon := Screen.MonitorFromRect(R);
|
||
if Mon <> nil then WA := Mon.WorkareaRect
|
||
else WA := Screen.PrimaryMonitor.WorkareaRect;
|
||
|
||
if Wd > WA.Right - WA.Left then Wd := WA.Right - WA.Left;
|
||
if Ht > WA.Bottom - WA.Top then Ht := WA.Bottom - WA.Top;
|
||
L := EnsureRange(L, WA.Left, Max(WA.Left, WA.Right - Wd));
|
||
T := EnsureRange(T, WA.Top, Max(WA.Top, WA.Bottom - Ht));
|
||
|
||
SetBounds(L, T, Wd, Ht);
|
||
if Maximized then
|
||
WindowState := wsMaximized
|
||
else
|
||
begin
|
||
// На Qt6/X11 frame extents ещё не известны Qt во время FormCreate,
|
||
// поэтому move() может поставить контент туда, куда мы хотели рамку.
|
||
// Повторно применим позицию в AfterShowTick, когда WM уже декорировал окно.
|
||
FRestoreL := L;
|
||
FRestoreT := T;
|
||
FHasPendingRestore := True;
|
||
end;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.SaveWindowBounds;
|
||
var
|
||
L, T, Wd, Ht, DPI: Integer;
|
||
Maximized: Boolean;
|
||
begin
|
||
Maximized := WindowState = wsMaximized;
|
||
if WindowState = wsNormal then
|
||
begin
|
||
L := Left; T := Top; Wd := Width; Ht := Height;
|
||
end
|
||
else
|
||
begin
|
||
L := RestoredLeft; T := RestoredTop;
|
||
Wd := RestoredWidth; Ht := RestoredHeight;
|
||
end;
|
||
|
||
// RestoredLeft/Top могут вернуть мусор на Qt6 при максимизации;
|
||
// ставим разумные запасные значения
|
||
if (L < -32000) or (T < -32000) then begin L := 80; T := 80; end;
|
||
if (Wd <= 400) or (Ht <= 300) then Exit;
|
||
DPI := CurrentScreenDPI;
|
||
FController.FSettings.SaveWindowBounds(L, T, Wd, Ht, DPI, Maximized);
|
||
end;
|
||
|
||
procedure TMainForm.SaveCurrentBand;
|
||
begin
|
||
// Логика сохранения (band-cache/XVTR-слот + конфиг) — в контроллере.
|
||
FController.SaveCurrentBand;
|
||
end;
|
||
|
||
procedure TMainForm.RestoreBand(BandIdx: Integer);
|
||
begin
|
||
// Тонкий делегат: состояние+DSP+финальная перестройка+рендер — в команде
|
||
// контроллера; сброс Wf-avg буфера — в OnControllerState(rfBandRestore).
|
||
FController.RestoreBand(BandIdx);
|
||
end;
|
||
|
||
procedure TMainForm.SaveAllAndExit;
|
||
begin
|
||
if FController.FDevConnected then
|
||
begin
|
||
SaveCurrentBand;
|
||
FController.SaveGlobalSettings;
|
||
if FController.FDevConnected then
|
||
FController.FSettings.SaveTX(FController.FDevMAC, FController.FTXSettings);
|
||
// Запомнить LastFreq A/B XVTR для следующего запуска
|
||
if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) then
|
||
begin
|
||
FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFreq := FController.FVfoA;
|
||
FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFreqB := FController.FVfoB;
|
||
end;
|
||
FController.FSettings.SaveXvtr(FController.FDevMAC, FController.FXvtrSettings);
|
||
FController.FSettings.Save;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.FormCreate(Sender: TObject);
|
||
var
|
||
bi_, StartupRate, i: Integer;
|
||
WebCfg: TWebSettings;
|
||
StartupVfoA, StartupVfoB: Double;
|
||
begin
|
||
SetExceptionMask(GetExceptionMask + [exZeroDivide, exInvalidOp]);
|
||
// Headless ядро: владеет состоянием и движками. Создаём первым — все
|
||
// присваивания FController.FXxx ниже зависят от него. Движки создаются/
|
||
// освобождаются здесь же (в полях FController) — Фаза 1 миграции.
|
||
FController := TRadioController.Create;
|
||
FController.OnInvoke := DoInvoke;
|
||
FController.OnStateChanged := OnControllerState;
|
||
// Адаптер каналов поверх контроллера: оркеструет команды, держит активный
|
||
// канал/pre-channel-drive. Рендер кнопки Channel — через OnActiveChanged;
|
||
// XVTR-вход/выход — через UI-обёртки (wideband/web живут в них).
|
||
FChannelController := TChannelController.Create(FController);
|
||
FChannelController.OnActiveChanged := OnChannelActiveChanged;
|
||
// Канал-оркестрация фаером из контроллера в конце SetVfoA (любой путь тюнинга).
|
||
FController.OnAfterTune := FChannelController.OnVfoTuned;
|
||
// Снимок панов+слайсов перед гашением DDC в StopRunning (персист).
|
||
FController.OnBeforeStop := SavePanWorkspace;
|
||
FController.FVfoA := 14200000;
|
||
FController.FVfoB := 7100000;
|
||
FController.FActiveVfo := 0;
|
||
FController.FMode := MODE_USB;
|
||
FController.FFilter := 5;
|
||
FController.FAGCMode := 1; // MEDIUM
|
||
FController.FAGCTop := 90; // −90 dBm
|
||
// default: зависит от режима, будет сброшен в UpdateFilterButtons
|
||
FController.FCTun := False;
|
||
// Фильтр (индекс + кромки + производная ширина) — одной точкой, чтобы
|
||
// состояние не расползалось: ширину отдельно больше не выставляем.
|
||
FController.SelectFilterSlotState(FController.FFilter);
|
||
FSpecDrag := False;
|
||
FController.FDriveLevel := 0;
|
||
FController.FPAMaxPower := 100.0;
|
||
for i := 0 to BAND_COUNT - 1 do FController.FPABandCal[i] := CFG_PA_BAND_CAL_DEFAULT[i];
|
||
for i := 0 to CFG_XVTR_COUNT - 1 do FController.FVHFBandCal[i] := CFG_VHF_CAL_DEFAULT;
|
||
FController.FRunning := False;
|
||
FController.FTransmitting := False;
|
||
FController.FSplitTxB := False;
|
||
FController.FDisplayDuplex := False;
|
||
FController.FTuning := False;
|
||
FController.FMuted := False;
|
||
FController.FVolume := 70;
|
||
FController.FCenterFreq := FController.FVfoA;
|
||
FController.FSpanHz := 192000;
|
||
FController.FSampleRate := 192000;
|
||
FController.FCurrentBand := 5; // 20m по умолчанию
|
||
// Waterfall normalization
|
||
FController.FWfManualHigh := -80.0;
|
||
FController.FWfManualLow := -130.0;
|
||
FController.FWfAGCOffset := 0.0;
|
||
FController.FWfAGCEnabled := True;
|
||
FController.FWfNFEnabled := False;
|
||
FController.FDitherEnabled := True;
|
||
FController.FRandomEnabled := True;
|
||
FController.FFMDeviation := FILT_FM_DEV[FILT_FM_DEF];
|
||
FController.FFMCTCSSOn := False;
|
||
FController.FFMCTCSSToneIdx := 0;
|
||
FController.FFMSQOn := False;
|
||
FController.FFMSQLevel := 30;
|
||
FController.FFMStepOn := True;
|
||
FController.FFMStepIdx := FM_STEP_DEF;
|
||
FController.FFMRptDir := RPT_NONE;
|
||
FController.FFMRptOffsetHz := RPT_DEFAULT_2M;
|
||
FController.FFMRptAutoActive := False;
|
||
FController.FFMCTCSSAutoActive := False;
|
||
FController.FDevConnected := False;
|
||
// Spectrum grid settings defaults
|
||
FController.FSpecRefLevel := -20.0;
|
||
FController.FSpecRange := 110.0;
|
||
FController.FSpecGridStep := 10.0;
|
||
// TX grid defaults (синхронно с TSettingsManager.DefaultTX)
|
||
FController.FTXSpecRefLevel := 0.0;
|
||
FController.FTXSpecRange := 80.0;
|
||
FController.FTXSpecGridStep := 10.0;
|
||
FSettingsForm := nil;
|
||
FChannelsForm := nil;
|
||
FCWMsgForm := nil;
|
||
FBeaconScopeForm := nil;
|
||
// F1..F8 для памяти телеграфа. Обработчик молчит, пока окно памяти не
|
||
// открывали и пока режим не CW, поэтому прочим клавишам ничего не мешает.
|
||
KeyPreview := True;
|
||
OnKeyDown := FormKeyDownCW;
|
||
FillChar(FController.FDevMAC, SizeOf(FController.FDevMAC), 0);
|
||
|
||
// Инициализируем кэш диапазонов умолчаниями
|
||
for bi_ := 0 to CFG_BAND_COUNT - 1 do
|
||
TSettingsManager.DefaultBand(bi_, FController.FBandCache[bi_]);
|
||
FController.FCurrentBand := 5;
|
||
|
||
// Загружаем JSON настройки (FSettings создаётся в TRadioController.Create)
|
||
FController.FSettings.Load;
|
||
|
||
// Загружаем список каналов
|
||
FController.FChannelStore := TChannelStore.Create(CHANNELS_FILE);
|
||
FController.FChannelStore.Load;
|
||
if FController.FSettings.LoadStartupPreview(StartupVfoA, StartupVfoB, StartupRate) then
|
||
begin
|
||
FController.FVfoA := StartupVfoA;
|
||
FController.FVfoB := StartupVfoB;
|
||
FController.FCenterFreq := FController.FVfoA;
|
||
if StartupRate > 0 then
|
||
begin
|
||
FController.FSampleRate := StartupRate;
|
||
FController.FSpanHz := StartupRate;
|
||
end;
|
||
end;
|
||
// Восстанавливаем размер/позицию окна при старте (до подключения устройства)
|
||
RestoreWindowBounds;
|
||
// S-метр позиционируем после рестора размера окна
|
||
// (будет пересчитан в первом тике SpectrumTimerTick)
|
||
FController.FLastSMeter := -130;
|
||
|
||
// Загружаем настройки веб-сервера из JSON и создаём сервер
|
||
FController.FSettings.LoadWebSettings(WebCfg);
|
||
FWebEnabled := WebCfg.Enabled;
|
||
FWebPort := WebCfg.Port;
|
||
FWebBindAddr := WebCfg.BindAddr;
|
||
FWebUser := WebCfg.User;
|
||
FWebPass := WebCfg.Pass;
|
||
FWebSpecPixels := EnsureRange(WebCfg.SpecPixels, 256, SPECTRUM_PIXELS);
|
||
FController.FWebSpecPixels := FWebSpecPixels;
|
||
FWebServer := TWebServer.Create(FWebUser, FWebPass, Word(FWebPort), FWebBindAddr);
|
||
// Вынесенные в TWebAdapter чистые-контроллерные команды (host=nil: их хендлеры
|
||
// ещё не нужны хосту; host-зависимые WebOnXxx пока на MainForm — переедут в WA2).
|
||
FWebAdapter := TWebAdapter.Create(FController, FWebServer, nil);
|
||
FWebServer.OnMode := FWebAdapter.OnMode;
|
||
FWebServer.OnFilter := FWebAdapter.OnFilter;
|
||
FWebServer.OnAGC := FWebAdapter.OnAGC;
|
||
FWebServer.OnAGCTop := FWebAdapter.OnAGCTop;
|
||
FWebServer.OnVolume := FWebAdapter.OnVolume;
|
||
FWebServer.OnWfAGC := FWebAdapter.OnWfAGC;
|
||
FWebServer.OnWfNF := FWebAdapter.OnWfNF;
|
||
FWebServer.OnMute := FWebAdapter.OnMute;
|
||
FWebServer.OnNR := FWebAdapter.OnNR;
|
||
FWebServer.OnNB := FWebAdapter.OnNB;
|
||
FWebServer.OnSNB := FWebAdapter.OnSNB;
|
||
FWebServer.OnANF := FWebAdapter.OnANF;
|
||
FWebServer.OnFreqB := FWebAdapter.OnFreqB;
|
||
FWebServer.OnCenter := FWebAdapter.OnCenter;
|
||
FWebServer.OnAttn := FWebAdapter.OnAttn;
|
||
// Web-слой держит FController.FWebClientActive актуальным (выбор mic-source
|
||
// при TX, в т.ч. для HWPTT из контроллера) — больше не зеркалим лениво.
|
||
FWebServer.OnClientActiveChanged := FWebAdapter.OnClientActiveChanged;
|
||
// Host-зависимые (UI-обёртки) и исходящий push — пока на MainForm:
|
||
FWebServer.OnCtun := FWebAdapter.OnCtun;
|
||
FWebServer.OnActiveVfo := FWebAdapter.OnActiveVfo;
|
||
FWebServer.OnMOX := FWebAdapter.OnMOX;
|
||
FWebServer.OnTun := FWebAdapter.OnTun;
|
||
FWebServer.OnFMStep := FWebAdapter.OnFMStep;
|
||
// Host-зависимые (UI-обёртки) — пока на MainForm:
|
||
FWebServer.OnFreq := FWebAdapter.OnFreq;
|
||
FWebServer.OnBand := FWebAdapter.OnBand;
|
||
FWebServer.OnSpan := FWebAdapter.OnSpan;
|
||
FWebServer.OnRun := WebOnRun;
|
||
FWebServer.OnDrive := FWebAdapter.OnDrive;
|
||
FWebServer.OnFreqA := FWebAdapter.OnFreqA;
|
||
FWebServer.OnWebMic := FWebAdapter.OnMic;
|
||
FWebServer.OnXvtrBand := FWebAdapter.OnXvtrBand;
|
||
FWebServer.OnBeacon := FWebAdapter.OnBeacon;
|
||
FWebServer.OnBeaconSeed := FWebAdapter.OnBeaconSeed;
|
||
FWebServer.OnRxGainMode := FWebAdapter.OnRxGainMode;
|
||
FWebServer.OnRxGain := FWebAdapter.OnRxGain;
|
||
FWebServer.OnRxMute := FWebAdapter.OnRxMute;
|
||
FWebServer.OnConnect := WebOnConnect;
|
||
FWebServer.OnDiscoverDev := WebOnDiscover;
|
||
FWebServer.OnDisconnectDev := WebOnDisconnect;
|
||
FWebServer.OnDevAdd := WebOnDevAdd;
|
||
FWebServer.OnDevRemove := WebOnDevRemove;
|
||
FWebServer.OnDevAutoStart := WebOnDevAutoStart;
|
||
if FWebEnabled then FWebServer.Start;
|
||
PushXvtrToWeb;
|
||
PushDeviceListToWeb; // saved-устройства сразу видны web-overlay
|
||
PushRatePresetsToWeb; // дефолтный бэкенд до connect; перезапишется на смене
|
||
PushBandsToWeb;
|
||
FillChar(FCATLastGlobal, SizeOf(FCATLastGlobal), 0);
|
||
FCATLastGlobal.CATTcpPort := 19090;
|
||
for i := 0 to CFG_CAT_SLICE_COUNT - 1 do
|
||
FCATLastGlobal.CATSlicePort[i] := CFG_CAT_SLICE_PORT0 + i; // 19091, 19092, …
|
||
for i := 0 to 3 do
|
||
begin
|
||
FCATLastGlobal.CATSerialBaud[i] := 9600;
|
||
FCATLastGlobal.CATSerialDataBits[i] := 8;
|
||
FCATLastGlobal.CATSerialStopBits[i] := 1;
|
||
{$IFDEF MSWINDOWS}
|
||
FCATLastGlobal.CATSerialPort[i] := 'COM' + IntToStr(i + 1);
|
||
{$ELSE}
|
||
FCATLastGlobal.CATSerialPort[i] := '/dev/ttyS' + IntToStr(i);
|
||
{$ENDIF}
|
||
end;
|
||
FController.FSettings.LoadCATSettings(FCATLastGlobal);
|
||
FLightTheme := FController.FSettings.LoadTheme;
|
||
FCATAdapter := TCATAdapter.Create(FController);
|
||
FCATAdapter.ApplySettings(FCATLastGlobal);
|
||
ApplySliceSlotAudio;
|
||
FSMeterPeak := -130;
|
||
FSMeterMin := -130;
|
||
FSMeterAvg := -130;
|
||
FController.FRXPacketCount := 0;
|
||
FController.FActiveDDC := 0;
|
||
FController.FLastDDCSeq := 0;
|
||
FController.FLastDDCIndex := 0;
|
||
FillChar(FController.FDDCLastSeq, SizeOf(FController.FDDCLastSeq), 0);
|
||
FillChar(FController.FDDCSeqValid, SizeOf(FController.FDDCSeqValid), 0);
|
||
FController.FSeqErrorCount := 0;
|
||
FController.FLastSeqErrorDDC := -1;
|
||
FController.FLastSeqErrorDelta:= 0;
|
||
FController.FSeqOkStreak := 0;
|
||
FController.FLastFwdW := 0;
|
||
FController.FLastSWR := 1;
|
||
FController.FLastSupplyV:= -1;
|
||
FController.FLastSupplyA:= -1;
|
||
FController.FLastPLLLock:= False;
|
||
FDeviceDialog := TDeviceDialog.Create(Self);
|
||
FDeviceDialog.Store := FController.FDeviceStore;
|
||
FDeviceDialog.OnDiscover := BtnDiscoverFromDialog;
|
||
FVfoOverlay := TVfoOverlay.Create(Self);
|
||
WireOverlayEvents(FVfoOverlay);
|
||
FVfoOverlay.SetSliceInfo(0, 'A', True); // главный приёмник = слайс A
|
||
FVfoOverlay.Visible := False;
|
||
FActiveSliceId := 0;
|
||
FSliceDrag := False;
|
||
FSliceDragId := 0;
|
||
FPanelHidden := False;
|
||
FController.FShowSpectrum := True;
|
||
FController.FShowWaterfall := True;
|
||
FController.FShowWideband := False;
|
||
FController.FWidebandFill := False;
|
||
FController.FSpectrumFill := True;
|
||
FDisplayFPS := 60;
|
||
FFreqMhzDigits := 3;
|
||
|
||
// OpenGLContextEx (QOpenGLWidget-бэкенд) работает и в нативном Wayland,
|
||
// старая X11-заглушка больше не нужна
|
||
FUseOpenGLSpectrum := HasOpenGLSpectrumSwitch;
|
||
// Панадаптер: владеет view/зум-баром/контролами спектра; контролы создаст
|
||
// FPan.Build в BuildUI. Owner=nil — освобождаем сами в FormDestroy.
|
||
FPan := TPanafallPanel.Create(nil, FUseOpenGLSpectrum);
|
||
FSpecView := FPan.View; // алиас (историческое имя)
|
||
FPanZoomBar := FPan.ZoomBar; // алиас
|
||
// Флаги: контроллер, проводка событий новых флагов, обратные вызовы.
|
||
FPan.Controller := FController;
|
||
FPan.OnWireFlag := WireOverlayEvents;
|
||
FPan.OnSliceClosed := OnPanSliceClosed;
|
||
FPan.OnFlagsInvalidate := OnVfoOverlayInvalidate;
|
||
FPan.AttachMainFlag(FVfoOverlay); // внутри: FSpecView.VfoOverlay := флаг A
|
||
// Стек панов (этап 3.3): пан 0 — в общем массиве; «⊞» добавляет пан.
|
||
FPan.PanId := 0;
|
||
FPan.OnAddPan := OnAddPanClick;
|
||
FPan.OnADCClick := OnPanADCClick; // бейдж RX1/RX2 (3.4, платы с 2 АЦП)
|
||
FPan.OnDisplayClick := OnPanDisplayClick; // «◑» дисплей-поповер (3.6)
|
||
FPans[0] := FPan;
|
||
FPanShare[0] := 1.0;
|
||
FPanColShare[0] := 1.0; // грид «две колонки»: пан 0 — тоже колонка
|
||
FPanGridMode := False;
|
||
FPanGridRowShare := 0.5; // грид: пан 0 — верхняя половина
|
||
FPanCbLock := SyncObjs.TCriticalSection.Create;
|
||
FSpecView.VfoA := FController.FVfoA;
|
||
FSpecView.VfoB := FController.FVfoB;
|
||
FSpecView.ActiveVfo := FController.FActiveVfo;
|
||
FSpecView.CenterFreq := FController.FCenterFreq;
|
||
FSpecView.SpanHz := FController.FSpanHz;
|
||
FSpecView.Mode := FController.FMode;
|
||
FSpecView.FilterBW := FController.FFilterBW;
|
||
FSpecView.AGCTop := FController.FAGCTop;
|
||
FSpecView.WfAGCEnabled := FController.FWfAGCEnabled;
|
||
FSpecView.WfNFEnabled := FController.FWfNFEnabled;
|
||
FSpecView.WfManualHigh := FController.FWfManualHigh;
|
||
FSpecView.WfManualLow := FController.FWfManualLow;
|
||
FSpecView.WfAGCOffset := FController.FWfAGCOffset;
|
||
FSpecView.WfPalette := FController.FWfPalette;
|
||
FSpecView.WfGamma := FController.FWfGamma;
|
||
FSpecView.SpecRefLevel := FController.FSpecRefLevel;
|
||
FSpecView.SpecRange := FController.FSpecRange;
|
||
FSpecView.SpecGridStep := FController.FSpecGridStep;
|
||
// TX overlay: ширина окна = TX_SAMPLE_RATE из движка (фикс 192k)
|
||
FSpecView.TXSpanHz := WDSPEngine.TX_SAMPLE_RATE;
|
||
FSpecView.WfFrameInterval := EffectiveWfInterval;
|
||
FSpecView.PAMaxPower := FController.FPAMaxPower;
|
||
FWidebandView := TWidebandView.Create;
|
||
|
||
// Движки создаёт контроллер (владелец lifecycle — нужно для демона); здесь
|
||
// лишь навешиваем колбэки на методы MainForm. FSampleRate уже загружен из
|
||
// startup-preview выше, поэтому FDSPEngine рождается с нужной частотой.
|
||
FController.CreateEngines(FController.FSampleRate);
|
||
FController.FNetwork.OnDeviceFound := FController.OnDeviceFound;
|
||
FController.FNetwork.OnHPStatus := FController.OnHPStatus;
|
||
FController.FNetwork.OnDDCIQ := FController.OnDDCIQ;
|
||
FController.FNetwork.OnMicPacket := FController.OnMicPacket;
|
||
FController.FNetwork.OnWideband := OnWidebandCB;
|
||
// Дефолты TX, Alex и XVTR (на случай если устройство ещё не выбрано).
|
||
TSettingsManager.DefaultTX(FController.FTXSettings);
|
||
// Заводской набор TX-профилей до подключения (ConnectDevice перечитает свой).
|
||
DefaultTXProfiles(FController.FTXSettings, FController.FDrivePercent,
|
||
FController.FTXProfiles);
|
||
TSettingsManager.DefaultAlex(FController.FAlexSettings);
|
||
TSettingsManager.DefaultOC(FController.FOCSettings);
|
||
TSettingsManager.DefaultXvtr(FController.FXvtrSettings);
|
||
FController.FCurrentXvtr := -1;
|
||
|
||
BuildUI;
|
||
ResizeSMeter;
|
||
ApplyDarkTheme;
|
||
UpdateVfoDisplay;
|
||
UpdateFilterButtons;
|
||
|
||
// DSP Engine.Open (загрузка libwdsp, ~1-2 сек) вызывается при нажатии START.
|
||
FController.FWDSPReady := False;
|
||
FController.FDSPEngine.OnAudio := FController.OnAudioReady;
|
||
FController.FDSPEngine.OnSpectrum := FController.OnSpectrumReady;
|
||
FController.FDSPEngine.OnWaterfall := FController.OnWaterfallReady;
|
||
// Пиксели доп. панов (этап 3.3) — прямо в UI (display-поток; см. FPanCbLock).
|
||
FController.FDSPEngine.OnPanSpectrum := OnPanSpectrumReady;
|
||
FController.FDSPEngine.OnPanWaterfall := OnPanWaterfallReady;
|
||
FController.FDSPEngine.OnTXIQ := FController.OnTXIQ;
|
||
// Web-клиент перехватывает RX-аудио (моно-микс) у контроллера, если подключён.
|
||
FController.OnAudioConsume := FWebAdapter.AudioConsume;
|
||
// Сырьё спектра/водопада из контроллера → рендер FSpecView (UI-подписчик).
|
||
FController.OnSpectrumData := OnSpectrumReady;
|
||
FController.OnWaterfallData := OnWaterfallReady;
|
||
// Sound-card mic путь: WDSP TX-thread дёргает этот колбэк перед каждым
|
||
// тиком, если выбран источник SoundCard. Для Radio колбэк не вызывается.
|
||
FController.FDSPEngine.OnPullMicSamples := FController.PullSoundCardMic;
|
||
|
||
FMeterTimer := TTimer.Create(Self);
|
||
FMeterTimer.Interval := 100;
|
||
FMeterTimer.OnTimer := MeterTimerTick;
|
||
FMeterTimer.Enabled := True;
|
||
|
||
|
||
FSpectrumTimer := TTimer.Create(Self);
|
||
FSpectrumTimer.Interval := 50;
|
||
FSpectrumTimer.OnTimer := SpectrumTimerTick;
|
||
FSpectrumTimer.Enabled := True;
|
||
|
||
// Однократный таймер — инициализация аудио после показа формы
|
||
FAfterShowTimer := TTimer.Create(Self);
|
||
FAfterShowTimer.Interval := 200;
|
||
FAfterShowTimer.OnTimer := AfterShowTick;
|
||
FAfterShowTimer.Enabled := True;
|
||
|
||
OnMouseWheel := FormMouseWheel;
|
||
|
||
// Проверяем и при необходимости добавляем исключение в Windows Firewall
|
||
// (UDP входящий трафик для HPSDR Protocol 2)
|
||
{$IFDEF WINDOWS}
|
||
FirewallEnsureAllowed(ParamStr(0), 'OpenHPSDR Transceiver');
|
||
{$ENDIF}
|
||
FUIReady := True; // с этого момента состояние контроллера можно рендерить
|
||
end;
|
||
|
||
procedure TMainForm.FormDestroy(Sender: TObject);
|
||
begin
|
||
FMeterTimer.Enabled := False;
|
||
FSpectrumTimer.Enabled := False;
|
||
// Грациозный стоп радио (Run=0) до освобождения; сами движки закрывает и
|
||
// освобождает TRadioController.Destroy (FreeEngines) ниже.
|
||
if FController.FNetwork.Running then
|
||
FController.FNetwork.SetRunAndFreq(False, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), 0);
|
||
FController.FNetwork.Disconnect;
|
||
// Панадаптер освобождает view, зум-бар и свои контролы; алиасы обнуляем.
|
||
FSpecView := nil;
|
||
FPanZoomBar := nil;
|
||
PbSpectrum := nil;
|
||
PbRuler := nil;
|
||
PanelSplitter := nil;
|
||
PbWaterfall := nil;
|
||
PbPanZoom := nil;
|
||
DestroyAllExtraPans; // доп. паны (DDC/движок/панели) — до пана 0
|
||
FreeAndNil(FPan);
|
||
FPans[0] := nil;
|
||
FreeAndNil(FPanCbLock);
|
||
FreeAndNil(FWidebandView);
|
||
|
||
// Сохраняем настройки при закрытии
|
||
if FController.FDevConnected then
|
||
begin
|
||
SaveCurrentBand;
|
||
FController.SaveGlobalSettings;
|
||
if FController.FDevConnected then
|
||
FController.FSettings.SaveTX(FController.FDevMAC, FController.FTXSettings);
|
||
end;
|
||
// Размер окна сохраняем всегда (не зависит от подключения)
|
||
FController.FSettings.SaveStartupPreview(FController.FVfoA, FController.FVfoB, FController.FSampleRate);
|
||
FController.FSettings.Save;
|
||
FreeAndNil(FChannelController); // адаптер каналов (не владеет store/контроллером)
|
||
FreeAndNil(FController.FChannelStore);
|
||
FWebServer.Stop;
|
||
FreeAndNil(FWebAdapter); // адаптер не владеет сервером — освобождаем после Stop
|
||
FWebServer.Free;
|
||
FreeAndNil(FCATAdapter); // его Destroy останавливает+освобождает CAT движок/транспорты
|
||
// Ядро освобождаем последним — его Destroy закрывает и освобождает движки
|
||
// (FreeEngines) и FSettings.
|
||
FreeAndNil(FController);
|
||
end;
|
||
|
||
// Маршалинг команд контроллера в UI-поток (фронтенды зовут FController.Invoke).
|
||
// В режиме демона хозяин подставит другой механизм (очередь/прямой вызов).
|
||
procedure TMainForm.DoInvoke(M: TThreadMethod);
|
||
begin
|
||
TThread.Synchronize(nil, M);
|
||
end;
|
||
|
||
// Контроллер изменил состояние — обновляем соответствующие виджеты.
|
||
// Выполняется в UI-потоке (команды контроллера вызываются из UI-потока либо
|
||
// маршалятся адаптерами). FSyncingFromController защищает от рекурсии, когда
|
||
// программное изменение виджета триггерит его OnChange.
|
||
procedure TMainForm.OnControllerState(Sender: TObject; Field: TRadioField);
|
||
var
|
||
i: Integer;
|
||
SavedFilter, SavedFilterBW: Integer;
|
||
begin
|
||
if not FUIReady then Exit; // виджетов ещё нет — рисовать нечего
|
||
FSyncingFromController := True;
|
||
try
|
||
case Field of
|
||
rfVfoA:
|
||
begin
|
||
FreqDispA.Frequency := Round(FController.FVfoA);
|
||
// Не рисуем синхронно из wheel/drag: частые события забивают UI-поток,
|
||
// а мгновенный Invalidate давал GL-кадр на КАЖДОЕ событие мыши (при
|
||
// драге рендер упирался в vsync и обходил FPS-гейт — на AMD APU это
|
||
// трещало в HDMI-звуке того же GPU). Кадр отрисует ближайший тик
|
||
// FSpectrumTimer по FSpectrumDirty. Линейка дешёвая — её сразу.
|
||
SyncSpecViewFreq;
|
||
FSpectrumDirty := True;
|
||
if PbRuler <> nil then PbRuler.Invalidate;
|
||
if FController.FLastVfoScrolled then
|
||
FWaterfallDirty := True;
|
||
end;
|
||
rfActiveVfo:
|
||
begin
|
||
UpdateVfoDisplay; // дисплеи A/B + подсветка RX/TX-кнопок
|
||
FSpecView.InvalidateRulerCache;
|
||
SyncSpecViewFreq;
|
||
FSpecView.DrawSpectrum;
|
||
PbSpectrum.Invalidate;
|
||
FSpecView.DrawWaterfall;
|
||
PbWaterfall.Invalidate;
|
||
if PbRuler <> nil then PbRuler.Invalidate;
|
||
end;
|
||
rfCenterFreq:
|
||
begin
|
||
// CTUN-скролл центра (drag/web): ruler-метки сдвинулись → инвалидируем
|
||
// кэш; спектр откладываем (FSpectrumDirty) — drag шлёт много событий,
|
||
// кадр отрисует таймер (FPS-гейт, см. rfVfoA).
|
||
FSpecView.InvalidateRulerCache;
|
||
SyncSpecViewFreq;
|
||
FSpectrumDirty := True;
|
||
if PbRuler <> nil then PbRuler.Invalidate;
|
||
end;
|
||
rfVfoB:
|
||
begin
|
||
FreqDispB.Frequency := Round(FController.FVfoB);
|
||
if FController.FActiveVfo = 1 then
|
||
begin
|
||
FSpecView.InvalidateRulerCache;
|
||
SyncSpecViewFreq;
|
||
FSpectrumDirty := True; // кадр — по таймеру (FPS-гейт, см. rfVfoA)
|
||
if PbRuler <> nil then PbRuler.Invalidate;
|
||
end;
|
||
end;
|
||
rfTransmitting:
|
||
begin
|
||
if FController.FTransmitting then
|
||
begin
|
||
BtnMOX.ClrNorm := TColor($00000044);
|
||
BtnMOX.ClrText := TColor($000000FF);
|
||
BtnMOX.ClrTextAct := TColor($000000FF);
|
||
BtnMOX.Active := True;
|
||
end else
|
||
StyleButton(BtnMOX, False);
|
||
// TX-overlay в SpectrumView: TX-частота/VFO-индекс (по селектору
|
||
// FSplitTxB, независимо от активного RX-VFO) и режим.
|
||
FSpecView.TXFreq := ActiveTXFreqHz;
|
||
if FController.FSplitTxB then FSpecView.TXVfoIndex := 1
|
||
else FSpecView.TXVfoIndex := 0;
|
||
// DUP: данные от RX-анализатора (TXMode=False), TX-полоса рисуется
|
||
// поверх через TXOverlay. Без DUP при TX оба флага True.
|
||
FSpecView.TXMode := FController.FTransmitting and not FController.FDisplayDuplex;
|
||
// Красная полоса главного VFO — только когда передаём с него: при
|
||
// TX со слайса краснеет полоса ТОГО слайса (DrawSliceFilterMarkers).
|
||
// ★RadioKeyed, а не FTransmitting: в телеграфе ключ замыкает прошивка,
|
||
// софт «не передаёт», и полоса оставалась бы приёмной всю связь.
|
||
FSpecView.TXOverlay := FController.RadioKeyed and
|
||
(FController.FTxSliceId = 0);
|
||
PushFilterEdgesToView; // на передаче полоса рисуется по TX-фильтру
|
||
ApplySpecViewGridFromState; // сетка спектра под активный тракт
|
||
RefreshTxIndicators; // TX-бейджи флагов (какой слайс передаёт)
|
||
UpdateTXControlAvailability;
|
||
UpdateIMDMarkers; // 2TON: маркеры IMD на TX-спектре
|
||
end; // (DUC-очередь сбрасывает SetMOX)
|
||
rfTuning:
|
||
StyleButton(BtnTUN, FController.FTuning);
|
||
rfPureSignal, rfTwoTone:
|
||
UpdatePSButtons; // подсветка PS/2TON + обновление статусов поповера
|
||
rfRunning:
|
||
// Run-состояние: кнопка START/STOP, доступность MOX, запрет сна.
|
||
// Текст offline-статуса контекстно-зависим (Disconnected / Connection
|
||
// lost / …) — его ставит вызывающий, не общий рендер.
|
||
if FController.FRunning then
|
||
begin
|
||
InhibitSleep;
|
||
BtnStartStop.Caption := 'STOP';
|
||
StyleButton(BtnStartStop, True);
|
||
UpdateTXControlAvailability;
|
||
UpdateAddPanButton; // «⊞» доступна только в Run
|
||
ResizeSpectrumPanels;
|
||
RestorePanWorkspace; // паны+слайсы прошлой сессии
|
||
end
|
||
else
|
||
begin
|
||
UninhibitSleep;
|
||
BtnStartStop.Caption := 'START';
|
||
StyleButton(BtnStartStop, False);
|
||
UpdateTXControlAvailability;
|
||
// Снимок панов уже сделан хуком OnBeforeStop=SavePanWorkspace (до
|
||
// гашения DDC в StopRunning). Здесь только разрешаем повторный
|
||
// restore на следующем старте.
|
||
FPansRestored := False;
|
||
// Доп. паны живут только в Run-сессии (их DDC уже погашены
|
||
// контроллером в StopRunning) — убираем и UI.
|
||
DestroyAllExtraPans;
|
||
UpdateAddPanButton;
|
||
ResizeSpectrumPanels;
|
||
end;
|
||
rfADCOverload:
|
||
if FSpecView <> nil then
|
||
begin
|
||
FSpecView.ADCOverloadVisible := FController.FADCOverloadActive;
|
||
if PbSpectrum <> nil then PbSpectrum.Invalidate;
|
||
end;
|
||
rfBand:
|
||
// В XVTR-режиме HF-кнопки гасим (активна XVTR-кнопка, см. rfXvtr).
|
||
for i := 0 to BAND_COUNT - 1 do
|
||
StyleButton(BtnBand[i],
|
||
(FController.FCurrentXvtr < 0) and (i = FController.FCurrentBand));
|
||
rfXvtr:
|
||
begin
|
||
for i := 0 to CFG_XVTR_COUNT - 1 do
|
||
if BtnXvtrBand[i] <> nil then
|
||
StyleButton(BtnXvtrBand[i], i = FController.FCurrentXvtr);
|
||
UpdateFreqDisplayMax; // трансвертер может быть >6 ГГц (QO-100)
|
||
// BEACON-кнопка — только Pluto в трансвертере (QO-100 lock); для
|
||
// openHPSDR смысла не имеет (нет дрейфующего LNB → прячем).
|
||
if BtnBeacon <> nil then
|
||
begin
|
||
// Только Pluto + full-duplex транспондер (QO-100) — beacon-lock завязан
|
||
// на дрейф LNB, в обычных диапазонах/трансвертерах смысла нет.
|
||
BtnBeacon.Visible := FController.InQO100;
|
||
UpdateBeaconButton;
|
||
end;
|
||
UpdateRxMuteButton; // RX MUTE — только Pluto + full-duplex (QO-100)
|
||
UpdateBandPlanOverlay; // полоска бэндплана QO-100
|
||
// Wideband-вид + web-зеркало xvtr (ранее в обёртках Activate/Deactivate).
|
||
// rfXvtr эмитят ТОЛЬКО ActivateXvtr/DeactivateXvtr — состояние финальное.
|
||
UpdateWidebandFrequencyView;
|
||
if PbWideband <> nil then PbWideband.Invalidate;
|
||
if PbWidebandRuler <> nil then PbWidebandRuler.Invalidate;
|
||
PushXvtrToWeb;
|
||
end;
|
||
rfBandRestore:
|
||
// Явная смена диапазона: сброс порогов водопада (не на каждом тюне).
|
||
FSpecView.ResetWfAvgBuf;
|
||
rfMute:
|
||
begin
|
||
StyleButton(BtnMute, FController.FMuted);
|
||
if FController.FMuted then BtnMute.Caption := 'UNMUTE'
|
||
else BtnMute.Caption := 'MUTE';
|
||
BtnMute.Invalidate;
|
||
end;
|
||
rfNR: UpdateNRButton;
|
||
rfNB: UpdateNBButton;
|
||
rfSNB: UpdateSNBButton;
|
||
rfANF: UpdateANFButton;
|
||
rfMode:
|
||
begin
|
||
for i := 0 to MODE_COUNT - 1 do StyleButton(BtnMode[i], i = FController.FMode);
|
||
// UpdateFilterButtons (для не-FM) мутирует FFilter → сбрасывает на дефолт
|
||
// режима. Это поведение нужно при СМЕНЕ режима (web/channel), но не при
|
||
// пассивном рендере: RestoreBand/ActivateXvtr уже задали фильтр из кэша.
|
||
// На реальной смене режима контроллер выставляет дефолт через
|
||
// ApplyModeDefaults ДО rfMode, так что сохраняется именно он — поведение
|
||
// не меняется. Последующий rfFilter стилит кнопки под восстановленный фильтр.
|
||
SavedFilter := FController.FFilter;
|
||
SavedFilterBW := FController.FFilterBW;
|
||
UpdateFilterButtons; // подписи + FM-панели под режим
|
||
// Возвращаем СЛОТ целиком: кромки — истина, одной ширины мало.
|
||
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;
|
||
// Пилюля профиля во флаге A: привязка ключуется режимом, значит на
|
||
// смене модуляции она показывает уже другой профиль. Флаг главного
|
||
// приёмника живёт только на пане 0 — как в RefreshTxIndicators.
|
||
FPan.PushMainFlagExtState;
|
||
end;
|
||
rfFilter:
|
||
begin
|
||
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
|
||
begin
|
||
TrkRawDev.Position := Round(FController.FRawFMDeviation) div 50;
|
||
LblRawDev.Caption := Format('%.2f k', [FController.FRawFMDeviation / 1000.0]);
|
||
end;
|
||
rfAGCMode:
|
||
for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = FController.FAGCMode);
|
||
rfVolume:
|
||
begin
|
||
TrkVolume.Position := FController.ActiveVolume; // RX или TX-монитор
|
||
UpdateVolumeLabel;
|
||
end;
|
||
rfRxGain:
|
||
UpdateRxGainUI;
|
||
rfAtten:
|
||
if TrkAtt <> nil then
|
||
begin
|
||
TrkAtt.Position := FController.FAtten;
|
||
UpdateAttLabel;
|
||
end;
|
||
rfDrive:
|
||
begin
|
||
TrkDrive.Position := FController.FDrivePercent; // слайдер ← drive%
|
||
UpdateDriveLabel; // % / дБ рядом с ползунком
|
||
FChannelController.OnDriveChanged; // внешнее изменение снимает память канала
|
||
// Мощность — часть TX-профиля: держим подпись в настройках свежей.
|
||
if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then
|
||
TSettingsForm(FSettingsForm).SetTXProfileDrive(FController.FDrivePercent);
|
||
end;
|
||
rfTXProfile:
|
||
RenderTXProfile;
|
||
rfAGCTop:
|
||
begin
|
||
TrkAGC.Position := FController.FAGCTop;
|
||
LblAGCTop.Caption := Format('%ddB', [FController.FAGCTop]);
|
||
FSpecView.AGCTop := FController.FAGCTop;
|
||
FSpecView.DrawSpectrum;
|
||
PbSpectrum.Invalidate;
|
||
end;
|
||
rfBeaconLock:
|
||
UpdateBeaconButton; // статус/коррекция QO-100 beacon lock
|
||
rfCTun:
|
||
begin
|
||
StyleButton(BtnCTun, FController.FCTun);
|
||
FSpecView.InvalidateRulerCache;
|
||
SyncSpecViewFreq;
|
||
FSpecView.DrawSpectrum;
|
||
FSpecView.DrawWaterfall;
|
||
PbSpectrum.Invalidate;
|
||
PbWaterfall.Invalidate;
|
||
if PbRuler <> nil then PbRuler.Invalidate;
|
||
end;
|
||
rfDuplex:
|
||
begin
|
||
// Единая точка рендера DUP — для кнопки И для авто-вкл в QO-100 (оба идут
|
||
// через SetDuplex → rfDuplex). Раньше QO-100 включал DUP, но кнопку не
|
||
// подсвечивал → визуал расходился с состоянием.
|
||
if BtnDUP <> nil then StyleButton(BtnDUP, FController.FDisplayDuplex);
|
||
UpdateRxMuteButton; // активность RX MUTE зависит от DUP
|
||
UpdateIMDMarkers; // IMD-маркеры 2TON живут только в DUP
|
||
// На передаче переключаем источник пикселей и сбрасываем буферы, чтобы
|
||
// старые TX/RX bin'ы не оставались на следующем кадре.
|
||
if FController.FTransmitting then
|
||
begin
|
||
if FController.FWDSPReady then
|
||
FController.FDSPEngine.SetDisplaySourceTX(not FController.FDisplayDuplex);
|
||
FSpecView.TXMode := FController.FTransmitting and not FController.FDisplayDuplex;
|
||
FSpecView.ResetSpectrumBuf;
|
||
FSpecView.ResetWfAvgBuf;
|
||
ApplySpecViewGridFromState;
|
||
FSpecView.DrawSpectrum;
|
||
FSpecView.DrawWaterfall;
|
||
PbSpectrum.Invalidate;
|
||
PbWaterfall.Invalidate;
|
||
end;
|
||
end;
|
||
rfRxMuteOnTx:
|
||
UpdateRxMuteButton; // QO-100 self-monitor toggle: подсветка кнопки RX MUTE
|
||
rfWfAGC:
|
||
FSpecView.WfAGCEnabled := FController.FWfAGCEnabled;
|
||
rfWfNF:
|
||
FSpecView.WfNFEnabled := FController.FWfNFEnabled;
|
||
rfFMStepIdx:
|
||
begin
|
||
if FStepDropDown <> nil then FStepDropDown.SetItemIndex(FController.FFMStepIdx);
|
||
if FWebServer <> nil then FWebServer.FMStepIdx := FController.FFMStepIdx;
|
||
if (FController.FMode in [MODE_FM, MODE_DMR, MODE_FMRAW]) and
|
||
(FSpecView <> nil) then
|
||
FSpecView.FMGridStepHz := FM_STEP_HZ[FController.FFMStepIdx];
|
||
end;
|
||
rfFMStep:
|
||
if BtnFMStep <> nil then
|
||
begin
|
||
StyleButton(BtnFMStep, FController.FFMStepOn);
|
||
BtnFMStep.Repaint;
|
||
end;
|
||
rfFMSQ:
|
||
begin
|
||
StyleButton(BtnFMSQ, FController.FFMSQOn);
|
||
BtnFMSQ.Repaint;
|
||
end;
|
||
rfFMSQLevel:
|
||
begin
|
||
TrkFMSQ.Position := FController.FFMSQLevel;
|
||
if LblFMSQ <> nil then LblFMSQ.Caption := IntToStr(FController.FFMSQLevel);
|
||
end;
|
||
rfFMCTCSS:
|
||
begin
|
||
StyleButton(BtnFMCTCSS, FController.FFMCTCSSOn);
|
||
BtnFMCTCSS.Repaint;
|
||
end;
|
||
rfFMCTCSSTone:
|
||
if FCTCSSDropDown <> nil then FCTCSSDropDown.SetItemIndex(FController.FFMCTCSSToneIdx);
|
||
rfSampleRate:
|
||
begin
|
||
if Assigned(FSampleRateOverlay) then
|
||
FSampleRateOverlay.SetCurrentRate(FController.FSampleRate);
|
||
// ChangeSampleRate пересоздал WDSP-канал — пересчёт AGC-линий под
|
||
// актуальную ширину спектра (UI-зависимая величина).
|
||
if FController.FWDSPReady then
|
||
FController.FDSPEngine.UpdateAGCLines(FPan.SpectrumWidth);
|
||
UpdatePanZoomBar;
|
||
end;
|
||
rfZoom:
|
||
begin
|
||
// Зум/пан изменились: окно просмотра, шаг линейки и полоса фильтра
|
||
// следуют за span → пересинк + полная перерисовка спектра и водопада.
|
||
FSpecView.InvalidateRulerCache;
|
||
SyncSpecViewFreq;
|
||
UpdatePanZoomBar;
|
||
FPan.LayoutFlags;
|
||
FSpecView.DrawSpectrum;
|
||
PbSpectrum.Invalidate;
|
||
FSpecView.DrawWaterfall;
|
||
if PbWaterfall <> nil then PbWaterfall.Invalidate;
|
||
if PbRuler <> nil then PbRuler.Invalidate;
|
||
end;
|
||
rfFMRpt:
|
||
begin
|
||
if BtnFMRptMinus <> nil then
|
||
begin
|
||
StyleButton(BtnFMRptMinus, FController.FFMRptDir = RPT_MINUS);
|
||
BtnFMRptMinus.Repaint;
|
||
end;
|
||
if BtnFMRptPlus <> nil then
|
||
begin
|
||
StyleButton(BtnFMRptPlus, FController.FFMRptDir = RPT_PLUS);
|
||
BtnFMRptPlus.Repaint;
|
||
end;
|
||
// Текст оффсета не трогаем, пока пользователь печатает в поле.
|
||
if (EdFMRptOffset <> nil) and not EdFMRptOffset.Focused then
|
||
EdFMRptOffset.Text := RptFormatOffset(FController.FFMRptOffsetHz);
|
||
end;
|
||
rfDevice:
|
||
begin
|
||
// Полная синхронизация device-виджетов после ConnectDevice (load-by-MAC).
|
||
// Состояние уже в контроллере; здесь только рендер + UI/CAT/web-побочки.
|
||
FCATLastGlobal := FController.FLoadedGlobal;
|
||
// Тема — app-global (своё хранилище LoadTheme/SaveTheme), не из device-блоба:
|
||
// возвращаем живое значение в persist-буфер, чтобы blob нёс актуальную тему.
|
||
FController.FLoadedGlobal.LightTheme := FLightTheme;
|
||
FCATAdapter.ApplySettings(FController.FLoadedGlobal);
|
||
ApplySliceSlotAudio;
|
||
RelabelBands; // HF ↔ VHF/UHF подписи под тип бэкенда
|
||
ApplyBackendCapsToUI; // пресеты sample rate + гейтинг полей
|
||
RebuildXvtrButtons;
|
||
PushXvtrToWeb;
|
||
TrkDrive.Position := FController.FDrivePercent; // слайдер ← drive%
|
||
TrkVolume.Position := FController.ActiveVolume; // слайдер ← загруженная громкость (был баг: не синхронизировался)
|
||
UpdateDriveLabel; // переключаем единицу %/дБ под бэкенд
|
||
UpdateVolumeLabel;
|
||
if Assigned(FSampleRateOverlay) then
|
||
FSampleRateOverlay.SetCurrentRate(FController.FSampleRate);
|
||
UpdateNRButton; UpdateNBButton; UpdateSNBButton; UpdateANFButton;
|
||
FSpecView.WfAGCEnabled := FController.FWfAGCEnabled;
|
||
FSpecView.WfNFEnabled := FController.FWfNFEnabled;
|
||
FSpecView.WfManualHigh := FController.FWfManualHigh;
|
||
FSpecView.WfManualLow := FController.FWfManualLow;
|
||
FSpecView.WfAGCOffset := FController.FWfAGCOffset;
|
||
FSpecView.WfPalette := FController.FWfPalette;
|
||
FSpecView.WfGamma := FController.FWfGamma;
|
||
FSpecView.WfFrameInterval := EffectiveWfInterval;
|
||
ApplySpecMSAA(FController.FSpecMSAA);
|
||
FSpecView.ResetWfAvgBuf;
|
||
if Assigned(FWidebandView) then
|
||
FWidebandView.SetFillSpectrum(FController.FWidebandFill);
|
||
FSpecView.FillSpectrum := FController.FSpectrumFill;
|
||
if BtnDUP <> nil then StyleButton(BtnDUP, FController.FDisplayDuplex);
|
||
RenderTXProfile; // список профилей этого устройства (загружен по MAC)
|
||
if FController.FLoadedGlobal.DisplayFPS > 0 then
|
||
ApplyFPS(FController.FLoadedGlobal.DisplayFPS);
|
||
ApplyFreqMhzDigits(EnsureRange(FController.FLoadedGlobal.FreqMhzDigits, 3, 5));
|
||
if FController.FLoadedGlobal.FFTSize > 0 then
|
||
begin
|
||
FSpecView.SpecRefLevel := FController.FSpecRefLevel;
|
||
FSpecView.SpecRange := FController.FSpecRange;
|
||
FSpecView.SpecGridStep := FController.FSpecGridStep;
|
||
InvalidateGridCache;
|
||
end;
|
||
end;
|
||
|
||
rfSliceFreq:
|
||
begin
|
||
// Слайс перестроен по своему CAT-порту в пределах захваченной полосы:
|
||
// окно DDC не двигалось (rfPanFreq не придёт), но цифры и полоса
|
||
// фильтра во флаге — его собственные поля, их надо перезалить.
|
||
PushFlagStateAllPans(FController.FSliceFreqId);
|
||
LayoutFlagsAllPans;
|
||
for i := 0 to MAX_PANS - 1 do
|
||
if FPans[i] <> nil then MarkPanDirty(FPans[i]);
|
||
end;
|
||
|
||
rfPanFreq:
|
||
begin
|
||
// Центр доп. пана переехал не из UI (CAT слайса перестроил его за
|
||
// пределы захваченной полосы) — переложить флаги/шапку/зум-бар.
|
||
for i := 0 to MAX_PANS - 1 do
|
||
if FPans[i] <> nil then
|
||
begin
|
||
SyncPanZoomUI(FPans[i]);
|
||
FPans[i].LayoutFlags;
|
||
FPans[i].PushAllSliceFlagStates;
|
||
MarkPanDirty(FPans[i]);
|
||
end;
|
||
UpdatePanHeaders;
|
||
end;
|
||
rfDeviceList:
|
||
begin
|
||
// Discovery обновил список устройств (или завершился без них).
|
||
if Assigned(FDeviceDialog) then
|
||
begin
|
||
if FController.FDiscoverNoneFound then
|
||
begin
|
||
FDeviceDialog.NoDevicesFound;
|
||
SetStatusText(2, 'No hardware found');
|
||
end
|
||
else
|
||
begin
|
||
FDeviceDialog.RefreshFound;
|
||
if FController.FDeviceStore.DiscoveredCount > 0 then
|
||
SetStatusText(2, Format('Found: %s',
|
||
[FController.FDeviceStore.Discovered(
|
||
FController.FDeviceStore.DiscoveredCount - 1).IPAddress]));
|
||
end;
|
||
end;
|
||
PushDeviceListToWeb; // web-overlay тоже получает обновлённый список
|
||
end;
|
||
end;
|
||
// Если флаг A виден при ОТКРЫТОЙ панели (есть слайсы B+), держим его в
|
||
// синхроне с DSP/режимом/полосой/AGC, пришедшими с ЛЕВОЙ панели. При скрытой
|
||
// панели флаг правит себя сам (DoHit), синк не нужен. Только дискретные поля —
|
||
// SetState/SetDSPState безусловно инвалидируют (не звать на потоковых rfVfoA).
|
||
if Assigned(FVfoOverlay) and FVfoOverlay.Visible and (not FPanelHidden) and
|
||
(Field in [rfMode, rfFilter, rfNR, rfNB, rfSNB, rfANF, rfAGCMode,
|
||
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;
|
||
finally
|
||
FSyncingFromController := False;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.FormClose(Sender: TObject; var CloseAction: TCloseAction);
|
||
begin
|
||
// Сохраняем позицию здесь — окно ещё полностью видимо и стабильно.
|
||
// В FormDestroy Qt-виджет может быть уже в нестабильном состоянии.
|
||
SaveWindowBounds;
|
||
// Останавливаем трансивер как при нажатии STOP
|
||
if FController.FNetwork.Connected then
|
||
begin
|
||
FController.StopRunning;
|
||
FController.FNetwork.Disconnect;
|
||
end;
|
||
CloseAction := caFree;
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// Build UI (без анонимных процедур и inline var)
|
||
// ===========================================================================
|
||
|
||
procedure TMainForm.BuildUI;
|
||
const
|
||
BTN_H = 26;
|
||
BTN_SM = 24;
|
||
LEFT_W = LEFT_PANEL_CONTENT_W;
|
||
// Заголовок с отступом (20) + MODE_ROWS рядов с шагом 27 + нижний отступ.
|
||
MODE_PANEL_H = 20 + (MODE_ROWS - 1) * 27 + BTN_SM + 3;
|
||
PLUTO_AGC_H = 64; // высота отдельного Pluto RF-AGC блока (под AGC)
|
||
LEFT_SLIDER_LEFT = 56;
|
||
LEFT_SLIDER_W = LEFT_W - 96;
|
||
LEFT_SLIDER_VALUE_LEFT = LEFT_W - 38;
|
||
TOP_VFO_W = 244;
|
||
TOP_VFO_FREQ_H = 47;
|
||
var
|
||
i, X, Y, W: Integer;
|
||
B: TFlatButton;
|
||
SepLine: TPanel;
|
||
|
||
function MakeBtn(Parent: TWinControl; const Cap: string;
|
||
ALeft, ATop, AW, AH: Integer; Handler: TNotifyEvent): TFlatButton;
|
||
begin
|
||
Result := TFlatButton.Create(Self);
|
||
Result.Parent := Parent;
|
||
Result.Caption := Cap;
|
||
Result.Left := ALeft;
|
||
Result.Top := ATop;
|
||
Result.Width := AW;
|
||
Result.Height := AH;
|
||
Result.OnClick := Handler;
|
||
StyleButton(Result, False);
|
||
end;
|
||
|
||
procedure MakeLbl(Parent: TWinControl; const Cap: string; ALeft, ATop: Integer);
|
||
var
|
||
L: TLabel;
|
||
begin
|
||
L := TLabel.Create(Self);
|
||
L.Parent := Parent;
|
||
L.Caption := Cap;
|
||
L.Left := ALeft;
|
||
L.Top := ATop;
|
||
L.Font.Color := CLR_TEXTDIM;
|
||
L.Font.Size := 8;
|
||
end;
|
||
|
||
begin
|
||
// ---- Toolbar ----
|
||
PanelToolbar := TPanel.Create(Self);
|
||
PanelToolbar.Parent := Self;
|
||
PanelToolbar.Align := alTop;
|
||
PanelToolbar.Height := 72;
|
||
PanelToolbar.BevelOuter := bvNone;
|
||
PanelToolbar.Color := CLR_PANEL;
|
||
|
||
|
||
X := 4;
|
||
BtnDiscover := MakeBtn(PanelToolbar, 'DISCOVER', X, 3, 80, BTN_H, BtnDiscoverClick);
|
||
BtnStartStop := MakeBtn(PanelToolbar, 'START', X+84, 3, 76, BTN_H, BtnStartStopClick);
|
||
BtnSettings := MakeBtn(PanelToolbar, 'SETUP', X+164, 3, 66, BTN_H, BtnSettingsClick);
|
||
|
||
// DISCOVER — синеватый акцент
|
||
BtnDiscover.ClrNorm := TColor($00101828);
|
||
BtnDiscover.ClrHot := TColor($00182038);
|
||
BtnDiscover.ClrActive := TColor($00182038);
|
||
BtnDiscover.ClrBorder := TColor($00304860);
|
||
BtnDiscover.ClrText := TColor($0090B8D0);
|
||
BtnDiscover.ClrTextAct := TColor($00B0D0F0);
|
||
// START/STOP — зелёный тон в обоих состояниях
|
||
BtnStartStop.ClrNorm := TColor($00101810);
|
||
BtnStartStop.ClrBorder := TColor($00304830);
|
||
BtnStartStop.ClrText := TColor($0090C090);
|
||
|
||
// Линия-разделитель внутри PanelToolbar, прижата к его нижнему краю
|
||
SepLine := TPanel.Create(Self);
|
||
SepLine.Parent := PanelToolbar;
|
||
SepLine.BevelOuter := bvNone;
|
||
SepLine.Color := TColor($00606060);
|
||
SepLine.SetBounds(16, PanelToolbar.Height - 10, PanelToolbar.Width - 32, 2);
|
||
SepLine.Anchors := [akLeft, akRight, akBottom];
|
||
|
||
// ---- Status bar ----
|
||
StatusPanel := TMainStatusBar.Create(Self);
|
||
StatusPanel.Parent := Self;
|
||
|
||
// ---- Left panel ----
|
||
PanelLeft := TPanel.Create(Self);
|
||
PanelLeft.Parent := Self;
|
||
PanelLeft.Align := alLeft;
|
||
// Узкий правый gutter зарезервирован постоянно: появление overlay-scrollbar
|
||
// не меняет ширину панели и не дёргает область спектра.
|
||
PanelLeft.Width := LEFT_W + LEFT_SCROLL_GUTTER_W;
|
||
PanelLeft.BevelOuter := bvNone;
|
||
|
||
FLeftScrollOffset := 0;
|
||
FLeftScrollBar := TOverlayScrollBar.Create(Self);
|
||
FLeftScrollBar.Parent := PanelLeft;
|
||
FLeftScrollBar.Align := alRight;
|
||
FLeftScrollBar.Width := LEFT_SCROLL_GUTTER_W;
|
||
FLeftScrollBar.HoverTarget := PanelLeft;
|
||
FLeftScrollBar.OnPositionChange := LeftScrollChanged;
|
||
|
||
Y := 0;
|
||
|
||
// Compact top VFO group
|
||
PanelTopVfoGroup := TPanel.Create(Self);
|
||
PanelTopVfoGroup.Parent := PanelToolbar;
|
||
PanelTopVfoGroup.SetBounds(0, 0, TOP_VFO_W * 2 + 64, 60);
|
||
PanelTopVfoGroup.BevelOuter := bvNone;
|
||
PanelTopVfoGroup.Color := TColor($00181818);
|
||
|
||
// VFO A
|
||
PanelTopVfoA := TPanel.Create(Self);
|
||
PanelTopVfoA.Parent := PanelTopVfoGroup;
|
||
PanelTopVfoA.SetBounds(0, 0, TOP_VFO_W, 60);
|
||
PanelTopVfoA.BevelOuter := bvNone;
|
||
PanelTopVfoA.Color := CLR_PANEL;
|
||
|
||
PanelVfoA := TPanel.Create(Self);
|
||
PanelVfoA.Parent := PanelTopVfoA;
|
||
PanelVfoA.SetBounds(0, 0, TOP_VFO_W, TOP_VFO_FREQ_H);
|
||
PanelVfoA.BevelOuter := bvNone;
|
||
|
||
BtnTopVfoASelect := MakeBtn(PanelVfoA, 'A', 0, 5, 20, 18, BtnTopVfoASelectClick);
|
||
BtnTopVfoASelect.Font.Size := 8;
|
||
BtnTopVfoASelect.Font.Bold := True;
|
||
BtnTopVfoASelect.ClrNorm := TColor($00303030);
|
||
BtnTopVfoASelect.ClrHot := TColor($00404040);
|
||
BtnTopVfoASelect.ClrActive := TColor($00404040);
|
||
BtnTopVfoASelect.ClrBorder := TColor($00505050);
|
||
BtnTopVfoASelect.ClrText := CLR_TEXTDIM;
|
||
BtnTopVfoASelect.ClrTextAct := CLR_TEXT;
|
||
|
||
BtnTopVfoATX := MakeBtn(PanelVfoA, 'TX', 0, 25, 20, 18, BtnTopVfoATXClick);
|
||
BtnTopVfoATX.Font.Size := 8;
|
||
BtnTopVfoATX.Font.Bold := True;
|
||
BtnTopVfoATX.ClrNorm := TColor($00303030);
|
||
BtnTopVfoATX.ClrHot := TColor($00404040);
|
||
BtnTopVfoATX.ClrActive := TColor($00404040);
|
||
BtnTopVfoATX.ClrBorder := TColor($00505050);
|
||
BtnTopVfoATX.ClrText := CLR_TEXTDIM;
|
||
BtnTopVfoATX.ClrTextAct := CLR_TEXT;
|
||
|
||
LblVfoALabel := TLabel.Create(Self);
|
||
LblVfoALabel.Parent := PanelVfoA;
|
||
LblVfoALabel.Caption := 'VFO-A';
|
||
LblVfoALabel.Left := 0; LblVfoALabel.Top := 0;
|
||
LblVfoALabel.Font.Size := 8;
|
||
LblVfoALabel.Font.Color := CLR_TEXTDIM;
|
||
LblVfoALabel.Visible := False;
|
||
|
||
FreqDispA := TFreqDisplay.Create(Self);
|
||
FreqDispA.Parent := PanelVfoA;
|
||
FreqDispA.SetBounds(20, -2, TOP_VFO_W - 20, TOP_VFO_FREQ_H);
|
||
FreqDispA.FontSize := TOP_VFO_FONT;
|
||
FreqDispA.FontName := 'Courier New';
|
||
FreqDispA.Frequency := Round(FController.FVfoA);
|
||
FreqDispA.ColorNormal := CLR_FREQ;
|
||
FreqDispA.ColorHover := TColor($0040DDFF);
|
||
FreqDispA.ColorDim := CLR_TEXTDIM;
|
||
FreqDispA.MinMhzDigits := 3;
|
||
FreqDispA.DimLeadingZeros := True;
|
||
FreqDispA.CenterText := False;
|
||
FreqDispA.OnChange := FreqDispAChanged;
|
||
FreqDispA.OnClick := FreqDispAClick;
|
||
|
||
// VFO B
|
||
PanelTopVfoB := TPanel.Create(Self);
|
||
PanelTopVfoB.Parent := PanelTopVfoGroup;
|
||
PanelTopVfoB.SetBounds(0, 0, TOP_VFO_W, 60);
|
||
PanelTopVfoB.BevelOuter := bvNone;
|
||
PanelTopVfoB.Color := CLR_PANEL;
|
||
|
||
PanelVfoB := TPanel.Create(Self);
|
||
PanelVfoB.Parent := PanelTopVfoB;
|
||
PanelVfoB.SetBounds(0, 0, TOP_VFO_W, TOP_VFO_FREQ_H);
|
||
PanelVfoB.BevelOuter := bvNone;
|
||
|
||
BtnTopVfoBSelect := MakeBtn(PanelVfoB, 'B', 0, 5, 20, 18, BtnTopVfoBSelectClick);
|
||
BtnTopVfoBSelect.Font.Size := 8;
|
||
BtnTopVfoBSelect.Font.Bold := True;
|
||
BtnTopVfoBSelect.ClrNorm := TColor($00303030);
|
||
BtnTopVfoBSelect.ClrHot := TColor($00404040);
|
||
BtnTopVfoBSelect.ClrActive := TColor($00404040);
|
||
BtnTopVfoBSelect.ClrBorder := TColor($00505050);
|
||
BtnTopVfoBSelect.ClrText := CLR_TEXTDIM;
|
||
BtnTopVfoBSelect.ClrTextAct := CLR_TEXT;
|
||
|
||
BtnTopVfoBTX := MakeBtn(PanelVfoB, 'TX', 0, 25, 20, 18, BtnTopVfoBTXClick);
|
||
BtnTopVfoBTX.Font.Size := 8;
|
||
BtnTopVfoBTX.Font.Bold := True;
|
||
BtnTopVfoBTX.ClrNorm := TColor($00303030);
|
||
BtnTopVfoBTX.ClrHot := TColor($00404040);
|
||
BtnTopVfoBTX.ClrActive := TColor($00404040);
|
||
BtnTopVfoBTX.ClrBorder := TColor($00505050);
|
||
BtnTopVfoBTX.ClrText := CLR_TEXTDIM;
|
||
BtnTopVfoBTX.ClrTextAct := CLR_TEXT;
|
||
|
||
LblVfoBLabel := TLabel.Create(Self);
|
||
LblVfoBLabel.Parent := PanelVfoB;
|
||
LblVfoBLabel.Caption := 'VFO-B';
|
||
LblVfoBLabel.Left := 0; LblVfoBLabel.Top := 0;
|
||
LblVfoBLabel.Font.Size := 8;
|
||
LblVfoBLabel.Font.Color := CLR_TEXTDIM;
|
||
LblVfoBLabel.Visible := False;
|
||
|
||
FreqDispB := TFreqDisplay.Create(Self);
|
||
FreqDispB.Parent := PanelVfoB;
|
||
FreqDispB.SetBounds(20, -2, TOP_VFO_W - 20, TOP_VFO_FREQ_H);
|
||
FreqDispB.FontSize := TOP_VFO_FONT;
|
||
FreqDispB.FontName := 'Courier New';
|
||
FreqDispB.Frequency := Round(FController.FVfoB);
|
||
FreqDispB.ColorNormal := CLR_FREQ_DIM;
|
||
FreqDispB.ColorHover := TColor($0040DDFF);
|
||
FreqDispB.ColorDim := CLR_TEXTDIM;
|
||
FreqDispB.MinMhzDigits := 3;
|
||
FreqDispB.DimLeadingZeros := True;
|
||
FreqDispB.CenterText := False;
|
||
FreqDispB.OnChange := FreqDispBChanged;
|
||
FreqDispB.OnClick := FreqDispBClick;
|
||
|
||
// VFO transfer buttons — горизонтальный ряд по центру
|
||
PanelVfoButtons := TPanel.Create(Self);
|
||
PanelVfoButtons.Parent := PanelTopVfoGroup;
|
||
PanelVfoButtons.SetBounds(0, 0, 140, 20);
|
||
PanelVfoButtons.BevelOuter := bvNone;
|
||
PanelVfoButtons.Color := TColor($00181818);
|
||
|
||
BtnVfoACopyB := MakeBtn(PanelVfoButtons, 'A>B', 0, 0, 44, 20, BtnVfoACopyBClick);
|
||
BtnVfoSwap := MakeBtn(PanelVfoButtons, 'A<>B', 0, 0, 44, 20, BtnVfoSwapClick);
|
||
BtnVfoBCopyA := MakeBtn(PanelVfoButtons, 'B>A', 0, 0, 44, 20, BtnVfoBCopyAClick);
|
||
// Янтарный стиль, шрифт под размер кнопок
|
||
StyleSpanButton(BtnVfoACopyB, False);
|
||
StyleSpanButton(BtnVfoSwap, False);
|
||
StyleSpanButton(BtnVfoBCopyA, False);
|
||
BtnVfoACopyB.Font.Size := 8;
|
||
BtnVfoSwap.Font.Size := 8;
|
||
BtnVfoBCopyA.Font.Size := 8;
|
||
|
||
LayoutTopVfoBlock;
|
||
|
||
// TX controls
|
||
PanelTX := TPanel.Create(Self);
|
||
PanelTX.Parent := PanelLeft;
|
||
PanelTX.SetBounds(0, Y, LEFT_W, 96);
|
||
PanelTX.BevelOuter := bvNone;
|
||
MakeLbl(PanelTX, 'TX', 4, 2);
|
||
|
||
BtnMOX := MakeBtn(PanelTX, 'MOX',
|
||
LeftPanelButtonLeft(LEFT_W, 4, 0), 20,
|
||
LeftPanelButtonWidth(LEFT_W, 4, 0), BTN_SM, BtnMOXClick);
|
||
BtnTUN := MakeBtn(PanelTX, 'TUN',
|
||
LeftPanelButtonLeft(LEFT_W, 4, 1), 20,
|
||
LeftPanelButtonWidth(LEFT_W, 4, 1), BTN_SM, BtnTUNClick);
|
||
// PureSignal: ЛКМ = вкл/выкл, ПКМ = поповер настроек/управления.
|
||
BtnPS := MakeBtn(PanelTX, 'PS',
|
||
LeftPanelButtonLeft(LEFT_W, 4, 2), 20,
|
||
LeftPanelButtonWidth(LEFT_W, 4, 2), BTN_SM, BtnPSClick);
|
||
BtnPS.OnMouseDown := BtnPSMouseDown;
|
||
Btn2Ton := MakeBtn(PanelTX, '2TON',
|
||
LeftPanelButtonLeft(LEFT_W, 4, 3), 20,
|
||
LeftPanelButtonWidth(LEFT_W, 4, 3), BTN_SM, Btn2TonClick);
|
||
UpdateTXControlAvailability;
|
||
|
||
BtnDUP := MakeBtn(PanelTX, 'DUP',
|
||
LeftPanelButtonLeft(LEFT_W, 4, 0), 48,
|
||
LeftPanelButtonWidth(LEFT_W, 4, 0), BTN_SM, BtnDUPClick);
|
||
BtnDUP.Tag := 0;
|
||
StyleButton(BtnDUP, FController.FDisplayDuplex);
|
||
|
||
// Self-monitor RX-аудио на TX: рядом с DUP, col1. Видна на любом бэкенде,
|
||
// активна только в DUP (см. UpdateRxMuteButton). ON (подсвечена, умолч.) =
|
||
// RX-аудио глушится на TX; OFF = слышим свой downlink во время передачи.
|
||
BtnRxMute := MakeBtn(PanelTX, 'RX MUTE',
|
||
LeftPanelButtonLeft(LEFT_W, 4, 1), 48,
|
||
LeftPanelButtonWidth(LEFT_W, 4, 1), BTN_SM, BtnRxMuteClick);
|
||
StyleButton(BtnRxMute, FController.FRxMuteOnTx);
|
||
UpdateRxMuteButton; // видимость/активность по DUP
|
||
|
||
// TX-профиль — свободные колонки 2..3 того же ряда (панель не растёт по
|
||
// высоте). Подпись = имя активного профиля, ЛКМ — список, ПКМ — настройки.
|
||
BtnTXProfile := MakeBtn(PanelTX, 'Default',
|
||
LeftPanelButtonLeft(LEFT_W, 4, 2), 48,
|
||
LeftPanelButtonLeft(LEFT_W, 4, 3)
|
||
+ LeftPanelButtonWidth(LEFT_W, 4, 3)
|
||
- LeftPanelButtonLeft(LEFT_W, 4, 2),
|
||
BTN_SM, BtnTXProfileClick);
|
||
BtnTXProfile.OnMouseDown := BtnTXProfileMouseDown;
|
||
StyleButton(BtnTXProfile, False);
|
||
|
||
FTXProfileDropDown := TFlatDropDown.Create(BtnTXProfile, PanelLeft, 1, BTN_SM,
|
||
LEFT_SCROLL_GUTTER_W);
|
||
FTXProfileDropDown.OnSelect := OnTXProfileDropDownSelect;
|
||
RenderTXProfile;
|
||
|
||
LblDrv := TLabel.Create(Self);
|
||
LblDrv.Parent := PanelTX; LblDrv.Left := 4; LblDrv.Top := 77;
|
||
LblDrv.Caption := 'DRV'; LblDrv.Font.Color := CLR_TEXTDIM;
|
||
LblDrv.Font.Size := 8;
|
||
|
||
TrkDrive := TFlatSlider.Create(Self);
|
||
TrkDrive.Parent := PanelTX; TrkDrive.Left := LEFT_SLIDER_LEFT; TrkDrive.Top := 78;
|
||
TrkDrive.Width := LEFT_SLIDER_W; TrkDrive.Height := 16;
|
||
TrkDrive.Min := 0; TrkDrive.Max := 100; TrkDrive.Position := 50;
|
||
TrkDrive.OnChange := TrkDriveChange;
|
||
|
||
LblDrvVal := TLabel.Create(Self);
|
||
LblDrvVal.Parent := PanelTX;
|
||
LblDrvVal.Left := LEFT_SLIDER_VALUE_LEFT; LblDrvVal.Top := 77;
|
||
LblDrvVal.Font.Color := CLR_TEXT;
|
||
LblDrvVal.Font.Size := 8;
|
||
UpdateDriveLabel;
|
||
|
||
Inc(Y, 100);
|
||
|
||
// RX block: CTUN + VOL + NR/NB/SNB/ANF/MUTE
|
||
PanelRXBlock := TPanel.Create(Self);
|
||
PanelRXBlock.Parent := PanelLeft;
|
||
PanelRXBlock.SetBounds(0, Y, LEFT_W, RX_BLOCK_H_ATT);
|
||
PanelRXBlock.BevelOuter := bvNone;
|
||
MakeLbl(PanelRXBlock, 'RX', 4, 2);
|
||
|
||
BtnCTun := MakeBtn(PanelRXBlock, 'CTUN',
|
||
LeftPanelButtonLeft(LEFT_W, 3, 0), 20,
|
||
LeftPanelButtonWidth(LEFT_W, 3, 0),
|
||
BTN_H, BtnCTunClick);
|
||
BtnCTun.Tag := 0;
|
||
StyleButton(BtnCTun, FController.FCTun);
|
||
|
||
BtnChannels := MakeBtn(PanelRXBlock, 'Channel',
|
||
LeftPanelButtonLeft(LEFT_W, 3, 1), 20,
|
||
LeftPanelButtonWidth(LEFT_W, 3, 1),
|
||
BTN_H, BtnChannelsClick);
|
||
BtnChannels.OnMouseDown := BtnChannelsMouseDown;
|
||
StyleButton(BtnChannels, False);
|
||
|
||
FChannelsDropDown := TFlatDropDown.Create(BtnChannels, PanelLeft, 1, BTN_SM,
|
||
LEFT_SCROLL_GUTTER_W);
|
||
FChannelsDropDown.OnSelect := OnChannelsDropDownSelect;
|
||
RefreshChannelsDropDown;
|
||
|
||
// QO-100 beacon lock — 3-я колонка ряда. Видима только в XVTR-режиме
|
||
// (см. рендер rfXvtr ниже). Подпись/подсветка обновляются по rfBeaconLock.
|
||
BtnBeacon := MakeBtn(PanelRXBlock, 'BEACON',
|
||
LeftPanelButtonLeft(LEFT_W, 3, 2), 20,
|
||
LeftPanelButtonWidth(LEFT_W, 3, 2),
|
||
BTN_H, BtnBeaconClick);
|
||
BtnBeacon.Visible := False;
|
||
BtnBeacon.OnMouseDown := BtnBeaconMouseDown; // ПКМ → декодер + окно констелляции
|
||
StyleButton(BtnBeacon, False);
|
||
|
||
MakeLbl(PanelRXBlock, 'VOL', 4, 50);
|
||
TrkVolume := TFlatSlider.Create(Self);
|
||
TrkVolume.Parent := PanelRXBlock; TrkVolume.Left := LEFT_SLIDER_LEFT; TrkVolume.Top := 50;
|
||
TrkVolume.Width := LEFT_SLIDER_W; TrkVolume.Height := 16;
|
||
TrkVolume.Min := 0; TrkVolume.Max := 100;
|
||
TrkVolume.Position := FController.ActiveVolume;
|
||
TrkVolume.OnChange := TrkVolumeChange;
|
||
|
||
LblVolume := TLabel.Create(Self);
|
||
LblVolume.Parent := PanelRXBlock;
|
||
LblVolume.Left := LEFT_SLIDER_VALUE_LEFT; LblVolume.Top := 50;
|
||
LblVolume.Font.Color := CLR_TEXT;
|
||
LblVolume.Font.Size := 8;
|
||
UpdateVolumeLabel;
|
||
|
||
BtnNR := MakeBtn(PanelRXBlock, 'NR',
|
||
LeftPanelButtonLeft(LEFT_W, 5, 0), 74,
|
||
LeftPanelButtonWidth(LEFT_W, 5, 0), BTN_SM, BtnNRClick);
|
||
BtnNB := MakeBtn(PanelRXBlock, 'NB',
|
||
LeftPanelButtonLeft(LEFT_W, 5, 1), 74,
|
||
LeftPanelButtonWidth(LEFT_W, 5, 1), BTN_SM, BtnNBClick);
|
||
BtnSNB := MakeBtn(PanelRXBlock, 'SNB',
|
||
LeftPanelButtonLeft(LEFT_W, 5, 2), 74,
|
||
LeftPanelButtonWidth(LEFT_W, 5, 2), BTN_SM, BtnSNBClick);
|
||
BtnANF := MakeBtn(PanelRXBlock, 'ANF',
|
||
LeftPanelButtonLeft(LEFT_W, 5, 3), 74,
|
||
LeftPanelButtonWidth(LEFT_W, 5, 3), BTN_SM, BtnANFClick);
|
||
BtnMute := MakeBtn(PanelRXBlock, 'MUTE',
|
||
LeftPanelButtonLeft(LEFT_W, 5, 4), 74,
|
||
LeftPanelButtonWidth(LEFT_W, 5, 4), BTN_SM, BtnMuteClick);
|
||
|
||
// Шаговый аттенюатор ADC — плавный 0..31 дБ (железо openHPSDR так и умеет,
|
||
// ступени 0/10/20 были только в web-UI). Показания S-метра/спектра на эти
|
||
// дБ компенсируются в контроллере, шкала dBm остаётся абсолютной.
|
||
LblAtt := TLabel.Create(Self);
|
||
LblAtt.Parent := PanelRXBlock; LblAtt.Left := 4; LblAtt.Top := 106;
|
||
LblAtt.Caption := 'ATT'; LblAtt.Font.Color := CLR_TEXTDIM;
|
||
LblAtt.Font.Size := 8;
|
||
|
||
TrkAtt := TFlatSlider.Create(Self);
|
||
TrkAtt.Parent := PanelRXBlock; TrkAtt.Left := LEFT_SLIDER_LEFT; TrkAtt.Top := 106;
|
||
TrkAtt.Width := LEFT_SLIDER_W; TrkAtt.Height := 16;
|
||
TrkAtt.Min := 0; TrkAtt.Max := 31; // ADC step attenuator, дБ
|
||
TrkAtt.Position := FController.FAtten;
|
||
TrkAtt.OnChange := TrkAttChange;
|
||
|
||
LblAttVal := TLabel.Create(Self);
|
||
LblAttVal.Parent := PanelRXBlock;
|
||
LblAttVal.Left := LEFT_SLIDER_VALUE_LEFT; LblAttVal.Top := 106;
|
||
LblAttVal.Font.Color := CLR_TEXT;
|
||
LblAttVal.Font.Size := 8;
|
||
UpdateAttLabel;
|
||
|
||
Inc(Y, RX_BLOCK_H_ATT + 4);
|
||
|
||
// AGC controls
|
||
PanelAGC := TPanel.Create(Self);
|
||
PanelAGC.Parent := PanelLeft;
|
||
PanelAGC.SetBounds(0, Y, LEFT_W, 70);
|
||
PanelAGC.BevelOuter := bvNone;
|
||
|
||
// AGC mode — 5 кнопок в ряд
|
||
MakeLbl(PanelAGC, 'AGC', 4, 2);
|
||
BtnAGCMode[0] := MakeBtn(PanelAGC, 'FAST',
|
||
LeftPanelButtonLeft(LEFT_W, 5, 0), 20,
|
||
LeftPanelButtonWidth(LEFT_W, 5, 0),
|
||
BTN_SM, BtnAGCModeClick);
|
||
BtnAGCMode[0].Tag := 0;
|
||
BtnAGCMode[1] := MakeBtn(PanelAGC, 'MED',
|
||
LeftPanelButtonLeft(LEFT_W, 5, 1), 20,
|
||
LeftPanelButtonWidth(LEFT_W, 5, 1),
|
||
BTN_SM, BtnAGCModeClick);
|
||
BtnAGCMode[1].Tag := 1;
|
||
BtnAGCMode[2] := MakeBtn(PanelAGC, 'SLOW',
|
||
LeftPanelButtonLeft(LEFT_W, 5, 2), 20,
|
||
LeftPanelButtonWidth(LEFT_W, 5, 2),
|
||
BTN_SM, BtnAGCModeClick);
|
||
BtnAGCMode[2].Tag := 2;
|
||
BtnAGCMode[3] := MakeBtn(PanelAGC, 'LONG',
|
||
LeftPanelButtonLeft(LEFT_W, 5, 3), 20,
|
||
LeftPanelButtonWidth(LEFT_W, 5, 3),
|
||
BTN_SM, BtnAGCModeClick);
|
||
BtnAGCMode[3].Tag := 3;
|
||
BtnAGCMode[4] := MakeBtn(PanelAGC, 'OFF',
|
||
LeftPanelButtonLeft(LEFT_W, 5, 4), 20,
|
||
LeftPanelButtonWidth(LEFT_W, 5, 4),
|
||
BTN_SM, BtnAGCModeClick);
|
||
BtnAGCMode[4].Tag := 4;
|
||
for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = FController.FAGCMode);
|
||
|
||
// AGC level slider
|
||
MakeLbl(PanelAGC, 'THRESH', 4, 44);
|
||
TrkAGC := TFlatSlider.Create(Self);
|
||
TrkAGC.Parent := PanelAGC; TrkAGC.Left := LEFT_SLIDER_LEFT; TrkAGC.Top := 44;
|
||
TrkAGC.Width := LEFT_SLIDER_W; TrkAGC.Height := 16;
|
||
TrkAGC.Min := 20; TrkAGC.Max := 120; // 20..120 → −20..-120 dBm
|
||
TrkAGC.Position := FController.FAGCTop;
|
||
TrkAGC.Reversed := True;
|
||
TrkAGC.OnChange := TrkAGCChange;
|
||
|
||
LblAGCTop := TLabel.Create(Self);
|
||
LblAGCTop.Parent := PanelAGC;
|
||
LblAGCTop.Left := LEFT_SLIDER_VALUE_LEFT; LblAGCTop.Top := 44;
|
||
LblAGCTop.Caption := Format('-%ddB', [FController.FAGCTop]);
|
||
LblAGCTop.Font.Color := CLR_TEXT;
|
||
LblAGCTop.Font.Size := 8;
|
||
|
||
Inc(Y, 74);
|
||
|
||
// --- Pluto RF AGC/gain — отдельный блок ПОД блоком AGC (WDSP). Виден только
|
||
// когда подключён Pluto-бэкенд; раскладку панелей ниже сдвигает LayoutLeftPanel.
|
||
PanelPlutoAGC := TPanel.Create(Self);
|
||
PanelPlutoAGC.Parent := PanelLeft;
|
||
PanelPlutoAGC.SetBounds(0, Y, LEFT_W, PLUTO_AGC_H);
|
||
PanelPlutoAGC.BevelOuter := bvNone;
|
||
PanelPlutoAGC.Visible := False;
|
||
MakeLbl(PanelPlutoAGC, 'RF AGC', 4, 2);
|
||
// Режимы AD936x: FAST/SLOW/HYB/MAN. Tag = индекс режима (1/2/3/0).
|
||
BtnPlutoGain[0] := MakeBtn(PanelPlutoAGC, 'FAST',
|
||
LeftPanelButtonLeft(LEFT_W, 4, 0), 20, LeftPanelButtonWidth(LEFT_W, 4, 0),
|
||
BTN_SM, BtnPlutoGainClick);
|
||
BtnPlutoGain[0].Tag := 1;
|
||
BtnPlutoGain[1] := MakeBtn(PanelPlutoAGC, 'SLOW',
|
||
LeftPanelButtonLeft(LEFT_W, 4, 1), 20, LeftPanelButtonWidth(LEFT_W, 4, 1),
|
||
BTN_SM, BtnPlutoGainClick);
|
||
BtnPlutoGain[1].Tag := 2;
|
||
BtnPlutoGain[2] := MakeBtn(PanelPlutoAGC, 'HYB',
|
||
LeftPanelButtonLeft(LEFT_W, 4, 2), 20, LeftPanelButtonWidth(LEFT_W, 4, 2),
|
||
BTN_SM, BtnPlutoGainClick);
|
||
BtnPlutoGain[2].Tag := 3;
|
||
BtnPlutoGain[3] := MakeBtn(PanelPlutoAGC, 'MAN',
|
||
LeftPanelButtonLeft(LEFT_W, 4, 3), 20, LeftPanelButtonWidth(LEFT_W, 4, 3),
|
||
BTN_SM, BtnPlutoGainClick);
|
||
BtnPlutoGain[3].Tag := 0;
|
||
// Manual gain slider.
|
||
MakeLbl(PanelPlutoAGC, 'GAIN', 4, 44);
|
||
TrkRxGain := TFlatSlider.Create(Self);
|
||
TrkRxGain.Parent := PanelPlutoAGC; TrkRxGain.Left := LEFT_SLIDER_LEFT; TrkRxGain.Top := 44;
|
||
TrkRxGain.Width := LEFT_SLIDER_W; TrkRxGain.Height := 16;
|
||
TrkRxGain.Min := 0; TrkRxGain.Max := 73; // AD936x hardwaregain dB
|
||
TrkRxGain.Position := FController.FRxGainDb;
|
||
TrkRxGain.OnChange := TrkRxGainChange;
|
||
LblRxGain := TLabel.Create(Self);
|
||
LblRxGain.Parent := PanelPlutoAGC;
|
||
LblRxGain.Left := LEFT_SLIDER_VALUE_LEFT; LblRxGain.Top := 44;
|
||
LblRxGain.Caption := Format('%ddB', [FController.FRxGainDb]);
|
||
LblRxGain.Font.Color := CLR_TEXT;
|
||
LblRxGain.Font.Size := 8;
|
||
// НЕ Inc(Y): панель скрыта и наложена на позицию Bands; LayoutLeftPanel сдвинет.
|
||
|
||
// Band selector. Высота резервирует 2 дополнительных ряда под XVTR-кнопки
|
||
// (создаются динамически в RebuildXvtrButtons). В каждом ряду до 6 кнопок,
|
||
// итого до 12 XVTR-кнопок (наш CFG_XVTR_COUNT = 8 умещается с запасом).
|
||
PanelBands := TPanel.Create(Self);
|
||
PanelBands.Parent := PanelLeft;
|
||
PanelBands.SetBounds(0, Y, LEFT_W, 74);
|
||
PanelBands.BevelOuter := bvNone;
|
||
MakeLbl(PanelBands, 'BAND', 4, 2);
|
||
for i := 0 to BAND_COUNT - 1 do
|
||
begin
|
||
B := MakeBtn(PanelBands, BAND_NAMES[i],
|
||
LeftPanelButtonLeft(LEFT_W, 6, i mod 6),
|
||
20 + (i div 6) * 27,
|
||
LeftPanelButtonWidth(LEFT_W, 6, i mod 6),
|
||
BTN_SM, BtnBandClick);
|
||
B.Tag := i;
|
||
// Пустой стартовый слот (12-й в HF-плане) прячем до RelabelBands.
|
||
if BAND_NAMES[i] = '' then B.Visible := False;
|
||
BtnBand[i] := B;
|
||
end;
|
||
// XVTR кнопки создаются позже (в RebuildXvtrButtons), их Tag = -(idx+1)
|
||
for i := 0 to CFG_XVTR_COUNT - 1 do
|
||
BtnXvtrBand[i] := nil;
|
||
|
||
Inc(Y, 76);
|
||
|
||
// Mode selector — MODE_ROWS рядов по 4 кнопки (последний неполный).
|
||
PanelMode := TPanel.Create(Self);
|
||
PanelMode.Parent := PanelLeft;
|
||
PanelMode.SetBounds(0, Y, LEFT_W, MODE_PANEL_H);
|
||
PanelMode.BevelOuter := bvNone;
|
||
MakeLbl(PanelMode, 'MODE', 4, 2);
|
||
for i := 0 to MODE_COUNT - 1 do
|
||
begin
|
||
B := MakeBtn(PanelMode, MODE_NAMES[i],
|
||
LeftPanelButtonLeft(LEFT_W, 4, i mod 4),
|
||
20 + (i div 4) * 27,
|
||
LeftPanelButtonWidth(LEFT_W, 4, i mod 4),
|
||
BTN_SM, BtnModeClick);
|
||
B.Tag := i;
|
||
B.OnMouseDown := BtnModeMouseDown; // ПКМ по CWL/CWU → терминал
|
||
BtnMode[i] := B;
|
||
StyleButton(B, i = FController.FMode);
|
||
end;
|
||
|
||
Inc(Y, MODE_PANEL_H + 2);
|
||
|
||
// 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_SLOTS - 1 do
|
||
begin
|
||
B := MakeBtn(PanelFilter, '---',
|
||
LeftPanelButtonLeft(LEFT_W, FILT_GRID_COLS, i mod FILT_GRID_COLS),
|
||
20 + (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;
|
||
|
||
Inc(Y, 76);
|
||
|
||
// FM RAW discriminator deviation. Slider units are 50 Hz: fine enough for
|
||
// decoder calibration, while the full 0.5..10 kHz range remains usable.
|
||
PanelRawDev := TPanel.Create(Self);
|
||
PanelRawDev.Parent := PanelLeft;
|
||
PanelRawDev.SetBounds(0, Y, LEFT_W, 50);
|
||
PanelRawDev.BevelOuter := bvNone;
|
||
PanelRawDev.Visible := False;
|
||
MakeLbl(PanelRawDev, 'DEVIATION', 4, 2);
|
||
TrkRawDev := TFlatSlider.Create(Self);
|
||
TrkRawDev.Parent := PanelRawDev;
|
||
TrkRawDev.Left := 34;
|
||
TrkRawDev.Top := 22;
|
||
TrkRawDev.Width := LEFT_SLIDER_W;
|
||
TrkRawDev.Height := 14;
|
||
TrkRawDev.Min := RAW_FM_DEV_MIN div 50;
|
||
TrkRawDev.Max := RAW_FM_DEV_MAX div 50;
|
||
TrkRawDev.Position := Round(FController.FRawFMDeviation) div 50;
|
||
TrkRawDev.OnChange := TrkRawDevChange;
|
||
LblRawDev := TLabel.Create(Self);
|
||
LblRawDev.Parent := PanelRawDev;
|
||
LblRawDev.Left := LEFT_W - 61;
|
||
LblRawDev.Top := 20;
|
||
LblRawDev.Width := 57;
|
||
LblRawDev.Alignment := taRightJustify;
|
||
LblRawDev.Caption := Format('%.2f k', [FController.FRawFMDeviation / 1000.0]);
|
||
LblRawDev.Font.Color := CLR_TEXT;
|
||
LblRawDev.Font.Size := 8;
|
||
|
||
// FM Squelch panel (visible only in FM mode, above CTCSS)
|
||
PanelFMSQ := TPanel.Create(Self);
|
||
PanelFMSQ.Parent := PanelLeft;
|
||
PanelFMSQ.SetBounds(0, Y, LEFT_W, 50);
|
||
PanelFMSQ.BevelOuter := bvNone;
|
||
PanelFMSQ.Visible := False;
|
||
MakeLbl(PanelFMSQ, 'SQL', 4, 2);
|
||
W := LeftPanelButtonWidth(LEFT_W, 4, 0);
|
||
BtnFMSQ := MakeBtn(PanelFMSQ, 'SQL',
|
||
LeftPanelButtonLeft(LEFT_W, 4, 0), 20,
|
||
W, BTN_SM, BtnFMSQClick);
|
||
StyleButton(BtnFMSQ, False);
|
||
// Slider vertically centred with the button row.
|
||
// Reserve 28px on the right for the numeric label
|
||
TrkFMSQ := TFlatSlider.Create(Self);
|
||
TrkFMSQ.Parent := PanelFMSQ;
|
||
TrkFMSQ.Left := BtnFMSQ.Left + BtnFMSQ.Width + 3;
|
||
TrkFMSQ.Top := 25;
|
||
TrkFMSQ.Width := LEFT_SLIDER_W;
|
||
TrkFMSQ.Height := 14;
|
||
TrkFMSQ.Min := 0; TrkFMSQ.Max := 100;
|
||
TrkFMSQ.Position := FController.FFMSQLevel;
|
||
TrkFMSQ.OnChange := TrkFMSQChange;
|
||
// Numeric value label to the right of the slider
|
||
LblFMSQ := TLabel.Create(Self);
|
||
LblFMSQ.Parent := PanelFMSQ;
|
||
LblFMSQ.Left := TrkFMSQ.Left + TrkFMSQ.Width + 3;
|
||
LblFMSQ.Top := 23;
|
||
LblFMSQ.Width := 26;
|
||
LblFMSQ.Caption := IntToStr(FController.FFMSQLevel);
|
||
LblFMSQ.Font.Color := CLR_TEXT;
|
||
LblFMSQ.Font.Size := 8;
|
||
|
||
// FM CTCSS panel (visible only when FM mode is active)
|
||
PanelFMCTCSS := TPanel.Create(Self);
|
||
PanelFMCTCSS.Parent := PanelLeft;
|
||
PanelFMCTCSS.SetBounds(0, Y, LEFT_W, 50);
|
||
PanelFMCTCSS.BevelOuter := bvNone;
|
||
PanelFMCTCSS.Visible := False;
|
||
MakeLbl(PanelFMCTCSS, 'CTCSS', 4, 2);
|
||
BtnFMCTCSS := MakeBtn(PanelFMCTCSS, 'CTCSS',
|
||
LeftPanelButtonLeft(LEFT_W, 3, 0), 20,
|
||
LeftPanelButtonWidth(LEFT_W, 3, 0),
|
||
BTN_H, BtnFMCTCSSClick);
|
||
StyleButton(BtnFMCTCSS, False);
|
||
BtnFMCTCSSTone := MakeBtn(PanelFMCTCSS, CTCSS_NAMES[0],
|
||
LeftPanelButtonLeft(LEFT_W, 3, 1), 20,
|
||
LEFT_W - LEFT_PANEL_BTN_PAD -
|
||
LeftPanelButtonLeft(LEFT_W, 3, 1),
|
||
BTN_H, BtnFMCTCSSToneClick);
|
||
StyleButton(BtnFMCTCSSTone, False);
|
||
|
||
FCTCSSDropDown := TFlatDropDown.Create(BtnFMCTCSSTone, PanelLeft, 4, BTN_SM,
|
||
LEFT_SCROLL_GUTTER_W);
|
||
FCTCSSDropDown.SetItems(CTCSS_NAMES);
|
||
FCTCSSDropDown.SetItemIndex(FController.FFMCTCSSToneIdx);
|
||
FCTCSSDropDown.OnSelect := OnCTCSSDropDownSelect;
|
||
|
||
// FM Step panel (visible only in FM mode, between Filter and SQL)
|
||
PanelFMStep := TPanel.Create(Self);
|
||
PanelFMStep.Parent := PanelLeft;
|
||
PanelFMStep.SetBounds(0, Y, LEFT_W, 50);
|
||
PanelFMStep.BevelOuter := bvNone;
|
||
PanelFMStep.Visible := False;
|
||
MakeLbl(PanelFMStep, 'STEP', 4, 2);
|
||
BtnFMStep := MakeBtn(PanelFMStep, 'STEP',
|
||
LeftPanelButtonLeft(LEFT_W, 3, 0), 20,
|
||
LeftPanelButtonWidth(LEFT_W, 3, 0),
|
||
BTN_H, BtnFMStepClick);
|
||
StyleButton(BtnFMStep, FController.FFMStepOn);
|
||
BtnFMStepSel := MakeBtn(PanelFMStep, FM_STEP_NAMES[FController.FFMStepIdx],
|
||
LeftPanelButtonLeft(LEFT_W, 3, 1), 20,
|
||
LEFT_W - LEFT_PANEL_BTN_PAD -
|
||
LeftPanelButtonLeft(LEFT_W, 3, 1),
|
||
BTN_H, BtnFMStepSelClick);
|
||
StyleButton(BtnFMStepSel, False);
|
||
|
||
FStepDropDown := TFlatDropDown.Create(BtnFMStepSel, PanelLeft, FM_STEP_COUNT,
|
||
BTN_SM, LEFT_SCROLL_GUTTER_W);
|
||
FStepDropDown.SetItems(FM_STEP_NAMES);
|
||
FStepDropDown.SetItemIndex(FController.FFMStepIdx);
|
||
FStepDropDown.OnSelect := OnStepDropDownSelect;
|
||
|
||
// FM Repeater offset panel (visible only in FM mode, below CTCSS)
|
||
PanelFMRpt := TPanel.Create(Self);
|
||
PanelFMRpt.Parent := PanelLeft;
|
||
PanelFMRpt.SetBounds(0, Y, LEFT_W, 50);
|
||
PanelFMRpt.BevelOuter := bvNone;
|
||
PanelFMRpt.Visible := False;
|
||
MakeLbl(PanelFMRpt, 'RPT', 4, 2);
|
||
BtnFMRptMinus := MakeBtn(PanelFMRpt, '-',
|
||
LeftPanelButtonLeft(LEFT_W, 5, 0), 20,
|
||
LeftPanelButtonWidth(LEFT_W, 5, 0), BTN_H, BtnFMRptMinusClick);
|
||
StyleButton(BtnFMRptMinus, False);
|
||
BtnFMRptPlus := MakeBtn(PanelFMRpt, '+',
|
||
LeftPanelButtonLeft(LEFT_W, 5, 4), 20,
|
||
LeftPanelButtonWidth(LEFT_W, 5, 4), BTN_H, BtnFMRptPlusClick);
|
||
StyleButton(BtnFMRptPlus, False);
|
||
W := LeftPanelButtonLeft(LEFT_W, 5, 1);
|
||
EdFMRptOffset := TFlatEdit.Create(Self);
|
||
EdFMRptOffset.Parent := PanelFMRpt;
|
||
EdFMRptOffset.Left := W;
|
||
EdFMRptOffset.Top := 22;
|
||
EdFMRptOffset.Width := LeftPanelButtonLeft(LEFT_W, 5, 4) - W - LEFT_PANEL_BTN_GAP;
|
||
EdFMRptOffset.Height := BTN_H - 4;
|
||
EdFMRptOffset.Text := RptFormatOffset(FController.FFMRptOffsetHz);
|
||
EdFMRptOffset.OnChange := EdFMRptOffsetChange;
|
||
|
||
// ---- Right panel ----
|
||
PanelRight := TPanel.Create(Self);
|
||
PanelRight.Parent := Self;
|
||
PanelRight.Align := alClient;
|
||
PanelRight.BevelOuter := bvNone;
|
||
PanelRight.DoubleBuffered := True;
|
||
PanelRight.OnResize := RightPanelResize;
|
||
|
||
FSampleRateOverlay := TSampleRateOverlay.Create(Self);
|
||
FSampleRateOverlay.OnSpanSelect := OnSampleRateSelect;
|
||
FSampleRateOverlay.OnHidePanel := OnSampleRateHidePanel;
|
||
FSampleRateOverlay.OnInvalidate := OnSampleRateOverlayInvalidate;
|
||
FSampleRateOverlay.SetCurrentRate(FController.FSampleRate);
|
||
FSpecView.SampleRateOverlay := FSampleRateOverlay;
|
||
|
||
// Бэндплан QO-100 — полоска внизу спектра; видимость по InQO100.
|
||
FBandPlanOverlay := TBandPlanOverlay.Create(Self);
|
||
FBandPlanOverlay.SetView(FController.FCenterFreq, FController.FSpanHz);
|
||
FSpecView.BandPlanOverlay := FBandPlanOverlay;
|
||
|
||
// S-метр — внутри PanelToolbar, справа
|
||
PanelSMeterRight := TPanel.Create(Self);
|
||
PanelSMeterRight.Parent := PanelToolbar;
|
||
PanelSMeterRight.BevelOuter := bvNone;
|
||
PanelSMeterRight.Color := CLR_PANEL;
|
||
PbSMeterRight := TPaintBox.Create(Self);
|
||
PbSMeterRight.Parent := PanelSMeterRight;
|
||
PbSMeterRight.Align := alClient;
|
||
PbSMeterRight.OnPaint := FSpecView.PaintSMeterRight;
|
||
PbSMeterRight.Color := CLR_PANEL;
|
||
|
||
if FUseOpenGLSpectrum then
|
||
begin
|
||
PbWideband := TOpenGLControl.Create(Self);
|
||
TOpenGLControl(PbWideband).AutoResizeViewport := False;
|
||
TOpenGLControl(PbWideband).DoubleBuffered := True;
|
||
TOpenGLControl(PbWideband).OnPaint := FWidebandView.Paint;
|
||
TOpenGLControl(PbWideband).OnMouseDown := PbWidebandMouseDown;
|
||
end
|
||
else
|
||
begin
|
||
PbWideband := TPaintBox.Create(Self);
|
||
TPaintBox(PbWideband).OnPaint := FWidebandView.Paint;
|
||
TPaintBox(PbWideband).OnMouseDown := PbWidebandMouseDown;
|
||
end;
|
||
PbWideband.Parent := PanelRight;
|
||
PbWideband.Visible := False;
|
||
|
||
PbWidebandRuler := TPaintBox.Create(Self);
|
||
PbWidebandRuler.Parent := PanelRight;
|
||
PbWidebandRuler.OnPaint := FWidebandView.PaintRuler;
|
||
PbWidebandRuler.Cursor := crDefault;
|
||
PbWidebandRuler.Visible := False;
|
||
|
||
// ---- Панадаптер: контролы спектра/линейки/сплиттера/водопада/пан-зума ----
|
||
// Создаёт и раскладывает TPanafallPanel; семантика мыши остаётся здесь —
|
||
// обработчики подвешиваются ДО Build.
|
||
FPan.SpectrumMouseDown := PbSpectrumMouseDown;
|
||
FPan.SpectrumMouseMove := PbSpectrumMouseMove;
|
||
FPan.SpectrumMouseUp := PbSpectrumMouseUp;
|
||
FPan.SpectrumMouseLeave := PbSpectrumMouseLeave;
|
||
FPan.SpectrumDblClick := PbSpectrumDblClick;
|
||
FPan.WaterfallMouseDown := PbWaterfallMouseDown;
|
||
FPan.WaterfallMouseMove := PbWaterfallMouseMove;
|
||
FPan.WaterfallMouseUp := PbWaterfallMouseUp;
|
||
FPan.ZoomInClick := BtnZoomInClick;
|
||
FPan.ZoomDefClick := BtnZoomDefClick;
|
||
FPan.ZoomOutClick := BtnZoomOutClick;
|
||
FPan.GridClick := OnPanGridToggle; // «▦/▤» грид-раскладка панов
|
||
FPan.OnSplitterMoved := RightPanelResize;
|
||
FPan.Build(PanelRight, Max(1, FController.FSpecMSAA));
|
||
// Алиасы контролов (исторические имена; постепенная миграция на FPan.*)
|
||
PbSpectrum := FPan.PbSpectrum;
|
||
PbRuler := FPan.PbRuler;
|
||
PanelSplitter := FPan.Splitter;
|
||
PbWaterfall := FPan.PbWaterfall;
|
||
PbPanZoom := FPan.PbPanZoom;
|
||
BtnZoomIn := FPan.BtnZoomIn;
|
||
BtnZoomDef := FPan.BtnZoomDef;
|
||
BtnZoomOut := FPan.BtnZoomOut;
|
||
FPanZoomBar.OnZoomPan := OnPanZoomWindow;
|
||
|
||
// Initial layout
|
||
ResizeSpectrumPanels;
|
||
UpdatePanZoomBar;
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// Resize handler (normal method instead of anonymous procedure)
|
||
// Сплиттер спектр/водопад живёт в TPanafallPanel; по OnSplitterMoved панель
|
||
// зовёт RightPanelResize → полный пересчёт.
|
||
// ===========================================================================
|
||
|
||
procedure TMainForm.RightPanelResize(Sender: TObject);
|
||
begin
|
||
UpdateLeftPanelScroll;
|
||
ResizeSpectrumPanels;
|
||
end;
|
||
|
||
procedure TMainForm.LayoutTopVfoBlock;
|
||
const
|
||
MARGIN = 4;
|
||
TOP_OFFSET = 2;
|
||
TOP_VFO_FREQ_H = 47;
|
||
VFO_BTN_FREQ_GAP = 4;
|
||
VFO_FREQ_LEFT_PAD = 2;
|
||
VFO_FREQ_RIGHT_PAD = 8;
|
||
XFER_BTN_W = 44;
|
||
XFER_BTN_H = 20;
|
||
XFER_BTN_GAP = 4;
|
||
XFER_PANEL_W = XFER_BTN_W * 3 + XFER_BTN_GAP * 2; // = 140
|
||
XFER_SIDE_GAP = 14; // минимальный зазор между VFO и кнопками трансфера
|
||
SETTINGS_GAP = 8;
|
||
MAX_SPREAD = 100; // максимальный дополнительный отступ с каждой стороны при растяжении
|
||
// VFO-B получает левый отступ = правому паддингу VFO-A для симметрии зазоров
|
||
VFO_B_LEFT_PAD = VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD; // = 10
|
||
var
|
||
BlockH, FreqTop, CenterY, SafeLeft, SafeRight, GroupLeft, GroupTop: Integer;
|
||
RightW, SMW, SMLeft, AvailW, MinGroupW, ExtraPerSide, DynGap: Integer;
|
||
TopVfoAW, TopVfoBW, NeedFreqAW, NeedFreqBW, FreqTextW, GroupW: Integer;
|
||
XferX, XferY: Integer;
|
||
begin
|
||
if (PanelToolbar = nil) or (PanelTopVfoGroup = nil) or
|
||
(PanelTopVfoA = nil) or (PanelTopVfoB = nil) then Exit;
|
||
|
||
Canvas.Font.Name := FreqDispB.FontName;
|
||
Canvas.Font.Size := FreqDispB.FontSize;
|
||
Canvas.Font.Style := [fsBold];
|
||
FreqDispA.Left := BtnTopVfoASelect.Width + VFO_BTN_FREQ_GAP - VFO_FREQ_LEFT_PAD;
|
||
FreqDispB.Left := VFO_B_LEFT_PAD + BtnTopVfoBSelect.Width + VFO_BTN_FREQ_GAP - VFO_FREQ_LEFT_PAD;
|
||
FreqTextW := Canvas.TextWidth(StringOfChar('0', Max(3, FFreqMhzDigits)) + '.000.000');
|
||
NeedFreqAW := FreqDispA.Left + FreqTextW + VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD;
|
||
NeedFreqBW := FreqDispB.Left + FreqTextW + VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD;
|
||
TopVfoAW := NeedFreqAW;
|
||
TopVfoBW := NeedFreqBW;
|
||
|
||
// Минимальная ширина компактной группы
|
||
MinGroupW := TopVfoAW + XFER_SIDE_GAP + XFER_PANEL_W + XFER_SIDE_GAP + TopVfoBW;
|
||
|
||
SafeLeft := MARGIN;
|
||
if BtnSettings <> nil then
|
||
SafeLeft := BtnSettings.Left + BtnSettings.Width + SETTINGS_GAP;
|
||
SafeRight := ClientWidth - MARGIN;
|
||
if PanelLeft <> nil then
|
||
begin
|
||
RightW := ClientWidth - PanelLeft.Width;
|
||
if RightW > 200 then
|
||
begin
|
||
SMW := Round(RightW * SMETER_WIDTH_RATIO);
|
||
SMLeft := ClientWidth - SMW - SMETER_MARGIN;
|
||
SafeRight := Min(SafeRight, SMLeft - TOP_SMETER_GAP);
|
||
end;
|
||
end;
|
||
|
||
AvailW := SafeRight - SafeLeft;
|
||
if AvailW < MinGroupW then
|
||
begin
|
||
PanelTopVfoGroup.Visible := False;
|
||
Exit;
|
||
end;
|
||
|
||
// Динамический зазор: растёт с шириной окна, но не больше MAX_SPREAD
|
||
ExtraPerSide := Min((AvailW - MinGroupW) div 2, MAX_SPREAD);
|
||
DynGap := XFER_SIDE_GAP + ExtraPerSide;
|
||
GroupW := TopVfoAW + DynGap + XFER_PANEL_W + DynGap + TopVfoBW;
|
||
|
||
BlockH := PanelToolbar.Height - (MARGIN * 2) - 8;
|
||
if BlockH < 56 then BlockH := 56;
|
||
|
||
// Центрируем группу в доступном пространстве
|
||
GroupLeft := SafeLeft + (AvailW - GroupW) div 2;
|
||
GroupTop := TOP_OFFSET;
|
||
|
||
CenterY := PanelToolbar.Height div 2 - GroupTop;
|
||
|
||
PanelTopVfoGroup.SetBounds(GroupLeft, GroupTop, GroupW, BlockH);
|
||
PanelTopVfoGroup.Visible := True;
|
||
|
||
// VFO-A: левый край группы
|
||
PanelTopVfoA.SetBounds(0, 0, TopVfoAW, BlockH);
|
||
PanelTopVfoA.Visible := True;
|
||
PanelVfoA.SetBounds(0, 0, TopVfoAW, BlockH);
|
||
// A выше оси, TX ниже; 1px зазор с каждой стороны
|
||
BtnTopVfoASelect.SetBounds(0, CenterY - BtnTopVfoASelect.Height - 1,
|
||
BtnTopVfoASelect.Width, BtnTopVfoASelect.Height);
|
||
BtnTopVfoATX.SetBounds(0, CenterY + 1,
|
||
BtnTopVfoATX.Width, BtnTopVfoATX.Height);
|
||
FreqTop := CenterY - (TOP_VFO_FREQ_H div 2);
|
||
FreqDispA.SetBounds(FreqDispA.Left, FreqTop,
|
||
FreqTextW + VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD, TOP_VFO_FREQ_H);
|
||
|
||
// Кнопки трансфера: горизонтальный ряд, вертикально по оси
|
||
XferX := TopVfoAW + DynGap;
|
||
XferY := CenterY - (XFER_BTN_H div 2);
|
||
PanelVfoButtons.SetBounds(XferX, XferY, XFER_PANEL_W, XFER_BTN_H);
|
||
BtnVfoACopyB.SetBounds(0, 0, XFER_BTN_W, XFER_BTN_H);
|
||
BtnVfoSwap.SetBounds(XFER_BTN_W + XFER_BTN_GAP, 0, XFER_BTN_W, XFER_BTN_H);
|
||
BtnVfoBCopyA.SetBounds((XFER_BTN_W + XFER_BTN_GAP) * 2, 0, XFER_BTN_W, XFER_BTN_H);
|
||
|
||
// VFO-B: правый край группы
|
||
PanelTopVfoB.SetBounds(TopVfoAW + DynGap + XFER_PANEL_W + DynGap, 0, TopVfoBW, BlockH);
|
||
PanelTopVfoB.Visible := True;
|
||
PanelVfoB.SetBounds(0, 0, TopVfoBW, BlockH);
|
||
BtnTopVfoBSelect.SetBounds(VFO_B_LEFT_PAD, CenterY - BtnTopVfoBSelect.Height - 1,
|
||
BtnTopVfoBSelect.Width, BtnTopVfoBSelect.Height);
|
||
BtnTopVfoBTX.SetBounds(VFO_B_LEFT_PAD, CenterY + 1,
|
||
BtnTopVfoBTX.Width, BtnTopVfoBTX.Height);
|
||
FreqTop := CenterY - (TOP_VFO_FREQ_H div 2);
|
||
FreqDispB.SetBounds(FreqDispB.Left, FreqTop,
|
||
FreqTextW + VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD, TOP_VFO_FREQ_H);
|
||
|
||
end;
|
||
|
||
procedure TMainForm.ResizeSMeter;
|
||
// PanelSMeterRight на главной форме (Parent=Self).
|
||
// По Y: от самого верха формы до низа тулбара.
|
||
var
|
||
FW: Integer;
|
||
RightW: Integer; // ширина PanelRight
|
||
SMW: Integer; // ширина S-метра
|
||
SMLeft: Integer;
|
||
SMTop: Integer;
|
||
SMBot: Integer;
|
||
begin
|
||
if (PanelSMeterRight = nil) or (PanelToolbar = nil) or (PanelLeft = nil) then Exit;
|
||
|
||
LayoutTopVfoBlock;
|
||
|
||
FW := ClientWidth;
|
||
// Ширина зоны спектра (PanelRight)
|
||
RightW := FW - PanelLeft.Width;
|
||
if RightW < 200 then Exit;
|
||
|
||
// Ширина S-метра = 32% от PanelRight
|
||
SMW := Round(RightW * SMETER_WIDTH_RATIO);
|
||
|
||
// X: прижат к правому краю PanelToolbar
|
||
SMLeft := FW - SMW - SMETER_MARGIN;
|
||
|
||
SMTop := SMETER_MARGIN;
|
||
SMBot := PanelToolbar.Height - SMETER_MARGIN - 9;
|
||
|
||
PanelSMeterRight.SetBounds(SMLeft, SMTop, SMW, SMBot - SMTop);
|
||
end;
|
||
|
||
function TMainForm.UpdateWidebandFrequencyView: Boolean;
|
||
var
|
||
E: TXvtrEntry;
|
||
SrcStart, SrcEnd: Double;
|
||
MarkerChanged, ViewChanged: Boolean;
|
||
begin
|
||
Result := False;
|
||
if FWidebandView = nil then Exit;
|
||
if FController.FActiveVfo = 0 then
|
||
MarkerChanged := FWidebandView.SetMarkerHz(FController.FVfoA)
|
||
else
|
||
MarkerChanged := FWidebandView.SetMarkerHz(FController.FVfoB);
|
||
if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) and
|
||
FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled then
|
||
begin
|
||
E := FController.FXvtrSettings.Entries[FController.FCurrentXvtr];
|
||
SrcStart := E.FreqBegin - E.LOOffset + E.LOError;
|
||
SrcEnd := E.FreqEnd - E.LOOffset + E.LOError;
|
||
ViewChanged := FWidebandView.SetFrequencyView(E.FreqBegin, E.FreqEnd,
|
||
SrcStart, SrcEnd);
|
||
end
|
||
else
|
||
ViewChanged := FWidebandView.SetFrequencyView(0.0, 61440000.0, 0.0,
|
||
61440000.0);
|
||
Result := MarkerChanged or ViewChanged;
|
||
end;
|
||
|
||
procedure TMainForm.ResizeSpectrumPanels;
|
||
const
|
||
WB_H = 118;
|
||
var
|
||
RW, RH, WideH, WideSpecH, WideTop: Integer;
|
||
RULER_H: Integer;
|
||
PANZOOM_H: Integer;
|
||
begin
|
||
RULER_H := DpiScale(18);
|
||
if PanelRight = nil then Exit;
|
||
if Assigned(FSpecView) then SyncSpecViewFreq;
|
||
RW := PanelRight.ClientWidth;
|
||
RH := PanelRight.ClientHeight;
|
||
// Низ занят панелью пана/зума (позиционирует FPan.Layout той же формулой) —
|
||
// wideband-блок делит оставшуюся высоту.
|
||
PANZOOM_H := DpiScale(22);
|
||
if FController.FShowSpectrum or FController.FShowWaterfall then
|
||
RH := RH - PANZOOM_H;
|
||
UpdateWidebandFrequencyView;
|
||
WideH := 0;
|
||
WideSpecH := 0;
|
||
if FController.FShowWideband then
|
||
WideH := Min(DpiScale(WB_H), Max(40, RH div 2));
|
||
if WideH > 0 then
|
||
WideSpecH := Max(1, WideH - RULER_H);
|
||
// Wideband — всегда во всю ширину PanelRight, самой верхней полосой.
|
||
// В обычном стеке над ним стоит шапка пана 0; в гриде «две колонки» шапки
|
||
// панов уходят в свои колонки ПОД wideband, поэтому он начинается с нуля.
|
||
WideTop := IfThen(PanGridTwoCol, 0, Pan0HeaderH);
|
||
if PbWideband <> nil then
|
||
begin
|
||
PbWideband.SetBounds(0, WideTop, RW, WideSpecH);
|
||
PbWideband.Visible := FController.FShowWideband and (WideSpecH > 0);
|
||
if FController.FShowWideband and (RW > 0) and (WideSpecH > 0) then
|
||
begin
|
||
FWidebandView.SetBitmapSize(RW, WideSpecH);
|
||
PbWideband.Invalidate;
|
||
end;
|
||
end;
|
||
if PbWidebandRuler <> nil then
|
||
begin
|
||
PbWidebandRuler.SetBounds(0, WideTop + WideSpecH, RW, IfThen(FController.FShowWideband and (WideH > 0), RULER_H, 0));
|
||
PbWidebandRuler.Visible := FController.FShowWideband and (WideH > 0);
|
||
if FController.FShowWideband and (RW > 0) and (WideH > 0) then
|
||
begin
|
||
FWidebandView.SetRulerSize(RW, RULER_H);
|
||
PbWidebandRuler.Invalidate;
|
||
end;
|
||
end;
|
||
// S-метр позиционируется отдельно в ResizeSMeter
|
||
ResizeSMeter;
|
||
|
||
// Стек панов: регионы по долям, каждый пан раскладывает свой стек
|
||
// спектр/линейка/сплиттер/водопад/пан-зум внутри своего региона.
|
||
LayoutPanStack(WideH);
|
||
// Доп. панадаптер приходит со своим слайсом и сразу показывает его флаг —
|
||
// главный приёмник должен быть представлен флагом A с того же момента
|
||
// (раньше флаг A появлялся только от слайса, созданного на самом пане 0).
|
||
if FPan <> nil then
|
||
begin
|
||
FPan.MainFlagForced := PanCount > 1;
|
||
FPan.UpdateMainFlagVisibility;
|
||
end;
|
||
PositionSampleRateOverlay;
|
||
end;
|
||
|
||
function TMainForm.Pan0HeaderVisible: Boolean;
|
||
// Шапка пана 0 — только при стеке панов (у одиночного пана она лишняя). Единый
|
||
// источник правды: LayoutPanStack (обе ветки), Pan0HeaderH, UpdatePanHeaders.
|
||
// Выбор приёмного входа у одноканальных по панам бэкендов (Pluto/LibreSDR) живёт
|
||
// не здесь, а во вкладке Antenna настроек.
|
||
begin
|
||
Result := PanCount > 1;
|
||
end;
|
||
|
||
function TMainForm.Pan0HeaderH: Integer;
|
||
begin
|
||
if Pan0HeaderVisible then
|
||
Result := DpiScale(20) // = HEADER_H панели
|
||
else
|
||
Result := 0;
|
||
end;
|
||
|
||
function TMainForm.ExtraPanCount: Integer;
|
||
// Доп. паны (без пана 0), участвующие в раскладке (плавающие не в счёт).
|
||
var i: Integer;
|
||
begin
|
||
Result := 0;
|
||
for i := 1 to MAX_PANS - 1 do
|
||
if (FPans[i] <> nil) and not PanFloating(i) then Inc(Result);
|
||
end;
|
||
|
||
function TMainForm.PanGridActive: Boolean;
|
||
// Грид реально применён: включён юзером и есть хотя бы один доп. пан в стеке.
|
||
begin
|
||
Result := FPanGridMode and (ExtraPanCount >= 1);
|
||
end;
|
||
|
||
function TMainForm.PanGridTwoCol: Boolean;
|
||
// Частный случай грида: пан 0 + ОДИН доп. пан = две колонки бок о бок во всю
|
||
// высоту (верхнего ряда/горизонтального сплиттера нет).
|
||
begin
|
||
Result := FPanGridMode and (ExtraPanCount = 1);
|
||
end;
|
||
|
||
function TMainForm.PanMinHeight(PanId: Integer): Integer;
|
||
// Мин. высота региона пана в стеке/гриде: ниже неё Layout панели клампит
|
||
// спектр/водопад к минимумам и виджеты вылезают за регион (паны наезжают).
|
||
begin
|
||
if FPans[PanId] = nil then Exit(0);
|
||
Result := FPans[PanId].MinLayoutHeight(
|
||
IfThen(PanId = 0, FPanWideH, 0),
|
||
FController.FShowSpectrum, FController.FShowWaterfall);
|
||
end;
|
||
|
||
procedure TMainForm.LayoutPanStack(AWideH: Integer);
|
||
const
|
||
PAN_SPLIT_H = 5;
|
||
var
|
||
ActiveIds: array[0..MAX_PANS-1] of Integer;
|
||
MinH: array[0..MAX_PANS-1] of Integer;
|
||
N, i, k, TotalH, TotalW, UsableH, UsableW, Y, X, Hi, Wi, MinW: Integer;
|
||
Row0H, RowTop, RowH, M, MinRest, RemainH, BotMin: Integer;
|
||
ShareSum: Double;
|
||
Grid: Boolean;
|
||
begin
|
||
// Плавающие (pop-out) паны в стеке не участвуют — у них своё окно.
|
||
N := 0;
|
||
for i := 0 to MAX_PANS - 1 do
|
||
if (FPans[i] <> nil) and not PanFloating(i) then
|
||
begin ActiveIds[N] := i; Inc(N); end;
|
||
if N = 0 then Exit;
|
||
FPanWideH := AWideH; // для PanMinHeight (в т.ч. из сплиттер-драга)
|
||
TotalH := PanelRight.ClientHeight;
|
||
TotalW := PanelRight.ClientWidth;
|
||
Grid := PanGridActive; // 2 колонки (пан 0 + 1 доп.) либо ряд + колонки
|
||
|
||
// Тумблер «▦/▤» в ряду зума пана 0: есть смысл уже при ОДНОМ доп. пане
|
||
// (грид = пан 0 и доп. пан бок о бок вместо «друг под другом»).
|
||
if FPans[0] <> nil then
|
||
begin
|
||
// Виден при >=1 доп. пане; при включённом гриде — всегда (выключить можно
|
||
// даже когда паны закрылись и раскладка сама откатилась в стек).
|
||
FPans[0].GridBtnEnabled := FPanGridMode or (N >= 2);
|
||
if FPans[0].BtnGrid <> nil then
|
||
begin
|
||
if FPanGridMode then FPans[0].BtnGrid.Caption := '▤'
|
||
else FPans[0].BtnGrid.Caption := '▦';
|
||
end;
|
||
end;
|
||
|
||
if not Grid then
|
||
begin
|
||
// ---- Вертикальный стек (как раньше) ----
|
||
for i := 1 to MAX_PANS - 1 do
|
||
if FPanVSplitters[i] <> nil then FPanVSplitters[i].Visible := False;
|
||
UsableH := TotalH - (N - 1) * PAN_SPLIT_H;
|
||
ShareSum := 0;
|
||
for k := 0 to N - 1 do ShareSum := ShareSum + FPanShare[ActiveIds[k]];
|
||
if ShareSum <= 0 then ShareSum := 1;
|
||
// Видимость шапки — по ОБЩЕМУ числу панов (включая плавающие), как
|
||
// Pan0HeaderH/UpdatePanHeaders: иначе при pop-out пана N шапка пана 0
|
||
// прячется, а wideband остаётся сдвинутым на Pan0HeaderH — спектр
|
||
// накрывает линейку частот wideband. Ставим ДО расчёта минимумов
|
||
// (MinLayoutHeight учитывает шапку).
|
||
MinRest := 0;
|
||
for k := 0 to N - 1 do
|
||
begin
|
||
i := ActiveIds[k];
|
||
if i = 0 then FPans[0].HeaderVisible := Pan0HeaderVisible
|
||
else FPans[i].HeaderVisible := PanCount > 1;
|
||
MinH[k] := PanMinHeight(i);
|
||
Inc(MinRest, MinH[k]);
|
||
end;
|
||
Y := 0;
|
||
RemainH := UsableH;
|
||
for k := 0 to N - 1 do
|
||
begin
|
||
i := ActiveIds[k];
|
||
Dec(MinRest, MinH[k]); // теперь = сумма минимумов панов ПОСЛЕ текущего
|
||
if k > 0 then
|
||
begin
|
||
FPanSplitters[i].Visible := True; // грид мог спрятать
|
||
FPanSplitters[i].SetBounds(0, Y, TotalW, PAN_SPLIT_H);
|
||
Inc(Y, PAN_SPLIT_H);
|
||
end;
|
||
if k = N - 1 then
|
||
Hi := RemainH // последний пан добирает остаток (округления долей)
|
||
else
|
||
begin
|
||
Hi := Round(UsableH * FPanShare[i] / ShareSum);
|
||
// Доли не смеют ужать пан ниже минимума содержимого (виджеты вылезут
|
||
// за регион и паны наедут друг на друга), а раздутый минимумом пан —
|
||
// отобрать у оставшихся их минимумы.
|
||
if Hi < MinH[k] then Hi := MinH[k];
|
||
if Hi > RemainH - MinRest then Hi := RemainH - MinRest;
|
||
if Hi < 1 then Hi := 1; // окно меньше суммы минимумов — деградируем без наезда выше
|
||
end;
|
||
Dec(RemainH, Hi);
|
||
FPans[i].StackTop := Y;
|
||
FPans[i].StackHeight := Hi;
|
||
FPans[i].StackLeft := 0;
|
||
FPans[i].StackWidth := 0; // вся ширина
|
||
if i = 0 then
|
||
FPans[i].Layout(AWideH, FController.FShowSpectrum, FController.FShowWaterfall)
|
||
else
|
||
FPans[i].Layout(0, FController.FShowSpectrum, FController.FShowWaterfall);
|
||
Inc(Y, Hi);
|
||
end;
|
||
Exit;
|
||
end;
|
||
|
||
if PanGridTwoCol then
|
||
begin
|
||
// ---- Грид «две колонки»: пан 0 слева, единственный доп. пан справа ----
|
||
// Wideband остаётся сверху во всю ширину (его полосу кладёт
|
||
// ResizeSpectrumPanels), обе колонки начинаются ПОД ним и идут до низа;
|
||
// между ними вертикальный сплиттер (доли FPanColShare[0]/FPanColShare[доп]).
|
||
i := ActiveIds[1]; // единственный застекованный доп. пан
|
||
for k := 1 to MAX_PANS - 1 do
|
||
begin
|
||
if FPanSplitters[k] <> nil then FPanSplitters[k].Visible := False;
|
||
if (k <> i) and (FPanVSplitters[k] <> nil) then
|
||
FPanVSplitters[k].Visible := False;
|
||
end;
|
||
FPans[0].HeaderVisible := Pan0HeaderVisible;
|
||
FPans[i].HeaderVisible := True;
|
||
UsableW := TotalW - PAN_SPLIT_H;
|
||
ShareSum := FPanColShare[0] + FPanColShare[i];
|
||
if ShareSum <= 0 then ShareSum := 1;
|
||
MinW := Round(0.15 * TotalW); // = кламп драга вертикального сплиттера
|
||
Wi := Round(UsableW * FPanColShare[0] / ShareSum);
|
||
if Wi < MinW then Wi := MinW;
|
||
if Wi > UsableW - MinW then Wi := UsableW - MinW;
|
||
if Wi < 1 then Wi := 1;
|
||
RowTop := AWideH; // низ полосы wideband = верх обеих колонок
|
||
RowH := TotalH - RowTop;
|
||
FPans[0].StackTop := RowTop;
|
||
FPans[0].StackHeight := RowH;
|
||
FPans[0].StackLeft := 0;
|
||
FPans[0].StackWidth := Wi;
|
||
// ATopOffset=0: wideband вне региона пана 0 (он уже учтён в StackTop).
|
||
FPans[0].Layout(0, FController.FShowSpectrum, FController.FShowWaterfall);
|
||
FPanVSplitters[i].Visible := True;
|
||
FPanVSplitters[i].SetBounds(Wi, RowTop, PAN_SPLIT_H, RowH);
|
||
FPans[i].StackTop := RowTop;
|
||
FPans[i].StackHeight := RowH;
|
||
FPans[i].StackLeft := Wi + PAN_SPLIT_H;
|
||
FPans[i].StackWidth := TotalW - Wi - PAN_SPLIT_H;
|
||
FPans[i].Layout(0, FController.FShowSpectrum, FController.FShowWaterfall);
|
||
Exit;
|
||
end;
|
||
|
||
// ---- Грид: пан 0 сверху во всю ширину, доп. паны колонками снизу ----
|
||
FPanGridRowShare := EnsureRange(FPanGridRowShare, 0.15, 0.85);
|
||
Row0H := Round((TotalH - PAN_SPLIT_H) * FPanGridRowShare);
|
||
// Кламп ряда минимумами содержимого (шапки — до расчёта минимумов):
|
||
// ни пан 0, ни колонки нижнего ряда не смеют стать ниже своих минимумов.
|
||
FPans[0].HeaderVisible := Pan0HeaderVisible;
|
||
BotMin := 0;
|
||
for k := 1 to N - 1 do
|
||
begin
|
||
i := ActiveIds[k];
|
||
FPans[i].HeaderVisible := True;
|
||
BotMin := Max(BotMin, PanMinHeight(i));
|
||
end;
|
||
if Row0H > TotalH - PAN_SPLIT_H - BotMin then
|
||
Row0H := TotalH - PAN_SPLIT_H - BotMin;
|
||
if Row0H < PanMinHeight(0) then Row0H := PanMinHeight(0);
|
||
FPans[0].StackTop := 0;
|
||
FPans[0].StackHeight := Row0H;
|
||
FPans[0].StackLeft := 0;
|
||
FPans[0].StackWidth := 0;
|
||
FPans[0].Layout(AWideH, FController.FShowSpectrum, FController.FShowWaterfall);
|
||
|
||
// Горизонтальный сплиттер ряда — сплиттер ПЕРВОЙ колонки (во всю ширину);
|
||
// сплиттеры остальных колонок прячем (между колонками — вертикальные).
|
||
M := N - 1; // колонок в нижнем ряду
|
||
FPanSplitters[ActiveIds[1]].Visible := True;
|
||
FPanSplitters[ActiveIds[1]].SetBounds(0, Row0H, TotalW, PAN_SPLIT_H);
|
||
for k := 2 to N - 1 do
|
||
FPanSplitters[ActiveIds[k]].Visible := False;
|
||
|
||
RowTop := Row0H + PAN_SPLIT_H;
|
||
RowH := TotalH - RowTop;
|
||
UsableW := TotalW - (M - 1) * PAN_SPLIT_H;
|
||
ShareSum := 0;
|
||
for k := 1 to N - 1 do ShareSum := ShareSum + FPanColShare[ActiveIds[k]];
|
||
if ShareSum <= 0 then ShareSum := 1;
|
||
X := 0;
|
||
for k := 1 to N - 1 do
|
||
begin
|
||
i := ActiveIds[k];
|
||
if k > 1 then
|
||
begin
|
||
FPanVSplitters[i].Visible := True;
|
||
FPanVSplitters[i].SetBounds(X, RowTop, PAN_SPLIT_H, RowH);
|
||
Inc(X, PAN_SPLIT_H);
|
||
end
|
||
else if FPanVSplitters[i] <> nil then
|
||
FPanVSplitters[i].Visible := False; // у первой колонки левого сплиттера нет
|
||
if k = N - 1 then
|
||
Wi := TotalW - X
|
||
else
|
||
Wi := Round(UsableW * FPanColShare[i] / ShareSum);
|
||
FPans[i].StackTop := RowTop;
|
||
FPans[i].StackHeight := RowH;
|
||
FPans[i].StackLeft := X;
|
||
FPans[i].StackWidth := Wi;
|
||
FPans[i].HeaderVisible := True;
|
||
FPans[i].Layout(0, FController.FShowSpectrum, FController.FShowWaterfall);
|
||
Inc(X, Wi);
|
||
end;
|
||
// Вертикальные сплиттеры панов вне нижнего ряда (плавающие) — спрятать.
|
||
for i := 1 to MAX_PANS - 1 do
|
||
if (FPanVSplitters[i] <> nil) and ((FPans[i] = nil) or PanFloating(i)) then
|
||
FPanVSplitters[i].Visible := False;
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// Dark Theme
|
||
// ===========================================================================
|
||
|
||
procedure TMainForm.ApplyDarkTheme;
|
||
var T: TAppTheme; i: Integer;
|
||
procedure DP(P: TPanel);
|
||
begin P.Color := T.Panel; P.Font.Color := T.Text; end;
|
||
procedure DPL(P: TPanel);
|
||
var
|
||
J: Integer;
|
||
begin
|
||
if P = nil then Exit;
|
||
for J := 0 to P.ControlCount - 1 do
|
||
if P.Controls[J] is TLabel then
|
||
TLabel(P.Controls[J]).Font.Color := T.TextDim;
|
||
end;
|
||
procedure DL(L: TLabel; Dim: Boolean = True);
|
||
begin if Dim then L.Font.Color := T.TextDim else L.Font.Color := T.Text; end;
|
||
procedure DS(S: TFlatSlider);
|
||
begin
|
||
S.SetThemeColors(T.SliderBG, T.SliderTrackEmpty, T.SliderTrackFill,
|
||
T.SliderThumbNorm, T.SliderThumbHot, T.SliderThumbDrag, T.SliderThumbBdr);
|
||
end;
|
||
begin
|
||
T := CurrentAppTheme;
|
||
|
||
Color := T.BG; Font.Color := T.Text;
|
||
DP(PanelToolbar); DP(PanelLeft);
|
||
if FLeftScrollBar <> nil then
|
||
FLeftScrollBar.SetColors(T.Panel, T.Border,
|
||
T.SliderThumbHot, T.SliderThumbDrag);
|
||
if FSampleRateOverlay <> nil then FSampleRateOverlay.SetTheme(T);
|
||
DP(PanelTopVfoGroup); DP(PanelTopVfoA); DP(PanelTopVfoB);
|
||
DP(PanelVfoA); DP(PanelVfoB); DP(PanelVfoButtons);
|
||
DP(PanelBands); DP(PanelMode); DP(PanelFilter);
|
||
if PanelPlutoAGC <> nil then DP(PanelPlutoAGC);
|
||
if PanelFMSQ <> nil then DP(PanelFMSQ);
|
||
if PanelRawDev <> nil then DP(PanelRawDev);
|
||
if PanelFMCTCSS <> nil then DP(PanelFMCTCSS);
|
||
if PanelFMStep <> nil then DP(PanelFMStep);
|
||
if PanelFMRpt <> nil then DP(PanelFMRpt);
|
||
if EdFMRptOffset <> nil then EdFMRptOffset.SetAppTheme(T);
|
||
DP(PanelRXBlock); DP(PanelAGC); DP(PanelTX);
|
||
DP(PanelRight);
|
||
|
||
if PanelSMeterRight <> nil then PanelSMeterRight.Color := T.Panel;
|
||
if PbSMeterRight <> nil then PbSMeterRight.Color := T.Panel;
|
||
ApplyStatusTheme(T);
|
||
|
||
// Заголовки групп и подписи строк.
|
||
DPL(PanelTX);
|
||
DPL(PanelRXBlock);
|
||
DPL(PanelAGC);
|
||
DPL(PanelPlutoAGC);
|
||
DPL(PanelBands);
|
||
DPL(PanelMode);
|
||
DPL(PanelFilter);
|
||
DPL(PanelRawDev);
|
||
DPL(PanelFMStep);
|
||
DPL(PanelFMSQ);
|
||
DPL(PanelFMCTCSS);
|
||
DPL(PanelFMRpt);
|
||
// Значения справа от ползунков ярче обычных подписей.
|
||
DL(LblAGCTop, False);
|
||
DL(LblDrv);
|
||
DL(LblDrvVal, False);
|
||
DL(LblVolume, False);
|
||
DL(LblAttVal, False);
|
||
DL(LblRxGain, False);
|
||
DL(LblRawDev, False);
|
||
if LblFMSQ <> nil then DL(LblFMSQ, False);
|
||
|
||
// Кнопки тулбара
|
||
BtnDiscover.ClrNorm := T.TbDiscoverNorm;
|
||
BtnDiscover.ClrHot := T.TbDiscoverHot;
|
||
BtnDiscover.ClrActive := T.TbDiscoverHot;
|
||
BtnDiscover.ClrBorder := T.TbDiscoverBorder;
|
||
BtnDiscover.ClrText := T.TbDiscoverText;
|
||
BtnDiscover.ClrTextAct := T.TbDiscoverText;
|
||
BtnDiscover.Invalidate;
|
||
BtnStartStop.ClrNorm := T.TbStartNorm;
|
||
BtnStartStop.ClrActive := T.TbStartNorm;
|
||
BtnStartStop.ClrHot := T.TbStartNorm;
|
||
BtnStartStop.ClrBorder := T.TbStartBorder;
|
||
BtnStartStop.ClrText := T.TbStartText;
|
||
BtnStartStop.ClrTextAct:= T.TbStartText;
|
||
BtnStartStop.Invalidate;
|
||
StyleButton(BtnSettings, False);
|
||
|
||
// Обычные кнопки — все массивы
|
||
for i := 0 to BAND_COUNT-1 do StyleButton(BtnBand[i], BtnBand[i].Active);
|
||
for i := 0 to CFG_XVTR_COUNT - 1 do
|
||
if BtnXvtrBand[i] <> nil then
|
||
StyleButton(BtnXvtrBand[i], BtnXvtrBand[i].Active);
|
||
for i := 0 to MODE_COUNT-1 do StyleButton(BtnMode[i], BtnMode[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);
|
||
StyleButton(BtnPS, BtnPS.Active);
|
||
StyleButton(Btn2Ton, Btn2Ton.Active);
|
||
StyleButton(BtnRxMute, BtnRxMute.Active);
|
||
StyleButton(BtnBeacon, BtnBeacon.Active);
|
||
if BtnFMSQ <> nil then StyleButton(BtnFMSQ, BtnFMSQ.Active);
|
||
if TrkFMSQ <> nil then DS(TrkFMSQ);
|
||
if BtnFMCTCSS <> nil then StyleButton(BtnFMCTCSS, BtnFMCTCSS.Active);
|
||
if BtnFMCTCSSTone <> nil then StyleButton(BtnFMCTCSSTone, False);
|
||
if BtnFMStep <> nil then StyleButton(BtnFMStep, BtnFMStep.Active);
|
||
if BtnFMStepSel <> nil then StyleButton(BtnFMStepSel, False);
|
||
if BtnFMRptMinus <> nil then StyleButton(BtnFMRptMinus, BtnFMRptMinus.Active);
|
||
if BtnFMRptPlus <> nil then StyleButton(BtnFMRptPlus, BtnFMRptPlus.Active);
|
||
for i := 0 to 3 do
|
||
if BtnPlutoGain[i] <> nil then
|
||
StyleButton(BtnPlutoGain[i], BtnPlutoGain[i].Active);
|
||
if FCTCSSDropDown <> nil then FCTCSSDropDown.ApplyStyle(StyleButton, T.Panel);
|
||
if FStepDropDown <> nil then FStepDropDown.ApplyStyle(StyleButton, T.Panel);
|
||
if BtnZoomIn <> nil then StyleButton(BtnZoomIn, False);
|
||
if BtnZoomDef <> nil then StyleButton(BtnZoomDef, False);
|
||
if BtnZoomOut <> nil then StyleButton(BtnZoomOut, False);
|
||
// Стек панов: шапки/кнопки всех панелей (пан 0 — «⊞», паны N — «×»).
|
||
for i := 0 to MAX_PANS - 1 do
|
||
if FPans[i] <> nil then
|
||
begin
|
||
FPans[i].View.SetTheme(T);
|
||
if FPans[i].PbSpectrum is TPaintBox then
|
||
TPaintBox(FPans[i].PbSpectrum).Color := T.BG;
|
||
if FPans[i].PbWaterfall is TPaintBox then
|
||
TPaintBox(FPans[i].PbWaterfall).Color := T.BG;
|
||
if FPans[i].Splitter <> nil then FPans[i].Splitter.Color := T.Border;
|
||
if FPans[i].BtnZoomIn <> nil then StyleButton(FPans[i].BtnZoomIn, False);
|
||
if FPans[i].BtnZoomDef <> nil then StyleButton(FPans[i].BtnZoomDef, False);
|
||
if FPans[i].BtnZoomOut <> nil then StyleButton(FPans[i].BtnZoomOut, False);
|
||
if FPans[i].BtnAddPan <> nil then StyleButton(FPans[i].BtnAddPan, False);
|
||
if FPans[i].BtnGrid <> nil then StyleButton(FPans[i].BtnGrid, False);
|
||
if FPans[i].BtnPopPan <> nil then StyleButton(FPans[i].BtnPopPan, False);
|
||
if FPans[i].BtnClosePan <> nil then StyleButton(FPans[i].BtnClosePan, False);
|
||
if FPans[i].BtnDisplay <> nil then StyleButton(FPans[i].BtnDisplay, False);
|
||
ApplyPanHeaderTheme(FPans[i]);
|
||
if i > 0 then
|
||
begin
|
||
FPans[i].SetFlagsTheme(T);
|
||
if FPanSplitters[i] <> nil then FPanSplitters[i].Color := T.Border;
|
||
if FPanVSplitters[i] <> nil then FPanVSplitters[i].Color := T.Border;
|
||
if FPanFloatForm[i] <> nil then FPanFloatForm[i].Color := T.BG;
|
||
// Зум-бар пана N (пан 0 — через алиас FPanZoomBar ниже).
|
||
if FPans[i].ZoomBar <> nil then FPans[i].ZoomBar.SetTheme(T);
|
||
if FPans[i].PbPanZoom <> nil then FPans[i].PbPanZoom.Color := T.SliderBG;
|
||
end;
|
||
// Тема инвалидирует кэши и GL-текстуры сама; историю водопада сохраняем.
|
||
MarkPanDisplayDirty(FPans[i], False);
|
||
end;
|
||
if FPanZoomBar <> nil then
|
||
begin
|
||
FPanZoomBar.SetTheme(T);
|
||
if PbPanZoom <> nil then PbPanZoom.Color := T.SliderBG;
|
||
end;
|
||
if BtnChannels <> nil then StyleButton(BtnChannels, False);
|
||
if FChannelsDropDown <> nil then FChannelsDropDown.ApplyStyle(StyleButton, T.Panel);
|
||
if BtnTXProfile <> nil then StyleButton(BtnTXProfile, False);
|
||
if FTXProfileDropDown <> nil then FTXProfileDropDown.ApplyStyle(StyleButton, T.Panel);
|
||
StyleButton(BtnNR, BtnNR.Active);
|
||
StyleButton(BtnNB, BtnNB.Active);
|
||
StyleButton(BtnSNB, BtnSNB.Active);
|
||
StyleButton(BtnANF, BtnANF.Active);
|
||
StyleButton(BtnMute, BtnMute.Active);
|
||
StyleButton(BtnMOX, BtnMOX.Active);
|
||
StyleButton(BtnTUN, BtnTUN.Active);
|
||
|
||
// VFO-copy
|
||
StyleSpanButton(BtnVfoACopyB, False);
|
||
StyleSpanButton(BtnVfoSwap, False);
|
||
StyleSpanButton(BtnVfoBCopyA, False);
|
||
|
||
// Ползунки
|
||
DS(TrkAGC); DS(TrkVolume); DS(TrkDrive);
|
||
DS(TrkAtt); DS(TrkRxGain); DS(TrkRawDev);
|
||
|
||
// Wideband не входит в стек обычных панов.
|
||
if Assigned(FWidebandView) then FWidebandView.SetTheme(T);
|
||
|
||
// Флаги слайсов (оверлеи) — цвета кнопок из темы, как левая панель.
|
||
if FPan <> nil then FPan.SetFlagsTheme(T);
|
||
|
||
// VFO display — вызывает UpdateVfoDisplay, который использует тему
|
||
UpdateVfoDisplay;
|
||
end;
|
||
|
||
procedure TMainForm.SetStatusText(Index: Integer; const Text: string);
|
||
begin
|
||
if StatusPanel = nil then Exit;
|
||
StatusPanel.SetStatusText(Index, Text);
|
||
end;
|
||
|
||
procedure TMainForm.SetRadioOfflineStatus(const ConnText: string; ClearDevice: Boolean);
|
||
begin
|
||
SetStatusText(2, ConnText);
|
||
SetStatusText(3, 'Supply --');
|
||
SetStatusText(4, 'RX idle');
|
||
SetStatusText(5, 'TX idle');
|
||
SetStatusText(6, 'SEQ --');
|
||
SetStatusText(7, 'PLL --');
|
||
UpdateDMRStatus;
|
||
if FSpecView <> nil then
|
||
FSpecView.ADCOverloadVisible := False;
|
||
if PbSpectrum <> nil then
|
||
PbSpectrum.Invalidate;
|
||
FillChar(FController.FDDCSeqValid, SizeOf(FController.FDDCSeqValid), 0);
|
||
FController.FSeqErrorCount := 0;
|
||
FController.FLastSeqErrorDDC := -1;
|
||
FController.FLastSeqErrorDelta := 0;
|
||
FController.FSeqOkStreak := 0;
|
||
if ClearDevice then
|
||
begin
|
||
SetStatusText(0, 'Board --');
|
||
SetStatusText(1, 'IP --');
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.ApplyStatusTheme(const T: TAppTheme);
|
||
begin
|
||
if StatusPanel = nil then Exit;
|
||
StatusPanel.ApplyTheme(T);
|
||
end;
|
||
|
||
procedure TMainForm.SetLightTheme(V: Boolean);
|
||
var
|
||
T: TAppTheme;
|
||
begin
|
||
if FLightTheme = V then Exit;
|
||
FLightTheme := V;
|
||
FController.FLoadedGlobal.LightTheme := V; // зеркало в persist-буфер контроллера
|
||
ApplyDarkTheme;
|
||
T := CurrentAppTheme;
|
||
if FDeviceDialog <> nil then FDeviceDialog.SetTheme(T);
|
||
if FChannelsForm <> nil then TChannelsForm(FChannelsForm).ApplyTheme(T);
|
||
if FCWMsgForm <> nil then TCWMessagesForm(FCWMsgForm).ApplyTheme(T);
|
||
if FCWTermForm <> nil then TCWTerminalForm(FCWTermForm).ApplyTheme(T);
|
||
if FBeaconScopeForm <> nil then FBeaconScopeForm.ApplyTheme(T);
|
||
FController.FSettings.SaveTheme(V);
|
||
FController.FSettings.Save;
|
||
if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then
|
||
TSettingsForm(FSettingsForm).LoadLightTheme(V);
|
||
end;
|
||
|
||
procedure TMainForm.StyleButton(B: TFlatButton; Active: Boolean);
|
||
var T: TAppTheme;
|
||
begin
|
||
T := CurrentAppTheme;
|
||
B.Active := Active;
|
||
B.ClrNorm := T.BtnNorm;
|
||
B.ClrActive := T.BtnActive;
|
||
B.ClrHot := T.BtnHot;
|
||
if Active then B.ClrBorder := T.BtnBorderActive
|
||
else B.ClrBorder := T.BtnBorderNorm;
|
||
B.ClrText := T.BtnText;
|
||
B.ClrTextAct := T.BtnTextActive;
|
||
B.Font.Size := 8;
|
||
end;
|
||
|
||
procedure TMainForm.StyleSpanButton(B: TFlatButton; Active: Boolean);
|
||
var T: TAppTheme;
|
||
begin
|
||
T := CurrentAppTheme;
|
||
B.Active := Active;
|
||
B.ClrNorm := T.SpanNorm;
|
||
B.ClrActive := T.SpanActive;
|
||
B.ClrHot := T.SpanActive;
|
||
B.ClrBorder := T.SpanBorder;
|
||
B.ClrText := T.SpanText;
|
||
B.ClrTextAct := T.SpanText;
|
||
B.Font.Size := 8;
|
||
end;
|
||
|
||
procedure TMainForm.PositionSampleRateOverlay;
|
||
const
|
||
MARGIN_X = 34;
|
||
MARGIN_Y = 4;
|
||
begin
|
||
if (FSampleRateOverlay = nil) or (PbSpectrum = nil) then Exit;
|
||
// Ширина = доступная ширина спектра справа от MARGIN_X (контент сам клампится
|
||
// к этому пределу). Раньше фиксировалось 320px → 8 Pluto-кнопок не влезали.
|
||
FSampleRateOverlay.SetBounds(
|
||
MARGIN_X,
|
||
MARGIN_Y,
|
||
Max(320, PbSpectrum.Width - MARGIN_X * 2),
|
||
FSampleRateOverlay.Height);
|
||
end;
|
||
|
||
procedure TMainForm.OnSampleRateOverlayInvalidate(Sender: TObject);
|
||
begin
|
||
FSpectrumDirty := True;
|
||
if Assigned(FSpecView) then
|
||
begin
|
||
FSpecView.SpectrumDirty := True;
|
||
FSpecView.InvalidateOverlayCache;
|
||
end;
|
||
if Assigned(PbSpectrum) then PbSpectrum.Invalidate;
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// VFO
|
||
// ===========================================================================
|
||
|
||
function TMainForm.FormatFreq(Hz: Double): string;
|
||
var
|
||
Mhz: Int64;
|
||
KHz, Rest: Integer;
|
||
begin
|
||
Mhz := Trunc(Hz / 1000000);
|
||
KHz := Trunc((Hz - Mhz * 1000000) / 1000);
|
||
Rest := Trunc(Hz) mod 1000;
|
||
Result := Format('%d.%3.3d.%3.3d', [Mhz, KHz, Rest]);
|
||
end;
|
||
|
||
procedure TMainForm.UpdateVfoDisplay;
|
||
const
|
||
TOP_VFO_FREQ_H = 47;
|
||
var T: TAppTheme;
|
||
begin
|
||
T := CurrentAppTheme;
|
||
|
||
FreqDispA.Frequency := Round(FController.FVfoA);
|
||
FreqDispB.Frequency := Round(FController.FVfoB);
|
||
FreqDispA.FontSize := TOP_VFO_FONT;
|
||
FreqDispB.FontSize := TOP_VFO_FONT;
|
||
FreqDispA.Height := TOP_VFO_FREQ_H;
|
||
FreqDispB.Height := TOP_VFO_FREQ_H;
|
||
|
||
FreqDispA.ColorHover := T.FreqHover;
|
||
FreqDispA.ColorHoverBG := T.FreqHoverBG;
|
||
FreqDispA.ColorDim := T.FreqDimDot;
|
||
FreqDispB.ColorHover := T.FreqHover;
|
||
FreqDispB.ColorHoverBG := T.FreqHoverBG;
|
||
FreqDispB.ColorDim := T.FreqDimDot;
|
||
|
||
if FController.FActiveVfo = 0 then
|
||
begin
|
||
FreqDispA.ColorNormal := T.FreqActive;
|
||
FreqDispB.ColorNormal := T.FreqDim;
|
||
LblVfoALabel.Font.Color := T.FreqActive;
|
||
LblVfoBLabel.Font.Color := T.TextDim;
|
||
if BtnTopVfoASelect <> nil then begin
|
||
BtnTopVfoASelect.ClrNorm := T.VfoSelActNorm;
|
||
BtnTopVfoASelect.ClrActive := T.VfoSelActNorm;
|
||
BtnTopVfoASelect.ClrHot := T.VfoSelActNorm;
|
||
BtnTopVfoASelect.ClrBorder := T.VfoSelActBorder;
|
||
BtnTopVfoASelect.ClrText := T.VfoSelActText;
|
||
BtnTopVfoASelect.ClrTextAct := T.VfoSelActTextAct;
|
||
BtnTopVfoASelect.Active := True;
|
||
BtnTopVfoASelect.Invalidate;
|
||
end;
|
||
if BtnTopVfoBSelect <> nil then begin
|
||
BtnTopVfoBSelect.ClrNorm := T.VfoSelInaNorm;
|
||
BtnTopVfoBSelect.ClrActive := T.VfoSelInaNorm;
|
||
BtnTopVfoBSelect.ClrHot := T.VfoSelInaNorm;
|
||
BtnTopVfoBSelect.ClrBorder := T.VfoSelInaBorder;
|
||
BtnTopVfoBSelect.ClrText := T.VfoSelInaText;
|
||
BtnTopVfoBSelect.ClrTextAct := T.VfoSelInaTextAct;
|
||
BtnTopVfoBSelect.Active := False;
|
||
BtnTopVfoBSelect.Invalidate;
|
||
end;
|
||
end else begin
|
||
FreqDispA.ColorNormal := T.FreqDim;
|
||
FreqDispB.ColorNormal := T.FreqActive;
|
||
LblVfoALabel.Font.Color := T.TextDim;
|
||
LblVfoBLabel.Font.Color := T.FreqActive;
|
||
if BtnTopVfoASelect <> nil then begin
|
||
BtnTopVfoASelect.ClrNorm := T.VfoSelInaNorm;
|
||
BtnTopVfoASelect.ClrActive := T.VfoSelInaNorm;
|
||
BtnTopVfoASelect.ClrHot := T.VfoSelInaNorm;
|
||
BtnTopVfoASelect.ClrBorder := T.VfoSelInaBorder;
|
||
BtnTopVfoASelect.ClrText := T.VfoSelInaText;
|
||
BtnTopVfoASelect.ClrTextAct := T.VfoSelInaTextAct;
|
||
BtnTopVfoASelect.Active := False;
|
||
BtnTopVfoASelect.Invalidate;
|
||
end;
|
||
if BtnTopVfoBSelect <> nil then begin
|
||
BtnTopVfoBSelect.ClrNorm := T.VfoSelActNorm;
|
||
BtnTopVfoBSelect.ClrActive := T.VfoSelActNorm;
|
||
BtnTopVfoBSelect.ClrHot := T.VfoSelActNorm;
|
||
BtnTopVfoBSelect.ClrBorder := T.VfoSelActBorder;
|
||
BtnTopVfoBSelect.ClrText := T.VfoSelActText;
|
||
BtnTopVfoBSelect.ClrTextAct := T.VfoSelActTextAct;
|
||
BtnTopVfoBSelect.Active := True;
|
||
BtnTopVfoBSelect.Invalidate;
|
||
end;
|
||
end;
|
||
if BtnTopVfoATX <> nil then
|
||
begin
|
||
if not FController.FSplitTxB then
|
||
begin
|
||
BtnTopVfoATX.ClrNorm := T.VfoTxActNorm;
|
||
BtnTopVfoATX.ClrHot := T.VfoTxActNorm;
|
||
BtnTopVfoATX.ClrActive := T.VfoTxActNorm;
|
||
BtnTopVfoATX.ClrBorder := T.VfoTxActBorder;
|
||
BtnTopVfoATX.ClrText := T.VfoTxActText;
|
||
BtnTopVfoATX.ClrTextAct := T.VfoTxActText;
|
||
BtnTopVfoATX.Active := True;
|
||
end
|
||
else
|
||
begin
|
||
BtnTopVfoATX.ClrNorm := T.VfoTxInaNorm;
|
||
BtnTopVfoATX.ClrHot := T.VfoTxInaNorm;
|
||
BtnTopVfoATX.ClrActive := T.VfoTxInaNorm;
|
||
BtnTopVfoATX.ClrBorder := T.VfoTxInaBorder;
|
||
BtnTopVfoATX.ClrText := T.VfoTxInaText;
|
||
BtnTopVfoATX.ClrTextAct := T.VfoTxInaText;
|
||
BtnTopVfoATX.Active := False;
|
||
end;
|
||
BtnTopVfoATX.Invalidate;
|
||
end;
|
||
if BtnTopVfoBTX <> nil then
|
||
begin
|
||
if FController.FSplitTxB then
|
||
begin
|
||
BtnTopVfoBTX.ClrNorm := T.VfoTxActNorm;
|
||
BtnTopVfoBTX.ClrHot := T.VfoTxActNorm;
|
||
BtnTopVfoBTX.ClrActive := T.VfoTxActNorm;
|
||
BtnTopVfoBTX.ClrBorder := T.VfoTxActBorder;
|
||
BtnTopVfoBTX.ClrText := T.VfoTxActText;
|
||
BtnTopVfoBTX.ClrTextAct := T.VfoTxActText;
|
||
BtnTopVfoBTX.Active := True;
|
||
end
|
||
else
|
||
begin
|
||
BtnTopVfoBTX.ClrNorm := T.VfoTxInaNorm;
|
||
BtnTopVfoBTX.ClrHot := T.VfoTxInaNorm;
|
||
BtnTopVfoBTX.ClrActive := T.VfoTxInaNorm;
|
||
BtnTopVfoBTX.ClrBorder := T.VfoTxInaBorder;
|
||
BtnTopVfoBTX.ClrText := T.VfoTxInaText;
|
||
BtnTopVfoBTX.ClrTextAct := T.VfoTxInaText;
|
||
BtnTopVfoBTX.Active := False;
|
||
end;
|
||
BtnTopVfoBTX.Invalidate;
|
||
end;
|
||
FreqDispA.Invalidate;
|
||
FreqDispB.Invalidate;
|
||
LblVfoALabel.Invalidate;
|
||
LblVfoBLabel.Invalidate;
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// Rendering helpers — delegated to TSpectrumView
|
||
// ===========================================================================
|
||
|
||
procedure TMainForm.InvalidateGridCache;
|
||
begin
|
||
FSpecView.InvalidateGridCache;
|
||
end;
|
||
|
||
procedure TMainForm.SyncSpecViewFreq;
|
||
var ViewCenter, ViewSpan: Double;
|
||
begin
|
||
// Видимое окно с учётом зума (центр+span). Без зума == FCenterFreq/FSpanHz.
|
||
FController.GetViewWindow(ViewCenter, ViewSpan);
|
||
FSpecView.VfoA := FController.FVfoA;
|
||
FSpecView.VfoB := FController.FVfoB;
|
||
FSpecView.ActiveVfo := FController.FActiveVfo;
|
||
FSpecView.CenterFreq := ViewCenter;
|
||
FSpecView.SpanHz := ViewSpan;
|
||
FSpecView.Mode := FController.FMode;
|
||
FSpecView.FilterBW := FController.FFilterBW;
|
||
if FController.FMode in [MODE_FM, MODE_DMR, MODE_FMRAW] then
|
||
FSpecView.FMGridStepHz := FM_STEP_HZ[FController.FFMStepIdx]
|
||
else
|
||
FSpecView.FMGridStepHz := 0;
|
||
// TX overlay следует за активной TX-частотой (учитывает split). При CTUN
|
||
// спектр TX будет отрисован на VFO, а не в центре дисплея.
|
||
FSpecView.TXFreq := ActiveTXFreqHz;
|
||
// Какой VFO привязан к TX (для split-полос фильтра): задаётся селектором
|
||
// FSplitTxB (TX-A/TX-B), независимо от активного RX-VFO.
|
||
if FController.FSplitTxB then FSpecView.TXVfoIndex := 1
|
||
else FSpecView.TXVfoIndex := 0;
|
||
// Бэндплан QO-100 следует за видом спектра (downlink-домен). SetView сам
|
||
// отсекает неизменное → пересборки кэша на каждый вызов нет.
|
||
if Assigned(FBandPlanOverlay) then
|
||
FBandPlanOverlay.SetView(ViewCenter, ViewSpan);
|
||
if UpdateWidebandFrequencyView and FController.FShowWideband and (PbWideband <> nil) then
|
||
PbWideband.Invalidate;
|
||
UpdatePanZoomBar; // линейка пана следует за частотой/зумом (no-op если без изменений)
|
||
end;
|
||
|
||
|
||
// ===========================================================================
|
||
// Timers
|
||
// ===========================================================================
|
||
|
||
procedure TMainForm.MeterTimerTick(Sender: TObject);
|
||
const
|
||
AVG_ALPHA = 0.25; // EMA для сглаженного среднего уровня
|
||
ZONE_DB = 6.0; // полуширина зоны вокруг среднего (дБ)
|
||
// Адаптивный alpha: базовый + ускорение при большой разнице
|
||
PEAK_BASE = 0.06; // базовая скорость пика
|
||
MIN_BASE = 0.06; // базовая скорость минимума
|
||
ACCEL_FACTOR = 0.018; // ускорение на каждый dB разницы
|
||
MAX_ALPHA = 0.85; // максимальная скорость (не мгновенно)
|
||
var
|
||
PeakTarget, MinTarget: Double;
|
||
PeakDiff, MinDiff: Double;
|
||
PeakAlpha, MinAlpha: Double;
|
||
begin
|
||
if not FController.FRunning then
|
||
begin
|
||
if PbSMeterRight <> nil then PbSMeterRight.Invalidate;
|
||
Exit;
|
||
end;
|
||
|
||
// QO-100 beacon lock: одна итерация контура (no-op если лок выключен).
|
||
FController.ServiceBeaconLock;
|
||
// Pluto: опрос температуры/RSSI (троттлинг внутри; no-op для HPSDR).
|
||
FController.ServicePlutoTelemetry;
|
||
// Телеграф: фронты «железо в эфире» (у Pluto HP-статуса нет — гнать индикацию
|
||
// и отпускать реле T/R после выдержки больше некому).
|
||
FController.ServiceCWKeyed;
|
||
// Телеграф: разбор принятого в текст + окно-терминал + лента под спектром.
|
||
ServiceCWTerminal;
|
||
// PureSignal: state machine команд + auto-attenuate + опрос GetPSInfo
|
||
// (no-op на бэкендах без PS; рендер кнопок/поповера через rfPureSignal).
|
||
FController.PureSignalTick;
|
||
|
||
// Сглаженное среднее (EMA)
|
||
FSMeterAvg := FSMeterAvg * (1.0 - AVG_ALPHA) + FController.FLastSMeter * AVG_ALPHA;
|
||
|
||
// Цели для Peak и Min
|
||
PeakTarget := Max(FController.FLastSMeter, FSMeterAvg + ZONE_DB);
|
||
MinTarget := Min(FController.FLastSMeter, FSMeterAvg - ZONE_DB);
|
||
|
||
// Адаптивный alpha: чем дальше от цели — тем быстрее догоняем
|
||
PeakDiff := Abs(PeakTarget - FSMeterPeak);
|
||
MinDiff := Abs(MinTarget - FSMeterMin);
|
||
PeakAlpha := Min(MAX_ALPHA, PEAK_BASE + PeakDiff * ACCEL_FACTOR);
|
||
MinAlpha := Min(MAX_ALPHA, MIN_BASE + MinDiff * ACCEL_FACTOR);
|
||
|
||
FSMeterPeak := FSMeterPeak + PeakAlpha * (PeakTarget - FSMeterPeak);
|
||
FSMeterMin := FSMeterMin + MinAlpha * (MinTarget - FSMeterMin);
|
||
|
||
FSpecView.LastSMeter := FController.FLastSMeter;
|
||
FSpecView.SMeterPeak := FSMeterPeak;
|
||
FSpecView.SMeterMin := FSMeterMin;
|
||
FSpecView.LastFwdW := FController.FLastFwdW;
|
||
FSpecView.LastSWR := FController.FLastSWR;
|
||
// Метр — по RadioKeyed, а не по FTransmitting: в телеграфе софт «не передаёт»,
|
||
// ключ замыкает прошивка, и стрелка осталась бы на S-метре всю связь.
|
||
FSpecView.Transmitting := FController.RadioKeyed;
|
||
|
||
if PbSMeterRight <> nil then PbSMeterRight.Invalidate;
|
||
// PWR/SWR метки и бары обновляем здесь (10 Гц), а не в DoUpdateStatus —
|
||
// HP Status пакеты идут ~150 раз/с, при таком rate цифры дёргаются.
|
||
UpdateTXMeters;
|
||
|
||
if FController.FWDSPReady then
|
||
begin
|
||
if FController.FTransmitting then
|
||
begin
|
||
case FController.FDSPEngine.TXMicSource of
|
||
txmsSoundCard: SetStatusText(5, 'TX sound card');
|
||
txmsWeb: SetStatusText(5, 'TX web mic');
|
||
else
|
||
SetStatusText(5, 'TX radio mic');
|
||
end;
|
||
end
|
||
else
|
||
SetStatusText(5, 'TX idle');
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.SpectrumTimerTick(Sender: TObject);
|
||
var
|
||
NowTick: QWord;
|
||
ElapsedTick: QWord;
|
||
LastPktTick: QWord;
|
||
I: Integer;
|
||
begin
|
||
// «×» шапки пана: закрытие отложено сюда (нельзя free панель из её кнопки).
|
||
if FPendingPanClose <> 0 then
|
||
begin
|
||
I := FPendingPanClose;
|
||
FPendingPanClose := 0;
|
||
ClosePanadapter(I);
|
||
end;
|
||
// Синхронизируем размеры если размер панели изменился (после ресайза).
|
||
// Важно: проверяем только видимые панели — когда спектр скрыт,
|
||
// PbSpectrum.Height может быть ненулевым при FPan.SpectrumHeight=0,
|
||
// что вызвало бы сброс буфера водопада на каждом тике.
|
||
// Wideband сверяем с шириной PanelRight, а НЕ со спектром пана 0: в гриде
|
||
// «две колонки» пан 0 — половина ширины, а wideband остаётся во всю (иначе
|
||
// условие вечно истинно и тик дёргает ResizeSpectrumPanels каждый кадр —
|
||
// раскладка пересчитывается под курсором и сплиттеры не тянутся).
|
||
if (FPan.SpectrumWidth = 0) or
|
||
(FController.FShowWideband and (PanelRight <> nil) and
|
||
(PbWideband.Width <> PanelRight.ClientWidth)) or
|
||
(FController.FShowSpectrum and ((PbSpectrum.Width <> FPan.SpectrumWidth) or
|
||
(PbSpectrum.Height <> FPan.SpectrumHeight))) or
|
||
(FController.FShowWaterfall and not FController.FShowSpectrum and
|
||
((PbWaterfall.Width <> FPan.SpectrumWidth) or
|
||
(PbWaterfall.Height <> FPan.WaterfallHeight))) then
|
||
ResizeSpectrumPanels;
|
||
if FPan.SpectrumWidth <= 0 then Exit;
|
||
SyncSpecViewFreq;
|
||
|
||
// Проверка таймаута — трансивер не отвечает
|
||
if FController.FRunning and FController.FNetwork.Running then
|
||
begin
|
||
NowTick := GetTickCount64;
|
||
ElapsedTick := NowTick - FController.FRXStartTime;
|
||
// Сетевой поток может обновить FRXLastPktTime на 1 ms ПОСЛЕ NowTick.
|
||
// QWord-вычитание в таком случае underflow'ится в огромное значение и
|
||
// давало ложный "Connection lost" при совершенно живом DDC-потоке.
|
||
LastPktTick := FController.FRXLastPktTime;
|
||
// Ждём первый пакет 5 секунд после старта
|
||
if (LastPktTick = 0) and (ElapsedTick > 5000) then
|
||
begin
|
||
BtnStartStop.Caption := 'START';
|
||
StyleButton(BtnStartStop, False);
|
||
FController.FRunning := False;
|
||
UninhibitSleep;
|
||
FController.FNetwork.SetRunAndFreq(False, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), 0);
|
||
SetRadioOfflineStatus('Hardware timeout');
|
||
UpdateTXControlAvailability;
|
||
Exit;
|
||
end;
|
||
// После первого пакета — следим чтобы поток не прерывался более 3 сек
|
||
if (LastPktTick > 0) and (NowTick >= LastPktTick) and
|
||
((NowTick - LastPktTick) > 3000) then
|
||
begin
|
||
BtnStartStop.Caption := 'START';
|
||
StyleButton(BtnStartStop, False);
|
||
FController.FRunning := False;
|
||
UninhibitSleep;
|
||
FController.FNetwork.SetRunAndFreq(False, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), 0);
|
||
SetRadioOfflineStatus('Connection lost');
|
||
UpdateTXControlAvailability;
|
||
Exit;
|
||
end;
|
||
end;
|
||
|
||
if FController.FRunning then
|
||
begin
|
||
// Обновляем статус из таймера — без Synchronize в сетевом потоке.
|
||
SetStatusText(4, 'RX running');
|
||
if FController.FSeqErrorCount > 0 then
|
||
SetStatusText(6, Format('SEQ ERR DDC%d', [FController.FLastSeqErrorDDC]))
|
||
else
|
||
SetStatusText(6, 'SEQ OK');
|
||
|
||
if FController.FWDSPReady then
|
||
begin
|
||
FController.FLastSMeter := FController.ReadSMeterDBm;
|
||
FController.FDSPEngine.SetSpectrumWidth(FPan.SpectrumWidth);
|
||
Inc(FAgcLineCounter);
|
||
if FAgcLineCounter >= 6 then
|
||
begin
|
||
FController.FDSPEngine.UpdateAGCLines(FPan.SpectrumWidth);
|
||
FSpecView.AGCThresh := FController.FDSPEngine.AGCThresh;
|
||
FSpecView.AGCHangLevel := FController.FDSPEngine.AGCHangLevel;
|
||
FAgcLineCounter := 0;
|
||
end;
|
||
end;
|
||
FSpecView.WDSPReady := FController.FWDSPReady;
|
||
// Флаги: метр/частота главного, позиции, троттленный S-метр слайсов.
|
||
FPan.TickFlags;
|
||
// Доп. паны: синк вьюх + тик флагов + рендер готовых кадров.
|
||
SyncPanViews;
|
||
for I := 1 to MAX_PANS - 1 do
|
||
if FPans[I] <> nil then
|
||
begin
|
||
FPans[I].TickFlags;
|
||
if FPanSpecDirty[I] then
|
||
begin
|
||
FPans[I].View.DrawSpectrum;
|
||
FPans[I].PbSpectrum.Invalidate;
|
||
if FPans[I].View.NeedsRulerRedraw then
|
||
FPans[I].PbRuler.Invalidate;
|
||
FPanSpecDirty[I] := False;
|
||
end;
|
||
if FPanWfDirty[I] then
|
||
begin
|
||
FPans[I].View.DrawWaterfall;
|
||
FPans[I].PbWaterfall.Invalidate;
|
||
FPanWfDirty[I] := False;
|
||
end;
|
||
end;
|
||
// Панорама и водопад теперь рисуются независимо по факту новых кадров.
|
||
// Это лучше совпадает с Thetis: маленький FFT даёт новые строки чаще.
|
||
if FSpectrumDirty then
|
||
begin
|
||
FSpecView.DrawSpectrum;
|
||
PbSpectrum.Invalidate;
|
||
if FSpecView.NeedsRulerRedraw then
|
||
PbRuler.Invalidate;
|
||
FSpectrumDirty := False;
|
||
end;
|
||
if FWaterfallDirty then
|
||
begin
|
||
FSpecView.DrawWaterfall;
|
||
PbWaterfall.Invalidate;
|
||
FWaterfallDirty := False;
|
||
end;
|
||
if FWidebandDirty and FController.FShowWideband then
|
||
begin
|
||
PbWideband.Invalidate;
|
||
FWidebandDirty := False;
|
||
end;
|
||
end
|
||
else
|
||
begin
|
||
// Не подключены — показываем чистый экран (без анимации)
|
||
if not FController.FNetwork.Connected then
|
||
begin
|
||
// При первом старте показываем демо-спектр
|
||
if FSpecView.SpectrumBitmapWidth <> FPan.SpectrumWidth then
|
||
begin
|
||
FSpecView.SetSpectrumBitmapSize(FPan.SpectrumWidth, FPan.SpectrumHeight);
|
||
FSpecView.ResetSpectrumBuf;
|
||
FSpecView.DrawSpectrum;
|
||
PbSpectrum.Invalidate;
|
||
end;
|
||
end
|
||
else
|
||
begin
|
||
// Подключены, но не запущены — статичный спектр (шум)
|
||
FSpecView.DrawSpectrum;
|
||
PbSpectrum.Invalidate;
|
||
FSpectrumDirty := False;
|
||
end;
|
||
end;
|
||
|
||
// Если не в режиме RUN, но есть "грязный" кадр (drag/marker/UI), перерисуем.
|
||
if (FSpectrumDirty or FWaterfallDirty) and (not FController.FRunning) then
|
||
begin
|
||
if FSpectrumDirty then
|
||
FSpecView.DrawSpectrum;
|
||
if FWaterfallDirty then
|
||
FSpecView.DrawWaterfall;
|
||
if FSpectrumDirty then
|
||
PbSpectrum.Invalidate;
|
||
if FSpecView.NeedsRulerRedraw then
|
||
PbRuler.Invalidate;
|
||
if FWaterfallDirty then
|
||
PbWaterfall.Invalidate;
|
||
FWaterfallDirty := False;
|
||
FSpectrumDirty := False;
|
||
end;
|
||
|
||
// Исходящее зеркало состояния + спектр/водопад в web — в адаптере (всё из
|
||
// FController, включая последний кадр пикселей). Сервер сам гейтит по FRunning.
|
||
if Assigned(FWebAdapter) then
|
||
FWebAdapter.PushState;
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// Network callbacks → sync helpers
|
||
// ===========================================================================
|
||
|
||
// OnDeviceFound / discovery теперь в TRadioController (сетевой поток → store +
|
||
// rfDeviceList). UI слушает rfDeviceList в OnControllerState.
|
||
|
||
procedure TMainForm.UpdateTXMeters;
|
||
// Вызывается из MeterTimerTick (10 Гц). FController.FLastFwdW/FLastSWR/
|
||
// FLastSupplyV/FLastSupplyA обновляются на rate HP Status (~150/с)
|
||
// в TRadioController.ApplyHPStatus.
|
||
begin
|
||
UpdateStatusField7; // PLL (openHPSDR) или статус beacon lock (Pluto)
|
||
UpdateDMRStatus; // отдельное динамическое поле DMR
|
||
UpdateStatusField3; // Supply (openHPSDR) или Temp/RSSI (Pluto)
|
||
end;
|
||
|
||
procedure TMainForm.UpdateTXControlAvailability;
|
||
var MOXAllowed, TuneAllowed: Boolean;
|
||
begin
|
||
if (BtnMOX = nil) or (BtnTUN = nil) or (FController = nil) then Exit;
|
||
MOXAllowed := FController.FRunning and
|
||
(FController.ActiveTXMode <> MODE_DMR);
|
||
// FM RAW transmits external PCM only; the speech-path tune generator is not
|
||
// useful there and remains guarded in the controller as well.
|
||
TuneAllowed := MOXAllowed and (FController.ActiveTXMode <> MODE_FMRAW);
|
||
BtnMOX.Enabled := MOXAllowed;
|
||
BtnTUN.Enabled := TuneAllowed;
|
||
// Выбор TX-профиля: в DMR передатчика нет вовсе, в FM RAW профиль ни на что
|
||
// не влияет (ApplyTXModeSettings глушит там всю обработку). Гейт по режиму
|
||
// ИСТОЧНИКА передачи, а не главного приёмника: с главным в DMR можно
|
||
// передавать со слайса, и там профиль по-прежнему нужен. Run не требуем —
|
||
// профиль выбирается и оффлайн, как и на вкладке настроек.
|
||
if BtnTXProfile <> nil then
|
||
begin
|
||
BtnTXProfile.Enabled :=
|
||
not (FController.ActiveTXMode in [MODE_DMR, MODE_FMRAW]);
|
||
if (not BtnTXProfile.Enabled) and (FTXProfileDropDown <> nil) then
|
||
FTXProfileDropDown.ClosePopup; // открытый список закрываем сами
|
||
end;
|
||
UpdatePSButtons;
|
||
end;
|
||
|
||
procedure TMainForm.UpdatePSButtons;
|
||
// PS: доступен на openHPSDR-платах с feedback-DDC (Caps.HasPureSignal) при
|
||
// Run. 2TON — обычный TX-тест, доступен как MOX/TUN.
|
||
var
|
||
PSAllowed, TXAllowed: Boolean;
|
||
begin
|
||
if (BtnPS = nil) or (Btn2Ton = nil) or (FController = nil) then Exit;
|
||
TXAllowed := FController.FRunning and
|
||
not (FController.ActiveTXMode in [MODE_DMR, MODE_FMRAW]);
|
||
PSAllowed := TXAllowed and FController.PSAvailable;
|
||
BtnPS.Enabled := PSAllowed;
|
||
Btn2Ton.Enabled := TXAllowed;
|
||
StyleButton(BtnPS, FController.FPSEnabled or FController.PSSingleCalActive);
|
||
StyleButton(Btn2Ton, FController.FTwoTone);
|
||
if (FPSPopup <> nil) and TPureSignalPopup(FPSPopup).Visible then
|
||
TPureSignalPopup(FPSPopup).RefreshStatus;
|
||
UpdateIMDMarkers;
|
||
end;
|
||
|
||
procedure TMainForm.UpdateIMDMarkers;
|
||
// 2TON: маркеры пиков тонов и продуктов IMD3. Только в DUP — дисплей остаётся
|
||
// на RX и показывает СВОЙ сигнал после PA (настоящие плечи IMD); цифровой
|
||
// TX-спектр продуктов PA не содержит, там замер бессмыслен. Абсолютные
|
||
// частоты = TX ± аудио-оффсеты тонов (знак как у генератора: для нижней
|
||
// боковой тона лежат НИЖЕ несущей). Замер/отрисовка — в SpectrumView.
|
||
var
|
||
Sgn: Double;
|
||
begin
|
||
if (FSpecView = nil) or (FController = nil) then Exit;
|
||
if (FController.ActiveTXMode = MODE_LSB) or
|
||
(FController.ActiveTXMode = MODE_CWL) or
|
||
(FController.ActiveTXMode = MODE_DIGL) then
|
||
Sgn := -1.0
|
||
else
|
||
Sgn := 1.0;
|
||
FSpecView.SetIMDMarkers(
|
||
FController.FTwoTone and FController.FTransmitting
|
||
and FController.FDisplayDuplex,
|
||
ActiveTXFreqHz + Sgn * FController.FTXSettings.PSTTFreq1,
|
||
ActiveTXFreqHz + Sgn * FController.FTXSettings.PSTTFreq2);
|
||
end;
|
||
|
||
procedure TMainForm.UpdateStatusField3;
|
||
// Поле 3 статус-бара. openHPSDR — supply V/A (HP status). Pluto — температура
|
||
// чипа и RSSI приёмника (нет PA → supply неприменим). Значения берём из
|
||
// контроллера (FLastPlutoTempC/RSSIdB / FLastSupplyV/A) — логика опроса в ядре.
|
||
var
|
||
S: string;
|
||
begin
|
||
// Pluto: нет PA/supply — показываем аппаратную телеметрию AD936x.
|
||
if FController.IsPluto then
|
||
begin
|
||
if not FController.FRunning then begin SetStatusText(3, 'Pluto --'); Exit; end;
|
||
S := '';
|
||
// Temp X/YC: X — трансивер AD9361, Y — Zynq SoC/FPGA (xadc).
|
||
if (FController.FLastPlutoTempC > TELEMETRY_NONE)
|
||
and (FController.FLastPlutoSocTempC > TELEMETRY_NONE) then
|
||
S := Format('Temp %.0f/%.0fC', [FController.FLastPlutoTempC, FController.FLastPlutoSocTempC])
|
||
else if FController.FLastPlutoTempC > TELEMETRY_NONE then
|
||
S := Format('Temp %.0fC', [FController.FLastPlutoTempC])
|
||
else if FController.FLastPlutoSocTempC > TELEMETRY_NONE then
|
||
S := Format('Temp %.0fC', [FController.FLastPlutoSocTempC]);
|
||
if FController.FLastPlutoRSSIdB > TELEMETRY_NONE then
|
||
begin
|
||
if S <> '' then S := S + ' ';
|
||
S := S + Format('RSSI %.0fdB', [FController.FLastPlutoRSSIdB]);
|
||
end;
|
||
if S = '' then S := 'Pluto --';
|
||
SetStatusText(3, S);
|
||
Exit;
|
||
end;
|
||
|
||
if not FController.FRunning then
|
||
begin
|
||
SetStatusText(3, 'Supply --');
|
||
Exit;
|
||
end;
|
||
|
||
if not BoardSupportsSupplyVoltage(FController.FNetwork.Device.BoardType) then
|
||
begin
|
||
SetStatusText(3, 'Supply n/a');
|
||
Exit;
|
||
end;
|
||
|
||
if FController.FLastSupplyV >= 0 then
|
||
begin
|
||
if FController.FLastSupplyA >= 0 then
|
||
SetStatusText(3,
|
||
Format('Supply %.1fV %.1fA', [FController.FLastSupplyV, FController.FLastSupplyA]))
|
||
else
|
||
SetStatusText(3,
|
||
Format('Supply %.1fV', [FController.FLastSupplyV]));
|
||
end else
|
||
SetStatusText(3, 'Supply --');
|
||
end;
|
||
|
||
function TMainForm.BeaconStatusText: string;
|
||
// Компактный статус QO-100 beacon lock для поля 7 статус-бара (Pluto). Courier 8pt,
|
||
// поле ~200px → держим ≤ ~24 символов.
|
||
begin
|
||
if not FController.BeaconLockEnabled then Exit('BCN: off');
|
||
if FController.BeaconDecodeFreqHz <= 0 then Exit('BCN: click beacon');
|
||
case FController.BeaconState of
|
||
bsLock: Result := Format('BCN LOCK %.0fHz /%.0fdB',
|
||
[FController.BeaconCorrectionHz, FController.BeaconSNRdB]);
|
||
bsSearch: Result := Format('BCN sync /%.0fdB', [FController.BeaconSNRdB]);
|
||
else Result := 'BCN --';
|
||
end;
|
||
end;
|
||
|
||
function TMainForm.DMRStatusText: string;
|
||
var
|
||
S: TDMRStatus;
|
||
Inv, Details, TargetText: string;
|
||
begin
|
||
if not FController.GetDMRStatus(S) then Exit('DMR unavailable');
|
||
if not S.Enabled then Exit('DMR off');
|
||
if not S.Synced then Exit(Format('DMR sync... %.3f', [S.SignalRMS]));
|
||
if S.Inverted then Inv := ' INV' else Inv := '';
|
||
Details := '';
|
||
if S.LinkControlValid then Details := Format(' TS%d', [S.LCSlot + 1])
|
||
else if S.CACHValid then Details := Format(' TS%d', [S.Slot + 1]);
|
||
if S.SlotTypeValid then
|
||
Details := Details + Format(' CC%d/DT%d', [S.ColorCode, S.DataType]);
|
||
TargetText := TDMRDecoder.LinkControlTargetText(S);
|
||
if TargetText <> '' then Details := Details + ' ' + TargetText;
|
||
Result := Format('DMR %s%s%s #%d',
|
||
[TDMRDecoder.SyncKindName(S.SyncKind), Details, Inv,
|
||
S.SyncCount]);
|
||
end;
|
||
|
||
procedure TMainForm.UpdateStatusField7;
|
||
// Поле 7 статус-бара. openHPSDR — PLL-lock (как прежде). Pluto в трансвертере —
|
||
// статус QO-100 beacon lock (у Pluto нет PLL-телеметрии); Pluto без XVTR — пусто.
|
||
begin
|
||
if FController.IsPluto then
|
||
begin
|
||
if FController.FCurrentXvtr >= 0 then SetStatusText(7, BeaconStatusText)
|
||
else SetStatusText(7, '');
|
||
Exit;
|
||
end;
|
||
if not FController.FRunning then SetStatusText(7, 'PLL --')
|
||
else if FController.FLastPLLLock then SetStatusText(7, 'PLL OK')
|
||
else SetStatusText(7, 'PLL?');
|
||
end;
|
||
|
||
procedure TMainForm.UpdateDMRStatus;
|
||
begin
|
||
if StatusPanel = nil then Exit;
|
||
StatusPanel.SetStatusVisible(8, FController.FMode = MODE_DMR);
|
||
if FController.FMode = MODE_DMR then
|
||
SetStatusText(8, DMRStatusText)
|
||
else
|
||
SetStatusText(8, '');
|
||
end;
|
||
|
||
procedure TMainForm.OnWidebandCB(ADCIndex: Integer;
|
||
const Samples: array of SmallInt; Count: Integer);
|
||
begin
|
||
if (not FController.FShowWideband) or (FWidebandView = nil) then Exit;
|
||
FWidebandView.SetSamples(Samples, Count);
|
||
FWidebandDirty := True;
|
||
end;
|
||
|
||
procedure TMainForm.DoUpdateDDCSeq(DDCIdx: Integer; Seq: LongWord);
|
||
begin
|
||
SetStatusText(4, 'RX running');
|
||
end;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// TX settings — применение к WDSP, сборка DUC Specific, sound-card pull
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// BuildMicLineSelectByte / SendDUCSpecificFromSettings переехали в
|
||
// TRadioController (чистые над FTXSettings/движком; нужны headless StartRunning).
|
||
|
||
function TMainForm.DefaultMicSource: TTXMicSource;
|
||
// Mic source для MOX-кнопки и Web-TX: если настроена звуковая карта — берём с неё,
|
||
// иначе используем HW mic-стрим от трансивера.
|
||
// HW PTT всегда переключает на txmsRadio непосредственно в ApplyMOX.
|
||
begin
|
||
// Учитываем вход TX-слайса: у него может быть свой (виртуальный кабель),
|
||
// даже когда общего входа нет — иначе взяли бы микрофон радио.
|
||
if FController.ActiveMicInDevName <> '' then Result := txmsSoundCard
|
||
else Result := txmsRadio;
|
||
end;
|
||
|
||
// ApplyTXSettingsToDSP переехал в TRadioController (вся TX-цепь над FTXSettings).
|
||
|
||
// ===========================================================================
|
||
// Button handlers
|
||
// ===========================================================================
|
||
|
||
{ Поток открытия — отдельный класс, без анонимных процедур }
|
||
// Открываем WDSP в фоновом потоке чтобы не блокировать UI при нажатии START
|
||
type
|
||
TWDSPOpenThread = class(TThread)
|
||
private
|
||
FForm: TMainForm;
|
||
FResult: Boolean;
|
||
procedure SyncDone;
|
||
protected
|
||
procedure Execute; override;
|
||
public
|
||
constructor Create(AForm: TMainForm);
|
||
end;
|
||
|
||
constructor TWDSPOpenThread.Create(AForm: TMainForm);
|
||
begin
|
||
inherited Create(False);
|
||
FForm := AForm;
|
||
FResult := False;
|
||
FreeOnTerminate := True;
|
||
end;
|
||
|
||
procedure TWDSPOpenThread.Execute;
|
||
var
|
||
M: TThreadMethod;
|
||
begin
|
||
try
|
||
FResult := FForm.FController.FDSPEngine.Open;
|
||
except
|
||
FResult := False;
|
||
end;
|
||
M := SyncDone;
|
||
TThread.Synchronize(nil, M);
|
||
end;
|
||
|
||
procedure TWDSPOpenThread.SyncDone;
|
||
begin
|
||
FForm.OnWDSPOpenDone(FResult);
|
||
end;
|
||
|
||
// TDiscoverThread / no-device-сигнал переехали в TRadioController.Discover.
|
||
|
||
procedure TMainForm.RecreateDSPEngine(ASampleRate: Integer);
|
||
begin
|
||
if ASampleRate <= 0 then Exit;
|
||
if Assigned(FController.FDSPEngine) then
|
||
begin
|
||
FController.FDSPEngine.Close;
|
||
FreeAndNil(FController.FDSPEngine);
|
||
end;
|
||
FController.FWDSPReady := False;
|
||
FController.FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512);
|
||
FController.FDSPEngine.OnAudio := FController.OnAudioReady;
|
||
FController.FDSPEngine.OnSpectrum := FController.OnSpectrumReady;
|
||
FController.FDSPEngine.OnWaterfall := FController.OnWaterfallReady;
|
||
// Пиксели доп. панов (этап 3.3) — прямо в UI (display-поток; см. FPanCbLock).
|
||
FController.FDSPEngine.OnPanSpectrum := OnPanSpectrumReady;
|
||
FController.FDSPEngine.OnPanWaterfall := OnPanWaterfallReady;
|
||
FController.FDSPEngine.OnTXIQ := FController.OnTXIQ;
|
||
// Иначе sound-card mic путь теряется после смены sample rate (колбэк
|
||
// навешивается в FormCreate, но новый движок про него не знал).
|
||
FController.FDSPEngine.OnPullMicSamples := FController.PullSoundCardMic;
|
||
end;
|
||
|
||
function TMainForm.EnsureWDSPWisdom: Boolean;
|
||
var
|
||
WisdomDir: string;
|
||
WisdomPath: string;
|
||
DirA: AnsiString;
|
||
BuildThread: TWisdomBuildThread;
|
||
Dlg: TWisdomProgressDialog;
|
||
begin
|
||
Result := True;
|
||
|
||
if not Assigned(@WDSPwisdom) then Exit;
|
||
|
||
WisdomDir := GetAppCfgDir;
|
||
WisdomPath := WisdomDir + WDSP_WISDOM_FILE;
|
||
|
||
if FileExists(WisdomPath) then
|
||
begin
|
||
// По рекомендации WDSP вызываем функцию на каждом старте:
|
||
// если файл уже есть, библиотека просто импортирует wisdom.
|
||
DirA := AnsiString(WisdomDir);
|
||
WDSPwisdom(PAnsiChar(DirA));
|
||
Exit;
|
||
end;
|
||
|
||
BtnStartStop.Enabled := False;
|
||
SetStatusText(2, 'Creating WDSP wisdom...');
|
||
|
||
BuildThread := TWisdomBuildThread.Create(WisdomDir);
|
||
try
|
||
if FLightTheme then
|
||
Dlg := TWisdomProgressDialog.Create(Self, BuildThread, LightTheme)
|
||
else
|
||
Dlg := TWisdomProgressDialog.Create(Self, BuildThread, DarkTheme);
|
||
try
|
||
Dlg.ShowModal;
|
||
finally
|
||
Dlg.Free;
|
||
end;
|
||
|
||
BuildThread.WaitFor;
|
||
|
||
if BuildThread.ErrorText <> '' then
|
||
begin
|
||
ShowMessage('Failed to create WDSP wisdom.' + LineEnding +
|
||
BuildThread.ErrorText);
|
||
Result := False;
|
||
end
|
||
else if not FileExists(WisdomPath) then
|
||
begin
|
||
ShowMessage('WDSP wisdom was not created.' + LineEnding +
|
||
'Expected file: ' + WisdomPath);
|
||
Result := False;
|
||
end;
|
||
finally
|
||
BuildThread.Free;
|
||
BtnStartStop.Enabled := True;
|
||
end;
|
||
|
||
if Result then
|
||
SetStatusText(2, 'DSP ready')
|
||
else
|
||
SetStatusText(2, 'WDSP wisdom missing');
|
||
end;
|
||
|
||
procedure TMainForm.BtnDiscoverClick(Sender: TObject);
|
||
begin
|
||
// Открываем диалог выбора устройства
|
||
if not Assigned(FDeviceDialog) then
|
||
FDeviceDialog := TDeviceDialog.Create(Self);
|
||
FDeviceDialog.Store := FController.FDeviceStore;
|
||
FDeviceDialog.OnDiscover := BtnDiscoverFromDialog;
|
||
FDeviceDialog.SetTheme(CurrentAppTheme);
|
||
FDeviceDialog.ClearResult; // сбрасываем старый выбор перед открытием
|
||
FDeviceDialog.ShowModal;
|
||
// Если пользователь нажал CONNECT — результат хранится в DialogResult,
|
||
// START его заберёт и использует с приоритетом над autostart
|
||
end;
|
||
|
||
procedure TMainForm.BtnDiscoverFromDialog(Sender: TObject);
|
||
// Запускается когда пользователь нажимает DISCOVER внутри диалога.
|
||
// Контроллер чистит список и стартует поток; UI слушает rfDeviceList.
|
||
// Сетевые Pluto (без mDNS) не находятся scan'ом — собираем список URI для
|
||
// прямого probe: сохранённые Pluto + ручной ввод IP в поле диалога.
|
||
var
|
||
URIs: array of string;
|
||
i: Integer;
|
||
ip: string;
|
||
|
||
procedure AddURI(const U: string);
|
||
var k: Integer;
|
||
begin
|
||
if U = '' then Exit;
|
||
for k := 0 to High(URIs) do if SameText(URIs[k], U) then Exit;
|
||
SetLength(URIs, Length(URIs) + 1);
|
||
URIs[High(URIs)] := U;
|
||
end;
|
||
|
||
begin
|
||
SetLength(URIs, 0);
|
||
for i := 0 to FController.FDeviceStore.SavedCount - 1 do
|
||
if FController.FDeviceStore.Saved(i).Kind = bkPluto then
|
||
AddURI(FController.FDeviceStore.Saved(i).URI);
|
||
if Assigned(FDeviceDialog) then
|
||
begin
|
||
ip := FDeviceDialog.ManualIP;
|
||
if ip <> '' then
|
||
begin
|
||
if (Pos('ip:', ip) = 1) or (Pos('usb:', ip) = 1) or (Pos('local:', ip) = 1) then
|
||
AddURI(ip)
|
||
else
|
||
AddURI('ip:' + ip);
|
||
end;
|
||
end;
|
||
FController.SetPlutoProbeURIs(URIs);
|
||
|
||
SetStatusText(2, 'Discovering...');
|
||
FController.Discover;
|
||
end;
|
||
|
||
procedure TMainForm.BtnStartStopClick(Sender: TObject);
|
||
var
|
||
Dev: THPSDRDevice;
|
||
AutoIP: string;
|
||
begin
|
||
if FController.FNetwork.Connected then
|
||
begin
|
||
// --- STOP ---
|
||
DoStopAndDisconnect;
|
||
Exit;
|
||
end;
|
||
|
||
// --- START ---
|
||
AutoIP := '';
|
||
FPendingBoardType := 0;
|
||
|
||
// Явный выбор через CONNECT в диалоге имеет приоритет над autostart
|
||
if Assigned(FDeviceDialog) and FDeviceDialog.DialogResult.Accepted then
|
||
begin
|
||
FPendingIP := FDeviceDialog.DialogResult.IPAddress;
|
||
FPendingBoardType := FDeviceDialog.GetSavedBoardType(
|
||
FDeviceDialog.DialogResult.SavedIdx);
|
||
FDeviceDialog.ClearResult; // consumed — следующий START снова спросит
|
||
end
|
||
else
|
||
begin
|
||
if Assigned(FDeviceDialog) then
|
||
AutoIP := FDeviceDialog.GetAutoStartIP;
|
||
|
||
if AutoIP <> '' then
|
||
begin
|
||
FPendingIP := AutoIP;
|
||
FPendingBoardType := FDeviceDialog.GetAutoStartBoardType;
|
||
end
|
||
else
|
||
begin
|
||
if not Assigned(FDeviceDialog) then
|
||
FDeviceDialog := TDeviceDialog.Create(Self);
|
||
FDeviceDialog.Store := FController.FDeviceStore;
|
||
FDeviceDialog.OnDiscover := BtnDiscoverFromDialog;
|
||
FDeviceDialog.SetTheme(CurrentAppTheme);
|
||
if FDeviceDialog.ShowModal <> mrOk then Exit;
|
||
if not FDeviceDialog.DialogResult.Accepted then Exit;
|
||
FPendingIP := FDeviceDialog.DialogResult.IPAddress;
|
||
FPendingBoardType := FDeviceDialog.GetSavedBoardType(
|
||
FDeviceDialog.DialogResult.SavedIdx);
|
||
FDeviceDialog.ClearResult;
|
||
end;
|
||
end;
|
||
|
||
// Создаём запись устройства. Если это известный (discovered/saved) Pluto —
|
||
// восстанавливаем Kind/URI/Serial/MAC из store по IP; иначе HPSDR по IP.
|
||
Dev := ResolveDevice(FPendingIP);
|
||
if Dev.Kind = bkHPSDR then Dev.BoardType := FPendingBoardType;
|
||
StartWithDevice(Dev);
|
||
end;
|
||
|
||
function TMainForm.ResolveDevice(const IP: string): THPSDRDevice;
|
||
// По IP/URI ищет устройство среди discovered, затем saved, чтобы восстановить
|
||
// Kind/URI/Serial/MAC (нужно для Pluto). Логика общая с демоном — в контроллере.
|
||
begin
|
||
Result := FController.ResolveDevice(IP);
|
||
end;
|
||
|
||
procedure TMainForm.DoStopAndDisconnect;
|
||
// STOP-teardown, общий для десктоп-STOP и web-disconnect. Движок (graceful Run=0,
|
||
// сброс TX/телеметрии) + персист настроек + Disconnect — в контроллере
|
||
// (StopAndDisconnect); рендер run-кнопки/MOX/sleep — через rfRunning. Здесь —
|
||
// только очистка спектра и контекстный offline-статус.
|
||
begin
|
||
FController.StopAndDisconnect;
|
||
FSpecView.ResetSpectrumBuf;
|
||
FSpecView.DrawSpectrum;
|
||
PbSpectrum.Invalidate;
|
||
SetRadioOfflineStatus('Disconnected', True);
|
||
end;
|
||
|
||
procedure TMainForm.StartWithDevice(const Dev: THPSDRDevice);
|
||
// Запуск с уже выбранным устройством — общий путь для десктоп-диалога и
|
||
// web-оверлея (без модального диалога). Подбор sample rate до WDSP, открытие
|
||
// WDSP (async) и подключение.
|
||
var
|
||
PreloadRate: Integer;
|
||
FoundByIP: Boolean;
|
||
i: Integer;
|
||
PreG: TGlobalSettings;
|
||
PreBands: array[0..CFG_BAND_COUNT-1] of TBandSettings;
|
||
begin
|
||
// Подбираем sample rate до открытия WDSP, чтобы не делать двойной Open:
|
||
// 1) Open на 192k, 2) сразу ChangeSampleRate+Open ещё раз.
|
||
// Если устройство уже было обнаружено (есть MAC) и есть сохранённый профиль,
|
||
// создаём WDSPEngine сразу с нужной частотой.
|
||
PreloadRate := FController.FSampleRate;
|
||
FoundByIP := False;
|
||
i := FController.FDeviceStore.FindDiscoveredByIP(Dev.IPAddress);
|
||
if i >= 0 then
|
||
begin
|
||
FoundByIP := True;
|
||
if FController.FSettings.LoadDevice(FController.FDeviceStore.Discovered(i).MAC, PreG, PreBands)
|
||
and (PreG.SampleRate > 0) then
|
||
PreloadRate := PreG.SampleRate;
|
||
end;
|
||
if FoundByIP and (PreloadRate > 0) then
|
||
begin
|
||
FController.FSampleRate := PreloadRate;
|
||
FController.FSpanHz := PreloadRate;
|
||
if (not FController.FWDSPReady) and (Assigned(FController.FDSPEngine)) and (FController.FDSPEngine.SampleRate <> PreloadRate) then
|
||
RecreateDSPEngine(PreloadRate);
|
||
end;
|
||
|
||
// --- Открываем WDSP асинхронно чтобы не блокировать UI ---
|
||
FPendingDev := Dev;
|
||
BtnStartStop.Enabled := False;
|
||
SetStatusText(2, 'Opening DSP...');
|
||
if FController.FWDSPReady then
|
||
// WDSP уже открыт — сразу подключаемся
|
||
DoConnectDevice(Dev)
|
||
else
|
||
// Открываем в фоновом потоке
|
||
TWDSPOpenThread.Create(Self);
|
||
end;
|
||
|
||
procedure TMainForm.OnWDSPOpenDone(Success: Boolean);
|
||
begin
|
||
// Вызывается из TWDSPOpenThread.SyncDone — уже в главном потоке
|
||
FController.FWDSPReady := Success;
|
||
if not Success then
|
||
begin
|
||
SetStatusText(2, 'DSP failed');
|
||
BtnStartStop.Enabled := True;
|
||
BtnStartStop.Caption := 'START';
|
||
StyleButton(BtnStartStop, False);
|
||
ShowMessage('WDSP not loaded.' + LineEnding +
|
||
IfThen(FController.FDSPEngine.LastError <> '', FController.FDSPEngine.LastError, 'Unknown WDSP init error.') + LineEnding +
|
||
'Put wdsp.dll рядом с ewsdr.exe (той же разрядности, что и приложение).');
|
||
Exit;
|
||
end;
|
||
DoConnectDevice(FPendingDev);
|
||
end;
|
||
|
||
procedure TMainForm.RelabelBands;
|
||
// Переписывает подписи band-кнопок под план активного бэкенда (HF ↔ VHF/UHF).
|
||
var i: Integer; pl: Boolean;
|
||
begin
|
||
pl := FController.IsPluto;
|
||
for i := 0 to BAND_COUNT - 1 do
|
||
if BtnBand[i] <> nil then
|
||
begin
|
||
BtnBand[i].Caption := BandName(i, pl);
|
||
// Пустой слот плана (HF: 12-й) — прячем кнопку.
|
||
BtnBand[i].Visible := BtnBand[i].Caption <> '';
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.ApplyBackendCapsToUI;
|
||
// Пресеты sample-rate оверлея из Caps (Pluto >560k) + (TODO) гейтинг полей,
|
||
// которых нет у бэкенда (PA/SWR/Alex/Wideband).
|
||
var
|
||
Caps: TBackendCaps;
|
||
begin
|
||
Caps := FController.BackendCaps;
|
||
if Assigned(FSampleRateOverlay) then
|
||
begin
|
||
// Единый источник — контроллер (caps бэкенда). UI больше не владеет списком.
|
||
FSampleRateOverlay.SetRatePresets(FController.SampleRatePresets);
|
||
FSampleRateOverlay.SetCurrentRate(FController.FSampleRate);
|
||
end;
|
||
PushRatePresetsToWeb; // тот же источник зеркалим в web-морду
|
||
PushBandsToWeb; // band-план бэкенда → web (HF ↔ VHF/UHF)
|
||
|
||
// Отдельный Pluto RF-AGC блок под AGC: показываем и сдвигаем панели ниже.
|
||
LayoutLeftPanel(Caps.HasHWGain);
|
||
if Caps.HasHWGain then UpdateRxGainUI;
|
||
UpdateRxMuteButton; // RX MUTE — видимость по Pluto + full-duplex (если QO-100 уже активен)
|
||
UpdateBandPlanOverlay; // полоска бэндплана QO-100
|
||
end;
|
||
|
||
procedure TMainForm.DoConnectDevice(const Dev: THPSDRDevice);
|
||
begin
|
||
BtnStartStop.Enabled := True;
|
||
|
||
// Connect + load-by-MAC + перестройка диапазона/XVTR/активного VFO + go-live —
|
||
// вся оркестрация в контроллере (ConnectAndRun); рендер device/band/run-
|
||
// виджетов идёт через события (rfDevice/rfBandRestore/rfXvtr/rfActiveVfo/
|
||
// rfRunning). Здесь — только UI-extras (статус-строки/AGCTop/resize).
|
||
if not FController.ConnectAndRun(Dev, FPan.SpectrumWidth) then
|
||
begin
|
||
ShowMessage('Failed to connect to ' + Dev.IPAddress +
|
||
IfThen(FController.FNetwork.LastError <> '', ': ' + FController.FNetwork.LastError, ''));
|
||
Exit;
|
||
end;
|
||
|
||
// Активный VFO=A: подсветку (рисовалась в FormCreate при FActiveVfo=0) освежаем
|
||
// здесь — для B это сделал SetActiveVfo(1) внутри ConnectAndRun.
|
||
if FController.FActiveVfo <> 1 then
|
||
UpdateVfoDisplay;
|
||
|
||
SetStatusText(1, 'IP: ' + FController.FNetwork.Device.IPAddress);
|
||
SetStatusText(0, 'Board: ' + FController.BoardDisplayName);
|
||
FSpecView.AGCTop := FController.FAGCTop;
|
||
SetStatusText(6, 'SEQ OK');
|
||
SetStatusText(2, 'Running');
|
||
ResizeSpectrumPanels;
|
||
end;
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ActivateVfo — переключаем активный VFO и перестраиваем приёмник.
|
||
// Idx=0 → VFO-A активен, Idx=1 → VFO-B активен.
|
||
// Перестройка всегда происходит на частоту активного VFO (не на оффсет).
|
||
// CTUN при этом сбрасывается чтобы не было путаницы с оффсетом.
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ActiveVfoFreq — частота активного VFO
|
||
// ---------------------------------------------------------------------------
|
||
function TMainForm.ActiveVfoFreq: Int64;
|
||
begin
|
||
if FController.FActiveVfo = 0 then Result := Round(FController.FVfoA)
|
||
else Result := Round(FController.FVfoB);
|
||
end;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ActivateVfo — переключаем активный VFO и перестраиваем приёмник.
|
||
// Idx=0 → VFO-A активен, Idx=1 → VFO-B активен.
|
||
// Перестройка на частоту выбранного VFO. CTUN не трогаем.
|
||
// ---------------------------------------------------------------------------
|
||
procedure TMainForm.ActivateVfo(Idx: Integer);
|
||
begin
|
||
// Движок (переключение RX-VFO, CTUN-логика, центр/сеть, диапазон) — в
|
||
// контроллере; рендер — OnControllerState(rfActiveVfo/rfBand/rfCTun).
|
||
FController.SetActiveVfo(Idx);
|
||
end;
|
||
|
||
procedure TMainForm.FreqDispAClick(Sender: TObject);
|
||
begin
|
||
// Клик на VFO-A → активируем A (перестройка на FController.FVfoA)
|
||
if FController.FActiveVfo <> 0 then
|
||
ActivateVfo(0);
|
||
end;
|
||
|
||
procedure TMainForm.FreqDispBClick(Sender: TObject);
|
||
begin
|
||
// Клик на VFO-B → активируем B (перестройка на FController.FVfoB)
|
||
if FController.FActiveVfo <> 1 then
|
||
ActivateVfo(1);
|
||
end;
|
||
|
||
procedure TMainForm.BtnTopVfoASelectClick(Sender: TObject);
|
||
begin
|
||
if FController.FActiveVfo <> 0 then
|
||
ActivateVfo(0);
|
||
end;
|
||
|
||
procedure TMainForm.BtnTopVfoATXClick(Sender: TObject);
|
||
begin
|
||
FController.FSplitTxB := False;
|
||
UpdateVfoDisplay;
|
||
|
||
if FController.FNetwork.Connected and FController.FNetwork.Running then
|
||
begin
|
||
FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), FController.TXTuneFreqHz, FController.FDriveLevel,
|
||
FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone);
|
||
FController.FNetwork.SendFullHP;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.BtnTopVfoBSelectClick(Sender: TObject);
|
||
begin
|
||
if FController.FActiveVfo <> 1 then
|
||
ActivateVfo(1);
|
||
end;
|
||
|
||
procedure TMainForm.BtnTopVfoBTXClick(Sender: TObject);
|
||
begin
|
||
FController.FSplitTxB := True;
|
||
if FController.FSplitTxB and (FController.FActiveVfo <> 0) then
|
||
ActivateVfo(0)
|
||
else
|
||
UpdateVfoDisplay;
|
||
|
||
if FController.FNetwork.Connected and FController.FNetwork.Running then
|
||
begin
|
||
FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), FController.TXTuneFreqHz, FController.FDriveLevel,
|
||
FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone);
|
||
FController.FNetwork.SendFullHP;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.FreqDispAChanged(Sender: TObject; NewFreq: Int64);
|
||
begin
|
||
if FController.FActiveVfo = 0 then
|
||
ApplyVfoA(NewFreq)
|
||
else
|
||
FController.FVfoA := NewFreq; // просто сохраняем, приёмник не перестраиваем
|
||
end;
|
||
|
||
procedure TMainForm.FreqDispBChanged(Sender: TObject; NewFreq: Int64);
|
||
begin
|
||
// Движок (сохранение B, перестройка при активном B, диапазон, сеть) — в
|
||
// контроллере; рендер — OnControllerState(rfVfoB).
|
||
FController.SetVfoB(NewFreq);
|
||
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;
|
||
HeaderH, RowStep, BottomPad: 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;
|
||
HeaderH := PanelFilter.Scale96ToForm(20);
|
||
RowStep := PanelFilter.Scale96ToForm(27);
|
||
BottomPad := PanelFilter.Scale96ToForm(4);
|
||
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 := HeaderH + RowOf * RowStep;
|
||
BtnFilter[i].Caption := FController.FilterSlot(FController.FMode, i).Name;
|
||
BtnFilter[i].Visible := True;
|
||
StyleButton(BtnFilter[i], i = FController.FFilter);
|
||
end;
|
||
PanelFilter.Height := HeaderH + Rows * RowStep + BottomPad;
|
||
end;
|
||
|
||
procedure TMainForm.UpdateFilterButtons;
|
||
var
|
||
i: Integer;
|
||
DefIdx: Integer;
|
||
begin
|
||
// DMR has a mandatory 12.5 kHz channel. There is nothing for the operator
|
||
// to choose, so hide the whole filter selector and restore it on exit.
|
||
PanelFilter.Visible := FController.FMode <> MODE_DMR;
|
||
if FController.FMode = MODE_FM then
|
||
begin
|
||
// FM: NFM/FM + VAR1/VAR2 в один ряд (панель остаётся низкой, SQL/CTCSS
|
||
// садятся сразу под ней).
|
||
CloseCTCSSPopup;
|
||
FController.SanitizeFilterState;
|
||
LayoutFilterButtons(FILT_FM_COUNT);
|
||
if PanelFMSQ <> nil then
|
||
begin
|
||
PanelFMSQ.Visible := True;
|
||
StyleButton(BtnFMSQ, FController.FFMSQOn);
|
||
TrkFMSQ.Position := FController.FFMSQLevel;
|
||
LblFMSQ.Caption := IntToStr(FController.FFMSQLevel);
|
||
end;
|
||
if PanelFMCTCSS <> nil then PanelFMCTCSS.Visible := True;
|
||
if PanelFMStep <> nil then
|
||
begin
|
||
StyleButton(BtnFMStep, FController.FFMStepOn);
|
||
if FStepDropDown <> nil then FStepDropDown.SetItemIndex(FController.FFMStepIdx);
|
||
PanelFMStep.Visible := True;
|
||
end;
|
||
if PanelFMRpt <> nil then
|
||
begin
|
||
StyleButton(BtnFMRptMinus, FController.FFMRptDir = RPT_MINUS);
|
||
StyleButton(BtnFMRptPlus, FController.FFMRptDir = RPT_PLUS);
|
||
EdFMRptOffset.Text := RptFormatOffset(FController.FFMRptOffsetHz);
|
||
PanelFMRpt.Visible := True;
|
||
end;
|
||
if PanelRawDev <> nil then PanelRawDev.Visible := False;
|
||
RelayoutBelowBands;
|
||
Exit;
|
||
end;
|
||
|
||
// Non-FM: restore full 2-row height and 5-column layout, hide FM panels.
|
||
// DMR reuses only the tuning STEP panel; SQL/CTCSS/repeater stay FM-only.
|
||
PanelFilter.Height := PanelFilter.Scale96ToForm(74);
|
||
if PanelFMSQ <> nil then PanelFMSQ.Visible := False;
|
||
if PanelFMCTCSS <> nil then PanelFMCTCSS.Visible := False;
|
||
if PanelFMStep <> nil then
|
||
begin
|
||
if FController.FMode in [MODE_DMR, MODE_FMRAW] then
|
||
begin
|
||
StyleButton(BtnFMStep, FController.FFMStepOn);
|
||
if FStepDropDown <> nil then
|
||
FStepDropDown.SetItemIndex(FController.FFMStepIdx);
|
||
PanelFMStep.Visible := True;
|
||
end
|
||
else
|
||
begin
|
||
CloseFMStepPopup;
|
||
PanelFMStep.Visible := False;
|
||
end;
|
||
end;
|
||
if PanelFMRpt <> nil then PanelFMRpt.Visible := False;
|
||
if PanelRawDev <> nil then PanelRawDev.Visible := False;
|
||
|
||
if FController.FMode = MODE_WFM then
|
||
begin
|
||
// WFM: полосы вещательного канала — три кнопки + VAR1/VAR2 в один ряд.
|
||
CloseCTCSSPopup;
|
||
FController.SanitizeFilterState;
|
||
LayoutFilterButtons(FILT_WFM_COUNT);
|
||
RelayoutBelowBands;
|
||
Exit;
|
||
end;
|
||
|
||
if FController.FMode = MODE_FMRAW then
|
||
begin
|
||
CloseCTCSSPopup;
|
||
FController.SanitizeFilterState;
|
||
LayoutFilterButtons(FILT_RAW_COUNT);
|
||
if PanelRawDev <> nil then
|
||
begin
|
||
TrkRawDev.Position := Round(FController.FRawFMDeviation) div 50;
|
||
LblRawDev.Caption := Format('%.2f k', [FController.FRawFMDeviation / 1000.0]);
|
||
PanelRawDev.Visible := True;
|
||
end;
|
||
RelayoutBelowBands;
|
||
Exit;
|
||
end;
|
||
|
||
if FController.FMode = MODE_DMR then
|
||
begin
|
||
CloseCTCSSPopup;
|
||
FController.SanitizeFilterState;
|
||
for i := 0 to FILT_SLOTS - 1 do BtnFilter[i].Visible := False;
|
||
RelayoutBelowBands;
|
||
Exit;
|
||
end;
|
||
|
||
// Прочие режимы: смена режима возвращает заводской индекс фильтра (как было
|
||
// до таблицы), кромки и подписи приезжают из таблицы контроллера.
|
||
PanelFilter.Height := PanelFilter.Scale96ToForm(74);
|
||
DefIdx := FilterDefIdx(FController.FMode);
|
||
FController.SelectFilterSlotState(DefIdx);
|
||
LayoutFilterButtons(FILT_COUNT);
|
||
RelayoutBelowBands;
|
||
end;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// ApplyVfoA — единая точка смены частоты VFO A
|
||
//
|
||
// CTUN ВЫКЛ (классический режим):
|
||
// FController.FCenterFreq = FController.FVfoA — спектр центрирован на VFO, маркер всегда в центре
|
||
//
|
||
// CTUN ВКЛ (piHPSDR/Thetis режим):
|
||
// FController.FCenterFreq статичен — спектр/водопад не двигаются
|
||
// FController.FVfoA движется по дисплею (маркер гуляет)
|
||
// Перецентрирование — только когда КРАЙ ПОЛОСЫ ФИЛЬТРА выходит за край дисплея
|
||
// (по образцу OpenHPSDR/PowerSDR: "re-centering occurs as the edge of the passband
|
||
// hits the edge of the display")
|
||
// ---------------------------------------------------------------------------
|
||
procedure TMainForm.ApplyVfoA(NewFreq: Int64);
|
||
begin
|
||
// Тонкий делегат: движок (XVTR-клэмп, CTUN, band-detect, FM авто-RPT, сеть) —
|
||
// в SetVfoA; канальная оркестрация — в FController.OnAfterTune (хук в конце
|
||
// SetVfoA → FChannelController.OnVfoTuned); рендер — OnControllerState.
|
||
FController.SetVfoA(NewFreq);
|
||
end;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CTUN toggle
|
||
// ---------------------------------------------------------------------------
|
||
procedure TMainForm.BtnCTunClick(Sender: TObject);
|
||
begin
|
||
// Логика (band cache, center/shift, сеть) — в контроллере; рендер — в
|
||
// OnControllerState(rfCTun).
|
||
FController.SetCTun(not FController.FCTun);
|
||
end;
|
||
|
||
procedure TMainForm.BtnBeaconClick(Sender: TObject);
|
||
// ЛКМ по BEACON: вкл/выкл лок маяка (= декодер + трим LOError). При включении
|
||
// «вооружаем» клик (следующий ЛКМ по спектру наведёт декодер на маяк) и открываем
|
||
// окно констелляции/бюллетеня. Декодер ведёт несущую, контур стекает дрейф в LOError.
|
||
begin
|
||
FController.SetBeaconLock(not FController.BeaconLockEnabled);
|
||
FBeaconArming := FController.BeaconLockEnabled;
|
||
if FController.BeaconLockEnabled then
|
||
begin
|
||
if FBeaconScopeForm = nil then
|
||
FBeaconScopeForm := TBeaconScopeForm.CreateWith(Self, FController);
|
||
FBeaconScopeForm.ApplyTheme(CurrentAppTheme);
|
||
FBeaconScopeForm.Show;
|
||
end;
|
||
UpdateBeaconButton;
|
||
end;
|
||
|
||
procedure TMainForm.BtnBeaconMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
// ПКМ по BEACON: показать/скрыть окно констелляции+бюллетеня (диагностика декодера).
|
||
// Лок не трогает — он на ЛКМ.
|
||
begin
|
||
if Button <> mbRight then Exit;
|
||
if not FController.BeaconLockEnabled then Exit;
|
||
if FBeaconScopeForm = nil then
|
||
FBeaconScopeForm := TBeaconScopeForm.CreateWith(Self, FController);
|
||
FBeaconScopeForm.ApplyTheme(CurrentAppTheme);
|
||
if FBeaconScopeForm.Visible then FBeaconScopeForm.Hide
|
||
else FBeaconScopeForm.Show;
|
||
end;
|
||
|
||
procedure TMainForm.UpdateBeaconButton;
|
||
// Beacon-маркеры + кнопка BEACON. Кнопка видима только на Pluto в трансвертере
|
||
// (см. rfXvtr) — это ПРОСТО кнопка: подпись 'BEACON', подсветка = лок включён.
|
||
// Вся инфа (lock/коррекция/поиск/«click») — в статус-баре, поле 7 (вместо PLL),
|
||
// см. UpdateStatusField7. На openHPSDR кнопка скрыта, beacon-маркеры неактивны.
|
||
begin
|
||
// Маркеры на спектре: опорная частота (зелёная) + отслеживаемый центроид (оранж).
|
||
if FSpecView <> nil then
|
||
begin
|
||
FSpecView.SetBeaconMarkers(FController.BeaconLockEnabled,
|
||
FController.BeaconRefHz,
|
||
FController.BeaconTrackedFreqHz);
|
||
// Узкий фильтр-маркер декодера (±450 Гц ≈ полоса BPSK-маяка).
|
||
FSpecView.SetBeaconDecMarker(
|
||
FController.BeaconLockEnabled and (FController.BeaconDecodeFreqHz > 0),
|
||
FController.BeaconDecodeFreqHz, 450.0);
|
||
end;
|
||
UpdateStatusField7; // моментальный апдейт статус-бара (не ждём MeterTimer)
|
||
if BtnBeacon = nil then Exit;
|
||
BtnBeacon.Caption := 'BEACON';
|
||
StyleButton(BtnBeacon, FController.BeaconLockEnabled);
|
||
BtnBeacon.Repaint;
|
||
end;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// DUP toggle (Thetis-style local display mode — без аппаратной команды)
|
||
// ---------------------------------------------------------------------------
|
||
procedure TMainForm.ApplyDUP(Active: Boolean);
|
||
begin
|
||
// Единый путь: контроллер ставит FDisplayDuplex + KeepRXDuringTX (живьём) и шлёт
|
||
// rfDuplex → там рендер кнопки/дисплея. Так тумблер и QO-100-авто согласованы
|
||
// (раньше кнопка писала FDisplayDuplex напрямую, минуя KeepRXDuringTX и rfDuplex).
|
||
FController.SetDuplex(Active);
|
||
end;
|
||
|
||
procedure TMainForm.BtnDUPClick(Sender: TObject);
|
||
begin
|
||
ApplyDUP(not FController.FDisplayDuplex);
|
||
end;
|
||
|
||
procedure TMainForm.BtnRxMuteClick(Sender: TObject);
|
||
begin
|
||
FController.SetRxMuteOnTx(not FController.FRxMuteOnTx); // рендер через rfRxMuteOnTx
|
||
end;
|
||
|
||
procedure TMainForm.UpdateRxMuteButton;
|
||
// Кнопка RX MUTE видна на ЛЮБОМ бэкенде, но АКТИВНА только в DUP (self-monitor
|
||
// осмыслен лишь в full-duplex; без DUP звук на TX и так не проходит — движок
|
||
// гейтит по FKeepRXDuringTX=FDisplayDuplex). ON (подсвечена) = RX-аудио глушится
|
||
// на TX; OFF = слышим свой downlink во время передачи.
|
||
begin
|
||
if BtnRxMute = nil then Exit;
|
||
BtnRxMute.Visible := True;
|
||
BtnRxMute.Enabled := FController.DisplayDuplex;
|
||
StyleButton(BtnRxMute, FController.FRxMuteOnTx);
|
||
end;
|
||
|
||
procedure TMainForm.UpdateBandPlanOverlay;
|
||
// Полоска бэндплана видна только в QO-100 (Pluto + full-duplex транспондер).
|
||
begin
|
||
if FBandPlanOverlay = nil then Exit;
|
||
FBandPlanOverlay.SetEnabled(FController.InQO100);
|
||
FSpectrumDirty := True; // моментальная перерисовка (не ждём таймер)
|
||
end;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// TUN — Thetis-style тон: WDSP TXA PostGen + MOX с отдельным уровнем Drive
|
||
// ---------------------------------------------------------------------------
|
||
procedure TMainForm.ApplyTUN(Active: Boolean);
|
||
begin
|
||
// FWebClientActive держит web-слой актуальным (OnClientActiveChanged).
|
||
FController.SetTune(Active); // ядро TUN (safety/тон/drive/MOX) + рендер через rfTuning
|
||
end;
|
||
|
||
procedure TMainForm.BtnTUNClick(Sender: TObject);
|
||
begin
|
||
ApplyTUN(not FController.FTuning);
|
||
end;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// PureSignal: кнопка PS (ЛКМ = вкл/выкл, ПКМ = поповер настроек) + 2TON
|
||
// ---------------------------------------------------------------------------
|
||
procedure TMainForm.BtnPSClick(Sender: TObject);
|
||
begin
|
||
if not FController.PSAvailable then Exit;
|
||
FController.SetPureSignal(not FController.FPSEnabled); // рендер через rfPureSignal
|
||
end;
|
||
|
||
procedure TMainForm.BtnPSMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
var
|
||
Pop: TPureSignalPopup;
|
||
begin
|
||
if Button <> mbRight then Exit;
|
||
if not FController.PSAvailable then Exit;
|
||
if FPSPopup = nil then
|
||
begin
|
||
Pop := TPureSignalPopup.Create(Self);
|
||
Pop.OnChanged := OnPSPopupChanged;
|
||
FPSPopup := Pop;
|
||
end
|
||
else
|
||
Pop := TPureSignalPopup(FPSPopup);
|
||
if Pop.Visible then
|
||
Pop.Hide
|
||
else
|
||
Pop.PopupBelow(FController, BtnPS);
|
||
end;
|
||
|
||
procedure TMainForm.Btn2TonClick(Sender: TObject);
|
||
begin
|
||
FController.SetTwoTone(not FController.FTwoTone); // рендер через rfTwoTone
|
||
end;
|
||
|
||
procedure TMainForm.OnPSPopupChanged(Sender: TObject);
|
||
// Правка PS-параметров в поповере: применение к WDSP делает сам поповер
|
||
// (ApplyPSSettingsToDSP), здесь — персист per-device + live-обновление
|
||
// частот IMD-маркеров (если правили тона при активном 2TON).
|
||
begin
|
||
if FController.FDevConnected then
|
||
begin
|
||
FController.FSettings.SaveTX(FController.FDevMAC, FController.FTXSettings);
|
||
FController.FSettings.Save;
|
||
end;
|
||
UpdateIMDMarkers;
|
||
end;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Клик/драг по спектру/водопаду
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Перевод X пикселя в частоту
|
||
function PixelToFreq(PixelX, PanelWidth: Integer;
|
||
CenterFreq, SpanHz: Double): Double;
|
||
begin
|
||
Result := CenterFreq + (PixelX / PanelWidth - 0.5) * SpanHz;
|
||
end;
|
||
|
||
procedure TMainForm.DoSpectrumClick(PixelX: Integer; PanelWidth: Integer);
|
||
var
|
||
ClickFreq: Int64;
|
||
StepHz: Int64;
|
||
ViewCenter, ViewSpan: Double;
|
||
begin
|
||
if PanelWidth <= 0 then Exit;
|
||
if (FController.FMode in [MODE_FM, MODE_DMR, MODE_FMRAW]) and
|
||
FController.FFMStepOn then
|
||
StepHz := FM_STEP_HZ[FController.FFMStepIdx]
|
||
else
|
||
StepHz := 100;
|
||
FController.GetViewWindow(ViewCenter, ViewSpan);
|
||
ClickFreq := Round(ViewCenter + (PixelX / PanelWidth - 0.5) * ViewSpan);
|
||
ClickFreq := ((ClickFreq + StepHz div 2) div StepHz) * StepHz;
|
||
if FController.FActiveVfo = 0 then
|
||
ApplyVfoA(ClickFreq)
|
||
else
|
||
FController.SetVfoB(ClickFreq); // движок + рендер через OnControllerState(rfVfoB)
|
||
end;
|
||
|
||
procedure TMainForm.DoWidebandClick(PixelX: Integer; PanelWidth: Integer);
|
||
var
|
||
FreqHz: Double;
|
||
ClickFreq: Int64;
|
||
StepHz: Int64;
|
||
BandIdx: Integer;
|
||
WasCTun: Boolean;
|
||
begin
|
||
if (FWidebandView = nil) or
|
||
(not FWidebandView.TryPixelToFrequency(PixelX, PanelWidth, FreqHz)) then Exit;
|
||
|
||
if (FController.FMode in [MODE_FM, MODE_DMR, MODE_FMRAW]) and
|
||
FController.FFMStepOn then
|
||
StepHz := FM_STEP_HZ[FController.FFMStepIdx]
|
||
else
|
||
StepHz := 100;
|
||
ClickFreq := Round(FreqHz);
|
||
ClickFreq := ((ClickFreq + StepHz div 2) div StepHz) * StepHz;
|
||
|
||
if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) and
|
||
FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled then
|
||
begin
|
||
if ClickFreq < FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqBegin then
|
||
ClickFreq := Round(FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqBegin);
|
||
if ClickFreq > FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqEnd then
|
||
ClickFreq := Round(FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqEnd);
|
||
end;
|
||
|
||
if FController.FCurrentXvtr < 0 then
|
||
begin
|
||
BandIdx := FreqToBandIdx(ClickFreq, FController.IsPluto);
|
||
if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then
|
||
begin
|
||
if FController.FDevConnected then
|
||
begin
|
||
SaveCurrentBand;
|
||
FController.FSettings.Save;
|
||
end;
|
||
FController.FCurrentBand := BandIdx;
|
||
RestoreBand(FController.FCurrentBand);
|
||
end;
|
||
end;
|
||
|
||
if FController.FActiveVfo = 0 then
|
||
begin
|
||
WasCTun := FController.FCTun;
|
||
FController.FCTun := False;
|
||
ApplyVfoA(ClickFreq);
|
||
FController.FCTun := WasCTun;
|
||
if WasCTun and FController.FWDSPReady then
|
||
FController.FDSPEngine.SetShift(0.0);
|
||
end
|
||
else
|
||
begin
|
||
FController.FVfoB := ClickFreq;
|
||
FreqDispB.Frequency := Round(FController.FVfoB);
|
||
FController.FCenterFreq := ClickFreq;
|
||
if FController.FWDSPReady then FController.FDSPEngine.SetShift(0.0);
|
||
if FController.FNetwork.Connected and FController.FNetwork.Running then
|
||
begin
|
||
FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq),
|
||
FController.TXTuneFreqHz, FController.FDriveLevel, FController.FTransmitting, True, True,
|
||
FController.FTuning, FController.FTwoTone);
|
||
FController.FNetwork.SendFullHP;
|
||
end;
|
||
if FController.FCurrentXvtr < 0 then
|
||
begin
|
||
BandIdx := FreqToBandIdx(FController.FVfoB, FController.IsPluto);
|
||
if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then
|
||
begin
|
||
if FController.FCurrentBand >= 0 then
|
||
StyleButton(BtnBand[FController.FCurrentBand], False);
|
||
FController.FCurrentBand := BandIdx;
|
||
StyleButton(BtnBand[FController.FCurrentBand], True);
|
||
end;
|
||
end;
|
||
UpdateVfoDisplay;
|
||
SyncSpecViewFreq;
|
||
FSpectrumDirty := True;
|
||
PbSpectrum.Invalidate;
|
||
end;
|
||
|
||
FSpecView.InvalidateRulerCache;
|
||
if PbRuler <> nil then PbRuler.Invalidate;
|
||
FWaterfallDirty := True;
|
||
if PbWaterfall <> nil then PbWaterfall.Invalidate;
|
||
if UpdateWidebandFrequencyView and (PbWideband <> nil) then
|
||
PbWideband.Invalidate;
|
||
end;
|
||
|
||
procedure TMainForm.DoSpectrumDrag(PixelX: Integer; PanelWidth: Integer);
|
||
var
|
||
dPix: Integer;
|
||
dFreq: Double;
|
||
ViewCenter, ViewSpan: Double;
|
||
begin
|
||
if PanelWidth <= 0 then Exit;
|
||
FController.GetViewWindow(ViewCenter, ViewSpan);
|
||
dPix := PixelX - FSpecDragX0;
|
||
dFreq := dPix / PanelWidth * ViewSpan;
|
||
if FController.FCTun then
|
||
// CTUN ON: drag двигает окно просмотра (DDC-центр) без смены VFO; shift,
|
||
// сеть и рендер — в контроллере SetCenter / OnControllerState(rfCenterFreq).
|
||
FController.SetCenter(FSpecDragFreq - dFreq)
|
||
else
|
||
begin
|
||
// CTUN OFF: drag двигает VFO, FController.FCenterFreq следует через Apply
|
||
if FController.FActiveVfo = 0 then
|
||
ApplyVfoA(Round(FSpecDragFreq - dFreq))
|
||
else
|
||
FController.SetVfoB(FSpecDragFreq - dFreq); // движок + рендер (rfVfoB)
|
||
end;
|
||
end;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Панель пана / зума
|
||
// ---------------------------------------------------------------------------
|
||
|
||
procedure TMainForm.UpdatePanZoomBar;
|
||
begin
|
||
if FPanZoomBar <> nil then
|
||
begin
|
||
// Полоса — линейка ПОЛНОГО диапазона (базовые центр/span), стекло-ползунок
|
||
// показывает видимое окно (zoom/pan).
|
||
FPanZoomBar.SetFreq(FController.FCenterFreq, FController.FSpanHz);
|
||
FPanZoomBar.SetState(FController.ZoomFactor, FController.PanSlider);
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.OnPanZoomWindow(Sender: TObject; AZoom, APan: Double);
|
||
var i: Integer;
|
||
begin
|
||
// Чей бар? Паны N — свой зум (пан-локальный, без band-персиста);
|
||
// иначе пан 0 — контроллерный зум (rfZoom обновит UI).
|
||
for i := 1 to MAX_PANS - 1 do
|
||
if (FPans[i] <> nil) and (TObject(FPans[i].ZoomBar) = Sender) then
|
||
begin
|
||
FController.SetPanZoomPan(i, AZoom, APan);
|
||
RefreshPanZoom(FPans[i]);
|
||
Exit;
|
||
end;
|
||
// Окно-селектор задаёт и зум, и пан напрямую (без якоря на VFO).
|
||
FController.ApplyZoom(AZoom, APan);
|
||
end;
|
||
|
||
function TMainForm.PanOfZoomButton(Sender: TObject): TPanafallPanel;
|
||
// Зум-кнопки создаются MakeFlatBtn с Owner = контейнер (PanelRight/окно), а не
|
||
// панель — PanFromSender их НЕ находит (клик уходил в зум главного пана).
|
||
// Матчим по идентичности контролов.
|
||
var i: Integer;
|
||
begin
|
||
Result := nil;
|
||
for i := 0 to MAX_PANS - 1 do
|
||
if FPans[i] <> nil then
|
||
if (Sender = TObject(FPans[i].BtnZoomIn)) or
|
||
(Sender = TObject(FPans[i].BtnZoomDef)) or
|
||
(Sender = TObject(FPans[i].BtnZoomOut)) then
|
||
Exit(FPans[i]);
|
||
end;
|
||
|
||
procedure TMainForm.BtnZoomInClick(Sender: TObject);
|
||
var P: TPanafallPanel;
|
||
begin
|
||
P := PanOfZoomButton(Sender);
|
||
if (P <> nil) and (P.PanId > 0) then
|
||
begin
|
||
FController.PanZoomIn(P.PanId);
|
||
RefreshPanZoom(P);
|
||
end
|
||
else
|
||
FController.ZoomIn;
|
||
end;
|
||
|
||
procedure TMainForm.BtnZoomDefClick(Sender: TObject);
|
||
var P: TPanafallPanel;
|
||
begin
|
||
P := PanOfZoomButton(Sender);
|
||
if (P <> nil) and (P.PanId > 0) then
|
||
begin
|
||
FController.PanZoomDefault(P.PanId);
|
||
RefreshPanZoom(P);
|
||
end
|
||
else
|
||
FController.ZoomDefault;
|
||
end;
|
||
|
||
procedure TMainForm.BtnZoomOutClick(Sender: TObject);
|
||
var P: TPanafallPanel;
|
||
begin
|
||
P := PanOfZoomButton(Sender);
|
||
if (P <> nil) and (P.PanId > 0) then
|
||
begin
|
||
FController.PanZoomOut(P.PanId);
|
||
RefreshPanZoom(P);
|
||
end
|
||
else
|
||
FController.ZoomOut;
|
||
end;
|
||
|
||
// Спектр
|
||
procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
var VC, VS: Double;
|
||
begin
|
||
if FPan.DispatchFlagsMouseDown(Button, X, Y) then
|
||
begin
|
||
FPan.ProcessPendingSliceClose; // × мог пометить слайс к удалению
|
||
Exit;
|
||
end;
|
||
if Assigned(FSampleRateOverlay) and
|
||
FSampleRateOverlay.HandleMouseDown(Button, X, Y) then Exit;
|
||
|
||
// Ctrl+ЛКМ по спектру — добавить софт-слайс на этой частоте (в пределах захвата).
|
||
if (Button = mbLeft) and (ssCtrl in Shift) and (PbSpectrum.Width > 0) then
|
||
begin
|
||
FController.GetViewWindow(VC, VS);
|
||
AddSliceAtFreq(PixelToFreq(X, PbSpectrum.Width, VC, VS));
|
||
Exit;
|
||
end;
|
||
|
||
if Button = mbLeft then
|
||
begin
|
||
// Наведение маяка: ЛКМ указывает на маяк (после включения BEACON «вооружено»,
|
||
// либо Shift+ЛКМ в любой момент при включённом локе для перенаведения). Декодер
|
||
// наводится на эту частоту, дальше сам ведёт несущую. Не трогает VFO.
|
||
if FController.BeaconLockEnabled and (PbSpectrum.Width > 0) and
|
||
(FBeaconArming or (ssShift in Shift)) then
|
||
begin
|
||
FController.GetViewWindow(VC, VS);
|
||
FController.BeaconSeedAtHz(
|
||
PixelToFreq(X, PbSpectrum.Width, VC, VS));
|
||
FBeaconArming := False;
|
||
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
|
||
FSliceDragId := FPan.SliceAtPixel(X);
|
||
if FSliceDragId <> 0 then
|
||
begin
|
||
FSliceDrag := True;
|
||
FActiveSliceId := FSliceDragId;
|
||
PbSpectrum.Cursor := crSizeWE;
|
||
Exit;
|
||
end;
|
||
end;
|
||
// Левая кнопка: сбрасываем маркер если активен
|
||
if FSpecView.MarkerActive then
|
||
begin
|
||
FSpecView.MarkerActive := False;
|
||
PbSpectrum.Invalidate;
|
||
PbWaterfall.Invalidate;
|
||
end;
|
||
FSpecDrag := True;
|
||
FSpecDragX0 := X;
|
||
if FController.FCTun then FSpecDragFreq := FController.FCenterFreq
|
||
else begin
|
||
if FController.FActiveVfo = 0 then FSpecDragFreq := FController.FVfoA
|
||
else FSpecDragFreq := FController.FVfoB;
|
||
end;
|
||
PbSpectrum.Cursor := crSizeWE;
|
||
end
|
||
else if Button = mbRight then
|
||
begin
|
||
// Правая кнопка: toggle маркера
|
||
if FSpecView.MarkerActive and (Abs(Round(X / PbSpectrum.Width * 1000) - FSpecView.MarkerX) < 8) then
|
||
begin
|
||
// Клик рядом с текущим маркером — убираем
|
||
FSpecView.MarkerActive := False;
|
||
end
|
||
else
|
||
begin
|
||
// Ставим маркер в новое место
|
||
FSpecView.MarkerActive := True;
|
||
FSpecView.MarkerX := Round(X / PbSpectrum.Width * 1000);
|
||
end;
|
||
PbSpectrum.Invalidate;
|
||
PbWaterfall.Invalidate;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.PbWidebandMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
begin
|
||
if Button = mbLeft then
|
||
DoWidebandClick(X, TControl(Sender).Width);
|
||
end;
|
||
|
||
procedure TMainForm.PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState;
|
||
X, Y: Integer);
|
||
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
|
||
if (ssLeft in Shift) and (PbSpectrum.Width > 0) then
|
||
begin
|
||
FController.GetViewWindow(VC, VS);
|
||
NewHz := PixelToFreq(X, PbSpectrum.Width, VC, VS);
|
||
FController.CapturedSpan(LoHz, HiHz);
|
||
if NewHz < LoHz then NewHz := LoHz;
|
||
if NewHz > HiHz then NewHz := HiHz;
|
||
FController.SetSliceTarget(FSliceDragId, NewHz);
|
||
FPan.PushSliceFlagState(FSliceDragId); // обновить частоту во флаге
|
||
FPan.LayoutFlags;
|
||
FSpectrumDirty := True; // кадр — по таймеру (FPS-гейт, как при drag VFO)
|
||
end;
|
||
Exit;
|
||
end;
|
||
|
||
if Assigned(FSampleRateOverlay) then
|
||
begin
|
||
FSampleRateOverlay.HandleMouseMove(X, Y);
|
||
end;
|
||
FPan.DispatchFlagsMouseMove(X, Y);
|
||
if not FSpecDrag then
|
||
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
|
||
PbSpectrum.Cursor := crDefault;
|
||
end;
|
||
|
||
if FSpecDrag and (ssLeft in Shift) then
|
||
DoSpectrumDrag(X, PbSpectrum.Width);
|
||
// Маркер следует за курсором — обновление произойдёт в ближайшем тике таймера (50ms)
|
||
if FSpecView.MarkerActive then
|
||
begin
|
||
FSpecView.MarkerX := Round(X / PbSpectrum.Width * 1000);
|
||
FSpectrumDirty := True;
|
||
end;
|
||
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;
|
||
FSliceDragId := 0;
|
||
PbSpectrum.Cursor := crDefault;
|
||
Exit;
|
||
end;
|
||
if FPan.DispatchFlagsMouseUp(X, Y) then
|
||
begin
|
||
FSpecDrag := False;
|
||
PbSpectrum.Cursor := crDefault;
|
||
Exit; // клик/drag по накладке — не пускаем в спектр
|
||
end;
|
||
if Button = mbLeft then
|
||
begin
|
||
if (Abs(X - FSpecDragX0) < 4) then
|
||
DoSpectrumClick(X, PbSpectrum.Width); // это был клик, не драг
|
||
FSpecDrag := False;
|
||
PbSpectrum.Cursor := crDefault;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.PbSpectrumMouseLeave(Sender: TObject);
|
||
begin
|
||
if Assigned(FSampleRateOverlay) then
|
||
FSampleRateOverlay.HandleMouseLeave;
|
||
FPan.DispatchFlagsMouseLeave;
|
||
if not FSpecDrag then PbSpectrum.Cursor := crDefault;
|
||
end;
|
||
|
||
// Водопад (аналогично)
|
||
procedure TMainForm.PbWaterfallMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
begin
|
||
if Button = mbLeft then
|
||
begin
|
||
if FSpecView.MarkerActive then
|
||
begin
|
||
FSpecView.MarkerActive := False;
|
||
PbSpectrum.Invalidate;
|
||
PbWaterfall.Invalidate;
|
||
end;
|
||
FSpecDrag := True;
|
||
FSpecDragX0 := X;
|
||
if FController.FCTun then FSpecDragFreq := FController.FCenterFreq
|
||
else begin
|
||
if FController.FActiveVfo = 0 then FSpecDragFreq := FController.FVfoA
|
||
else FSpecDragFreq := FController.FVfoB;
|
||
end;
|
||
PbWaterfall.Cursor := crSizeWE;
|
||
end
|
||
else if Button = mbRight then
|
||
begin
|
||
if FSpecView.MarkerActive and (Abs(Round(X / PbWaterfall.Width * 1000) - FSpecView.MarkerX) < 8) then
|
||
FSpecView.MarkerActive := False
|
||
else
|
||
begin
|
||
FSpecView.MarkerActive := True;
|
||
FSpecView.MarkerX := Round(X / PbWaterfall.Width * 1000);
|
||
end;
|
||
PbSpectrum.Invalidate;
|
||
PbWaterfall.Invalidate;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.PbWaterfallMouseMove(Sender: TObject; Shift: TShiftState;
|
||
X, Y: Integer);
|
||
begin
|
||
if FSpecDrag and (ssLeft in Shift) then
|
||
DoSpectrumDrag(X, PbWaterfall.Width);
|
||
if FSpecView.MarkerActive then
|
||
begin
|
||
FSpecView.MarkerX := Round(X / PbWaterfall.Width * 1000);
|
||
FSpectrumDirty := True;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.PbWaterfallMouseUp(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
begin
|
||
if Button = mbLeft then
|
||
begin
|
||
if (Abs(X - FSpecDragX0) < 4) then
|
||
DoSpectrumClick(X, PbWaterfall.Width);
|
||
FSpecDrag := False;
|
||
PbWaterfall.Cursor := crDefault;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.BtnBandClick(Sender: TObject);
|
||
var
|
||
NewBand: Integer;
|
||
begin
|
||
NewBand := (Sender as TFlatButton).Tag;
|
||
if (NewBand < 0) or (NewBand >= BAND_COUNT) then Exit;
|
||
// Если в XVTR-режиме — DeactivateXvtr вернёт HF-состояние (RestoreBand
|
||
// на сохранённом FController.FCurrentBand). Если NewBand совпадает с восстановленным
|
||
// FController.FCurrentBand, дальше делать ничего не нужно.
|
||
if FController.FCurrentXvtr >= 0 then
|
||
begin
|
||
DeactivateXvtr;
|
||
if NewBand = FController.FCurrentBand then Exit;
|
||
end
|
||
else if NewBand = FController.FCurrentBand then
|
||
Exit;
|
||
|
||
if FController.FDevConnected then
|
||
begin
|
||
SaveCurrentBand;
|
||
FController.FSettings.Save;
|
||
end;
|
||
FController.FCurrentBand := NewBand;
|
||
RestoreBand(FController.FCurrentBand);
|
||
end;
|
||
|
||
procedure TMainForm.BtnModeClick(Sender: TObject);
|
||
begin
|
||
// Логика (FMode, дефолтный фильтр, DSP SetMode/TUN/ApplyModeFilter, band cache)
|
||
// — в контроллере; рендер кнопок/фильтра/спектра — в OnControllerState(rfMode).
|
||
FController.SetMode((Sender as TFlatButton).Tag);
|
||
end;
|
||
|
||
procedure TMainForm.BtnFilterClick(Sender: TObject);
|
||
begin
|
||
// Логика (слот → кромки, 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, i: Integer;
|
||
begin
|
||
if FSpecView = nil then Exit;
|
||
FSpecView.SetRXEdges(FController.FilterLo, FController.FilterHi);
|
||
FController.TXFilterEdges(Lo, Hi);
|
||
FSpecView.SetTXEdges(Lo, Hi);
|
||
// Доп. паны рисуют полосу передающего слайса теми же кромками
|
||
// (CalcSliceBandX). Без этого их FTXEdge* оставались нулями, ветка TX не
|
||
// срабатывала, и слайс на передаче показывал RX-ширину. RX-кромки главного
|
||
// сюда не отдаём: на панах N главный VFO не рисуется.
|
||
for i := 1 to MAX_PANS - 1 do
|
||
if FPans[i] <> nil then
|
||
begin
|
||
FPans[i].View.SetTXEdges(Lo, Hi);
|
||
FPanSpecDirty[i] := True;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.BtnModeMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
// ПКМ по CWL/CWU открывает телеграфный терминал — по той же идиоме, что ПКМ по
|
||
// кнопке фильтра открывает редактор фильтров. ЛКМ не трогаем: переключение
|
||
// режима не должно тащить за собой окно.
|
||
begin
|
||
if Button <> mbRight then Exit;
|
||
if not ((Sender as TFlatButton).Tag in [MODE_CWL, MODE_CWU]) then Exit;
|
||
ShowCWTerminal;
|
||
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
|
||
// ---------------------------------------------------------------------------
|
||
|
||
procedure TMainForm.ApplyFMSquelch;
|
||
begin
|
||
if FController.FWDSPReady then
|
||
FController.FDSPEngine.SetFMSquelch(FController.FFMSQOn and (FController.FMode = MODE_FM), FController.FFMSQLevel);
|
||
end;
|
||
|
||
procedure TMainForm.BtnFMSQClick(Sender: TObject);
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
FController.SetFMSquelch(not FController.FFMSQOn); // DSP + OnControllerState
|
||
SaveCurrentBand;
|
||
end;
|
||
|
||
procedure TMainForm.TrkFMSQChange(Sender: TObject);
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
FController.SetFMSquelchLevel(TrkFMSQ.Position);
|
||
SaveCurrentBand;
|
||
end;
|
||
|
||
procedure TMainForm.TrkRawDevChange(Sender: TObject);
|
||
var Hz: Integer;
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
Hz := TrkRawDev.Position * 50;
|
||
FController.SetRawFMDeviation(Hz);
|
||
end;
|
||
|
||
procedure TMainForm.CloseCTCSSPopup;
|
||
begin
|
||
if FCTCSSDropDown <> nil then FCTCSSDropDown.ClosePopup;
|
||
CloseFMStepPopup;
|
||
if FChannelsDropDown <> nil then FChannelsDropDown.ClosePopup;
|
||
if FTXProfileDropDown <> nil then FTXProfileDropDown.ClosePopup;
|
||
end;
|
||
|
||
procedure TMainForm.BtnFMRptMinusClick(Sender: TObject);
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
// Toggle решает UI; команда несёт сетевой сдвиг + рендер кнопок (OnControllerState).
|
||
if FController.FFMRptDir = RPT_MINUS then
|
||
FController.SetFMRpt(RPT_NONE)
|
||
else
|
||
FController.SetFMRpt(RPT_MINUS);
|
||
SaveCurrentBand;
|
||
end;
|
||
|
||
procedure TMainForm.BtnFMRptPlusClick(Sender: TObject);
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
if FController.FFMRptDir = RPT_PLUS then
|
||
FController.SetFMRpt(RPT_NONE)
|
||
else
|
||
FController.SetFMRpt(RPT_PLUS);
|
||
SaveCurrentBand;
|
||
end;
|
||
|
||
procedure TMainForm.EdFMRptOffsetChange(Sender: TObject);
|
||
var Hz: Double;
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
Hz := RptParseOffset(EdFMRptOffset.Text);
|
||
if Hz > 0.0 then
|
||
begin
|
||
FController.SetFMRptOffset(Hz); // применяет, если RPT вкл; OnControllerState
|
||
if FController.FFMRptDir <> RPT_NONE then SaveCurrentBand;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.UpdateRptAutoState;
|
||
begin
|
||
// Логика авто-MINUS — в контроллере; рендер кнопок — OnControllerState(rfFMRpt).
|
||
FController.UpdateRptAutoState;
|
||
end;
|
||
|
||
procedure TMainForm.CloseFMStepPopup;
|
||
begin
|
||
if FStepDropDown <> nil then FStepDropDown.ClosePopup;
|
||
if FChannelsDropDown <> nil then FChannelsDropDown.ClosePopup;
|
||
end;
|
||
|
||
procedure TMainForm.SetFMStep(Idx: Integer);
|
||
begin
|
||
// Тонкая обёртка: команда контроллера обновит UI/web/спецвью через
|
||
// OnControllerState(rfFMStepIdx). Сохранена для существующих вызовов
|
||
// (RestoreBand, dropdown-select, web-sync, XVTR-restore).
|
||
FController.SetFMStep(Idx);
|
||
end;
|
||
|
||
procedure TMainForm.BtnFMStepClick(Sender: TObject);
|
||
begin
|
||
FController.FFMStepOn := not FController.FFMStepOn;
|
||
StyleButton(BtnFMStep, FController.FFMStepOn);
|
||
BtnFMStep.Repaint;
|
||
SaveCurrentBand;
|
||
end;
|
||
|
||
procedure TMainForm.BtnFMStepSelClick(Sender: TObject);
|
||
begin
|
||
FCTCSSDropDown.ClosePopup;
|
||
FStepDropDown.Toggle;
|
||
end;
|
||
|
||
procedure TMainForm.OnStepDropDownSelect(Sender: TObject; Idx: Integer);
|
||
begin
|
||
SetFMStep(Idx);
|
||
SaveCurrentBand;
|
||
end;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Channels dropdown / store
|
||
// ---------------------------------------------------------------------------
|
||
|
||
procedure TMainForm.RefreshChannelsDropDown;
|
||
var
|
||
names: array of string;
|
||
i: Integer;
|
||
begin
|
||
if (FController.FChannelStore = nil) or (FChannelsDropDown = nil) then Exit;
|
||
SetLength(names, FController.FChannelStore.Count);
|
||
for i := 0 to FController.FChannelStore.Count - 1 do
|
||
names[i] := FController.FChannelStore.GetChannel(i).Name;
|
||
FChannelsDropDown.SetItems(names);
|
||
if (FChannelController.ActiveIdx >= 0) and (FChannelController.ActiveIdx < FController.FChannelStore.Count) then
|
||
FChannelsDropDown.SetItemIndex(FChannelController.ActiveIdx)
|
||
else if BtnChannels <> nil then
|
||
BtnChannels.Caption := 'Channel';
|
||
end;
|
||
|
||
procedure TMainForm.OnChannelStoreChanged(Sender: TObject);
|
||
begin
|
||
RefreshChannelsDropDown;
|
||
end;
|
||
|
||
procedure TMainForm.BtnChannelsClick(Sender: TObject);
|
||
begin
|
||
if FController.FChannelStore.Count = 0 then Exit;
|
||
FCTCSSDropDown.ClosePopup;
|
||
FStepDropDown.ClosePopup;
|
||
if FTXProfileDropDown <> nil then FTXProfileDropDown.ClosePopup;
|
||
FChannelsDropDown.Toggle;
|
||
end;
|
||
|
||
procedure TMainForm.BtnChannelsMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
var
|
||
Def: TChannel;
|
||
begin
|
||
if Button <> mbRight then Exit;
|
||
FChannelsDropDown.ClosePopup;
|
||
if FChannelsForm = nil then
|
||
FChannelsForm := TChannelsForm.Create(Self, FController.FChannelStore, OnChannelStoreChanged);
|
||
// Передаём текущее состояние радио как значения по умолчанию для нового канала
|
||
TChannelStore.DefaultChannel(Def);
|
||
Def.RXFreq := FController.FVfoA;
|
||
Def.TXFreq := ActiveTXFreqHz;
|
||
Def.Mode := FController.FMode;
|
||
Def.RptDir := FController.FFMRptDir;
|
||
Def.RptOffsetHz := FController.FFMRptOffsetHz;
|
||
Def.CTCSSOn := FController.FFMCTCSSOn;
|
||
Def.CTCSSToneIdx := FController.FFMCTCSSToneIdx;
|
||
Def.Power := 100;
|
||
Def.Name := '';
|
||
TChannelsForm(FChannelsForm).SetCurrentDefaults(Def);
|
||
TChannelsForm(FChannelsForm).ApplyTheme(CurrentAppTheme);
|
||
TChannelsForm(FChannelsForm).Show;
|
||
end;
|
||
|
||
procedure TMainForm.OnChannelsDropDownSelect(Sender: TObject; Idx: Integer);
|
||
begin
|
||
// Логика применения канала (band/XVTR/mode/FM/drive/freq) — в адаптере каналов;
|
||
// рендер кнопки Channel — в OnChannelActiveChanged.
|
||
FChannelController.Select(Idx);
|
||
end;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// TX-профили (кнопка блока TX + список)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
procedure TMainForm.RenderTXProfile;
|
||
// Единая точка рендера: подпись кнопки, состав списка и — если открыт диалог
|
||
// настроек — его вкладка Transmit (профиль переписал живые TX-настройки).
|
||
var
|
||
Names: array of string;
|
||
i, Act, Cnt: Integer;
|
||
begin
|
||
if BtnTXProfile = nil then Exit;
|
||
Cnt := FController.TXProfileCount;
|
||
Act := FController.ActiveTXProfile;
|
||
if (Act >= 0) and (Act < Cnt) then
|
||
BtnTXProfile.Caption := FController.TXProfileName(Act)
|
||
else
|
||
BtnTXProfile.Caption := 'TX Profile';
|
||
BtnTXProfile.Invalidate;
|
||
// Профиль несёт кромки TX-фильтра: на передаче красная полоска обязана
|
||
// переехать сразу, а не ждать следующего rfMOX/rfFilter (их на TX не будет).
|
||
PushFilterEdgesToView;
|
||
FSpectrumDirty := True;
|
||
if FTXProfileDropDown <> nil then
|
||
begin
|
||
SetLength(Names, Cnt);
|
||
for i := 0 to Cnt - 1 do Names[i] := FController.TXProfileName(i);
|
||
FTXProfileDropDown.SetItems(Names);
|
||
if (Act >= 0) and (Act < Cnt) then FTXProfileDropDown.SetItemIndex(Act);
|
||
end;
|
||
if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then
|
||
begin
|
||
TSettingsForm(FSettingsForm).LoadTXSettings(FController.FTXSettings,
|
||
IfThen(FController.FNetwork.Connected,
|
||
FController.FNetwork.Device.BoardType, FPendingBoardType));
|
||
TSettingsForm(FSettingsForm).LoadCWSettings(FController.CWSettings);
|
||
PushTXProfilesToSettings;
|
||
end;
|
||
// Пилюли профиля на флагах: список общий, привязки могли съехать после
|
||
// удаления/сброса — обновляем на всех панах.
|
||
for i := 0 to MAX_PANS - 1 do
|
||
if FPans[i] <> nil then FPans[i].PushAllFlagTXProfiles;
|
||
end;
|
||
|
||
procedure TMainForm.PushTXProfilesToSettings;
|
||
var
|
||
Names: array of string;
|
||
i: Integer;
|
||
begin
|
||
if FSettingsForm = nil then Exit;
|
||
SetLength(Names, FController.TXProfileCount);
|
||
for i := 0 to FController.TXProfileCount - 1 do
|
||
Names[i] := FController.TXProfileName(i);
|
||
TSettingsForm(FSettingsForm).LoadTXProfiles(Names, FController.ActiveTXProfile,
|
||
FController.FDrivePercent);
|
||
end;
|
||
|
||
procedure TMainForm.BtnTXProfileClick(Sender: TObject);
|
||
begin
|
||
if FController.TXProfileCount = 0 then Exit;
|
||
// Чужие списки гасим поимённо: общий CloseCTCSSPopup закрыл бы и наш, и
|
||
// повторный клик по кнопке никогда не сворачивал бы список.
|
||
if FCTCSSDropDown <> nil then FCTCSSDropDown.ClosePopup;
|
||
CloseFMStepPopup;
|
||
FTXProfileDropDown.Toggle;
|
||
end;
|
||
|
||
procedure TMainForm.BtnTXProfileMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
begin
|
||
if Button <> mbRight then Exit;
|
||
// ПКМ — прямо в редактор профиля (вкладка Transmit), как ПКМ по кнопке фильтра.
|
||
if FTXProfileDropDown <> nil then FTXProfileDropDown.ClosePopup;
|
||
BtnSettingsClick(nil);
|
||
if FSettingsForm <> nil then TSettingsForm(FSettingsForm).ShowTransmitPage;
|
||
end;
|
||
|
||
procedure TMainForm.OnTXProfileDropDownSelect(Sender: TObject; Idx: Integer);
|
||
begin
|
||
// Применение (WDSP + mic-байт + drive) и персист — в контроллере;
|
||
// рендер придёт назад событием rfTXProfile.
|
||
FController.SelectTXProfile(Idx);
|
||
end;
|
||
|
||
procedure TMainForm.OnTXProfileSelectFromSettings(Idx: Integer);
|
||
begin
|
||
FController.SelectTXProfile(Idx);
|
||
end;
|
||
|
||
procedure TMainForm.OnTXProfileAddFromSettings(const AName: string);
|
||
begin
|
||
FController.AddTXProfile(AName);
|
||
end;
|
||
|
||
procedure TMainForm.OnTXProfileRenameFromSettings(Idx: Integer; const AName: string);
|
||
begin
|
||
FController.RenameTXProfile(Idx, AName);
|
||
end;
|
||
|
||
procedure TMainForm.OnTXProfileDeleteFromSettings(Idx: Integer);
|
||
begin
|
||
FController.DeleteTXProfile(Idx);
|
||
end;
|
||
|
||
procedure TMainForm.OnTXProfileFactoryFromSettings(Sender: TObject);
|
||
begin
|
||
FController.ResetTXProfilesToFactory;
|
||
end;
|
||
|
||
procedure TMainForm.OnChannelActiveChanged(Idx: Integer; const Name: string; Active: Boolean);
|
||
begin
|
||
// Рендер кнопки Channel по событию адаптера каналов (Active=True → подсветка+имя).
|
||
if BtnChannels = nil then Exit;
|
||
StyleButton(BtnChannels, Active);
|
||
if Active then BtnChannels.Caption := Name
|
||
else BtnChannels.Caption := 'Channel';
|
||
BtnChannels.Repaint;
|
||
end;
|
||
|
||
procedure TMainForm.SetFMCTCSSTone(Idx: Integer);
|
||
begin
|
||
// Тонкая обёртка: команда применяет к DSP, OnControllerState(rfFMCTCSSTone)
|
||
// обновит выпадающий список. Сохранена для вызовов из restore/channel/XVTR.
|
||
FController.SetFMCTCSSTone(Idx);
|
||
end;
|
||
|
||
procedure TMainForm.BtnFMCTCSSClick(Sender: TObject);
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
FController.FFMCTCSSAutoActive := False; // ручное действие снимает авто-флаг
|
||
FController.SetFMCTCSS(not FController.FFMCTCSSOn);
|
||
end;
|
||
|
||
procedure TMainForm.BtnFMCTCSSToneClick(Sender: TObject);
|
||
begin
|
||
FStepDropDown.ClosePopup;
|
||
FCTCSSDropDown.Toggle;
|
||
end;
|
||
|
||
procedure TMainForm.OnCTCSSDropDownSelect(Sender: TObject; Idx: Integer);
|
||
begin
|
||
SetFMCTCSSTone(Idx);
|
||
end;
|
||
|
||
procedure TMainForm.BtnVfoSwapClick(Sender: TObject);
|
||
begin
|
||
// Своп + перестройка — в контроллере; рендер дисплеев/спектра/band — через
|
||
// OnControllerState(rfVfoA/rfVfoB/rfBand). Кэш линейки сбрасываем здесь
|
||
// (большой скачок частоты — позиции тиков меняются).
|
||
FController.VfoSwap;
|
||
FSpecView.InvalidateRulerCache;
|
||
if PbRuler <> nil then PbRuler.Invalidate;
|
||
end;
|
||
|
||
procedure TMainForm.BtnVfoACopyBClick(Sender: TObject);
|
||
begin
|
||
// A>B: B получает частоту A. Команда сохраняет B, перестраивает если B
|
||
// активен, рендерит через OnControllerState(rfVfoB).
|
||
FController.VfoACopyB;
|
||
end;
|
||
|
||
procedure TMainForm.BtnVfoBCopyAClick(Sender: TObject);
|
||
begin
|
||
// B>A: A получает частоту B. При активном A идём через ApplyVfoA (сохраняем
|
||
// канальную оркестрацию); иначе только сохраняем + рендер дисплея.
|
||
FController.FVfoA := FController.FVfoB;
|
||
if FController.FActiveVfo = 0 then
|
||
ApplyVfoA(Round(FController.FVfoA))
|
||
else
|
||
FreqDispA.Frequency := Round(FController.FVfoA);
|
||
end;
|
||
|
||
function TMainForm.ActiveTXFreqHz: Double;
|
||
begin
|
||
// Логика перенесена в контроллер; обёртка для существующих вызовов в UI.
|
||
Result := FController.ActiveTXFreqHz;
|
||
end;
|
||
|
||
procedure TMainForm.ApplyMOX(Active: Boolean);
|
||
begin
|
||
// FWebClientActive держит web-слой актуальным (OnClientActiveChanged) —
|
||
// определяет mic-source при TX-on.
|
||
FController.SetMOX(Active); // ядро TX (safety/PTT/DSP) + рендер через rfTransmitting
|
||
end;
|
||
|
||
procedure TMainForm.BtnMOXClick(Sender: TObject);
|
||
begin
|
||
// Если идёт tune — клик MOX просто выходит из tune (без перехода на голос).
|
||
if FController.FTuning then
|
||
begin
|
||
ApplyTUN(False);
|
||
Exit;
|
||
end;
|
||
ApplyMOX(not FController.FTransmitting);
|
||
end;
|
||
|
||
procedure TMainForm.BtnMuteClick(Sender: TObject);
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
FController.ToggleMute; // логика (движок) + OnControllerState обновит кнопку
|
||
end;
|
||
|
||
procedure TMainForm.UpdateNRButton;
|
||
begin
|
||
case FController.FNRMode of
|
||
1: BtnNR.Caption := 'NR';
|
||
2: BtnNR.Caption := 'NR2';
|
||
3: BtnNR.Caption := 'NR3';
|
||
4: BtnNR.Caption := 'NR4';
|
||
else
|
||
BtnNR.Caption := 'NR';
|
||
end;
|
||
StyleButton(BtnNR, FController.FNRMode <> 0);
|
||
BtnNR.Invalidate;
|
||
end;
|
||
|
||
procedure TMainForm.UpdateNBButton;
|
||
begin
|
||
case FController.FNBMode of
|
||
1: BtnNB.Caption := 'NB';
|
||
2: BtnNB.Caption := 'NB2';
|
||
else
|
||
BtnNB.Caption := 'NB';
|
||
end;
|
||
StyleButton(BtnNB, FController.FNBMode <> 0);
|
||
BtnNB.Invalidate;
|
||
end;
|
||
|
||
procedure TMainForm.UpdateSNBButton;
|
||
begin
|
||
BtnSNB.Caption := 'SNB';
|
||
StyleButton(BtnSNB, FController.FSNB);
|
||
BtnSNB.Invalidate;
|
||
end;
|
||
|
||
procedure TMainForm.UpdateANFButton;
|
||
begin
|
||
BtnANF.Caption := 'ANF';
|
||
StyleButton(BtnANF, FController.FANF);
|
||
BtnANF.Invalidate;
|
||
end;
|
||
|
||
// ApplyNoiseFilterButtonsToDSP переехал в TRadioController (NR/NB/SNB/ANF → WDSP).
|
||
|
||
procedure TMainForm.BtnNRClick(Sender: TObject);
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
FController.SetNR((FController.FNRMode + 1) mod 5);
|
||
end;
|
||
|
||
procedure TMainForm.BtnNBClick(Sender: TObject);
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
FController.SetNB((FController.FNBMode + 1) mod 3);
|
||
end;
|
||
|
||
procedure TMainForm.BtnSNBClick(Sender: TObject);
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
FController.SetSNB(not FController.FSNB);
|
||
end;
|
||
|
||
procedure TMainForm.BtnANFClick(Sender: TObject);
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
FController.SetANF(not FController.FANF);
|
||
end;
|
||
|
||
|
||
procedure TMainForm.ApplyWfAGCNF(WfAGC, WfNF: Boolean);
|
||
begin
|
||
FController.SetWfAGC(WfAGC);
|
||
FController.SetWfNF(WfNF);
|
||
end;
|
||
|
||
procedure TMainForm.ApplyADCSettings(Dither, Random: Boolean);
|
||
begin
|
||
FController.FDitherEnabled := Dither;
|
||
FController.FRandomEnabled := Random;
|
||
if FController.FNetwork.Connected and FController.FRunning then
|
||
FController.FNetwork.ConfigureDDCs(1, FController.FSampleRate div 1000, 0, FController.FDitherEnabled, FController.FRandomEnabled);
|
||
if FController.FDevConnected then
|
||
FController.SaveGlobalSettings;
|
||
end;
|
||
|
||
procedure TMainForm.ApplyWebSettings(Enabled: Boolean; Port: Integer;
|
||
const BindAddr, User, Pass: string; SpecPixels: Integer);
|
||
var W: TWebSettings;
|
||
begin
|
||
FWebEnabled := Enabled;
|
||
FWebPort := Port;
|
||
FWebBindAddr := BindAddr;
|
||
FWebUser := User;
|
||
FWebPass := Pass;
|
||
// Потолок точек кадра для web — применяется сразу, следующий же кадр DSP
|
||
// снимается с новым разрешением (переарм анализатора не нужен).
|
||
FWebSpecPixels := EnsureRange(SpecPixels, 256, SPECTRUM_PIXELS);
|
||
FController.FWebSpecPixels := FWebSpecPixels;
|
||
FWebServer.Reconfigure(User, Pass, BindAddr, Word(Port));
|
||
if Enabled then FWebServer.Start;
|
||
W.Enabled := Enabled;
|
||
W.Port := Port;
|
||
W.BindAddr := BindAddr;
|
||
W.User := User;
|
||
W.Pass := Pass;
|
||
W.SpecPixels := FWebSpecPixels;
|
||
FController.FSettings.SaveWebSettings(W);
|
||
end;
|
||
|
||
procedure TMainForm.OnSampleRateHidePanel;
|
||
begin
|
||
FPanelHidden := not FPanelHidden;
|
||
if FPanelHidden then
|
||
begin
|
||
FLeftPanelW := PanelLeft.Width; // сохраняем реальную ширину (учитывает DPI на Win32)
|
||
PanelLeft.Width := 0;
|
||
end
|
||
else
|
||
PanelLeft.Width := FLeftPanelW; // восстанавливаем сохранённую ширину
|
||
// Видимость флага A решает общий инвариант (панель скрыта ИЛИ есть слайсы).
|
||
FPan.MainFlagPinned := FPanelHidden;
|
||
FPan.UpdateMainFlagVisibility;
|
||
if Assigned(FSampleRateOverlay) then
|
||
FSampleRateOverlay.SetPanelHidden(FPanelHidden);
|
||
ResizeSpectrumPanels;
|
||
ResizeSMeter;
|
||
end;
|
||
|
||
procedure TMainForm.PbSpectrumDblClick(Sender: TObject);
|
||
begin
|
||
// Двойной клик — ничего не делаем (оверлей всегда виден при скрытой панели)
|
||
end;
|
||
|
||
procedure TMainForm.OnModeFilterSelect(Mode: Integer; FilterBW: Integer);
|
||
begin
|
||
// Событие пришло от флага слайса (B+) — маршрутизируем в slice-API, главный не трогаем.
|
||
if CurSliceId <> 0 then
|
||
begin
|
||
FController.SetSliceModeBW(CurSliceId, Mode, FilterBW);
|
||
PushFlagStateAllPans(CurSliceId);
|
||
LayoutFlagsAllPans;
|
||
// Режим ИСТОЧНИКА передачи мог смениться — MOX/TUN/PS/профиль гейтятся им.
|
||
UpdateTXControlAvailability;
|
||
Exit;
|
||
end;
|
||
// Main flag A and the left panel edit the same receiver. Use the public
|
||
// controller commands so rfMode/rfFilter repaint every duplicate control.
|
||
if Mode <> FController.FMode then FController.SetMode(Mode);
|
||
FController.SetFilterBW(FilterBW);
|
||
FPan.LayoutFlags; // перепозиционируем при смене LSB↔USB
|
||
end;
|
||
|
||
procedure TMainForm.OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
|
||
begin
|
||
if CurSliceId <> 0 then
|
||
begin
|
||
FController.SetSliceDSP(CurSliceId, NRMode, NBMode, SNBOn, ANFOn);
|
||
Exit;
|
||
end;
|
||
// Команды контроллера применяют к DSP и через OnControllerState обновляют кнопки.
|
||
FController.SetNR(NRMode);
|
||
FController.SetNB(NBMode);
|
||
FController.SetSNB(SNBOn);
|
||
FController.SetANF(ANFOn);
|
||
end;
|
||
|
||
procedure TMainForm.OnVfoOverlaySquelchChange(SQLOn: Boolean; SQLLevel: Integer);
|
||
begin
|
||
if CurSliceId <> 0 then
|
||
begin
|
||
FController.SetSliceFMSquelch(CurSliceId, SQLOn, SQLLevel);
|
||
Exit;
|
||
end;
|
||
FController.SetFMSquelchLevel(SQLLevel);
|
||
FController.SetFMSquelch(SQLOn);
|
||
end;
|
||
|
||
procedure TMainForm.OnVfoOverlayAGCChange(AGCMode: Integer);
|
||
begin
|
||
if CurSliceId <> 0 then
|
||
begin
|
||
FController.SetSliceAGCMode(CurSliceId, TRadioController.AGCModeFromUI(AGCMode));
|
||
Exit;
|
||
end;
|
||
FController.SetAGCMode(AGCMode);
|
||
end;
|
||
|
||
procedure TMainForm.OnVfoOverlayVolume(Vol: Integer);
|
||
begin
|
||
if CurSliceId <> 0 then
|
||
begin
|
||
FController.SetSliceVolume(CurSliceId, Vol / 100.0);
|
||
Exit;
|
||
end;
|
||
FController.SetVolume(Vol);
|
||
end;
|
||
|
||
procedure TMainForm.OnVfoOverlayMute(Sender: TObject);
|
||
var S: TCtrlSlice;
|
||
begin
|
||
if CurSliceId <> 0 then
|
||
begin
|
||
if FController.GetSlice(CurSliceId, S) then
|
||
FController.SetSliceMute(CurSliceId, not S.Muted);
|
||
Exit;
|
||
end;
|
||
FController.ToggleMute;
|
||
end;
|
||
|
||
procedure TMainForm.OnVfoOverlaySplit(Sender: TObject);
|
||
begin
|
||
if CurSliceId <> 0 then Exit; // SPLIT-бейджа у слайсов нет (страховка)
|
||
FController.SetSplit(not FController.FSplitTxB);
|
||
FPan.PushMainFlagExtState;
|
||
end;
|
||
|
||
procedure TMainForm.OnVfoOverlayTxSelect(Sender: TObject);
|
||
begin
|
||
// Мультислайс-TX: клик по TX-бейджу делает слайс источником передачи
|
||
// (эксклюзивно). CurSliceId=0 — главный VFO.
|
||
FController.SetTxSlice(CurSliceId); // → Changed(rfTransmitting) → RefreshTxIndicators
|
||
end;
|
||
|
||
procedure TMainForm.OnVfoOverlayRxMuteTx(Sender: TObject);
|
||
// Бейдж RXM на флаге слайса: слышим ли этот слайс, пока сами передаём.
|
||
// У главного приёмника то же делает кнопка RX MUTE на левой панели.
|
||
var S: TCtrlSlice;
|
||
begin
|
||
if CurSliceId = 0 then Exit; // у флага A бейджа RXM нет (страховка)
|
||
if not FController.GetSlice(CurSliceId, S) then Exit;
|
||
FController.SetSliceRxMuteOnTx(CurSliceId, not S.RxMuteOnTx);
|
||
PushFlagStateAllPans(CurSliceId);
|
||
end;
|
||
|
||
procedure TMainForm.OnVfoOverlayTXProfile(Idx: Integer);
|
||
// Пилюля TX-профиля во флаге: «этот источник передачи звучит вот так».
|
||
// Idx = -1 — привязки нет, передаёт активный профиль.
|
||
begin
|
||
// Привязка главного живёт в секции профилей (контроллер её уже сохранил),
|
||
// привязка слайса уедет в конфиг с общим персистом панов — как RxMuteOnTx.
|
||
FController.SetSliceTXProfile(CurSliceId, Idx);
|
||
PushFlagStateAllPans(CurSliceId);
|
||
end;
|
||
|
||
procedure TMainForm.RefreshTxIndicators;
|
||
// Обновить TX-бейджи (TxSel/Tx) на всех флагах всех панов после смены
|
||
// TX-слайса / состояния передачи.
|
||
var i: Integer;
|
||
begin
|
||
FPan.PushMainFlagExtState;
|
||
for i := 0 to MAX_PANS - 1 do
|
||
if FPans[i] <> nil then FPans[i].PushAllSliceFlagStates;
|
||
end;
|
||
|
||
procedure TMainForm.OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string);
|
||
begin
|
||
if CurSliceId <> 0 then
|
||
begin
|
||
FController.SetSliceDevice(CurSliceId, DevIndex, DevName);
|
||
StoreSliceSlotAudio(CurSliceId); // вкладка Slices показывает то же устройство
|
||
PushFlagStateAllPans(CurSliceId); // обновить подсветку текущего устройства слайса
|
||
Exit;
|
||
end;
|
||
ApplyAudioDevice(DevIndex, DevName);
|
||
FPan.PushMainFlagAudioDevices; // обновить подсветку текущего устройства
|
||
end;
|
||
|
||
procedure TMainForm.OnVfoOverlayAudioInDevice(DevIndex: Integer; const DevName: string);
|
||
// Вкладка IN во флаге: откуда слайс берёт модуляцию на передаче. У флага
|
||
// главного VFO это общий вход приложения (то же поле, что в настройках).
|
||
begin
|
||
if CurSliceId <> 0 then
|
||
begin
|
||
FController.SetSliceInDevice(CurSliceId, DevIndex, DevName);
|
||
StoreSliceSlotAudio(CurSliceId);
|
||
PushFlagStateAllPans(CurSliceId);
|
||
Exit;
|
||
end;
|
||
ApplyAudioInputDevice(DevIndex, DevName);
|
||
FPan.PushMainFlagAudioDevices;
|
||
end;
|
||
|
||
procedure TMainForm.OnVfoOverlayInvalidate(Sender: TObject);
|
||
var P: TPanafallPanel;
|
||
begin
|
||
// Флаг/панель доп. пана → грязним и репейнтим ЕГО канву, пан 0 не трогаем.
|
||
P := PanOfOverlay(Sender);
|
||
if (P <> nil) and (P <> FPan) then
|
||
begin
|
||
MarkPanDirty(P);
|
||
Exit;
|
||
end;
|
||
FSpectrumDirty := True;
|
||
if Assigned(FSpecView) then
|
||
begin
|
||
FSpecView.SpectrumDirty := True;
|
||
// Полная инвалидация кэша текстур оверлеев (band/samplerate/главный VFO) нужна
|
||
// ТОЛЬКО когда у главного оверлея изменился хром (CacheDirty). Если сдвинулся
|
||
// лишь S-метр (MeterDirty) — перезаливаем только текстуру главного флага, не
|
||
// дёргая band/samplerate/слайсы. Слайсы перезаливают свою текстуру сами.
|
||
if Sender = TObject(FVfoOverlay) then
|
||
begin
|
||
if FVfoOverlay.CacheDirty then
|
||
FSpecView.InvalidateOverlayCache
|
||
else
|
||
FSpecView.InvalidateVfoOverlay;
|
||
end;
|
||
end;
|
||
if Assigned(PbSpectrum) then
|
||
begin
|
||
PbSpectrum.Invalidate;
|
||
end;
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// Мультислайсы (UI): коллекция флагов, роутинг событий по SliceId
|
||
// ===========================================================================
|
||
|
||
// SliceId флага, чьё событие сейчас обрабатывается (0 = главный приёмник / слайс A).
|
||
function TMainForm.CurSliceId: Integer;
|
||
// Диспетчер флагов синхронный — ненулевой CurSliceId сейчас максимум у одной
|
||
// панели (той, чей флаг обрабатывает событие).
|
||
var i: Integer;
|
||
begin
|
||
Result := 0;
|
||
for i := 0 to MAX_PANS - 1 do
|
||
if (FPans[i] <> nil) and (FPans[i].CurSliceId <> 0) then
|
||
Exit(FPans[i].CurSliceId);
|
||
end;
|
||
|
||
// Единая проводка событий флага (главного и слайсов) на общие обработчики.
|
||
procedure TMainForm.WireOverlayEvents(O: TVfoOverlay);
|
||
begin
|
||
O.OnSelect := OnModeFilterSelect;
|
||
O.OnDSPChange := OnVfoOverlayDSPChange;
|
||
O.OnSquelchChange := OnVfoOverlaySquelchChange;
|
||
O.OnAGCChange := OnVfoOverlayAGCChange;
|
||
O.OnVolume := OnVfoOverlayVolume;
|
||
O.OnMute := OnVfoOverlayMute;
|
||
O.OnAudioDevice := OnVfoOverlayAudioDevice;
|
||
O.OnAudioInDevice := OnVfoOverlayAudioInDevice;
|
||
O.OnSplit := OnVfoOverlaySplit;
|
||
O.OnTxSelect := OnVfoOverlayTxSelect;
|
||
O.OnRxMuteTx := OnVfoOverlayRxMuteTx;
|
||
O.OnTXProfile := OnVfoOverlayTXProfile;
|
||
O.OnSliceSelect := OnVfoOverlaySliceSelect;
|
||
O.OnClose := OnVfoOverlayClose;
|
||
O.OnInvalidate := OnVfoOverlayInvalidate;
|
||
// Актуальная тема (цвета кнопок флага = как на левой панели).
|
||
O.SetTheme(CurrentAppTheme);
|
||
end;
|
||
|
||
// Добавить софт-слайс на частоте Hz (режим/полоса копируются с главного).
|
||
procedure TMainForm.AddSliceAtFreq(Hz: Double);
|
||
var
|
||
Lo, Hi, Id: Integer;
|
||
begin
|
||
if not FController.SliceFitsCapture(Hz) then Exit; // вне захваченной полосы (см. Этап 3)
|
||
TRadioController.FilterEdgesFor(FController.FMode, FController.FFilterBW, Lo, Hi);
|
||
Id := FController.AddSlice(Hz, FController.FMode, Lo, Hi,
|
||
TRadioController.AGCModeFromUI(FController.FAGCMode), 0.7, -1, '');
|
||
if Id < 0 then Exit;
|
||
FPan.CreateSliceFlag(Id);
|
||
FActiveSliceId := Id;
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// Стек панадаптеров (этап 3.3): доп. паны на своих DDC
|
||
// ===========================================================================
|
||
|
||
function TMainForm.PanCount: Integer;
|
||
var i: Integer;
|
||
begin
|
||
Result := 0;
|
||
for i := 0 to MAX_PANS - 1 do
|
||
if FPans[i] <> nil then Inc(Result);
|
||
end;
|
||
|
||
function TMainForm.PanFromSender(Sender: TObject): TPanafallPanel;
|
||
// Контролы панели создаются с Owner=панель (Build) — восстановление пана
|
||
// из Sender мышиного события.
|
||
begin
|
||
Result := nil;
|
||
if (Sender is TComponent) and (TComponent(Sender).Owner is TPanafallPanel) then
|
||
Result := TPanafallPanel(TComponent(Sender).Owner);
|
||
end;
|
||
|
||
function TMainForm.PanOfOverlay(Sender: TObject): TPanafallPanel;
|
||
// Панель, которой принадлежит источник события: сама панель (Sender=панель),
|
||
// её флаг (Owner=панель) или главный флаг/nil → пан 0.
|
||
begin
|
||
Result := FPan;
|
||
if Sender is TPanafallPanel then Exit(TPanafallPanel(Sender));
|
||
if (Sender is TComponent) and (TComponent(Sender).Owner is TPanafallPanel) then
|
||
Result := TPanafallPanel(TComponent(Sender).Owner);
|
||
end;
|
||
|
||
procedure TMainForm.PushFlagStateAllPans(SliceId: Integer);
|
||
// Флаг слайса живёт ровно на одной панели; у остальных PushSliceFlagState
|
||
// не найдёт флаг и выйдет.
|
||
var i: Integer;
|
||
begin
|
||
for i := 0 to MAX_PANS - 1 do
|
||
if FPans[i] <> nil then FPans[i].PushSliceFlagState(SliceId);
|
||
end;
|
||
|
||
procedure TMainForm.LayoutFlagsAllPans;
|
||
var i: Integer;
|
||
begin
|
||
for i := 0 to MAX_PANS - 1 do
|
||
if FPans[i] <> nil then FPans[i].LayoutFlags;
|
||
end;
|
||
|
||
procedure TMainForm.MarkPanDirty(P: TPanafallPanel);
|
||
begin
|
||
if P = nil then Exit;
|
||
if P = FPan then
|
||
begin
|
||
FSpectrumDirty := True;
|
||
if Assigned(FSpecView) then FSpecView.SpectrumDirty := True;
|
||
if Assigned(PbSpectrum) then PbSpectrum.Invalidate;
|
||
Exit;
|
||
end;
|
||
FPanSpecDirty[P.PanId] := True;
|
||
P.View.SpectrumDirty := True;
|
||
if P.PbSpectrum <> nil then P.PbSpectrum.Invalidate;
|
||
end;
|
||
|
||
procedure TMainForm.PanViewWindow(P: TPanafallPanel; out VC, VS: Double);
|
||
// Видимое окно доп. пана (с учётом его пан-зума).
|
||
begin
|
||
FController.GetPanViewWindow(P.PanId, VC, VS);
|
||
if VS <= 0 then VS := 384000;
|
||
end;
|
||
|
||
procedure TMainForm.SyncPanZoomUI(P: TPanafallPanel);
|
||
// Вьюха и зум-бар пана ← контроллер (после смены зума/центра/rate пана).
|
||
var VC, VS, StepHz: Double;
|
||
begin
|
||
if (P = nil) or (P.PanId = 0) then Exit;
|
||
FController.GetPanViewWindow(P.PanId, VC, VS);
|
||
P.View.CenterFreq := VC;
|
||
P.View.SpanHz := VS;
|
||
// Сетка каналов по шагу FM — как на главном пане, но по режиму слайсов
|
||
// ЭТОГО пана (у доп. панов нет своего FMode).
|
||
if FController.PanHasFMSlice(P.PanId) then
|
||
StepHz := FM_STEP_HZ[FController.FFMStepIdx]
|
||
else
|
||
StepHz := 0;
|
||
if Abs(P.View.FMGridStepHz - StepHz) > 0.5 then
|
||
begin
|
||
P.View.FMGridStepHz := StepHz; // сеттер сам сбросит кэши спектра/линейки
|
||
MarkPanDirty(P);
|
||
if P.PbRuler <> nil then P.PbRuler.Invalidate;
|
||
end;
|
||
P.ZoomBar.SetFreq(FController.PanDDCFreq(P.PanId),
|
||
FController.PanDDCRateKHz(P.PanId) * 1000.0);
|
||
P.ZoomBar.SetState(FController.PanZoomFactor(P.PanId),
|
||
FController.PanPanSlider(P.PanId));
|
||
// Инвалидация бара — внутри Set{Freq,State} и ТОЛЬКО при реальной смене:
|
||
// нас зовут из тика на каждый пан каждый кадр, безусловный Invalidate =
|
||
// полный софт-рендер бара + Qt-рекомпозит на частоте кадров (жор CPU).
|
||
end;
|
||
|
||
procedure TMainForm.RefreshPanZoom(P: TPanafallPanel);
|
||
// Полное обновление пана после смены зума: окно вьюхи, бар, флаги, спектр.
|
||
begin
|
||
if P = nil then Exit;
|
||
SyncPanZoomUI(P);
|
||
P.LayoutFlags;
|
||
MarkPanDirty(P);
|
||
if P.PbWaterfall <> nil then P.PbWaterfall.Invalidate;
|
||
if P.PbPanZoom <> nil then P.PbPanZoom.Invalidate; // событийный путь — можно
|
||
end;
|
||
|
||
procedure TMainForm.AddSliceAtFreqPan(P: TPanafallPanel; Hz: Double);
|
||
// Слайс в доп. пане (режим/полоса копируются с главного, звуковуха default).
|
||
var Lo, Hi, Id: Integer;
|
||
begin
|
||
if not FController.SliceFitsCapture(Hz, P.PanId) then Exit;
|
||
TRadioController.FilterEdgesFor(FController.FMode, FController.FFilterBW, Lo, Hi);
|
||
Id := FController.AddSlice(Hz, FController.FMode, Lo, Hi,
|
||
TRadioController.AGCModeFromUI(FController.FAGCMode), 0.7, -1, '',
|
||
P.PanId);
|
||
if Id < 0 then Exit;
|
||
P.CreateSliceFlag(Id);
|
||
FActiveSliceId := Id;
|
||
end;
|
||
|
||
procedure TMainForm.OnPanRateClick(Sender: TObject);
|
||
// Клик по «NNN kHz» в шапке пана N: меню с полной сеткой rate бэкенда
|
||
// (фильтр PanRateAllowed — кратные 48 кГц). Текущий rate отмечен галкой.
|
||
var
|
||
P: TPanafallPanel;
|
||
i, kHz, Cur, CurDDC: Integer;
|
||
begin
|
||
if not (Sender is TPanafallPanel) then Exit;
|
||
P := TPanafallPanel(Sender);
|
||
if (P.PanId < 1) or not FController.PanDDCActive(P.PanId) then Exit;
|
||
if FPanRateMenu = nil then
|
||
begin
|
||
FPanRateMenu := TFlatPopupMenu.Create(Self);
|
||
FPanRateMenu.OnSelect := OnPanRateSelect;
|
||
end;
|
||
FPanRateMenu.SetTheme(CurrentAppTheme);
|
||
FPanRateMenu.Clear;
|
||
Cur := FController.PanDDCRateKHz(P.PanId);
|
||
for i := 0 to High(FController.BackendCaps.RatePresets) do
|
||
begin
|
||
kHz := FController.BackendCaps.RatePresets[i] div 1000;
|
||
if not FController.PanRateAllowed(Word(kHz)) then Continue;
|
||
FPanRateMenu.AddItem(Format('%d kHz', [kHz]), kHz, kHz = Cur);
|
||
end;
|
||
// Аппаратный DDC пана: свободные индексы платы + текущий (отмечен галкой).
|
||
CurDDC := FController.PanDDCIndex(P.PanId);
|
||
for i := FController.PanDDCIndex(0) + 1 to FController.MaxDDCIndex do
|
||
if (i = CurDDC) or FController.DDCFree(i) then
|
||
FPanRateMenu.AddItem(Format('→ DDC %d', [i]), DDC_MENU_TAG + i, i = CurDDC);
|
||
FPanRateMenuPanId := P.PanId;
|
||
FPanRateMenu.PopupBelow(P.HeaderRateLabel); // дочерний контрол формы
|
||
end;
|
||
|
||
procedure TMainForm.OnPanRateSelect(Tag: Integer);
|
||
var
|
||
P: TPanafallPanel;
|
||
PanId: Integer;
|
||
begin
|
||
PanId := FPanRateMenuPanId;
|
||
FPanRateMenuPanId := 0;
|
||
if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
|
||
P := FPans[PanId];
|
||
if P = nil then Exit;
|
||
// Теги DDC_MENU_TAG+ — переезд пана на другой hw DDC (движок не трогаем).
|
||
if Tag >= DDC_MENU_TAG then
|
||
begin
|
||
if FController.MovePanDDC(PanId, Tag - DDC_MENU_TAG) then UpdatePanHeaders;
|
||
Exit;
|
||
end;
|
||
if not FController.SetPanDDCRate(PanId, Word(Tag)) then Exit;
|
||
// Вьюха/шапка/флаги под новый span (слайсы могли клампнуться к краям).
|
||
SyncPanViews;
|
||
P.PushAllSliceFlagStates;
|
||
P.LayoutFlags;
|
||
MarkPanDirty(P);
|
||
end;
|
||
|
||
function TMainForm.CurrentAppTheme: TAppTheme;
|
||
begin
|
||
if FLightTheme then Result := LightTheme else Result := DarkTheme;
|
||
end;
|
||
|
||
function TMainForm.PanBandSelectorVisible: Boolean;
|
||
// Селектор диапазона пана — только на «настоящих КВ»: не в XVTR и не Pluto
|
||
// (у Pluto/трансвертера band-стек другой, сценарий не актуален).
|
||
begin
|
||
Result := (FController.FCurrentXvtr < 0) and not FController.IsPluto;
|
||
end;
|
||
|
||
procedure TMainForm.OnPanBandClick(Sender: TObject);
|
||
// Клик по бейджу диапазона в шапке пана N: меню КВ-диапазонов, текущий (по
|
||
// частоте DDC пана) отмечен галкой.
|
||
var
|
||
P: TPanafallPanel;
|
||
i, CurBand: Integer;
|
||
begin
|
||
if not (Sender is TPanafallPanel) then Exit;
|
||
P := TPanafallPanel(Sender);
|
||
if (P.PanId < 1) or not FController.PanDDCActive(P.PanId) then Exit;
|
||
if not PanBandSelectorVisible then Exit;
|
||
CurBand := FreqToBandIdx(FController.PanDDCFreq(P.PanId), False);
|
||
if FPanBandMenu = nil then
|
||
begin
|
||
FPanBandMenu := TFlatPopupMenu.Create(Self);
|
||
FPanBandMenu.OnSelect := OnPanBandSelect;
|
||
end;
|
||
FPanBandMenu.SetTheme(CurrentAppTheme);
|
||
FPanBandMenu.Clear;
|
||
for i := 0 to CFG_BAND_COUNT - 1 do
|
||
begin
|
||
if BandName(i, False) = '' then Continue; // 6m-дубль/пустые слоты
|
||
FPanBandMenu.AddItem(BandName(i, False), i, i = CurBand);
|
||
end;
|
||
FPanBandMenuPanId := P.PanId;
|
||
FPanBandMenu.PopupBelow(P.HeaderBandLabel);
|
||
end;
|
||
|
||
procedure TMainForm.OnPanBandSelect(Tag: Integer);
|
||
var PanId: Integer;
|
||
begin
|
||
PanId := FPanBandMenuPanId;
|
||
FPanBandMenuPanId := 0;
|
||
if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
|
||
if FPans[PanId] = nil then Exit;
|
||
ApplyPanBand(FPans[PanId], Tag);
|
||
end;
|
||
|
||
procedure TMainForm.ApplyPanBand(P: TPanafallPanel; BandIdx: Integer);
|
||
// Flex-style смена диапазона пана N: ретюн DDC на VfoA диапазона (из общего
|
||
// band-кэша, как пан 0), активный приёмник садится на VfoA с mode/filter/AGC
|
||
// (+zoom/pan) диапазона, прочие слайсы пана убираются (они вне новой полосы).
|
||
var
|
||
B: TBandSettings;
|
||
Freq: Double;
|
||
Lo, Hi, Id: Integer;
|
||
begin
|
||
if (P = nil) or (P.PanId < 1) then Exit;
|
||
if not PanBandSelectorVisible then Exit;
|
||
if (BandIdx < 0) or (BandIdx >= CFG_BAND_COUNT) then Exit;
|
||
B := FController.FBandCache[BandIdx];
|
||
Freq := B.VfoA;
|
||
// Кэш диапазона мог нести чужую частоту (дефолты/битый конфиг) — тогда центр
|
||
// диапазона по плану.
|
||
if FreqToBandIdx(Freq, False) <> BandIdx then
|
||
Freq := BandDefaultFreq(BandIdx, False);
|
||
|
||
// Слайсы вне новой полосы — снести до ретюна DDC.
|
||
P.DestroyAllSliceFlags;
|
||
FActiveSliceId := 0;
|
||
// Ретюн DDC пана + зум/пан диапазона.
|
||
FController.SetPanDDCFreq(P.PanId, Freq);
|
||
FController.SetPanZoomPan(P.PanId,
|
||
EnsureRange(B.ZoomFactor, 0.0, 1.0), EnsureRange(B.PanSlider, 0.0, 1.0));
|
||
// Один приёмник на VfoA с настройками диапазона.
|
||
TRadioController.FilterEdgesFor(B.Mode, B.FilterBW, Lo, Hi);
|
||
Id := FController.AddSlice(Freq, B.Mode, Lo, Hi,
|
||
TRadioController.AGCModeFromUI(B.AGCMode), 0.7, -1, '', P.PanId);
|
||
if Id >= 0 then
|
||
begin
|
||
P.CreateSliceFlag(Id);
|
||
FActiveSliceId := Id;
|
||
end;
|
||
// Синк вьюхи/зум-бара/флагов/шапки под новый центр.
|
||
P.PushAllSliceFlagStates;
|
||
RefreshPanZoom(P);
|
||
UpdatePanHeaders;
|
||
end;
|
||
|
||
procedure TMainForm.OnPanADCClick(Sender: TObject);
|
||
// Бейдж «RX1/RX2»: тумблер ADC-источника пана (двух-АЦПшные платы, 3.4).
|
||
var
|
||
P: TPanafallPanel;
|
||
N: Integer;
|
||
begin
|
||
if not (Sender is TPanafallPanel) then Exit;
|
||
P := TPanafallPanel(Sender);
|
||
N := FController.BackendCaps.NumADCs;
|
||
if N < 2 then Exit;
|
||
if not FController.SetPanADC(P.PanId, (FController.PanADC(P.PanId) + 1) mod N) then
|
||
Exit;
|
||
UpdatePanHeaders;
|
||
end;
|
||
|
||
// ---- Per-pan дисплей (3.6): «◑» палитра/gamma/уровни отдельно на пан ----
|
||
|
||
procedure TMainForm.ApplyGlobalRenderToView(V: TSpectrumView);
|
||
// Глобальный дефолт рендера (контроллер) → вьюха пана. Используется при
|
||
// создании пана (дефолт) и «Сбросе к дефолту».
|
||
begin
|
||
if V = nil then Exit;
|
||
V.WfPalette := FController.FWfPalette;
|
||
V.WfGamma := FController.FWfGamma;
|
||
V.SpecRefLevel := FController.FSpecRefLevel;
|
||
V.SpecRange := FController.FSpecRange;
|
||
V.SpecGridStep := FController.FSpecGridStep;
|
||
V.WfAGCEnabled := FController.FWfAGCEnabled;
|
||
V.WfAGCOffset := FController.FWfAGCOffset;
|
||
V.WfManualHigh := FController.FWfManualHigh;
|
||
V.WfManualLow := FController.FWfManualLow;
|
||
end;
|
||
|
||
procedure TMainForm.MarkPanDisplayDirty(P: TPanafallPanel; ClearWf: Boolean);
|
||
// Полный репейнт пана после смены рендера: сетка спектра + водопад
|
||
// (LUT/палитра пересоберётся в SpectrumView по своим no-op-гардам).
|
||
// ClearWf=True (смена палитры/gamma/авто) чистит историю водопада — GL хранит
|
||
// уже раскрашенные пиксели, иначе старые строки остаются в прежней палитре.
|
||
begin
|
||
if P = nil then Exit;
|
||
P.View.InvalidateGridCache;
|
||
if ClearWf then P.View.ClearWaterfall;
|
||
if P = FPan then
|
||
begin
|
||
FSpectrumDirty := True;
|
||
FWaterfallDirty := True;
|
||
if Assigned(PbSpectrum) then PbSpectrum.Invalidate;
|
||
if Assigned(PbWaterfall) then PbWaterfall.Invalidate;
|
||
Exit;
|
||
end;
|
||
P.View.SpectrumDirty := True;
|
||
FPanSpecDirty[P.PanId] := True;
|
||
FPanWfDirty[P.PanId] := True;
|
||
if P.PbSpectrum <> nil then P.PbSpectrum.Invalidate;
|
||
if P.PbWaterfall <> nil then P.PbWaterfall.Invalidate;
|
||
end;
|
||
|
||
procedure TMainForm.OnPanDisplayClick(Sender: TObject);
|
||
// «◑» в шапке пана: тоггл поповера под кнопкой (дочерний контрол формы).
|
||
var
|
||
P: TPanafallPanel;
|
||
Pop: TPanDisplayPopup;
|
||
begin
|
||
if not (Sender is TPanafallPanel) then Exit;
|
||
P := TPanafallPanel(Sender);
|
||
if P.BtnDisplay = nil then Exit;
|
||
if FPanDisplayPopup = nil then
|
||
begin
|
||
Pop := TPanDisplayPopup.Create(Self);
|
||
Pop.OnApplied := OnPanDisplayApplied;
|
||
Pop.OnResetDefault := OnPanDisplayReset;
|
||
Pop.OnApplyAll := OnPanDisplayApplyAll;
|
||
FPanDisplayPopup := Pop;
|
||
end
|
||
else
|
||
Pop := TPanDisplayPopup(FPanDisplayPopup);
|
||
// Повторный клик по «◑» — закрыть.
|
||
if Pop.Visible then begin Pop.Hide; Exit; end;
|
||
Pop.PopupBelow(P, P.BtnDisplay);
|
||
end;
|
||
|
||
procedure TMainForm.OnPanDisplayApplied(P: TPanafallPanel; FullRefresh: Boolean);
|
||
// Поповер применил правку к P.View. Пан 0 = глобальный дефолт (зеркалим в
|
||
// контроллер, чтобы персистилось и наследовалось новыми панами); паны N —
|
||
// ставим override-флаг (персист в воркспейс).
|
||
begin
|
||
if P = nil then Exit;
|
||
if P.PanId = 0 then
|
||
begin
|
||
FController.FWfPalette := P.View.WfPalette;
|
||
FController.FWfGamma := P.View.WfGamma;
|
||
FController.FSpecRefLevel := P.View.SpecRefLevel;
|
||
FController.FSpecRange := P.View.SpecRange;
|
||
FController.FWfAGCEnabled := P.View.WfAGCEnabled;
|
||
FController.FWfAGCOffset := P.View.WfAGCOffset;
|
||
FController.FWfManualHigh := P.View.WfManualHigh;
|
||
FController.FWfManualLow := P.View.WfManualLow;
|
||
// На диск не пишем на каждый тик — глобальные сохранятся при закрытии
|
||
// (FormDestroy → SaveGlobalSettings), как и для главного диалога настроек.
|
||
end
|
||
else
|
||
P.DisplayOverride := True;
|
||
MarkPanDisplayDirty(P, FullRefresh);
|
||
end;
|
||
|
||
procedure TMainForm.OnPanDisplayReset(P: TPanafallPanel);
|
||
// «Сброс к дефолту»: пан снова наследует глобальный рендер.
|
||
begin
|
||
if (P = nil) or (P.PanId = 0) then Exit;
|
||
ApplyGlobalRenderToView(P.View);
|
||
P.DisplayOverride := False;
|
||
MarkPanDisplayDirty(P, True); // палитра могла смениться → чистим водопад
|
||
end;
|
||
|
||
procedure TMainForm.OnPanDisplayApplyAll(P: TPanafallPanel);
|
||
// «Применить ко всем»: рендер пана P раскатывается на все паны + становится
|
||
// глобальным дефолтом (для пана 0 и новых панов).
|
||
var i: Integer; Q: TPanafallPanel;
|
||
begin
|
||
if P = nil then Exit;
|
||
// Глобальный дефолт ← P.
|
||
FController.FWfPalette := P.View.WfPalette;
|
||
FController.FWfGamma := P.View.WfGamma;
|
||
FController.FSpecRefLevel := P.View.SpecRefLevel;
|
||
FController.FSpecRange := P.View.SpecRange;
|
||
FController.FWfAGCEnabled := P.View.WfAGCEnabled;
|
||
FController.FWfAGCOffset := P.View.WfAGCOffset;
|
||
FController.FWfManualHigh := P.View.WfManualHigh;
|
||
FController.FWfManualLow := P.View.WfManualLow;
|
||
FController.SaveGlobalSettings;
|
||
for i := 0 to MAX_PANS - 1 do
|
||
begin
|
||
Q := FPans[i];
|
||
if Q = nil then Continue;
|
||
if Q <> P then ApplyGlobalRenderToView(Q.View);
|
||
Q.DisplayOverride := False; // все синхронны с дефолтом ⇒ не override
|
||
MarkPanDisplayDirty(Q, True); // палитра раскатана → чистим водопад
|
||
end;
|
||
end;
|
||
|
||
// ---- Pop-out (3.5): пан N в отдельном OS-окне ----
|
||
|
||
function TMainForm.PanFloating(PanId: Integer): Boolean;
|
||
begin
|
||
Result := (PanId >= 1) and (PanId < MAX_PANS) and (FPanFloatForm[PanId] <> nil);
|
||
end;
|
||
|
||
procedure TMainForm.ResetPanGLCaches(P: TPanafallPanel);
|
||
// Смена родителя пересоздаёт хэндлы GL-канв → старые контексты (и все
|
||
// текстуры) мертвы; кэши вьюхи обнуляем БЕЗ glDelete (как при смене MSAA).
|
||
begin
|
||
if FUseOpenGLSpectrum and (P.View is TSpectrumViewOpenGL) then
|
||
TSpectrumViewOpenGL(P.View).ResetGLCache;
|
||
end;
|
||
|
||
procedure TMainForm.OnPanPopOutClick(Sender: TObject);
|
||
begin
|
||
if Sender is TPanafallPanel then
|
||
PopOutPan(TPanafallPanel(Sender).PanId);
|
||
end;
|
||
|
||
procedure TMainForm.PopOutPan(PanId: Integer);
|
||
var
|
||
P: TPanafallPanel;
|
||
F: TForm;
|
||
T: TAppTheme;
|
||
begin
|
||
if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
|
||
P := FPans[PanId];
|
||
if (P = nil) or PanFloating(PanId) then Exit;
|
||
T := CurrentAppTheme;
|
||
F := TForm.CreateNew(Self);
|
||
F.Caption := Format('EWSDR · PAN %d', [PanId]);
|
||
F.Color := T.BG;
|
||
F.Font.Assign(Font); // ParentFont-наследники (лейблы шапки) берут шрифт
|
||
// формы — без этого текст дефолтно-тёмный на тёмном
|
||
F.Position := poDefault;
|
||
F.Width := PanelRight.ClientWidth;
|
||
F.Height := Max(300, P.StackHeight);
|
||
F.Tag := PanId;
|
||
F.OnClose := PanFloatClose;
|
||
F.OnResize := PanFloatResize;
|
||
F.OnMouseWheel := FormMouseWheel; // колесо над флагами/фоном как в стеке
|
||
FPanFloatForm[PanId] := F;
|
||
P.ReparentTo(F);
|
||
ResetPanGLCaches(P);
|
||
P.StackTop := 0;
|
||
P.StackHeight := 0; // 0/0 = весь родитель (окно)
|
||
P.StackLeft := 0;
|
||
P.StackWidth := 0;
|
||
if FPanSplitters[PanId] <> nil then FPanSplitters[PanId].Visible := False;
|
||
if FPanVSplitters[PanId] <> nil then FPanVSplitters[PanId].Visible := False;
|
||
F.Show;
|
||
UpdatePanHeaders;
|
||
ResizeSpectrumPanels; // пере-стек оставшихся в главном окне
|
||
P.Layout(0, FController.FShowSpectrum, FController.FShowWaterfall);
|
||
P.LayoutFlags;
|
||
MarkPanDirty(P);
|
||
end;
|
||
|
||
procedure TMainForm.DockBackPan(PanId: Integer);
|
||
var
|
||
P: TPanafallPanel;
|
||
F: TForm;
|
||
begin
|
||
P := FPans[PanId];
|
||
F := FPanFloatForm[PanId];
|
||
if F = nil then Exit;
|
||
FPanFloatForm[PanId] := nil;
|
||
if P <> nil then
|
||
begin
|
||
P.ReparentTo(PanelRight);
|
||
ResetPanGLCaches(P);
|
||
end;
|
||
if FPanSplitters[PanId] <> nil then FPanSplitters[PanId].Visible := True;
|
||
F.Release; // безопасно из событий самого окна
|
||
UpdatePanHeaders;
|
||
ResizeSpectrumPanels;
|
||
if P <> nil then
|
||
begin
|
||
P.LayoutFlags;
|
||
MarkPanDirty(P);
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.PanFloatClose(Sender: TObject; var CloseAction: TCloseAction);
|
||
// [×] окна = вернуть пан в стек (не закрыть пан). Release — в DockBackPan.
|
||
begin
|
||
CloseAction := caNone;
|
||
DockBackPan(TForm(Sender).Tag);
|
||
end;
|
||
|
||
procedure TMainForm.PanFloatResize(Sender: TObject);
|
||
var P: TPanafallPanel; PanId: Integer;
|
||
begin
|
||
PanId := TForm(Sender).Tag;
|
||
if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
|
||
P := FPans[PanId];
|
||
if (P = nil) or (FPanFloatForm[PanId] <> Sender) then Exit;
|
||
P.Layout(0, FController.FShowSpectrum, FController.FShowWaterfall);
|
||
P.LayoutFlags;
|
||
MarkPanDirty(P);
|
||
end;
|
||
|
||
procedure TMainForm.PanClickTune(P: TPanafallPanel; X, W: Integer);
|
||
// Клик по фону доп. пана = перестроить его слайс на частоту клика (аналог
|
||
// DoSpectrumClick главного). Цель — активный слайс, если он живёт в этом
|
||
// пане, иначе первый слайс пана; слайсов нет — создать в точке.
|
||
var
|
||
VC, VS, LoHz, HiHz: Double;
|
||
Hz, StepHz: Int64;
|
||
Id, ModeHere: Integer;
|
||
S: TCtrlSlice;
|
||
begin
|
||
PanViewWindow(P, VC, VS);
|
||
if (W <= 0) or (VS <= 0) then Exit;
|
||
if (FActiveSliceId <> 0) and FController.GetSlice(FActiveSliceId, S) and
|
||
(S.PanId = P.PanId) then
|
||
Id := FActiveSliceId
|
||
else
|
||
Id := P.FirstSliceId;
|
||
// Канальные режимы: клик садится на ту же сетку, что нарисована на пане.
|
||
if Id <> 0 then
|
||
begin
|
||
if FController.GetSlice(Id, S) then ModeHere := S.Mode
|
||
else ModeHere := FController.FMode;
|
||
end
|
||
else
|
||
ModeHere := FController.FMode;
|
||
if (ModeHere in [MODE_FM, MODE_DMR, MODE_FMRAW]) and FController.FFMStepOn then
|
||
StepHz := FM_STEP_HZ[FController.FFMStepIdx]
|
||
else
|
||
StepHz := 100;
|
||
Hz := Round(VC + (X / W - 0.5) * VS);
|
||
Hz := ((Hz + StepHz div 2) div StepHz) * StepHz;
|
||
FController.CapturedSpan(LoHz, HiHz, P.PanId);
|
||
if Hz < LoHz then Hz := Round(LoHz);
|
||
if Hz > HiHz then Hz := Round(HiHz);
|
||
if Id = 0 then
|
||
begin
|
||
AddSliceAtFreqPan(P, Hz);
|
||
Exit;
|
||
end;
|
||
FController.SetSliceTarget(Id, Hz);
|
||
FActiveSliceId := Id;
|
||
P.PushSliceFlagState(Id);
|
||
P.LayoutFlags;
|
||
MarkPanDirty(P);
|
||
end;
|
||
|
||
procedure TMainForm.ApplyPanHeaderTheme(P: TPanafallPanel);
|
||
// Явные цвета шапки пана. Зовётся и из ApplyDarkTheme, и из UpdatePanHeaders:
|
||
// лейблы без явного Font.Color наследуют шрифт формы-родителя (ParentFont),
|
||
// и в pop-out окне текст становился дефолтно-тёмным на тёмном фоне.
|
||
var T: TAppTheme;
|
||
begin
|
||
if (P = nil) or (P.HeaderPanel = nil) then Exit;
|
||
T := CurrentAppTheme;
|
||
P.HeaderPanel.Color := T.Panel;
|
||
P.HeaderLabel.Font.Color := T.Text;
|
||
// Rate-лейбл: кликабельный (паны N) подсвечиваем акцентом селектора.
|
||
if P.RateClickable then
|
||
P.HeaderRateLabel.Font.Color := T.BtnTextActive
|
||
else
|
||
P.HeaderRateLabel.Font.Color := T.Text;
|
||
// Band-лейбл (3.6): кликабельный — акцент селектора.
|
||
if P.HeaderBandLabel <> nil then
|
||
begin
|
||
if P.BandClickable then
|
||
P.HeaderBandLabel.Font.Color := T.BtnTextActive
|
||
else
|
||
P.HeaderBandLabel.Font.Color := T.Text;
|
||
end;
|
||
// ADC-бейдж кликабелен на 2-АЦПшных платах — тоже акцент.
|
||
if P.ADCClickable then
|
||
P.HeaderADCLabel.Font.Color := T.BtnTextActive
|
||
else
|
||
P.HeaderADCLabel.Font.Color := T.Text;
|
||
end;
|
||
|
||
procedure TMainForm.UpdatePanHeaders;
|
||
// Текст/видимость шапок: у одного пана шапки нет, у стека — у всех;
|
||
// у плавающего (pop-out) пана шапка всегда (там его rate/ADC/×).
|
||
var
|
||
i, N, NADC, bi: Integer;
|
||
P: TPanafallPanel;
|
||
begin
|
||
N := PanCount;
|
||
NADC := FController.BackendCaps.NumADCs;
|
||
for i := 0 to MAX_PANS - 1 do
|
||
begin
|
||
P := FPans[i];
|
||
if P = nil then Continue;
|
||
if i = 0 then P.HeaderVisible := Pan0HeaderVisible
|
||
else P.HeaderVisible := (N > 1) or PanFloating(i);
|
||
if P.BtnClosePan <> nil then P.BtnClosePan.Visible := (N > 1) and (i > 0);
|
||
if P.BtnPopPan <> nil then
|
||
P.BtnPopPan.Visible := (N > 1) and (i > 0) and not PanFloating(i);
|
||
// «◑» дисплей-поповер — на всех панах с видимой шапкой (вкл. пан 0).
|
||
if P.BtnDisplay <> nil then P.BtnDisplay.Visible := P.HeaderVisible;
|
||
if i = 0 then
|
||
begin
|
||
P.SetHeaderText(Format('PAN 0 · %.6f MHz ·',
|
||
[FController.FCenterFreq / 1e6]));
|
||
P.SetHeaderRate(Format('%d kHz', [FController.FSampleRate div 1000]));
|
||
end
|
||
else
|
||
begin
|
||
P.SetHeaderText(Format('PAN %d · %.6f MHz ·',
|
||
[i, FController.PanDDCFreq(i) / 1e6]));
|
||
// hw-индекс DDC в шапке: по клику там же выбирается rate и DDC пана.
|
||
P.SetHeaderRate(Format('%d kHz · DDC%d',
|
||
[FController.PanDDCRateKHz(i), FController.PanDDCIndex(i)]));
|
||
end;
|
||
// Бейдж диапазона (3.6): только паны N на «настоящих КВ»; клик = меню.
|
||
if (i > 0) and PanBandSelectorVisible then
|
||
begin
|
||
bi := FreqToBandIdx(FController.PanDDCFreq(i), False);
|
||
if (bi >= 0) and (BandName(bi, False) <> '') then
|
||
P.SetHeaderBand(BandName(bi, False))
|
||
else
|
||
P.SetHeaderBand('—');
|
||
P.BandClickable := True;
|
||
end
|
||
else
|
||
begin
|
||
P.SetHeaderBand('');
|
||
P.BandClickable := False;
|
||
end;
|
||
// Бейдж АЦП: только на двух-АЦПшных платах; клик = тумблер.
|
||
if NADC > 1 then
|
||
begin
|
||
P.SetHeaderADC(Format('RX%d', [FController.PanADC(i) + 1]));
|
||
P.ADCClickable := True;
|
||
end
|
||
else
|
||
begin
|
||
P.SetHeaderADC('');
|
||
P.ADCClickable := False;
|
||
end;
|
||
ApplyPanHeaderTheme(P); // клик-статусы могли смениться + свежесозданные паны
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.UpdateAddPanButton;
|
||
var HasSlot: Boolean; i: Integer;
|
||
begin
|
||
HasSlot := False;
|
||
for i := 1 to MAX_PANS - 1 do
|
||
if FPans[i] = nil then begin HasSlot := True; Break; end;
|
||
FPan.AddPanEnabled := (FController.BackendCaps.MaxPans > 1) and
|
||
FController.FRunning and HasSlot;
|
||
end;
|
||
|
||
function TMainForm.CreatePanCore(PanId: Integer; Freq: Double;
|
||
RateKHz: Word; ADC: Byte): TPanafallPanel;
|
||
// Ядро создания доп. пана: DDC + панель + проводка + сплиттеры + регистрация
|
||
// в FPans. НЕ создаёт слайс, НЕ трогает раскладку/шапки — это делают
|
||
// вызывающие (OnAddPanClick — новый пан; RestorePanWorkspace — восстановление).
|
||
// Возвращает nil, если DDC не создался.
|
||
var
|
||
P: TPanafallPanel;
|
||
Split: TPanel;
|
||
begin
|
||
Result := nil;
|
||
if not FController.AddPanDDC(PanId, Freq, RateKHz, ADC) then Exit;
|
||
|
||
P := TPanafallPanel.Create(nil, FUseOpenGLSpectrum);
|
||
P.PanId := PanId;
|
||
P.Controller := FController;
|
||
P.ShowZoomRow := True; // пан-зум пана: свой бар/кнопки (диспетчер по Sender)
|
||
P.ZoomInClick := BtnZoomInClick; // до Build — кнопки создаются в Build
|
||
P.ZoomDefClick := BtnZoomDefClick;
|
||
P.ZoomOutClick := BtnZoomOutClick;
|
||
P.OnWireFlag := WireOverlayEvents;
|
||
P.OnSliceClosed := OnPanSliceClosed;
|
||
P.OnFlagsInvalidate := OnVfoOverlayInvalidate;
|
||
P.OnPanClose := OnPanHeaderClose;
|
||
P.OnSplitterMoved := RightPanelResize; // ratio обновлён → полный re-layout
|
||
P.OnRateClick := OnPanRateClick; // «NNN kHz» в шапке → меню rate
|
||
P.RateClickable := True;
|
||
P.OnADCClick := OnPanADCClick; // «RX1/RX2» тумблер АЦП (3.4)
|
||
P.OnPanPopOut := OnPanPopOutClick; // «⧉» в отдельное окно (3.5)
|
||
P.OnDisplayClick := OnPanDisplayClick; // «◑» дисплей-поповер (3.6)
|
||
P.OnBandClick := OnPanBandClick; // «20m» селектор диапазона (3.6, КВ)
|
||
P.SpectrumMouseDown := PanNSpectrumMouseDown;
|
||
P.SpectrumMouseMove := PanNSpectrumMouseMove;
|
||
P.SpectrumMouseUp := PanNSpectrumMouseUp;
|
||
P.SpectrumMouseLeave := PanNSpectrumMouseLeave;
|
||
P.SpectrumDblClick := PanNSpectrumDblClick;
|
||
P.WaterfallMouseDown := PanNSpectrumMouseDown; // X→частота одинаково
|
||
P.WaterfallMouseMove := PanNSpectrumMouseMove;
|
||
P.WaterfallMouseUp := PanNSpectrumMouseUp;
|
||
P.Build(PanelRight, Max(1, FController.FSpecMSAA));
|
||
P.ZoomBar.OnZoomPan := OnPanZoomWindow; // drag окна-селектора
|
||
P.ZoomBar.SetFreq(Freq, FController.PanDDCRateKHz(PanId) * 1000.0);
|
||
P.ZoomBar.SetState(0.0, 0.5);
|
||
|
||
// Параметры отображения — с главного view (пан-персональные — позже).
|
||
P.View.WfAGCEnabled := FController.FWfAGCEnabled;
|
||
P.View.WfNFEnabled := FController.FWfNFEnabled;
|
||
P.View.WfManualHigh := FController.FWfManualHigh;
|
||
P.View.WfManualLow := FController.FWfManualLow;
|
||
P.View.WfAGCOffset := FController.FWfAGCOffset;
|
||
P.View.WfPalette := FController.FWfPalette;
|
||
P.View.WfGamma := FController.FWfGamma;
|
||
P.View.SpecRefLevel := FController.FSpecRefLevel;
|
||
P.View.SpecRange := FController.FSpecRange;
|
||
P.View.SpecGridStep := FController.FSpecGridStep;
|
||
P.View.WfFrameInterval := EffectiveWfInterval;
|
||
P.View.PAMaxPower := FController.FPAMaxPower;
|
||
P.View.CenterFreq := Freq;
|
||
P.View.SpanHz := FController.PanDDCRateKHz(PanId) * 1000.0;
|
||
// VFO/TX-маркеры главного на доп. пане не рисуем — уводим за пределы окна.
|
||
P.View.VfoA := -1e15;
|
||
P.View.VfoB := -1e15;
|
||
P.View.TXFreq := -1e15;
|
||
|
||
// Сплиттер над паном.
|
||
Split := TPanel.Create(Self);
|
||
Split.Parent := PanelRight;
|
||
Split.BevelOuter := bvNone;
|
||
Split.Color := TColor($00303030);
|
||
Split.Cursor := crVSplit;
|
||
Split.Tag := PanId;
|
||
Split.OnMouseDown := PanSplitMouseDown;
|
||
Split.OnMouseMove := PanSplitMouseMove;
|
||
Split.OnMouseUp := PanSplitMouseUp;
|
||
FPanSplitters[PanId] := Split;
|
||
|
||
// Вертикальный сплиттер слева от колонки пана (грид-раскладка).
|
||
Split := TPanel.Create(Self);
|
||
Split.Parent := PanelRight;
|
||
Split.BevelOuter := bvNone;
|
||
Split.Color := TColor($00303030);
|
||
Split.Cursor := crHSplit;
|
||
Split.Tag := PanId;
|
||
Split.Visible := False; // включает LayoutPanStack в грид-режиме
|
||
Split.OnMouseDown := PanVSplitMouseDown;
|
||
Split.OnMouseMove := PanVSplitMouseMove;
|
||
Split.OnMouseUp := PanVSplitMouseUp;
|
||
FPanVSplitters[PanId] := Split;
|
||
FPanColShare[PanId] := 1.0;
|
||
|
||
// Публикуем пан для пиксельных колбэков (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;
|
||
|
||
AddSliceAtFreqPan(P, Freq);
|
||
ApplyDarkTheme; // темизация шапки/кнопок нового пана
|
||
UpdatePanHeaders;
|
||
UpdateAddPanButton;
|
||
ResizeSpectrumPanels;
|
||
end;
|
||
|
||
procedure TMainForm.OnPanHeaderClose(Sender: TObject);
|
||
// «×» шапки: нельзя free панель из обработчика её же кнопки — откладываем
|
||
// в тик (FPendingPanClose).
|
||
begin
|
||
if Sender is TPanafallPanel then
|
||
FPendingPanClose := TPanafallPanel(Sender).PanId;
|
||
end;
|
||
|
||
procedure TMainForm.ClosePanadapter(PanId: Integer);
|
||
var
|
||
P: TPanafallPanel;
|
||
S: TCtrlSlice;
|
||
i: Integer;
|
||
begin
|
||
if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
|
||
P := FPans[PanId];
|
||
if P = nil then Exit;
|
||
// Дисплей-поповер мог быть привязан к этому пану — спрятать (FPan станет
|
||
// висячим; поповер перепривяжется при следующем показе через LoadFrom).
|
||
if FPanDisplayPopup <> nil then TPanDisplayPopup(FPanDisplayPopup).Hide;
|
||
// Отписываем от пиксельных колбэков ДО удаления DDC/панели.
|
||
FPanCbLock.Enter;
|
||
try
|
||
FPans[PanId] := nil;
|
||
finally
|
||
FPanCbLock.Leave;
|
||
end;
|
||
// Слайсы пана (со звуковухами) + движок + DDC.
|
||
FController.RemovePanDDC(PanId);
|
||
if (FActiveSliceId <> 0) and not FController.GetSlice(FActiveSliceId, S) then
|
||
FActiveSliceId := 0;
|
||
FreeAndNil(FPanSplitters[PanId]);
|
||
FreeAndNil(FPanVSplitters[PanId]);
|
||
P.Free;
|
||
// Пан был в отдельном окне — окно больше не нужно. Release (не Free):
|
||
// сюда можно попасть из событий контролов этого же окна («×» шапки → тик).
|
||
if FPanFloatForm[PanId] <> nil then
|
||
begin
|
||
FPanFloatForm[PanId].Release;
|
||
FPanFloatForm[PanId] := nil;
|
||
end;
|
||
for i := 0 to MAX_PANS - 1 do
|
||
if FPans[i] <> nil then FPanShare[i] := 1.0 / PanCount;
|
||
UpdatePanHeaders;
|
||
UpdateAddPanButton;
|
||
ResizeSpectrumPanels;
|
||
end;
|
||
|
||
procedure TMainForm.DestroyAllExtraPans;
|
||
var i: Integer;
|
||
begin
|
||
for i := 1 to MAX_PANS - 1 do
|
||
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];
|
||
Cfg.Pans[i].ColShare := FPanColShare[i]; // пан 0 — колонка в гриде «2 колонки»
|
||
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);
|
||
// Per-pan дисплей (3.6): пишем override только если пан его задал.
|
||
if (FPans[i] <> nil) and FPans[i].DisplayOverride then
|
||
begin
|
||
Cfg.Pans[i].HasDisplay := True;
|
||
Cfg.Pans[i].DPalette := FPans[i].View.WfPalette;
|
||
Cfg.Pans[i].DGamma := FPans[i].View.WfGamma;
|
||
Cfg.Pans[i].DSpecRef := FPans[i].View.SpecRefLevel;
|
||
Cfg.Pans[i].DSpecRange := FPans[i].View.SpecRange;
|
||
Cfg.Pans[i].DWfAGC := FPans[i].View.WfAGCEnabled;
|
||
Cfg.Pans[i].DWfOffset := FPans[i].View.WfAGCOffset;
|
||
Cfg.Pans[i].DWfHigh := FPans[i].View.WfManualHigh;
|
||
Cfg.Pans[i].DWfLow := FPans[i].View.WfManualLow;
|
||
end;
|
||
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].RxMuteOnTx := FController.FSlices[s].RxMuteOnTx;
|
||
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;
|
||
Cfg.Pans[i].Slices[n].InDevName := FController.FSlices[s].InDevName;
|
||
Cfg.Pans[i].Slices[n].TXProfByMode := FController.FSlices[s].TXProfByMode;
|
||
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;
|
||
// Свой вход слайса (имя устройства → PA-индекс текущей сессии).
|
||
if SC.InDevName <> '' then
|
||
FController.SetSliceInDevice(Id,
|
||
FController.FAudioIn.FindDeviceByName(SC.InDevName), SC.InDevName);
|
||
if SC.Muted then FController.SetSliceMute(Id, True);
|
||
FController.SetSliceRxMuteOnTx(Id, SC.RxMuteOnTx);
|
||
// Таблица «режим → профиль» этого слайса целиком (не через SetSliceTXProfile:
|
||
// тот пишет только ячейку текущего режима).
|
||
FController.SetSliceTXProfileTable(Id, SC.TXProfByMode);
|
||
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;
|
||
FPanColShare[0] := Cfg.Pans[0].ColShare;
|
||
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;
|
||
// Per-pan дисплей (3.6): CreatePanCore уже залил глоб. дефолт — при наличии
|
||
// override перекрываем сохранёнкой.
|
||
if Cfg.Pans[i].HasDisplay then
|
||
begin
|
||
P.View.WfPalette := EnsureRange(Cfg.Pans[i].DPalette, 0, 2);
|
||
P.View.WfGamma := EnsureRange(Cfg.Pans[i].DGamma, 0.30, 3.00);
|
||
P.View.SpecRefLevel := Cfg.Pans[i].DSpecRef;
|
||
P.View.SpecRange := Cfg.Pans[i].DSpecRange;
|
||
P.View.WfAGCEnabled := Cfg.Pans[i].DWfAGC;
|
||
P.View.WfAGCOffset := Cfg.Pans[i].DWfOffset;
|
||
P.View.WfManualHigh := Cfg.Pans[i].DWfHigh;
|
||
P.View.WfManualLow := Cfg.Pans[i].DWfLow;
|
||
P.DisplayOverride := True;
|
||
end;
|
||
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-маркеров — они уведены за окно при создании).
|
||
var i: Integer; P: TPanafallPanel;
|
||
begin
|
||
for i := 1 to MAX_PANS - 1 do
|
||
begin
|
||
P := FPans[i];
|
||
if P = nil then Continue;
|
||
SyncPanZoomUI(P); // центр/span вьюхи (с пан-зумом) + зум-бар
|
||
P.View.WDSPReady := FController.FWDSPReady;
|
||
end;
|
||
// Шапки следуют за частотами (Caption-сеттер — no-op при том же тексте).
|
||
if PanCount > 1 then UpdatePanHeaders;
|
||
end;
|
||
|
||
procedure TMainForm.OnPanSpectrumReady(PanId: Integer;
|
||
const Pixels: array of Single; Count: Integer);
|
||
// Display-поток движка. Лок закрывает гонку с Close/AddPanadapter (UI).
|
||
begin
|
||
FPanCbLock.Enter;
|
||
try
|
||
if (PanId >= 1) and (PanId < MAX_PANS) and (FPans[PanId] <> nil) then
|
||
begin
|
||
FPans[PanId].View.SetSpectrumData(Pixels, Count);
|
||
FPanSpecDirty[PanId] := True;
|
||
end;
|
||
finally
|
||
FPanCbLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.OnPanWaterfallReady(PanId: Integer;
|
||
const Pixels: array of Single; Count: Integer);
|
||
begin
|
||
FPanCbLock.Enter;
|
||
try
|
||
if (PanId >= 1) and (PanId < MAX_PANS) and (FPans[PanId] <> nil) then
|
||
begin
|
||
FPans[PanId].View.SetWaterfallData(Pixels, Count);
|
||
if FPans[PanId].View.WaterfallDirty then FPanWfDirty[PanId] := True;
|
||
end;
|
||
finally
|
||
FPanCbLock.Leave;
|
||
end;
|
||
end;
|
||
|
||
// ---- Мышь доп. панов: тюн слайсов + drag центра (ретюн DDC). Семантика
|
||
// главного пана (VFO/CTUN/маяк/оверлеи) сюда не переносится. ----
|
||
|
||
procedure TMainForm.PanNSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
var
|
||
P: TPanafallPanel;
|
||
VC, VS: Double;
|
||
Id, W: Integer;
|
||
IsSpec: Boolean;
|
||
begin
|
||
P := PanFromSender(Sender);
|
||
if P = nil then Exit;
|
||
// Флаги и маркеры несущих живут ТОЛЬКО на спектре: координаты водопада в
|
||
// диспетчер флагов отдавать нельзя (его прямоугольники — в системе спектра).
|
||
IsSpec := TControl(Sender) = P.PbSpectrum;
|
||
if IsSpec and P.DispatchFlagsMouseDown(Button, X, Y) then
|
||
begin
|
||
P.ProcessPendingSliceClose;
|
||
Exit;
|
||
end;
|
||
W := TControl(Sender).Width;
|
||
if W <= 0 then Exit;
|
||
if (Button = mbLeft) and (ssCtrl in Shift) then
|
||
begin
|
||
PanViewWindow(P, VC, VS);
|
||
AddSliceAtFreqPan(P, PixelToFreq(X, W, VC, VS));
|
||
Exit;
|
||
end;
|
||
if Button = mbLeft then
|
||
begin
|
||
if IsSpec then
|
||
begin
|
||
Id := P.SliceAtPixel(X);
|
||
if Id <> 0 then
|
||
begin
|
||
FPanSliceDrag := True;
|
||
FPanSliceDragId := Id;
|
||
FPanMousePan := P;
|
||
FActiveSliceId := Id;
|
||
TControl(Sender).Cursor := crSizeWE;
|
||
Exit;
|
||
end;
|
||
end;
|
||
// Пустое место: армируем drag ЦЕНТРА пана (ретюн его DDC; слайсы стоят на
|
||
// своих абсолютных частотах). Клик без движения = тюн слайса пана (MouseUp).
|
||
// Курсор не трогаем до реального сдвига.
|
||
FPanCenterDrag := True;
|
||
FPanCenterMoved := False;
|
||
FPanMousePan := P;
|
||
FPanCenterDragX0 := X;
|
||
FPanCenterDragF0 := FController.PanDDCFreq(P.PanId);
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.PanNSpectrumMouseMove(Sender: TObject; Shift: TShiftState;
|
||
X, Y: Integer);
|
||
var
|
||
P: TPanafallPanel;
|
||
VC, VS, NewHz, LoHz, HiHz: Double;
|
||
W: Integer;
|
||
begin
|
||
P := PanFromSender(Sender);
|
||
if P = nil then Exit;
|
||
W := TControl(Sender).Width;
|
||
|
||
if FPanSliceDrag and (FPanMousePan = P) then
|
||
begin
|
||
if (ssLeft in Shift) and (W > 0) then
|
||
begin
|
||
PanViewWindow(P, VC, VS);
|
||
NewHz := PixelToFreq(X, W, VC, VS);
|
||
FController.CapturedSpan(LoHz, HiHz, P.PanId);
|
||
if NewHz < LoHz then NewHz := LoHz;
|
||
if NewHz > HiHz then NewHz := HiHz;
|
||
FController.SetSliceTarget(FPanSliceDragId, NewHz);
|
||
P.PushSliceFlagState(FPanSliceDragId);
|
||
P.LayoutFlags;
|
||
MarkPanDirty(P);
|
||
end;
|
||
Exit;
|
||
end;
|
||
|
||
if FPanCenterDrag and (FPanMousePan = P) then
|
||
begin
|
||
if (ssLeft in Shift) and (W > 0) then
|
||
begin
|
||
// Гистерезис 4px: до него это ещё «клик» (тюн по MouseUp), не drag.
|
||
if (not FPanCenterMoved) and (Abs(X - FPanCenterDragX0) < 4) then Exit;
|
||
if not FPanCenterMoved then
|
||
begin
|
||
FPanCenterMoved := True;
|
||
TControl(Sender).Cursor := crSizeWE;
|
||
end;
|
||
PanViewWindow(P, VC, VS);
|
||
NewHz := FPanCenterDragF0 - (X - FPanCenterDragX0) * VS / W;
|
||
// Кламп в ВИДИМЫХ частотах: при активном XVTR — его диапазон (иначе
|
||
// 1296 МГц «падала» на MaxFreqHz железа ~61 МГц), без XVTR — железо.
|
||
FController.VisibleFreqBounds(LoHz, HiHz);
|
||
NewHz := Max(LoHz, Min(HiHz, NewHz));
|
||
FController.SetPanDDCFreq(P.PanId, NewHz);
|
||
SyncPanZoomUI(P); // окно вьюхи с учётом зума + freq зум-бара
|
||
P.LayoutFlags;
|
||
UpdatePanHeaders;
|
||
MarkPanDirty(P);
|
||
end;
|
||
Exit;
|
||
end;
|
||
|
||
// Hover-логика (флаги, маркеры несущих) — только на спектре; на водопаде
|
||
// курсор остаётся обычным, как у главного пана.
|
||
if TControl(Sender) = P.PbSpectrum then
|
||
begin
|
||
P.DispatchFlagsMouseMove(X, Y);
|
||
if P.SliceAtPixel(X) <> 0 then
|
||
TControl(Sender).Cursor := crSizeWE
|
||
else
|
||
TControl(Sender).Cursor := crArrow; // crDefault «снимает» курсор — см. Build
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.PanNSpectrumMouseUp(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
var P: TPanafallPanel; W: Integer;
|
||
begin
|
||
P := PanFromSender(Sender);
|
||
if P = nil then Exit;
|
||
if FPanSliceDrag and (FPanMousePan = P) then
|
||
begin
|
||
FPanSliceDrag := False;
|
||
FPanMousePan := nil;
|
||
TControl(Sender).Cursor := crArrow;
|
||
Exit;
|
||
end;
|
||
if FPanCenterDrag and (FPanMousePan = P) then
|
||
begin
|
||
FPanCenterDrag := False;
|
||
FPanMousePan := nil;
|
||
TControl(Sender).Cursor := crArrow;
|
||
// Пресс без движения = клик: перестроить слайс пана на частоту клика.
|
||
W := TControl(Sender).Width;
|
||
if (Button = mbLeft) and (not FPanCenterMoved) and (W > 0) then
|
||
PanClickTune(P, X, W);
|
||
Exit;
|
||
end;
|
||
if (TControl(Sender) = P.PbSpectrum) and P.DispatchFlagsMouseUp(X, Y) then
|
||
P.ProcessPendingSliceClose;
|
||
end;
|
||
|
||
procedure TMainForm.PanNSpectrumMouseLeave(Sender: TObject);
|
||
var P: TPanafallPanel;
|
||
begin
|
||
P := PanFromSender(Sender);
|
||
if P <> nil then P.DispatchFlagsMouseLeave;
|
||
end;
|
||
|
||
procedure TMainForm.PanNSpectrumDblClick(Sender: TObject);
|
||
begin
|
||
// Дабл-клик на доп. пане пока без действия.
|
||
end;
|
||
|
||
// ---- Сплиттеры между панами: пересчёт долей по отпусканию мыши. ----
|
||
|
||
procedure TMainForm.PanSplitMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
const
|
||
PAN_SPLIT_H = 5;
|
||
var
|
||
BelowId, AboveId, i, TotalH, MinPx, BotMin: Integer;
|
||
Split: TPanel;
|
||
begin
|
||
if Button <> mbLeft then Exit;
|
||
if PanGridTwoCol then Exit; // две колонки: горизонтальных сплиттеров нет
|
||
Split := TPanel(Sender);
|
||
BelowId := Split.Tag;
|
||
TotalH := PanelRight.ClientHeight;
|
||
if TotalH <= 0 then Exit;
|
||
MinPx := Round(0.15 * TotalH);
|
||
if PanGridActive then
|
||
begin
|
||
// Грид: этот сплиттер — граница ряда пан 0 / нижний ряд колонок.
|
||
// Минимум — 15% ИЛИ минимум содержимого (что больше): ниже минимума
|
||
// виджеты пана вылезают за регион и паны наезжают друг на друга.
|
||
FPanSplitMinTop := Max(MinPx, PanMinHeight(0));
|
||
BotMin := MinPx; // минимум нижнего ряда — макс. по его колонкам
|
||
for i := 1 to MAX_PANS - 1 do
|
||
if (FPans[i] <> nil) and not PanFloating(i) then
|
||
BotMin := Max(BotMin, PanMinHeight(i));
|
||
FPanSplitMaxTop := TotalH - BotMin - PAN_SPLIT_H;
|
||
end
|
||
else
|
||
begin
|
||
// Пан над сплиттером — ближайший активный с меньшим PanId.
|
||
AboveId := -1;
|
||
for i := BelowId - 1 downto 0 do
|
||
if FPans[i] <> nil then begin AboveId := i; Break; end;
|
||
if (AboveId < 0) or (FPans[BelowId] = nil) then Exit;
|
||
// Границы призрака: каждому пану — минимум 15% стека ИЛИ минимум его
|
||
// содержимого (что больше; = кламп раздачи в LayoutPanStack).
|
||
FPanSplitMinTop := FPans[AboveId].StackTop
|
||
+ Max(MinPx, PanMinHeight(AboveId));
|
||
FPanSplitMaxTop := FPans[BelowId].StackTop + FPans[BelowId].StackHeight
|
||
- Max(MinPx, PanMinHeight(BelowId)) - PAN_SPLIT_H;
|
||
end;
|
||
if FPanSplitMaxTop < FPanSplitMinTop then FPanSplitMaxTop := FPanSplitMinTop;
|
||
FPanSplitDrag := BelowId;
|
||
FPanSplitY0 := Mouse.CursorPos.Y;
|
||
FPanSplitTop0 := Split.Top;
|
||
Split.Color := TColor($005050A0); // подсветка при захвате (как у внутрипанного)
|
||
end;
|
||
|
||
procedure TMainForm.PanSplitMouseMove(Sender: TObject; Shift: TShiftState;
|
||
X, Y: Integer);
|
||
var NewTop: Integer;
|
||
begin
|
||
// Живой призрак: двигаем сам сплиттер, пересчёт регионов — по MouseUp.
|
||
if (FPanSplitDrag = 0) or (TPanel(Sender).Tag <> FPanSplitDrag) then Exit;
|
||
NewTop := FPanSplitTop0 + (Mouse.CursorPos.Y - FPanSplitY0);
|
||
if NewTop < FPanSplitMinTop then NewTop := FPanSplitMinTop;
|
||
if NewTop > FPanSplitMaxTop then NewTop := FPanSplitMaxTop;
|
||
TPanel(Sender).Top := NewTop;
|
||
end;
|
||
|
||
procedure TMainForm.PanSplitMouseUp(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
var
|
||
BelowId, AboveId, i, DeltaY, TotalH: Integer;
|
||
DShare, MinShare: Double;
|
||
begin
|
||
if FPanSplitDrag = 0 then Exit;
|
||
BelowId := FPanSplitDrag;
|
||
FPanSplitDrag := 0;
|
||
TPanel(Sender).Color := TColor($00303030);
|
||
TotalH := PanelRight.ClientHeight;
|
||
if TotalH <= 0 then Exit;
|
||
if PanGridActive and not PanGridTwoCol then
|
||
begin
|
||
// Грид: сплиттер задаёт долю высоты верхнего ряда (пан 0) напрямую.
|
||
FPanGridRowShare := EnsureRange(TPanel(Sender).Top / TotalH, 0.15, 0.85);
|
||
ResizeSpectrumPanels;
|
||
Exit;
|
||
end;
|
||
// Пан над сплиттером — ближайший активный с меньшим PanId.
|
||
AboveId := -1;
|
||
for i := BelowId - 1 downto 0 do
|
||
if FPans[i] <> nil then begin AboveId := i; Break; end;
|
||
if (AboveId < 0) or (FPans[BelowId] = nil) then Exit;
|
||
// Дельта — по финальному положению призрака (он уже клампился в Move).
|
||
DeltaY := TPanel(Sender).Top - FPanSplitTop0;
|
||
DShare := DeltaY / TotalH;
|
||
MinShare := 0.15;
|
||
if FPanShare[AboveId] + DShare < MinShare then
|
||
DShare := MinShare - FPanShare[AboveId];
|
||
if FPanShare[BelowId] - DShare < MinShare then
|
||
DShare := FPanShare[BelowId] - MinShare;
|
||
FPanShare[AboveId] := FPanShare[AboveId] + DShare;
|
||
FPanShare[BelowId] := FPanShare[BelowId] - DShare;
|
||
ResizeSpectrumPanels;
|
||
end;
|
||
|
||
procedure TMainForm.OnPanGridToggle(Sender: TObject);
|
||
begin
|
||
FPanGridMode := not FPanGridMode;
|
||
ResizeSpectrumPanels;
|
||
end;
|
||
|
||
procedure TMainForm.PanVSplitMouseDown(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
const
|
||
PAN_SPLIT_W = 5;
|
||
var
|
||
RightId, LeftId, i, TotalW, MinPx: Integer;
|
||
Split: TPanel;
|
||
begin
|
||
if (Button <> mbLeft) or not PanGridActive then Exit;
|
||
Split := TPanel(Sender);
|
||
RightId := Split.Tag;
|
||
// Колонка слева — ближайший застекованный пан с меньшим PanId (в гриде из
|
||
// двух колонок левая колонка — сам пан 0, в ряду колонок — доп. пан).
|
||
LeftId := -1;
|
||
for i := RightId - 1 downto IfThen(PanGridTwoCol, 0, 1) do
|
||
if (FPans[i] <> nil) and not PanFloating(i) then begin LeftId := i; Break; end;
|
||
if (LeftId < 0) or (FPans[RightId] = nil) then Exit;
|
||
TotalW := PanelRight.ClientWidth;
|
||
if TotalW <= 0 then Exit;
|
||
// Каждой колонке — минимум 15% ширины (= кламп долей в MouseUp).
|
||
MinPx := Round(0.15 * TotalW);
|
||
FPanVSplitMinLeft := FPans[LeftId].StackLeft + MinPx;
|
||
FPanVSplitMaxLeft := FPans[RightId].StackLeft + FPans[RightId].StackWidth
|
||
- MinPx - PAN_SPLIT_W;
|
||
FPanVSplitDrag := RightId;
|
||
FPanVSplitX0 := Mouse.CursorPos.X;
|
||
FPanVSplitLeft0 := Split.Left;
|
||
Split.Color := TColor($005050A0);
|
||
end;
|
||
|
||
procedure TMainForm.PanVSplitMouseMove(Sender: TObject; Shift: TShiftState;
|
||
X, Y: Integer);
|
||
var NewLeft: Integer;
|
||
begin
|
||
// Живой призрак: двигаем сам сплиттер, пересчёт долей — по MouseUp.
|
||
if (FPanVSplitDrag = 0) or (TPanel(Sender).Tag <> FPanVSplitDrag) then Exit;
|
||
NewLeft := FPanVSplitLeft0 + (Mouse.CursorPos.X - FPanVSplitX0);
|
||
if NewLeft < FPanVSplitMinLeft then NewLeft := FPanVSplitMinLeft;
|
||
if NewLeft > FPanVSplitMaxLeft then NewLeft := FPanVSplitMaxLeft;
|
||
TPanel(Sender).Left := NewLeft;
|
||
end;
|
||
|
||
procedure TMainForm.PanVSplitMouseUp(Sender: TObject; Button: TMouseButton;
|
||
Shift: TShiftState; X, Y: Integer);
|
||
var
|
||
RightId, LeftId, i, DeltaX, TotalW: Integer;
|
||
DShare, MinShare, ShareSum: Double;
|
||
begin
|
||
if FPanVSplitDrag = 0 then Exit;
|
||
RightId := FPanVSplitDrag;
|
||
FPanVSplitDrag := 0;
|
||
TPanel(Sender).Color := TColor($00303030);
|
||
LeftId := -1;
|
||
for i := RightId - 1 downto IfThen(PanGridTwoCol, 0, 1) do
|
||
if (FPans[i] <> nil) and not PanFloating(i) then begin LeftId := i; Break; end;
|
||
if (LeftId < 0) or (FPans[RightId] = nil) then Exit;
|
||
TotalW := PanelRight.ClientWidth;
|
||
if TotalW <= 0 then Exit;
|
||
// Доли колонок нормируются суммой (init 1.0 на пан) — пиксели → доли через неё.
|
||
ShareSum := 0;
|
||
for i := IfThen(PanGridTwoCol, 0, 1) to MAX_PANS - 1 do
|
||
if (FPans[i] <> nil) and not PanFloating(i) then
|
||
ShareSum := ShareSum + FPanColShare[i];
|
||
if ShareSum <= 0 then ShareSum := 1;
|
||
DeltaX := TPanel(Sender).Left - FPanVSplitLeft0;
|
||
DShare := DeltaX / TotalW * ShareSum;
|
||
MinShare := 0.15 * ShareSum;
|
||
if FPanColShare[LeftId] + DShare < MinShare then
|
||
DShare := MinShare - FPanColShare[LeftId];
|
||
if FPanColShare[RightId] - DShare < MinShare then
|
||
DShare := FPanColShare[RightId] - MinShare;
|
||
FPanColShare[LeftId] := FPanColShare[LeftId] + DShare;
|
||
FPanColShare[RightId] := FPanColShare[RightId] - DShare;
|
||
ResizeSpectrumPanels;
|
||
end;
|
||
|
||
procedure TMainForm.OnVfoOverlaySliceSelect(Sender: TObject);
|
||
begin
|
||
if Sender is TVfoOverlay then
|
||
FActiveSliceId := TVfoOverlay(Sender).SliceId;
|
||
end;
|
||
|
||
procedure TMainForm.OnVfoOverlayClose(Sender: TObject);
|
||
var P: TPanafallPanel;
|
||
begin
|
||
if not (Sender is TVfoOverlay) then Exit;
|
||
if TVfoOverlay(Sender).IsMain then Exit;
|
||
// Нельзя освобождать флаг внутри его же обработчика мыши — панель пометит
|
||
// и снимет его после возврата из диспетчера (ProcessPendingSliceClose).
|
||
P := PanOfOverlay(Sender);
|
||
if P <> nil then P.RequestSliceClose(TVfoOverlay(Sender).SliceId);
|
||
end;
|
||
|
||
// FPan закрыл слайс («×») — сбрасываем активный, если это был он.
|
||
procedure TMainForm.OnPanSliceClosed(SliceId: Integer);
|
||
begin
|
||
if FActiveSliceId = SliceId then FActiveSliceId := 0;
|
||
end;
|
||
|
||
procedure TMainForm.OnSampleRateSelect(SampleRate: Integer);
|
||
begin
|
||
// Тонкая обёртка: движок (пересоздание WDSP-канала, DDC-rate, DSP-restore) +
|
||
// персист — в команде SetSampleRate; рендер overlay/AGC — OnControllerState.
|
||
FController.SetSampleRate(SampleRate);
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// Веб-интерфейс: callbacks от TWebServer (вызываются из WS-потока)
|
||
// Все изменения состояния выполняем через TThread.Synchronize чтобы
|
||
// не трогать UI и WDSP из чужого потока.
|
||
// ===========================================================================
|
||
|
||
// Mode/Filter/AGC/AGCTop/Volume/Drive/Span/Band/XvtrBand/Freq/FreqA/FreqB/Center/
|
||
// WfAGC/WfNF/Mute/NR/NB/SNB/ANF/Attn/ClientActiveChanged перенесены в TWebAdapter
|
||
// (чистые команды контроллера).
|
||
|
||
// ── Run ────────────────────────────────────────────────────────────────────
|
||
|
||
procedure TMainForm.WebOnRun(On_: Boolean);
|
||
begin
|
||
FWebSyncBool := On_;
|
||
FWebSyncM := SyncWebRun;
|
||
TThread.Synchronize(nil, FWebSyncM);
|
||
end;
|
||
|
||
procedure TMainForm.SyncWebRun;
|
||
begin
|
||
// set_run отвязан от десктопного модального диалога: web стартует через connect
|
||
// (overlay), а set_run:true без выбранного устройства бессмысленно. Здесь только
|
||
// STOP-путь (set_run:false), симметрично web-disconnect.
|
||
if (not FWebSyncBool) and FController.FNetwork.Connected then
|
||
DoStopAndDisconnect;
|
||
end;
|
||
|
||
// ── Web discovery / connect / disconnect / saved-CRUD ───────────────────────
|
||
|
||
procedure TMainForm.WebOnConnect(const IP: string);
|
||
begin
|
||
FWebSyncStr1 := IP;
|
||
FWebSyncM := SyncWebConnect;
|
||
TThread.Synchronize(nil, FWebSyncM);
|
||
end;
|
||
|
||
procedure TMainForm.SyncWebConnect;
|
||
var
|
||
Dev: THPSDRDevice;
|
||
begin
|
||
if FController.FNetwork.Connected then Exit; // уже подключены
|
||
// Восстанавливаем Kind/URI/Serial/BoardType/MAC из стора (discovered/saved) по
|
||
// IP или URI — как десктоп-путь и демон (ConnectTo(ResolveDevice(...))). Без этого
|
||
// saved Pluto коннектился с Kind=0 (=bkHPSDR) → UDP discovery → Hardware Timeout,
|
||
// хотя web оптимистично показывал "connected / Unknown Board".
|
||
Dev := FController.ResolveDevice(FWebSyncStr1);
|
||
StartWithDevice(Dev);
|
||
end;
|
||
|
||
procedure TMainForm.WebOnDiscover;
|
||
begin
|
||
FWebSyncM := SyncWebDiscover;
|
||
TThread.Synchronize(nil, FWebSyncM);
|
||
end;
|
||
|
||
procedure TMainForm.SyncWebDiscover;
|
||
begin
|
||
SetStatusText(2, 'Discovering...');
|
||
FController.Discover; // rfDeviceList-рендер запушит обновлённый список в web
|
||
end;
|
||
|
||
procedure TMainForm.WebOnDisconnect;
|
||
begin
|
||
FWebSyncM := SyncWebDisconnect;
|
||
TThread.Synchronize(nil, FWebSyncM);
|
||
end;
|
||
|
||
procedure TMainForm.SyncWebDisconnect;
|
||
begin
|
||
if FController.FNetwork.Connected then
|
||
DoStopAndDisconnect;
|
||
end;
|
||
|
||
procedure TMainForm.WebOnDevAdd(const AName, IP: string);
|
||
begin
|
||
FWebSyncStr1 := AName;
|
||
FWebSyncStr2 := IP;
|
||
FWebSyncM := SyncWebDevAdd;
|
||
TThread.Synchronize(nil, FWebSyncM);
|
||
end;
|
||
|
||
procedure TMainForm.SyncWebDevAdd;
|
||
begin
|
||
FController.AddSavedDevice(FWebSyncStr1, FWebSyncStr2); // Pluto по схеме URI
|
||
PushDeviceListToWeb;
|
||
end;
|
||
|
||
procedure TMainForm.WebOnDevRemove(Idx: Integer);
|
||
begin
|
||
FWebSyncInt := Idx;
|
||
FWebSyncM := SyncWebDevRemove;
|
||
TThread.Synchronize(nil, FWebSyncM);
|
||
end;
|
||
|
||
procedure TMainForm.SyncWebDevRemove;
|
||
begin
|
||
FController.FDeviceStore.RemoveSaved(FWebSyncInt);
|
||
PushDeviceListToWeb;
|
||
end;
|
||
|
||
procedure TMainForm.WebOnDevAutoStart(Idx: Integer);
|
||
begin
|
||
FWebSyncInt := Idx;
|
||
FWebSyncM := SyncWebDevAutoStart;
|
||
TThread.Synchronize(nil, FWebSyncM);
|
||
end;
|
||
|
||
procedure TMainForm.SyncWebDevAutoStart;
|
||
begin
|
||
FController.FDeviceStore.SetAutoStart(FWebSyncInt);
|
||
PushDeviceListToWeb;
|
||
end;
|
||
|
||
procedure TMainForm.PushDeviceListToWeb;
|
||
// store (saved+discovered) → FWebServer.SetDeviceList. Saved идут первыми, поэтому
|
||
// их позиция в массиве == saved-индекс (web шлёт его в dev_remove/dev_autostart).
|
||
var
|
||
Arr: TWebDeviceArray;
|
||
i, k: Integer;
|
||
D: TDiscoveredDevice;
|
||
Sv: TSavedDevice;
|
||
begin
|
||
if FWebServer = nil then Exit;
|
||
SetLength(Arr, FController.FDeviceStore.SavedCount +
|
||
FController.FDeviceStore.DiscoveredCount);
|
||
k := 0;
|
||
for i := 0 to FController.FDeviceStore.SavedCount - 1 do
|
||
begin
|
||
Sv := FController.FDeviceStore.Saved(i);
|
||
Arr[k].Name := Sv.Name;
|
||
Arr[k].IP := Sv.IPAddress;
|
||
Arr[k].BoardType := Sv.BoardType;
|
||
Arr[k].IsSaved := True;
|
||
Arr[k].AutoStart := Sv.AutoStart;
|
||
Inc(k);
|
||
end;
|
||
for i := 0 to FController.FDeviceStore.DiscoveredCount - 1 do
|
||
begin
|
||
D := FController.FDeviceStore.Discovered(i);
|
||
Arr[k].Name := D.DisplayName;
|
||
Arr[k].IP := D.IPAddress;
|
||
Arr[k].BoardType := D.BoardType;
|
||
Arr[k].IsSaved := False;
|
||
Arr[k].AutoStart := False;
|
||
Inc(k);
|
||
end;
|
||
FWebServer.SetDeviceList(Arr);
|
||
end;
|
||
|
||
// ── CTUN ──────────────────────────────────────────────────────────────────
|
||
|
||
procedure TMainForm.TrkDriveChange(Sender: TObject);
|
||
begin
|
||
if FSyncingFromController then Exit; // программная установка из OnControllerState — не команда
|
||
FController.SetDrive(TrkDrive.Position); // rfDrive-рендер снимет память канала (OnDriveChanged)
|
||
end;
|
||
|
||
procedure TMainForm.UpdateDriveLabel;
|
||
// Значение рядом с ползунком drive: openHPSDR — проценты, Pluto — TX-аттенюация
|
||
// в дБ (берём из контроллера; маппинг drive%→дБ — там же).
|
||
begin
|
||
if LblDrvVal = nil then Exit;
|
||
if FController.IsPluto then
|
||
LblDrvVal.Caption := Format('%.0fdB', [FController.PlutoTxAttForDrive])
|
||
else
|
||
LblDrvVal.Caption := Format('%d%%', [FController.FDrivePercent]);
|
||
end;
|
||
|
||
function TMainForm.CalcDriveByte: Byte;
|
||
begin
|
||
// Формула — в контроллере (чистая над состоянием). Делегат для существующих
|
||
// вызовов MainForm.
|
||
Result := FController.CalcDriveByte;
|
||
end;
|
||
|
||
procedure TMainForm.OnPASettingsChange(MaxPower: Double;
|
||
const BandCal: array of Double);
|
||
var i: Integer;
|
||
begin
|
||
FController.FPAMaxPower := MaxPower;
|
||
if FSpecView <> nil then FSpecView.PAMaxPower := MaxPower;
|
||
for i := 0 to BAND_COUNT - 1 do
|
||
begin
|
||
if i <= High(BandCal) then
|
||
FController.FPABandCal[i] := BandCal[i]
|
||
else
|
||
FController.FPABandCal[i] := 100.0;
|
||
end;
|
||
FController.FDriveLevel := CalcDriveByte;
|
||
if FController.FNetwork.Connected and FController.FNetwork.Running then
|
||
FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), FController.TXTuneFreqHz, FController.FDriveLevel, FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone);
|
||
end;
|
||
|
||
procedure TMainForm.OnPlutoTxMaxAttChange(MaxAttDb: Double);
|
||
begin
|
||
// Логика (clamp/apply/persist) — в контроллере; UI лишь передаёт значение.
|
||
FController.SetPlutoTxMaxAtt(MaxAttDb);
|
||
UpdateDriveLabel; // изменился потолок → пересчитать дБ-подпись drive
|
||
end;
|
||
|
||
procedure TMainForm.OnCalibrationSettingsChange(const C: TCalibration);
|
||
begin
|
||
// Логика (санитизация/офсет S-метра в DSP/persist) — в контроллере.
|
||
FController.SetCalibration(C);
|
||
end;
|
||
|
||
procedure TMainForm.OnAnt936xChange(const A: TAD936xAnt);
|
||
// Вкладка Antenna (AD936x): таблица «диапазон → разъём». Контроллер сохраняет её
|
||
// и сразу применяет разъёмы текущего диапазона/трансвертера.
|
||
begin
|
||
FController.SetAnt936x(A);
|
||
end;
|
||
|
||
procedure TMainForm.OnVHFCalSettingsChange(const VHFCal: array of Double);
|
||
var i: Integer;
|
||
begin
|
||
for i := 0 to CFG_XVTR_COUNT - 1 do
|
||
begin
|
||
if i <= High(VHFCal) then
|
||
FController.FVHFBandCal[i] := VHFCal[i]
|
||
else
|
||
FController.FVHFBandCal[i] := CFG_VHF_CAL_DEFAULT;
|
||
end;
|
||
FController.FDriveLevel := CalcDriveByte;
|
||
if FController.FNetwork.Connected and FController.FNetwork.Running then
|
||
FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), FController.TXTuneFreqHz, FController.FDriveLevel, FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone);
|
||
end;
|
||
|
||
procedure TMainForm.OnTXSettingsChange(const T: TTXSettings);
|
||
// Любая правка в Settings→Transmit летит сюда: обновляем FController.FTXSettings,
|
||
// перешиваем WDSP TX-цепь и (если поменялись mic-биты) перепосылаем DUC Specific.
|
||
var
|
||
OldMicByte, NewMicByte: Byte;
|
||
OldAtt, OldTUNLevel, OldTUNFreq: Integer;
|
||
GridChanged: Boolean;
|
||
begin
|
||
OldMicByte := FController.BuildMicLineSelectByte;
|
||
OldAtt := FController.FTXSettings.AttOnTX;
|
||
OldTUNLevel := FController.FTXSettings.TUNLevel;
|
||
OldTUNFreq := FController.FTXSettings.TUNFreq;
|
||
GridChanged := (FController.FTXSpecRefLevel <> T.TXSpecRefLevel)
|
||
or (FController.FTXSpecRange <> T.TXSpecRange)
|
||
or (FController.FTXSpecGridStep <> T.TXSpecGridStep);
|
||
FController.FTXSettings := T;
|
||
NewMicByte := FController.BuildMicLineSelectByte;
|
||
// TX-grid в момент правки — обновляем рабочие поля и (если на передаче) пушим в FSpecView
|
||
FController.FTXSpecRefLevel := FController.FTXSettings.TXSpecRefLevel;
|
||
FController.FTXSpecRange := FController.FTXSettings.TXSpecRange;
|
||
if FController.FTXSettings.TXSpecGridStep > 0 then
|
||
FController.FTXSpecGridStep := FController.FTXSettings.TXSpecGridStep;
|
||
if GridChanged and FController.FTransmitting then
|
||
ApplySpecViewGridFromState;
|
||
FController.ApplyTXSettingsToDSP;
|
||
// Low/High из вкладки Transmit — та же полоска фильтра на спектре: кромки
|
||
// берём уже пересчитанными движком (знак боковой) и на передаче перерисовываем.
|
||
PushFilterEdgesToView;
|
||
if FController.FTransmitting then FSpectrumDirty := True;
|
||
if (OldMicByte <> NewMicByte) or (OldAtt <> FController.FTXSettings.AttOnTX) then
|
||
FController.SendDUCSpecificFromSettings;
|
||
// TUN-параметры live: если идёт tune — обновить тон/drive, иначе просто запомнить.
|
||
if FController.FTuning then
|
||
begin
|
||
if (OldTUNFreq <> FController.FTXSettings.TUNFreq) and FController.FWDSPReady then
|
||
FController.FDSPEngine.SetTXTone(True, FController.FTXSettings.TUNFreq, 1.0);
|
||
if OldTUNLevel <> FController.FTXSettings.TUNLevel then
|
||
begin
|
||
FController.FDriveLevel := CalcDriveByte;
|
||
if FController.FWDSPReady then
|
||
FController.FDSPEngine.SetDriveLevel(FController.FTXSettings.TUNLevel / 100.0);
|
||
if FController.FNetwork.Connected and FController.FNetwork.Running then
|
||
begin
|
||
FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), FController.TXTuneFreqHz, FController.FDriveLevel,
|
||
FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone);
|
||
FController.FNetwork.SendFullHP;
|
||
end;
|
||
end;
|
||
end;
|
||
// Правка «звуковых» полей — это правка активного профиля: отдельной кнопки
|
||
// «сохранить» нет, профиль ведётся за живым состоянием (см. TTXProfile).
|
||
FController.StoreActiveTXProfile;
|
||
// Сохраняем сразу — настройки per-device, попадают в hpsdr_settings.json
|
||
if FController.FDevConnected then
|
||
begin
|
||
FController.FSettings.SaveTX(FController.FDevMAC, FController.FTXSettings);
|
||
FController.FSettings.SaveTXProfiles(FController.FDevMAC, FController.FTXProfiles);
|
||
FController.FSettings.Save;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.OnCWSettingsChange(const C: TCWSettings);
|
||
// Телеграф целиком живёт в контроллере: pitch → движок, кейер/сайдтон → в
|
||
// прошивку, персист — там же. Форме остаётся только передать запись.
|
||
begin
|
||
FController.SetCWSettings(C);
|
||
// Скорость кейера = ширина полосы телеграфа на экране. Движок вьюху сам не
|
||
// уведомляет — правило то же, что для профилей и кромок TX.
|
||
PushFilterEdgesToView;
|
||
if FController.FTransmitting then FSpectrumDirty := True;
|
||
// Окно памяти показывает те же ячейки — держим его в курсе правок из настроек.
|
||
if FCWMsgForm <> nil then TCWMessagesForm(FCWMsgForm).LoadFrom(FController.CWSettings);
|
||
end;
|
||
|
||
procedure TMainForm.ShowCWMessages;
|
||
begin
|
||
if FCWMsgForm = nil then
|
||
begin
|
||
FCWMsgForm := TCWMessagesForm.Create(Self);
|
||
TCWMessagesForm(FCWMsgForm).OnSend := OnCWMessageSend;
|
||
TCWMessagesForm(FCWMsgForm).OnAbort := OnCWMessageAbort;
|
||
TCWMessagesForm(FCWMsgForm).OnChanged := OnCWSettingsChange;
|
||
end;
|
||
TCWMessagesForm(FCWMsgForm).LoadFrom(FController.CWSettings);
|
||
TCWMessagesForm(FCWMsgForm).ApplyTheme(CurrentAppTheme);
|
||
TCWMessagesForm(FCWMsgForm).Show;
|
||
end;
|
||
|
||
procedure TMainForm.ShowCWTerminal;
|
||
begin
|
||
if FCWTermForm = nil then
|
||
begin
|
||
FCWTermForm := TCWTerminalForm.Create(Self);
|
||
TCWTerminalForm(FCWTermForm).OnSend := OnCWMessageSend;
|
||
TCWTerminalForm(FCWTermForm).OnAbort := OnCWMessageAbort;
|
||
TCWTerminalForm(FCWTermForm).OnBackspace := OnCWTermBackspace;
|
||
TCWTerminalForm(FCWTermForm).OnDecoder := OnCWTermDecoder;
|
||
TCWTerminalForm(FCWTermForm).OnKbdKey := OnCWTermKbdKey;
|
||
TCWTerminalForm(FCWTermForm).OnSpeed := OnCWTermSpeed;
|
||
TCWTerminalForm(FCWTermForm).OnMacro := OnCWTermMacro;
|
||
// Лента собирается в контроллере (там же эхо своей передачи) и приходит
|
||
// сюда прибавками — окно только рисует.
|
||
FController.OnCWLog := OnCWLogAppend;
|
||
end;
|
||
TCWTerminalForm(FCWTermForm).LoadFrom(FController.CWSettings);
|
||
TCWTerminalForm(FCWTermForm).ApplyTheme(CurrentAppTheme);
|
||
TCWTerminalForm(FCWTermForm).Show;
|
||
end;
|
||
|
||
procedure TMainForm.OnCWLogAppend(const S: string; IsTX: Boolean);
|
||
begin
|
||
if FCWTermForm <> nil then TCWTerminalForm(FCWTermForm).AppendLog(S, IsTX);
|
||
end;
|
||
|
||
procedure TMainForm.OnCWTermBackspace(Sender: TObject);
|
||
begin
|
||
FController.CWXBackspace;
|
||
end;
|
||
|
||
procedure TMainForm.OnCWTermDecoder(Value: Boolean);
|
||
begin
|
||
FController.SetCWDecoder(Value);
|
||
end;
|
||
|
||
procedure TMainForm.OnCWTermKbdKey(Dot, Dash: Boolean);
|
||
begin
|
||
FController.CWKeyboardKey(Dot, Dash);
|
||
end;
|
||
|
||
procedure TMainForm.OnCWTermSpeed(WPM: Integer);
|
||
var C: TCWSettings;
|
||
begin
|
||
C := FController.CWSettings;
|
||
if C.Speed = WPM then Exit;
|
||
C.Speed := WPM;
|
||
FController.SetCWSettings(C);
|
||
end;
|
||
|
||
procedure TMainForm.OnCWTermMacro(Sender: TObject);
|
||
var Slot: Integer;
|
||
begin
|
||
Slot := TComponent(Sender).Tag;
|
||
if (Slot < 0) or (Slot >= CW_MSG_COUNT) then Exit;
|
||
if FController.CWSettings.Messages[Slot] = '' then Exit;
|
||
FController.CWXSend(FController.CWSettings.Messages[Slot]);
|
||
end;
|
||
|
||
procedure TMainForm.ServiceCWTerminal;
|
||
// Таймер: разбор принятого аудио + обновление окна и ленты под спектром.
|
||
// Разбор идёт ВСЕГДА, когда декодер включён, даже если окно закрыто: лента под
|
||
// спектром живёт сама по себе.
|
||
var
|
||
St: string;
|
||
begin
|
||
FController.ServiceCWDecoder;
|
||
if FCWTermForm <> nil then
|
||
begin
|
||
TCWTerminalForm(FCWTermForm).SetPending(FController.CWXPendingText);
|
||
if FController.CWDecoderOn then
|
||
begin
|
||
St := Format('RX %d WPM', [FController.CWDecSpeedWPM]);
|
||
if FController.CWDecToneOffsetHz <> 0 then
|
||
// Знак руками: флага '+' в Format Object Pascal нет, '%+d' съедается
|
||
// молча и в строку попадает голая 'd'.
|
||
St := St + Format(', tone %s%d Hz',
|
||
[IfThen(FController.CWDecToneOffsetHz > 0, '+', ''),
|
||
FController.CWDecToneOffsetHz]);
|
||
if not FController.CWDecSignal then St := St + ', no signal';
|
||
end
|
||
else
|
||
St := 'decoder off';
|
||
// Набор молча уходит в никуда, если телеграф не вооружён (не CW, запрет
|
||
// передачи на бэнде или в слоте трансвертера) — говорим об этом прямо.
|
||
if not FController.CWTXActive then St := St + ' | TX not armed';
|
||
TCWTerminalForm(FCWTermForm).SetStatus(St);
|
||
end;
|
||
if FPans[0] <> nil then
|
||
if FPans[0].SetCWStrip(FController.CWSettings.DecoderStrip and FController.CWDecoderOn,
|
||
FController.CWLogTail(300)) then
|
||
ResizeSpectrumPanels; // строка появилась/пропала — переложить пан
|
||
end;
|
||
|
||
procedure TMainForm.OnCWMessageSend(const Text: string);
|
||
begin
|
||
FController.CWXSend(Text);
|
||
end;
|
||
|
||
procedure TMainForm.OnCWMessageAbort(Sender: TObject);
|
||
begin
|
||
FController.CWXAbort;
|
||
end;
|
||
|
||
procedure TMainForm.FormKeyDownCW(Sender: TObject; var Key: Word;
|
||
Shift: TShiftState);
|
||
// F1..F8 — ячейки памяти телеграфа. Единственное условие — телеграф на
|
||
// передающем источнике; в прочих режимах клавиши свободны для всего другого.
|
||
// ★Текст берём ИЗ НАСТРОЕК, а не из окна памяти: окно создаётся лениво, и
|
||
// раньше клавиши молчали, пока его хоть раз не открыли.
|
||
var Slot: Integer;
|
||
begin
|
||
if Shift <> [] then Exit;
|
||
// F9 — окно телеграфного терминала (лента приёма + набор с клавиатуры).
|
||
if Key = VK_F9 then
|
||
begin
|
||
Key := 0;
|
||
ShowCWTerminal;
|
||
Exit;
|
||
end;
|
||
if (Key < VK_F1) or (Key > VK_F8) then Exit;
|
||
if not (FController.ActiveTXMode in [MODE_CWL, MODE_CWU]) then Exit;
|
||
Slot := Key - VK_F1;
|
||
if (Slot < 0) or (Slot >= CW_MSG_COUNT) then Exit;
|
||
Key := 0;
|
||
if FController.CWSettings.Messages[Slot] = '' then Exit;
|
||
FController.CWXSend(FController.CWSettings.Messages[Slot]);
|
||
end;
|
||
|
||
procedure TMainForm.OnAlexSettingsChange(const A: TAlexSettings);
|
||
begin
|
||
FController.FAlexSettings := A;
|
||
if FController.FNetwork.Connected then
|
||
FController.FNetwork.SetAlexConfig(A);
|
||
if FController.FDevConnected then
|
||
begin
|
||
FController.FSettings.SaveAlex(FController.FDevMAC, A);
|
||
FController.FSettings.Save;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.OnOCSettingsChange(const O: TOCSettings);
|
||
begin
|
||
FController.FOCSettings := O;
|
||
if FController.FNetwork.Connected then
|
||
FController.FNetwork.SetOCConfig(O);
|
||
if FController.FDevConnected then
|
||
begin
|
||
FController.FSettings.SaveOC(FController.FDevMAC, O);
|
||
FController.FSettings.Save;
|
||
end;
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// XVTR (Transverter) integration
|
||
// ===========================================================================
|
||
|
||
// Ищет XVTR слот, в чьё [FreqBegin..FreqEnd] попадает данная видимая частота.
|
||
// Возвращает индекс 0..CFG_XVTR_COUNT-1 или -1.
|
||
function TMainForm.FindXvtrIdxForFreq(FreqHz: Double): Integer;
|
||
var i: Integer;
|
||
begin
|
||
Result := -1;
|
||
for i := 0 to CFG_XVTR_COUNT - 1 do
|
||
with FController.FXvtrSettings.Entries[i] do
|
||
if Enabled and (FreqHz >= FreqBegin) and (FreqHz <= FreqEnd) then
|
||
Exit(i);
|
||
end;
|
||
|
||
// Транслирует видимую частоту в IF (то, что слышит/передаёт трансивер).
|
||
// Если активный XVTR не задан — возвращает входную частоту без изменений.
|
||
function TMainForm.XvtrTranslate(VisibleHz: Double): Double;
|
||
begin
|
||
// Логика перенесена в контроллер; обёртка для существующих вызовов в UI.
|
||
Result := FController.XvtrTranslate(VisibleHz);
|
||
end;
|
||
|
||
function TMainForm.XvtrTranslateTX(VisibleHz: Double): Double;
|
||
begin
|
||
// TX-трансляция (QO-100 split-LO); обёртка над контроллером.
|
||
Result := FController.XvtrTranslateTX(VisibleHz);
|
||
end;
|
||
|
||
// Передаёт XVTR-режим в сетевой слой. Вызывается при смене XVTR или его
|
||
// настроек. Управляет XVTR Enable bit, T/R relay suppression.
|
||
// XVTR DDC In (Alex bits 8+11) управляется через галочку XVTR в Antenna/Alex
|
||
// tab для IF-диапазона (Alex.RxOnly[IF_band]=3) — аналогично Thetis.
|
||
procedure TMainForm.ApplyXvtrToNetwork;
|
||
begin
|
||
// Логика перенесена в контроллер; обёртка для существующих вызовов в UI.
|
||
FController.ApplyXvtrToNetwork;
|
||
end;
|
||
|
||
// Перестраивает динамические XVTR-кнопки в PanelBands. Удаляет старые,
|
||
// создаёт новые для enabled слотов в 3-м и 4-м ряду PanelBands.
|
||
procedure TMainForm.RelayoutBelowBands;
|
||
var
|
||
Y, Gap: Integer;
|
||
begin
|
||
if PanelBands = nil then Exit;
|
||
Gap := Scale96ToForm(2);
|
||
Y := PanelBands.Top + PanelBands.Height + Gap;
|
||
PanelMode.Top := Y; Y := Y + PanelMode.Height + Gap;
|
||
if PanelFilter.Visible then
|
||
begin
|
||
PanelFilter.Top := Y;
|
||
Y := Y + PanelFilter.Height + Gap;
|
||
end;
|
||
if (PanelRawDev <> nil) and PanelRawDev.Visible then
|
||
begin
|
||
PanelRawDev.Top := Y; Y := Y + PanelRawDev.Height + Gap;
|
||
end;
|
||
if (PanelFMStep <> nil) and PanelFMStep.Visible then
|
||
begin
|
||
PanelFMStep.Top := Y; Y := Y + PanelFMStep.Height + Gap;
|
||
end;
|
||
if (PanelFMSQ <> nil) and PanelFMSQ.Visible then
|
||
begin
|
||
PanelFMSQ.Top := Y; Y := Y + PanelFMSQ.Height + Gap;
|
||
end;
|
||
if (PanelFMCTCSS <> nil) and PanelFMCTCSS.Visible then
|
||
begin
|
||
PanelFMCTCSS.Top := Y; Y := Y + PanelFMCTCSS.Height + Gap;
|
||
end;
|
||
if (PanelFMRpt <> nil) and PanelFMRpt.Visible then
|
||
begin
|
||
PanelFMRpt.Top := Y; Y := Y + PanelFMRpt.Height + Gap;
|
||
end;
|
||
UpdateLeftPanelScroll;
|
||
end;
|
||
|
||
function TMainForm.CursorOverLeftPanel: Boolean;
|
||
var
|
||
P: TPoint;
|
||
begin
|
||
Result := False;
|
||
if (PanelLeft = nil) or (not PanelLeft.Visible) then Exit;
|
||
P := PanelLeft.ScreenToClient(Mouse.CursorPos);
|
||
Result := (P.X >= 0) and (P.Y >= 0) and
|
||
(P.X < PanelLeft.ClientWidth) and (P.Y < PanelLeft.ClientHeight);
|
||
end;
|
||
|
||
procedure TMainForm.LeftScrollChanged(Sender: TObject);
|
||
var
|
||
NewValue, Delta: Integer;
|
||
|
||
procedure MovePanel(P: TPanel);
|
||
begin
|
||
if P <> nil then P.Top := P.Top + Delta;
|
||
end;
|
||
|
||
begin
|
||
if FLeftScrollBar = nil then Exit;
|
||
NewValue := FLeftScrollBar.Position;
|
||
if NewValue = FLeftScrollOffset then Exit;
|
||
// Top хранится уже с учётом текущего scroll offset. При движении вниз
|
||
// содержимое уходит вверх, поэтому знак у дельты обратный.
|
||
Delta := FLeftScrollOffset - NewValue;
|
||
FLeftScrollOffset := NewValue;
|
||
|
||
MovePanel(PanelTX);
|
||
MovePanel(PanelRXBlock);
|
||
MovePanel(PanelAGC);
|
||
MovePanel(PanelPlutoAGC);
|
||
MovePanel(PanelBands);
|
||
MovePanel(PanelMode);
|
||
MovePanel(PanelFilter);
|
||
MovePanel(PanelRawDev);
|
||
MovePanel(PanelFMStep);
|
||
MovePanel(PanelFMSQ);
|
||
MovePanel(PanelFMCTCSS);
|
||
MovePanel(PanelFMRpt);
|
||
|
||
if FTXProfileDropDown <> nil then FTXProfileDropDown.ClosePopup;
|
||
if FChannelsDropDown <> nil then FChannelsDropDown.ClosePopup;
|
||
if FCTCSSDropDown <> nil then FCTCSSDropDown.ClosePopup;
|
||
if FStepDropDown <> nil then FStepDropDown.ClosePopup;
|
||
end;
|
||
|
||
procedure TMainForm.UpdateLeftPanelScroll;
|
||
var
|
||
ContentBottom, ViewH, NewMax: Integer;
|
||
|
||
procedure MeasurePanel(P: TPanel);
|
||
var
|
||
B: Integer;
|
||
begin
|
||
if (P = nil) or (not P.Visible) then Exit;
|
||
// Возвращаем логическую позицию до сдвига viewport.
|
||
B := P.Top + FLeftScrollOffset + P.Height;
|
||
if B > ContentBottom then ContentBottom := B;
|
||
end;
|
||
|
||
begin
|
||
if (PanelLeft = nil) or (FLeftScrollBar = nil) then Exit;
|
||
ViewH := PanelLeft.ClientHeight;
|
||
if ViewH <= 0 then Exit;
|
||
|
||
ContentBottom := 0;
|
||
MeasurePanel(PanelTX);
|
||
MeasurePanel(PanelRXBlock);
|
||
MeasurePanel(PanelAGC);
|
||
MeasurePanel(PanelPlutoAGC);
|
||
MeasurePanel(PanelBands);
|
||
MeasurePanel(PanelMode);
|
||
MeasurePanel(PanelFilter);
|
||
MeasurePanel(PanelRawDev);
|
||
MeasurePanel(PanelFMStep);
|
||
MeasurePanel(PanelFMSQ);
|
||
MeasurePanel(PanelFMCTCSS);
|
||
MeasurePanel(PanelFMRpt);
|
||
|
||
NewMax := Max(0, ContentBottom + Scale96ToForm(4) - ViewH);
|
||
FLeftScrollBar.SetRange(NewMax, ViewH);
|
||
end;
|
||
|
||
procedure TMainForm.RebuildXvtrButtons;
|
||
var
|
||
i, Pos, XvtrRows: Integer;
|
||
BtnH, RowH, MaxBottom, RowGap, TopGap, BottomPad: Integer;
|
||
B: TFlatButton;
|
||
Cap: string;
|
||
begin
|
||
// Уничтожаем все старые
|
||
for i := 0 to CFG_XVTR_COUNT - 1 do
|
||
if BtnXvtrBand[i] <> nil then
|
||
begin
|
||
BtnXvtrBand[i].Free;
|
||
BtnXvtrBand[i] := nil;
|
||
end;
|
||
if PanelBands = nil then Exit;
|
||
|
||
// Измеряем фактический bottom последнего ряда обычных кнопок BAND
|
||
// (уже отмасштабированы LCL при показе формы)
|
||
MaxBottom := 0;
|
||
BtnH := 0;
|
||
for i := 0 to BAND_COUNT - 1 do
|
||
if BtnBand[i] <> nil then
|
||
begin
|
||
if BtnBand[i].Top + BtnBand[i].Height > MaxBottom then
|
||
MaxBottom := BtnBand[i].Top + BtnBand[i].Height;
|
||
BtnH := BtnBand[i].Height;
|
||
end;
|
||
if BtnH = 0 then BtnH := DpiScale(24);
|
||
RowGap := PanelBands.Scale96ToForm(3);
|
||
TopGap := PanelBands.Scale96ToForm(4);
|
||
BottomPad := PanelBands.Scale96ToForm(6);
|
||
RowH := BtnH + RowGap;
|
||
|
||
Pos := 0; // позиция среди enabled слотов
|
||
for i := 0 to CFG_XVTR_COUNT - 1 do
|
||
if FController.FXvtrSettings.Entries[i].Enabled then
|
||
begin
|
||
if Pos >= 12 then Break; // 2 ряда по 6 кнопок
|
||
Cap := FController.FXvtrSettings.Entries[i].ButtonText;
|
||
if Cap = '' then Cap := 'X' + IntToStr(i + 1);
|
||
B := TFlatButton.Create(Self);
|
||
B.Parent := PanelBands;
|
||
B.Caption := Cap;
|
||
B.Left := LeftPanelButtonLeft(PanelBands.Width, 6, Pos mod 6);
|
||
B.Top := MaxBottom + TopGap + (Pos div 6) * RowH;
|
||
B.Width := LeftPanelButtonWidth(PanelBands.Width, 6, Pos mod 6);
|
||
B.Height := BtnH;
|
||
B.OnClick := BtnXvtrBandClick;
|
||
B.Tag := -(i + 1); // отрицательное значение = XVTR индекс
|
||
BtnXvtrBand[i] := B;
|
||
StyleButton(B, i = FController.FCurrentXvtr);
|
||
Inc(Pos);
|
||
end;
|
||
// Динамически подгоняем высоту PanelBands под реальное содержимое
|
||
if Pos = 0 then XvtrRows := 0
|
||
else if Pos <= 6 then XvtrRows := 1
|
||
else XvtrRows := 2;
|
||
if XvtrRows = 0 then
|
||
PanelBands.Height := MaxBottom + BottomPad
|
||
else
|
||
PanelBands.Height := MaxBottom + TopGap + XvtrRows * RowH + BottomPad;
|
||
RelayoutBelowBands;
|
||
end;
|
||
|
||
// Обработчик клика по XVTR-кнопке. Tag = -(idx+1).
|
||
procedure TMainForm.BtnXvtrBandClick(Sender: TObject);
|
||
var
|
||
TagVal, Idx: Integer;
|
||
begin
|
||
TagVal := (Sender as TFlatButton).Tag;
|
||
Idx := -TagVal - 1;
|
||
if (Idx < 0) or (Idx >= CFG_XVTR_COUNT) then Exit;
|
||
if not FController.FXvtrSettings.Entries[Idx].Enabled then Exit;
|
||
if FController.FCurrentXvtr = Idx then Exit; // уже на этом XVTR
|
||
// Сохраняем текущий band перед сменой (как BtnBandClick)
|
||
if FController.FDevConnected then
|
||
begin
|
||
SaveCurrentBand;
|
||
FController.FSettings.Save;
|
||
end;
|
||
ActivateXvtrBand(Idx);
|
||
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);
|
||
begin
|
||
FController.FXvtrSettings := X;
|
||
RebuildXvtrButtons;
|
||
// Если активный XVTR стал disabled — выйти из XVTR режима
|
||
if (FController.FCurrentXvtr >= 0) and
|
||
((FController.FCurrentXvtr >= CFG_XVTR_COUNT) or
|
||
(not FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled)) then
|
||
DeactivateXvtr;
|
||
// Перепрошить XVTR в сеть (если параметры текущего изменились)
|
||
ApplyXvtrToNetwork;
|
||
// Правка LO/LOError при живых панах: freq-слова их DDC — через свежую трансляцию.
|
||
FController.RefreshPanDDCs;
|
||
UpdateWidebandFrequencyView;
|
||
if PbWideband <> nil then PbWideband.Invalidate;
|
||
if PbWidebandRuler <> nil then PbWidebandRuler.Invalidate;
|
||
PushXvtrToWeb;
|
||
if FController.FDevConnected then
|
||
begin
|
||
FController.FSettings.SaveXvtr(FController.FDevMAC, FController.FXvtrSettings);
|
||
FController.FSettings.Save;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.PushRatePresetsToWeb;
|
||
// Пресеты sample-rate бэкенда → web (HPSDR/Pluto разные). Единый источник —
|
||
// FController.SampleRatePresets, как и для desktop-оверлея.
|
||
begin
|
||
if FWebServer = nil then Exit;
|
||
FWebServer.SetRatePresets(FController.SampleRatePresets);
|
||
end;
|
||
|
||
procedure TMainForm.PushBandsToWeb;
|
||
// Band-план бэкенда → web (HF / VHF-UHF). Единый источник — FController.BandPlan,
|
||
// тот же BoardUtils-план, что и для desktop-кнопок (RelabelBands).
|
||
var
|
||
Plan: TBandPlanArray;
|
||
Names: array of string;
|
||
Freqs: array of Double;
|
||
i: Integer;
|
||
begin
|
||
if FWebServer = nil then Exit;
|
||
Plan := FController.BandPlan;
|
||
SetLength(Names, Length(Plan));
|
||
SetLength(Freqs, Length(Plan));
|
||
for i := 0 to High(Plan) do
|
||
begin
|
||
Names[i] := Plan[i].Name;
|
||
Freqs[i] := Plan[i].FreqHz;
|
||
end;
|
||
FWebServer.SetBands(Names, Freqs);
|
||
with FController.BackendCaps do FWebServer.SetFreqRange(MinFreqHz, MaxFreqHz);
|
||
end;
|
||
|
||
procedure TMainForm.PushXvtrToWeb;
|
||
var
|
||
Arr: TWebXvtrArray;
|
||
i, Cnt: Integer;
|
||
begin
|
||
if FWebServer = nil then Exit;
|
||
Cnt := 0;
|
||
for i := 0 to CFG_XVTR_COUNT - 1 do
|
||
if FController.FXvtrSettings.Entries[i].Enabled then Inc(Cnt);
|
||
SetLength(Arr, Cnt);
|
||
Cnt := 0;
|
||
for i := 0 to CFG_XVTR_COUNT - 1 do
|
||
if FController.FXvtrSettings.Entries[i].Enabled then
|
||
begin
|
||
Arr[Cnt].Idx := i;
|
||
Arr[Cnt].Name := FController.FXvtrSettings.Entries[i].ButtonText;
|
||
Inc(Cnt);
|
||
end;
|
||
FWebServer.SetXvtrBands(Arr, FController.FCurrentXvtr);
|
||
end;
|
||
|
||
procedure TMainForm.TrkVolumeChange(Sender: TObject);
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
FController.SetVolume(TrkVolume.Position);
|
||
UpdateVolumeLabel;
|
||
end;
|
||
|
||
procedure TMainForm.UpdateVolumeLabel;
|
||
begin
|
||
if LblVolume <> nil then
|
||
LblVolume.Caption := Format('%d%%', [FController.ActiveVolume]);
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// Mouse wheel — перестройка VFO
|
||
// ===========================================================================
|
||
|
||
procedure TMainForm.FormMouseWheel(Sender: TObject; Shift: TShiftState;
|
||
WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean);
|
||
// Колёсико на форме/спектре/водопаде — меняет активный VFO с квантованием
|
||
// Шаг зависит от модификаторов: Ctrl+Shift=1МГц, Ctrl=1кГц, Shift=10Гц, иначе 100Гц
|
||
// Квантование: результат выровнен до кратного шагу (как в FreqDisplay)
|
||
// CTUN логика: через ApplyVfoA
|
||
function OverFreqDisp(C: TControl): Boolean;
|
||
var P: TPoint;
|
||
begin
|
||
Result := False;
|
||
if (C = nil) or (not C.Visible) then Exit;
|
||
// Mouse.CursorPos — надёжные экранные координаты (переданный MousePos на
|
||
// Cocoa может быть не в экранной системе).
|
||
P := C.ScreenToClient(Mouse.CursorPos);
|
||
Result := (P.X >= 0) and (P.Y >= 0) and (P.X < C.Width) and (P.Y < C.Height);
|
||
end;
|
||
var
|
||
Step: Int64;
|
||
Delta: Int64;
|
||
Base: Int64;
|
||
NewFreq: Int64;
|
||
SliceId, i: Integer;
|
||
SLo, SHi, SNew: Double;
|
||
Sl: TCtrlSlice;
|
||
SlicePan: TPanafallPanel;
|
||
P: TPoint;
|
||
begin
|
||
// Над дисплеем VFO (A или B) колесо обрабатывает сам TFreqDisplay поразрядно
|
||
// (включая неактивный VFO). Не дублируем здесь, иначе менялись бы обе частоты:
|
||
// виджет крутил бы B, а этот хендлер — активный A.
|
||
if OverFreqDisp(FreqDispA) or OverFreqDisp(FreqDispB) then Exit;
|
||
|
||
// Если левая колонка не помещается, колесо над любой её частью прокручивает
|
||
// колонку, а не частоту. Scrollbar остаётся overlay и появляется по hover.
|
||
if CursorOverLeftPanel then
|
||
begin
|
||
UpdateLeftPanelScroll;
|
||
if (FLeftScrollBar <> nil) and (FLeftScrollBar.Maximum > 0) then
|
||
begin
|
||
if WheelDelta > 0 then
|
||
FLeftScrollBar.ScrollBy(-Scale96ToForm(64))
|
||
else if WheelDelta < 0 then
|
||
FLeftScrollBar.ScrollBy(Scale96ToForm(64));
|
||
Handled := True;
|
||
Exit;
|
||
end;
|
||
end;
|
||
|
||
// Колесо над раскрытым списком устройств во флаге — прокрутка этого списка.
|
||
for i := 0 to MAX_PANS - 1 do
|
||
if (FPans[i] <> nil) and Assigned(FPans[i].PbSpectrum) and
|
||
FPans[i].PbSpectrum.Visible then
|
||
begin
|
||
P := FPans[i].PbSpectrum.ScreenToClient(Mouse.CursorPos);
|
||
if FPans[i].DispatchFlagsMouseWheel(P.X, P.Y, WheelDelta) then
|
||
begin
|
||
MarkPanDirty(FPans[i]);
|
||
Handled := True;
|
||
Exit;
|
||
end;
|
||
end;
|
||
|
||
// Колесо над маркером слайса (любого пана) — двигаем частоту ЭТОГО слайса.
|
||
SliceId := 0;
|
||
SlicePan := nil;
|
||
for i := 0 to MAX_PANS - 1 do
|
||
if FPans[i] <> nil then
|
||
begin
|
||
SliceId := FPans[i].SliceUnderCursor;
|
||
if SliceId <> 0 then begin SlicePan := FPans[i]; Break; end;
|
||
end;
|
||
// Иначе колесо над спектром/водопадом ДОП. пана крутит его главный слайс
|
||
// (первый созданный на этом приёмнике), а не общий VFO. Пан 0 — общий VFO.
|
||
if SliceId = 0 then
|
||
for i := 1 to MAX_PANS - 1 do
|
||
if (FPans[i] <> nil) and FPans[i].CursorOverDisplay then
|
||
begin
|
||
SliceId := FController.PanPrimarySliceId(i);
|
||
if SliceId <> 0 then SlicePan := FPans[i];
|
||
Break;
|
||
end;
|
||
if (SliceId <> 0) and FController.GetSlice(SliceId, Sl) then
|
||
begin
|
||
// Канальные режимы: шаг = выбранный шаг сетки (тот же, что рисуется на
|
||
// пане), как на главном панадаптере. Ctrl/Shift — обычные мелкие шаги.
|
||
if (Sl.Mode in [MODE_FM, MODE_DMR, MODE_FMRAW]) and
|
||
FController.FFMStepOn and
|
||
not ((ssCtrl in Shift) or (ssShift in Shift)) then
|
||
Step := FM_STEP_HZ[FController.FFMStepIdx]
|
||
else if (ssCtrl in Shift) and (ssShift in Shift) then Step := 1000000
|
||
else if ssCtrl in Shift then Step := 1000
|
||
else if ssShift in Shift then Step := 10
|
||
else Step := 100;
|
||
Delta := 1;
|
||
if WheelDelta < 0 then Delta := -1;
|
||
// Квантование к сетке шага + шаг в сторону колеса.
|
||
Base := (Round(Sl.TargetHz) div Step) * Step;
|
||
if Delta > 0 then SNew := Base + Step
|
||
else if Round(Sl.TargetHz) = Base then SNew := Base - Step
|
||
else SNew := Base;
|
||
FController.CapturedSpan(SLo, SHi, Sl.PanId); // полоса ЕГО пана
|
||
if SNew < SLo then SNew := SLo;
|
||
if SNew > SHi then SNew := SHi;
|
||
FController.SetSliceTarget(SliceId, SNew);
|
||
SlicePan.PushSliceFlagState(SliceId);
|
||
SlicePan.LayoutFlags;
|
||
MarkPanDirty(SlicePan);
|
||
FActiveSliceId := SliceId;
|
||
Handled := True;
|
||
Exit;
|
||
end;
|
||
if (FController.FMode in [MODE_FM, MODE_DMR, MODE_FMRAW]) and
|
||
FController.FFMStepOn and
|
||
not ((ssCtrl in Shift) or (ssShift in Shift)) then
|
||
Step := FM_STEP_HZ[FController.FFMStepIdx]
|
||
else if (ssCtrl in Shift) and (ssShift in Shift) then Step := 1000000
|
||
else if ssCtrl in Shift then Step := 1000
|
||
else if ssShift in Shift then Step := 10
|
||
else Step := 100;
|
||
|
||
Delta := 1;
|
||
if WheelDelta < 0 then Delta := -1;
|
||
|
||
// Квантование (та же логика что в FreqDisplay.ChangeByDigit) — от частоты
|
||
// АКТИВНОГО VFO, а не всегда VFO A.
|
||
Base := (ActiveVfoFreq div Step) * Step;
|
||
if Delta > 0 then
|
||
NewFreq := Base + Step
|
||
else
|
||
begin
|
||
if ActiveVfoFreq = Base then
|
||
NewFreq := Base - Step
|
||
else
|
||
NewFreq := Base; // снэп к нижней границе
|
||
end;
|
||
|
||
// Клэмп диапазона перестройки. XVTR — ApplyVfoA сам ограничит по FreqBegin/End.
|
||
// Pluto — по диапазону бэкенда (Caps). Иначе HF (30 кГц..60 МГц).
|
||
if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) and
|
||
FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled then
|
||
NewFreq := Max(0, NewFreq)
|
||
else if FController.IsPluto then
|
||
NewFreq := Max(Round(FController.BackendCaps.MinFreqHz),
|
||
Min(Round(FController.BackendCaps.MaxFreqHz), NewFreq))
|
||
else
|
||
NewFreq := Max(30000, Min(60000000, NewFreq));
|
||
if FController.FActiveVfo = 0 then
|
||
ApplyVfoA(NewFreq)
|
||
else
|
||
FController.SetVfoB(NewFreq); // крутим активный VFO B
|
||
|
||
Handled := True;
|
||
end;
|
||
|
||
|
||
procedure TMainForm.BtnAGCModeClick(Sender: TObject);
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
FController.SetAGCMode((Sender as TFlatButton).Tag);
|
||
end;
|
||
|
||
procedure TMainForm.TrkAGCChange(Sender: TObject);
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
FController.SetAGCTop(TrkAGC.Position);
|
||
end;
|
||
|
||
procedure TMainForm.TrkRxGainChange(Sender: TObject);
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
FController.SetRxGain(TrkRxGain.Position); // переводит в manual
|
||
end;
|
||
|
||
procedure TMainForm.TrkAttChange(Sender: TObject);
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
FController.SetAtten(TrkAtt.Position);
|
||
UpdateAttLabel;
|
||
end;
|
||
|
||
procedure TMainForm.UpdateAttLabel;
|
||
begin
|
||
if LblAttVal <> nil then
|
||
LblAttVal.Caption := Format('%ddB', [FController.FAtten]);
|
||
end;
|
||
|
||
procedure TMainForm.BtnPlutoGainClick(Sender: TObject);
|
||
begin
|
||
if FSyncingFromController then Exit;
|
||
FController.SetRxGainMode((Sender as TFlatButton).Tag); // Tag = индекс режима
|
||
end;
|
||
|
||
procedure TMainForm.UpdateRxGainUI;
|
||
// Рендер Pluto RF-AGC блока: подсветка режима + слайдер/значение manual gain.
|
||
var i: Integer;
|
||
begin
|
||
if PanelPlutoAGC = nil then Exit;
|
||
FSyncingFromController := True;
|
||
try
|
||
for i := 0 to 3 do
|
||
if BtnPlutoGain[i] <> nil then
|
||
StyleButton(BtnPlutoGain[i], BtnPlutoGain[i].Tag = FController.FRxGainMode);
|
||
TrkRxGain.Enabled := (FController.FRxGainMode = 0); // активен только в manual
|
||
if FController.FRxGainMode = 0 then
|
||
begin
|
||
TrkRxGain.Position := FController.FRxGainDb;
|
||
LblRxGain.Caption := Format('%ddB', [FController.FRxGainDb]);
|
||
end
|
||
else if FController.FLastPlutoGainDb > TELEMETRY_NONE then
|
||
begin
|
||
// AGC: показываем ФАКТИЧЕСКОЕ усиление железа (опрос ~2 Гц) — ползунок
|
||
// ездит сам, тильда отличает измеренное от заданного. На это же значение
|
||
// компенсируются уровни (FrontEndCalDB), поэтому шкала dBm стоит.
|
||
TrkRxGain.Position := EnsureRange(Round(FController.FLastPlutoGainDb), 0, 73);
|
||
LblRxGain.Caption := Format('~%ddB', [Round(FController.FLastPlutoGainDb)]);
|
||
end
|
||
else
|
||
LblRxGain.Caption := 'AGC';
|
||
finally
|
||
FSyncingFromController := False;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.LayoutLeftPanel(Pluto: Boolean);
|
||
// Показывает/скрывает Pluto RF-AGC блок под AGC и сдвигает панели ниже
|
||
// (Bands и далее через RelayoutBelowBands).
|
||
var
|
||
baseTop, Gap2, Gap4: Integer;
|
||
begin
|
||
if (PanelPlutoAGC = nil) or (PanelAGC = nil) or (PanelBands = nil) then Exit;
|
||
// BuildUI создаёт геометрию в 96-DPI координатах, после чего LCL
|
||
// масштабирует её вместе с формой. Эта перекладка выполняется позднее,
|
||
// уже после подключения устройства, поэтому её литеральные размеры тоже
|
||
// нужно перевести в текущий DPI формы. Иначе при 125% RX-панель снова
|
||
// получала Height=96, а её уже масштабированный ряд NR/NB/... обрезался.
|
||
Gap2 := Scale96ToForm(2);
|
||
Gap4 := Scale96ToForm(4);
|
||
// Шаговый аттенюатор — только у openHPSDR; у AD936x усиление тракта в блоке
|
||
// RF AGC ниже, поэтому ряд ATT убираем и RX-блок ужимаем.
|
||
if (TrkAtt <> nil) and (LblAtt <> nil) and (LblAttVal <> nil) then
|
||
begin
|
||
TrkAtt.Visible := not Pluto;
|
||
LblAtt.Visible := not Pluto;
|
||
LblAttVal.Visible := not Pluto;
|
||
if Pluto then PanelRXBlock.Height := Scale96ToForm(RX_BLOCK_H)
|
||
else PanelRXBlock.Height := Scale96ToForm(RX_BLOCK_H_ATT);
|
||
PanelAGC.Top := PanelRXBlock.Top + PanelRXBlock.Height + Gap4;
|
||
end;
|
||
baseTop := PanelAGC.Top + PanelAGC.Height + Gap4;
|
||
PanelPlutoAGC.Top := baseTop;
|
||
PanelPlutoAGC.Visible := Pluto;
|
||
if Pluto then
|
||
PanelBands.Top := baseTop + PanelPlutoAGC.Height + Gap2
|
||
else
|
||
PanelBands.Top := baseTop;
|
||
RelayoutBelowBands;
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// DSP/Audio callbacks — вызываются из рабочих потоков
|
||
// ===========================================================================
|
||
|
||
procedure TMainForm.OnSpectrumReady(const Pixels: array of Single;
|
||
Count: Integer);
|
||
// Подписчик на FController.OnSpectrumData (сырьё из DSP-потока) — только рендер;
|
||
// web-снимок держит контроллер (FSpectrumBuf).
|
||
begin
|
||
FSpecView.SetSpectrumData(Pixels, Count);
|
||
FSpectrumDirty := True;
|
||
end;
|
||
|
||
procedure TMainForm.OnWaterfallReady(const Pixels: array of Single;
|
||
Count: Integer);
|
||
// Подписчик на FController.OnWaterfallData. Каждый кадр уходит во view —
|
||
// замедление/накопление (max-hold) делает сам TWaterfallView по
|
||
// WfFrameInterval; рендер дёргаем только когда view собрал строку.
|
||
begin
|
||
FSpecView.SetWaterfallData(Pixels, Count);
|
||
if FSpecView.WaterfallDirty then FWaterfallDirty := True;
|
||
end;
|
||
|
||
// ===========================================================================
|
||
// Settings apply methods (вызываются из TSettingsForm)
|
||
// ===========================================================================
|
||
|
||
procedure TMainForm.ApplyDisplayParams(FFTSize, WinType, SpecDet, SpecAvgMode: Integer;
|
||
SpecAvgTimeMS: Double);
|
||
begin
|
||
if FController.FWDSPReady then
|
||
FController.FDSPEngine.SetSpectrumDisplay(SpecDet, SpecAvgMode, SpecAvgTimeMS);
|
||
if FController.FWDSPReady then
|
||
FController.FDSPEngine.SetFFTParams(FFTSize, WinType);
|
||
FSpectrumDirty := True;
|
||
end;
|
||
|
||
procedure TMainForm.ApplyWaterfallParams(WfDet, WfAvgMode: Integer;
|
||
WfAvgTimeMS, WfHigh, WfLow, WfAGCOffset: Double);
|
||
begin
|
||
if FController.FWDSPReady then
|
||
FController.FDSPEngine.SetWaterfallDisplay(WfDet, WfAvgMode, WfAvgTimeMS);
|
||
FController.FWfManualHigh := WfHigh;
|
||
FController.FWfManualLow := WfLow;
|
||
FController.FWfAGCOffset := WfAGCOffset;
|
||
FSpecView.WfManualHigh := FController.FWfManualHigh;
|
||
FSpecView.WfManualLow := FController.FWfManualLow;
|
||
FSpecView.WfAGCOffset := FController.FWfAGCOffset;
|
||
FSpecView.ResetWfAvgBuf;
|
||
FSpectrumDirty := True;
|
||
end;
|
||
|
||
procedure TMainForm.ApplyWfRenderParams(Palette: Integer; Gamma: Double;
|
||
ResLimit, SpeedDiv: Integer);
|
||
begin
|
||
FController.FWfPalette := Palette;
|
||
FController.FWfGamma := Gamma;
|
||
FController.FDisplayPixels := ResLimit;
|
||
FController.FWfSpeedDiv := SpeedDiv;
|
||
FSpecView.WfPalette := Palette;
|
||
FSpecView.WfGamma := Gamma;
|
||
FSpecView.WfFrameInterval := EffectiveWfInterval;
|
||
if FController.FWDSPReady then
|
||
FController.FDSPEngine.SetDisplayResLimit(ResLimit);
|
||
FWaterfallDirty := True;
|
||
FSpectrumDirty := True;
|
||
end;
|
||
|
||
procedure TMainForm.ApplySpecMSAA(Samples: Integer);
|
||
begin
|
||
Samples := EnsureRange(Samples, 1, 8);
|
||
FController.FSpecMSAA := Samples;
|
||
if FUseOpenGLSpectrum and (PbSpectrum is TOpenGLControl) and
|
||
(TOpenGLControl(PbSpectrum).MultiSampling <> Cardinal(Samples)) then
|
||
begin
|
||
// Смена MSAA пересоздаёт хэндл → старый GL-контекст (и все текстуры)
|
||
// умирает; кэши view нужно обнулить БЕЗ glDelete.
|
||
TOpenGLControl(PbSpectrum).MultiSampling := Samples;
|
||
if FSpecView is TSpectrumViewOpenGL then
|
||
TSpectrumViewOpenGL(FSpecView).ResetGLCache;
|
||
FSpectrumDirty := True;
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.ApplyGridParams(RefLevel, Range, GridStep: Double);
|
||
begin
|
||
FController.FSpecRefLevel := RefLevel;
|
||
FController.FSpecRange := Range;
|
||
if GridStep > 0 then FController.FSpecGridStep := GridStep;
|
||
// На передаче FSpecView показывает TX-сетку — RX-правка не должна её перетирать.
|
||
if not FController.FTransmitting then ApplySpecViewGridFromState;
|
||
end;
|
||
|
||
procedure TMainForm.ApplySpecViewGridFromState;
|
||
begin
|
||
// При DUP во время TX данные приходят от RX-анализатора → используем RX-сетку.
|
||
if FController.FTransmitting and not FController.FDisplayDuplex then
|
||
begin
|
||
FSpecView.SpecRefLevel := FController.FTXSpecRefLevel;
|
||
FSpecView.SpecRange := FController.FTXSpecRange;
|
||
FSpecView.SpecGridStep := FController.FTXSpecGridStep;
|
||
end else
|
||
begin
|
||
FSpecView.SpecRefLevel := FController.FSpecRefLevel;
|
||
FSpecView.SpecRange := FController.FSpecRange;
|
||
FSpecView.SpecGridStep := FController.FSpecGridStep;
|
||
end;
|
||
InvalidateGridCache;
|
||
FSpectrumDirty := True;
|
||
end;
|
||
|
||
procedure TMainForm.ApplyAudioDevice(DevIndex: Integer; const DevName: string);
|
||
begin
|
||
if FController.FAudioOut.IsOpen then
|
||
FController.FAudioOut.Close;
|
||
FController.FAudioOut.DeviceIndex := DevIndex;
|
||
FController.FAudioOutDevName := DevName;
|
||
try
|
||
FController.FAudioOut.Open;
|
||
except
|
||
end;
|
||
end;
|
||
|
||
procedure TMainForm.ApplyAudioInputDevice(DevIndex: Integer; const DevName: string);
|
||
// Общий вход: контроллер сам переоткрывает поток и перепривязывает к нему
|
||
// слайсы, которые ссылались на общее устройство (mic-пул).
|
||
begin
|
||
FController.SetMainMicDevice(DevIndex, DevName);
|
||
end;
|
||
|
||
procedure TMainForm.ApplyAudioBufferSize(BufferSize: Integer);
|
||
var
|
||
WasOpen: Boolean;
|
||
begin
|
||
if BufferSize <= 128 then BufferSize := 128
|
||
else if BufferSize <= 256 then BufferSize := 256
|
||
else BufferSize := 512;
|
||
if FController.FAudioOut.OutputBufferSize = BufferSize then Exit;
|
||
|
||
WasOpen := FController.FAudioOut.IsOpen;
|
||
if WasOpen then FController.FAudioOut.Close;
|
||
FController.FAudioOut.OutputBufferSize := BufferSize;
|
||
if WasOpen then
|
||
begin
|
||
try
|
||
FController.FAudioOut.Open;
|
||
except
|
||
end;
|
||
end;
|
||
FController.FSettings.SaveAudioBufferSize(BufferSize);
|
||
FController.FSettings.Save;
|
||
end;
|
||
|
||
procedure TMainForm.ApplySendAudioToRadio(Enabled: Boolean);
|
||
begin
|
||
FController.FSendAudioToRadio := Enabled;
|
||
// Один переключатель: вкл = слать поток + снять мьют динамика,
|
||
// выкл = замьютить динамик (byte 1400 bit1) и не слать поток.
|
||
FController.FNetwork.SetSpeakerAudio(Enabled);
|
||
if FController.FDevConnected then
|
||
FController.SaveGlobalSettings;
|
||
end;
|
||
|
||
procedure TMainForm.ApplyVisibility(ShowSpectrum, ShowWaterfall,
|
||
ShowWideband, WidebandFill, SpectrumFill: Boolean);
|
||
begin
|
||
FController.FShowSpectrum := ShowSpectrum;
|
||
FController.FShowWaterfall := ShowWaterfall;
|
||
FController.FShowWideband := ShowWideband;
|
||
FController.FWidebandFill := WidebandFill;
|
||
FController.FSpectrumFill := SpectrumFill;
|
||
if Assigned(FWidebandView) then
|
||
FWidebandView.SetFillSpectrum(FController.FWidebandFill);
|
||
FSpecView.FillSpectrum := FController.FSpectrumFill;
|
||
if Assigned(FController.FNetwork) and FController.FNetwork.Connected then
|
||
FController.FNetwork.ConfigureWideband(0, FController.FShowWideband, 512, 16, 70, 32);
|
||
ResizeSpectrumPanels;
|
||
end;
|
||
|
||
procedure TMainForm.ApplyFPS(FPS: Integer);
|
||
begin
|
||
if FPS < 1 then FPS := 1;
|
||
if FPS > 100 then FPS := 100;
|
||
FDisplayFPS := FPS;
|
||
FController.FLoadedGlobal.DisplayFPS := FPS; // зеркало в persist-буфер контроллера
|
||
FSpectrumTimer.Interval := 1000 div FPS;
|
||
FWaterfallDirty := True;
|
||
FSpecView.WfFrameInterval := EffectiveWfInterval;
|
||
if FController.FWDSPReady then
|
||
FController.FDSPEngine.SetDisplayFPS(FPS);
|
||
end;
|
||
|
||
function TMainForm.EffectiveWfInterval: Integer;
|
||
// Делитель скорости водопада: пользовательский Speed (max-hold накопление
|
||
// в TWaterfallView); CPU-путь дополнительно не быстрее полукадровой частоты
|
||
// (исторически — экономия на полной перерисовке bitmap).
|
||
begin
|
||
Result := Max(1, FController.FWfSpeedDiv);
|
||
if not FUseOpenGLSpectrum then Result := Max(Result, 2);
|
||
end;
|
||
|
||
procedure TMainForm.ApplyFreqMhzDigits(Digits: Integer);
|
||
begin
|
||
Digits := EnsureRange(Digits, 3, 5);
|
||
FFreqMhzDigits := Digits;
|
||
FController.FLoadedGlobal.FreqMhzDigits := Digits; // зеркало в persist-буфер контроллера
|
||
FreqDispA.MinMhzDigits := Digits;
|
||
FreqDispB.MinMhzDigits := Digits;
|
||
UpdateFreqDisplayMax; // верхняя граница: активный XVTR / Pluto Caps / HF по цифрам
|
||
if Assigned(FWebServer) then
|
||
FWebServer.FreqMhzDigits := Digits;
|
||
LayoutTopVfoBlock;
|
||
end;
|
||
|
||
procedure TMainForm.UpdateFreqDisplayMax;
|
||
// Верхняя граница FreqDisplay. Активный трансвертер (XVTR/QO-100) может показывать
|
||
// частоты выше диапазона железа (напр. QO-100 — 10.5 ГГц) → берём его FreqEnd.
|
||
// Иначе Pluto — из Caps (до 6 ГГц), HPSDR — по числу MHz-цифр.
|
||
const
|
||
MaxFreqs: array[3..5] of Int64 = (999999999, 9999999999, 99999999999);
|
||
var
|
||
hi: Int64; xi: Integer;
|
||
begin
|
||
xi := FController.FCurrentXvtr;
|
||
if (xi >= 0) and (xi < CFG_XVTR_COUNT) then
|
||
hi := Round(FController.FXvtrSettings.Entries[xi].FreqEnd) + 1000000 // +1 МГц запас
|
||
else if FController.IsPluto then
|
||
hi := Round(FController.BackendCaps.MaxFreqHz)
|
||
else
|
||
hi := MaxFreqs[EnsureRange(FFreqMhzDigits, 3, 5)];
|
||
if hi < 1000000 then hi := MaxFreqs[3];
|
||
if FreqDispA <> nil then FreqDispA.MaxFreq := hi;
|
||
if FreqDispB <> nil then FreqDispB.MaxFreq := hi;
|
||
end;
|
||
|
||
procedure TMainForm.BtnSettingsClick(Sender: TObject);
|
||
var
|
||
SF: TSettingsForm;
|
||
begin
|
||
if not Assigned(FSettingsForm) then
|
||
begin
|
||
SF := TSettingsForm.Create(Self);
|
||
FSettingsForm := SF;
|
||
SF.OnDisplayChange := ApplyDisplayParams;
|
||
SF.OnWaterfallChange := ApplyWaterfallParams;
|
||
SF.OnWfRenderChange := ApplyWfRenderParams;
|
||
SF.OnSpecMSAAChange := ApplySpecMSAA;
|
||
SF.OnGridChange := ApplyGridParams;
|
||
SF.OnAudioDevChange := ApplyAudioDevice;
|
||
SF.OnAudioInDevChange := ApplyAudioInputDevice;
|
||
SF.OnAudioBufferChange := ApplyAudioBufferSize;
|
||
SF.OnSendAudioToRadioChange := ApplySendAudioToRadio;
|
||
SF.OnVisibilityChange := ApplyVisibility;
|
||
SF.OnFPSChange := ApplyFPS;
|
||
SF.OnPAChange := OnPASettingsChange;
|
||
SF.OnCalibrationChange := OnCalibrationSettingsChange;
|
||
SF.OnPlutoTxChange := OnPlutoTxMaxAttChange;
|
||
SF.OnAnt936xChange := OnAnt936xChange;
|
||
SF.OnVHFCalChange := OnVHFCalSettingsChange;
|
||
SF.OnCATChange := OnCATSettingsChange;
|
||
SF.OnSliceCATChange := OnSliceCATSettingsChange;
|
||
SF.OnThemeChange := SetLightTheme;
|
||
SF.OnFreqMhzDigitsChange := ApplyFreqMhzDigits;
|
||
SF.OnTXChange := OnTXSettingsChange;
|
||
SF.OnCWChange := OnCWSettingsChange;
|
||
SF.OnCWMessagesOpen := ShowCWMessages;
|
||
SF.OnCWTerminalOpen := ShowCWTerminal;
|
||
SF.OnTXProfileSelect := OnTXProfileSelectFromSettings;
|
||
SF.OnTXProfileAdd := OnTXProfileAddFromSettings;
|
||
SF.OnTXProfileRename := OnTXProfileRenameFromSettings;
|
||
SF.OnTXProfileDelete := OnTXProfileDeleteFromSettings;
|
||
SF.OnTXProfileFactory := OnTXProfileFactoryFromSettings;
|
||
SF.OnAlexChange := OnAlexSettingsChange;
|
||
SF.OnXvtrChange := OnXvtrSettingsChange;
|
||
SF.OnOCChange := OnOCSettingsChange;
|
||
SF.OnWfAGCNFChange := ApplyWfAGCNF;
|
||
SF.OnADCChange := ApplyADCSettings;
|
||
SF.OnWebSettingsChange := ApplyWebSettings;
|
||
end;
|
||
SF := TSettingsForm(FSettingsForm);
|
||
PushTXProfilesToSettings;
|
||
SF.LoadTXSettings(FController.FTXSettings,
|
||
IfThen(FController.FNetwork.Connected, FController.FNetwork.Device.BoardType, FPendingBoardType));
|
||
// Телеграф: есть ли кейер в железе. У AD936x его нет — там телеграф может
|
||
// быть только программным, и настройки прошивки в группе гасятся.
|
||
SF.SetCWBackend(FController.BackendCaps.HasCWKeyer);
|
||
SF.LoadCWSettings(FController.CWSettings);
|
||
SF.LoadAlexSettings(FController.FAlexSettings,
|
||
IfThen(FController.FNetwork.Connected, FController.FNetwork.Device.BoardType, FPendingBoardType));
|
||
// Вкладка Antenna: у AD936x (Pluto/LibreSDR) вместо Alex-таблицы — таблица
|
||
// физических разъёмов на диапазон/трансвертер (если разъёмов две пары).
|
||
SF.SetAntennaBackend(FController.BackendCaps.Kind = bkPluto,
|
||
FController.BackendCaps.HasAntSwitch);
|
||
// Вкладка Calibration: band-план и состав групп — по активному железу
|
||
// (у AD936x нет моста детектора мощности и телеметрии питания).
|
||
SF.SetCalibrationBackend(FController.BackendCaps.Kind = bkPluto);
|
||
SF.LoadAnt936x(FController.Ant936x, FController.FXvtrSettings);
|
||
SF.LoadXvtrSettings(FController.FXvtrSettings);
|
||
SF.LoadOCSettings(FController.FOCSettings);
|
||
// Вкладка OC Control — только openHPSDR, у Pluto/AD936x нет такого выхода.
|
||
SF.SetOCBackend(FController.BackendCaps.Kind = bkPluto);
|
||
SF.LoadPASettings(FController.FPAMaxPower, FController.FPABandCal);
|
||
SF.LoadCalibration(FController.FCal);
|
||
SF.LoadPlutoTxMaxAtt(FController.FPlutoTxMaxAttDb);
|
||
SF.LoadVHFCalSettings(FController.FVHFBandCal);
|
||
SF.LoadCATSettings(
|
||
FCATLastGlobal.CATSerialEnabled,
|
||
FCATLastGlobal.CATSerialPort,
|
||
FCATLastGlobal.CATSerialBaud,
|
||
FCATLastGlobal.CATSerialDataBits,
|
||
FCATLastGlobal.CATSerialStopBits,
|
||
FCATLastGlobal.CATSerialParity,
|
||
FCATLastGlobal.CATSerialAndromeda,
|
||
FCATLastGlobal.CATTcpEnabled,
|
||
FCATLastGlobal.CATTcpPort);
|
||
SF.LoadSliceCATSettings(
|
||
FCATLastGlobal.CATSliceEnabled,
|
||
FCATLastGlobal.CATSlicePort,
|
||
FCATLastGlobal.CATSliceAutoTX,
|
||
FCATLastGlobal.CATSliceOutDev,
|
||
FCATLastGlobal.CATSliceInDev);
|
||
// Перечисляем PA устройства
|
||
SF.RefreshAudioDevices(FController.FAudioOut, FController.FAudioIn);
|
||
SF.LoadVisibility(FController.FShowSpectrum, FController.FShowWaterfall, FController.FShowWideband, FController.FWidebandFill,
|
||
FController.FSpectrumFill);
|
||
SF.LoadWfAGCNF(FController.FWfAGCEnabled, FController.FWfNFEnabled);
|
||
SF.LoadWfRender(FController.FWfPalette, FController.FWfGamma,
|
||
FController.FDisplayPixels, FController.FWfSpeedDiv);
|
||
SF.LoadSpecMSAA(FController.FSpecMSAA);
|
||
SF.LoadADCSettings(FController.FDitherEnabled, FController.FRandomEnabled);
|
||
SF.LoadWebSettings(FWebEnabled, FWebPort, FWebBindAddr, FWebUser, FWebPass,
|
||
FWebSpecPixels);
|
||
SF.LoadFPS(FDisplayFPS);
|
||
SF.LoadLightTheme(FLightTheme);
|
||
SF.LoadFreqMhzDigits(FFreqMhzDigits);
|
||
SF.LoadAudioBufferSize(FController.FAudioOut.OutputBufferSize);
|
||
SF.LoadSendAudioToRadio(FController.FSendAudioToRadio);
|
||
// Загружаем текущие значения
|
||
if FController.FWDSPReady then
|
||
SF.LoadValues(
|
||
FController.FDSPEngine.FFTSize,
|
||
FController.FDSPEngine.WindowType,
|
||
FController.FDSPEngine.SpecDetector,
|
||
FController.FDSPEngine.SpecAvgMode,
|
||
FController.FDSPEngine.SpecAvgTimeMS,
|
||
FController.FDSPEngine.WfDetector,
|
||
FController.FDSPEngine.WfAvgMode,
|
||
FController.FDSPEngine.WfAvgTimeMS,
|
||
FController.FWfManualHigh,
|
||
FController.FWfManualLow,
|
||
FController.FWfAGCOffset,
|
||
FController.FSpecRefLevel, FController.FSpecRange, FController.FSpecGridStep,
|
||
FController.FAudioOutDevName, FController.FAudioInDevName)
|
||
else
|
||
SF.LoadValues(
|
||
131072, 2, 0, 3, 30.0,
|
||
0, 3, 120.0, FController.FWfManualHigh, FController.FWfManualLow, FController.FWfAGCOffset,
|
||
FController.FSpecRefLevel, FController.FSpecRange, FController.FSpecGridStep,
|
||
FController.FAudioOutDevName, FController.FAudioInDevName);
|
||
SF.Show;
|
||
end;
|
||
|
||
procedure TMainForm.AfterShowTick(Sender: TObject);
|
||
begin
|
||
// Вызывается один раз через 200 мс после старта формы
|
||
FAfterShowTimer.Enabled := False;
|
||
|
||
// Повторно применяем сохранённую позицию: к этому моменту WM уже декорировал
|
||
// окно и Qt знает frame extents, поэтому move() ставит рамку точно.
|
||
if FHasPendingRestore then
|
||
begin
|
||
SetBounds(FRestoreL, FRestoreT, Width, Height);
|
||
FHasPendingRestore := False;
|
||
end;
|
||
|
||
try
|
||
FController.FAudioOut.Open;
|
||
except
|
||
end;
|
||
|
||
EnsureWDSPWisdom;
|
||
end;
|
||
|
||
|
||
// ===========================================================================
|
||
// CAT subsystem
|
||
// ===========================================================================
|
||
|
||
procedure TMainForm.OnCATSettingsChange(
|
||
const SerEnabled: array of Boolean;
|
||
const SerPort: array of string;
|
||
const SerBaud, SerDataBits, SerStopBits, SerParity: array of Integer;
|
||
const SerAndromeda: array of Boolean;
|
||
TcpEnabled: Boolean; TcpPort: Integer);
|
||
var i: Integer;
|
||
begin
|
||
for i := 0 to 3 do
|
||
begin
|
||
if i <= High(SerEnabled) then FCATLastGlobal.CATSerialEnabled[i] := SerEnabled[i];
|
||
if i <= High(SerPort) then FCATLastGlobal.CATSerialPort[i] := SerPort[i];
|
||
if i <= High(SerBaud) then FCATLastGlobal.CATSerialBaud[i] := SerBaud[i];
|
||
if i <= High(SerDataBits) then FCATLastGlobal.CATSerialDataBits[i] := SerDataBits[i];
|
||
if i <= High(SerStopBits) then FCATLastGlobal.CATSerialStopBits[i] := SerStopBits[i];
|
||
if i <= High(SerParity) then FCATLastGlobal.CATSerialParity[i] := SerParity[i];
|
||
if i <= High(SerAndromeda)then FCATLastGlobal.CATSerialAndromeda[i]:= SerAndromeda[i];
|
||
end;
|
||
FCATLastGlobal.CATTcpEnabled := TcpEnabled;
|
||
FCATLastGlobal.CATTcpPort := TcpPort;
|
||
// Зеркалим CAT-поля в persist-буфер контроллера (BuildGlobalSettings берёт их
|
||
// сквозь из FLoadedGlobal; на connect они грузятся в FLoadedGlobal → FCATLastGlobal).
|
||
FController.FLoadedGlobal.CATSerialEnabled := FCATLastGlobal.CATSerialEnabled;
|
||
FController.FLoadedGlobal.CATSerialPort := FCATLastGlobal.CATSerialPort;
|
||
FController.FLoadedGlobal.CATSerialBaud := FCATLastGlobal.CATSerialBaud;
|
||
FController.FLoadedGlobal.CATSerialDataBits := FCATLastGlobal.CATSerialDataBits;
|
||
FController.FLoadedGlobal.CATSerialStopBits := FCATLastGlobal.CATSerialStopBits;
|
||
FController.FLoadedGlobal.CATSerialParity := FCATLastGlobal.CATSerialParity;
|
||
FController.FLoadedGlobal.CATSerialAndromeda := FCATLastGlobal.CATSerialAndromeda;
|
||
FController.FLoadedGlobal.CATTcpEnabled := FCATLastGlobal.CATTcpEnabled;
|
||
FController.FLoadedGlobal.CATTcpPort := FCATLastGlobal.CATTcpPort;
|
||
FCATAdapter.ApplySettings(FCATLastGlobal);
|
||
FController.FSettings.SaveCATSettings(FCATLastGlobal);
|
||
FController.SaveGlobalSettings;
|
||
FController.FSettings.Save;
|
||
end;
|
||
|
||
procedure TMainForm.OnSliceCATSettingsChange(
|
||
const Enabled: array of Boolean;
|
||
const Port: array of Integer;
|
||
const AutoTX: array of Boolean;
|
||
const OutDev: array of string;
|
||
const InDev: array of string);
|
||
// CAT дополнительных слайсов. Хранится там же, где остальные CAT-настройки
|
||
// (корневая секция + device-blob), применяется тем же ApplySettings: адаптер
|
||
// поднимет/опустит порты эндпоинтов и положит Auto TX в контроллер.
|
||
var i: Integer;
|
||
begin
|
||
for i := 0 to CFG_CAT_SLICE_COUNT - 1 do
|
||
begin
|
||
if i <= High(Enabled) then FCATLastGlobal.CATSliceEnabled[i] := Enabled[i];
|
||
if i <= High(Port) then FCATLastGlobal.CATSlicePort[i] := Port[i];
|
||
if i <= High(AutoTX) then FCATLastGlobal.CATSliceAutoTX[i] := AutoTX[i];
|
||
if i <= High(OutDev) then FCATLastGlobal.CATSliceOutDev[i] := OutDev[i];
|
||
if i <= High(InDev) then FCATLastGlobal.CATSliceInDev[i] := InDev[i];
|
||
// Аудио слота — сразу в контроллер: слайс, стоящий на слоте, переезжает
|
||
// на новое устройство, следующий получит его при создании.
|
||
FController.SetSliceSlotAudio(i, FCATLastGlobal.CATSliceOutDev[i],
|
||
FCATLastGlobal.CATSliceInDev[i]);
|
||
end;
|
||
FController.FLoadedGlobal.CATSliceEnabled := FCATLastGlobal.CATSliceEnabled;
|
||
FController.FLoadedGlobal.CATSlicePort := FCATLastGlobal.CATSlicePort;
|
||
FController.FLoadedGlobal.CATSliceAutoTX := FCATLastGlobal.CATSliceAutoTX;
|
||
FController.FLoadedGlobal.CATSliceOutDev := FCATLastGlobal.CATSliceOutDev;
|
||
FController.FLoadedGlobal.CATSliceInDev := FCATLastGlobal.CATSliceInDev;
|
||
FCATAdapter.ApplySettings(FCATLastGlobal);
|
||
FController.FSettings.SaveCATSettings(FCATLastGlobal);
|
||
FController.SaveGlobalSettings;
|
||
FController.FSettings.Save;
|
||
RefreshTxIndicators; // бейджи AutoTX на флагах слайсов — сразу
|
||
for i := 0 to MAX_PANS - 1 do // подсветка текущего устройства во флагах
|
||
if FPans[i] <> nil then FPans[i].PushAllSliceFlagStates;
|
||
end;
|
||
|
||
procedure TMainForm.ApplySliceSlotAudio;
|
||
var i: Integer;
|
||
begin
|
||
for i := 0 to CFG_CAT_SLICE_COUNT - 1 do
|
||
FController.SetSliceSlotAudio(i, FCATLastGlobal.CATSliceOutDev[i],
|
||
FCATLastGlobal.CATSliceInDev[i]);
|
||
end;
|
||
|
||
procedure TMainForm.StoreSliceSlotAudio(SliceId: Integer);
|
||
// Слот слайса и его устройства уже обновлены контроллером (SetSliceDevice/
|
||
// SetSliceInDevice) — здесь только зеркалим их в конфиг и сохраняем.
|
||
var Slot: Integer;
|
||
begin
|
||
Slot := FController.SliceSlotOf(SliceId);
|
||
if (Slot < 0) or (Slot >= CFG_CAT_SLICE_COUNT) then Exit;
|
||
FCATLastGlobal.CATSliceOutDev[Slot] := FController.SliceSlotOutDev(Slot);
|
||
FCATLastGlobal.CATSliceInDev[Slot] := FController.SliceSlotInDev(Slot);
|
||
FController.FLoadedGlobal.CATSliceOutDev := FCATLastGlobal.CATSliceOutDev;
|
||
FController.FLoadedGlobal.CATSliceInDev := FCATLastGlobal.CATSliceInDev;
|
||
FController.FSettings.SaveCATSettings(FCATLastGlobal);
|
||
FController.FSettings.Save;
|
||
end;
|
||
|
||
end.
|