{
  Copyright (C)
  2026 - Uladzimir Karpenka, EW8BAK

  This program is free software; you can redistribute it and/or
  modify it under the terms of the GNU General Public License
  as published by the Free Software Foundation; either version 2
  of the License, or (at your option) any later version.

  This program is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with this program; if not, write to the Free Software
  Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
}

unit SettingsForm;

{
  SettingsForm.pas — диалог настроек EWSDR SDR приложения.

  Вкладки: General | Audio | Display | DSP | Transmit | PA Settings | CAT
  Display имеет подвкладки: General | RX1 | RX2 | TX

  Все изменения применяются МГНОВЕННО через events:
    OnDisplayChange  — изменены FFT/Window/Detector/Avg параметры
    OnWaterfallChange — изменены параметры водопада
    OnGridChange     — изменена сетка спектра
    OnAudioChange    — изменено аудио устройство/буфер
    OnSampleRateChange — изменён audio output sample rate (только PortAudio, DDC не трогается)

  Для применения настроек: создайте форму, зарегистрируйте callbacks,
  потом вызовите Show или ShowModal.
}

{$IFDEF FPC}
  {$MODE Delphi}
{$ENDIF}

interface

uses
  Classes, SysUtils, Math, Types,
  Forms, Controls, Graphics, Dialogs,
  StdCtrls, ExtCtrls, LCLType,
  FlatButton, FlatCheckBox, FlatComboBox, FlatEdit, FlatSpinEdit, FlatFloatSpinEdit,
  FlatRadioButton, FlatMemo,
  EqualizerControl, OverlayScrollBar, AudioOutput, AudioInput, AppTheme, Settings,
  BoardUtils, DpiUtils, SerialPort;

const
  // Подвкладки страницы Transmit: Profile / EQ / Hardware / Display.
  TXTAB_COUNT = 4;
  // Tag контейнера подвкладки: ApplyTheme красит такие панели фоном страницы,
  // а не цветом группы (иначе подложка выглядела бы как одна гигантская группа).
  TAG_PAGE_BG = 909;
  TAG_RESPONSIVE_CARD = 910;
  TAG_PAGE_HEADER = 911;
  TAG_RESPONSIVE_HALF_LEFT = 912;
  TAG_RESPONSIVE_HALF_RIGHT = 913;
  TAG_RESPONSIVE_INSET = 914;
  TAG_BOTTOM_SPACER = 915;

type
  // PA: MaxPower в Вт, BandCal[0..10] — калибровка 38.8..100.0 на диапазон
  TOnPASettingsChange = procedure(MaxPower: Double;
                                  const BandCal: array of Double) of object;
  // Pluto TX: аттенюация выходного тракта на drive=100% (потолок), дБ<0
  TOnPlutoTxChange = procedure(MaxAttDb: Double) of object;
  // Антенные разъёмы AD936x (Pluto/LibreSDR): таблица «диапазон → RX/TX разъём»
  TOnAnt936xChange = procedure(const A: TAD936xAnt) of object;
  // VHF (XVTR) PA calibration: 8 slots, 38.8..100.0
  TOnVHFCalChange = procedure(const VHFCal: array of Double) of object;
  // Калибровки (вкладка Calibration): детектор мощности/КСВ, напряжение/ток, S-метр
  TOnCalibrationChange = procedure(const C: TCalibration) of object;
  // TX-настройки целиком (любая правка → один callback с актуальной структурой)
  TOnTXSettingsChange = procedure(const T: TTXSettings) of object;
  TOnCWSettingsChange = procedure(const C: TCWSettings) of object;
  TOnCWMessagesOpen   = procedure of object;
  TOnCWTerminalOpen   = procedure of object;
  // TX-профили: выбор/переименование/удаление по индексу, создание — по имени.
  TOnTXProfileIdx     = procedure(Idx: Integer) of object;
  TOnTXProfileName    = procedure(const AName: string) of object;
  TOnTXProfileIdxName = procedure(Idx: Integer; const AName: string) of object;

  TOnFreqMhzDigitsChange = procedure(Digits: Integer) of object;
  TOnDisplayParamChange = procedure(FFTSize, WindowType, SpecDetector,
    SpecAvgMode: Integer; SpecAvgTimeMS: Double) of object;
  TOnWaterfallParamChange = procedure(WfDetector, WfAvgMode: Integer;
    WfAvgTimeMS, WfHigh, WfLow, WfAGCOffset: Double) of object;
  TOnWfRenderChange = procedure(Palette: Integer; Gamma: Double;
    ResLimit, SpeedDiv: Integer) of object;
  TOnSpecMSAAChange = procedure(Samples: Integer) of object;
  TOnGridParamChange = procedure(RefLevel, Range, GridStep: Double) of object;
  TOnAudioDevChange = procedure(DevIndex: Integer; const DevName: string) of object;
  TOnAudioInDevChange = procedure(DevIndex: Integer; const DevName: string) of object;
  TOnAudioBufferChange = procedure(BufferSize: Integer) of object;
  TOnSendAudioToRadioChange = procedure(Enabled: Boolean) of object;
  TOnVisibilityChange = procedure(ShowSpectrum, ShowWaterfall, ShowWideband,
    WidebandFill, SpectrumFill: Boolean) of object;
  TOnFPSChange = procedure(FPS: Integer) of object;
  TOnThemeChange = procedure(LightTheme: Boolean) of object;
  TOnWfAGCNFChange = procedure(WfAGC, WfNF: Boolean) of object;
  TOnADCChange = procedure(Dither, Random: Boolean) of object;
  // Настройки DX-кластера уезжают наружу целиком записью: полей много и
  // добавлять их по одному в сигнатуру (как у веб-сервера) уже неудобно.
  TOnDXClusterChange = procedure(const D: TDXClusterSettings) of object;

  TOnWebSettingsChange = procedure(Enabled: Boolean; Port: Integer;
    const BindAddr, User, Pass: string; SpecPixels: Integer) of object;
  TOnTCISettingsChange = procedure(Enabled: Boolean; Port: Integer;
    const BindAddr: string) of object;
  TOnCATChange = procedure(
    const SerEnabled: array of Boolean;
    const SerPort: array of string;
    const SerBaud: array of Integer;
    const SerDataBits: array of Integer;
    const SerStopBits: array of Integer;
    const SerParity: array of Integer;
    const SerAndromeda: array of Boolean;
    TcpEnabled: Boolean; TcpPort: Integer) of object;
  // CAT дополнительных слайсов: индекс = слот (0 = слайс B).
  TOnSliceCATChange = procedure(
    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) of object;
  TOnAlexChange = procedure(const A: TAlexSettings) of object;
  TOnXvtrChange = procedure(const X: TXvtrSettings) of object;
  TOnOCChange   = procedure(const O: TOCSettings) of object;

  { TSettingsForm }
  TSettingsForm = class(TForm)
  private
    // ---- Layout ----
    FNavPanel:       TPanel;
    // Пункты левой колонки в порядке сверху вниз — заголовки секций (TLabel)
    // и кнопки (TFlatButton) вперемешку. Координаты им раздаёт RelayoutNav,
    // руками Top не ставим: пункт умеет прятаться (OC Control на Pluto).
    FNavItems:       array of TControl;
    FContentPanel:   TPanel;
    FFooterPanel:    TPanel;
    FScrollHost:     TPanel;
    FScrollBar:      TOverlayScrollBar;
    FActivePage:     TScrollBox;
    FPageAudio:      TScrollBox;
    FPageDisplay:    TScrollBox;
    FPageSpectrum:   TScrollBox;
    FPageWaterfall:  TScrollBox;
    FPagePA:         TScrollBox;
    FPageAdvanced:   TScrollBox;
    FPageDXCluster:  TScrollBox;
    // DX-кластер
    FChkDXEnabled:   TFlatCheckBox;
    FChkDXShow:      TFlatCheckBox;
    FEdDXHost:       TFlatEdit;
    FEdDXPort:       TFlatSpinEdit;
    FEdDXLogin:      TFlatEdit;
    FEdDXPass:       TFlatEdit;
    FEdDXPostLogin:  TFlatMemo;
    FEdDXTTL:        TFlatSpinEdit;
    FEdDXRows:       TFlatSpinEdit;
    FChkDXMode:      array[0..9] of TFlatCheckBox;   // индекс = Ord(TDXMode)
    FDXCfg:          TDXClusterSettings;
    FPageCAT:        TScrollBox;
    FPageSlices:     TScrollBox;
    FPageTransmit:   TScrollBox;
    FNavAudio:       TFlatButton;
    FNavDisplay:     TFlatButton;
    FNavSpectrum:    TFlatButton;
    FNavWaterfall:   TFlatButton;
    FNavPA:          TFlatButton;
    FNavAdvanced:    TFlatButton;
    FNavDXCluster:   TFlatButton;
    FNavCAT:         TFlatButton;
    FNavSlices:      TFlatButton;
    FNavTransmit:    TFlatButton;

    // ---- Audio tab controls ----
    FCmbRXDev:       TFlatComboBox;
    FRXDevIndices:   array[0..63] of Integer;
    FRXDevCount:     Integer;
    FCmbTXDev:       TFlatComboBox;
    FTXDevIndices:   array[0..63] of Integer;
    FTXDevCount:     Integer;
    FCmbAudioBuffer: TFlatComboBox;
    FChkSendAudioToRadio: TFlatCheckBox;

    // ---- RX1 sub-tab controls ----
    FCmbFFTSize:     TFlatComboBox;
    FCmbWindow:      TFlatComboBox;
    FCmbSpecDet:     TFlatComboBox;
    FCmbSpecAvg:     TFlatComboBox;
    FSpecAvgTime:    TFlatFloatSpinEdit;
    FCmbWfDet:       TFlatComboBox;
    FCmbWfAvg:       TFlatComboBox;
    FWfAvgTime:      TFlatFloatSpinEdit;
    FWfHigh:         TFlatFloatSpinEdit;
    FWfLow:          TFlatFloatSpinEdit;
    FWfAGCOffset:    TFlatFloatSpinEdit;
    FChkWfAGC:       TFlatCheckBox;
    FChkWfNF:        TFlatCheckBox;
    FCmbWfPalette:   TFlatComboBox;
    FWfGamma:        TFlatFloatSpinEdit;
    FCmbWfRes:       TFlatComboBox;
    FCmbWfSpeed:     TFlatComboBox;
    FCmbSpecMSAA:    TFlatComboBox;
    FEdRefLevel:     TFlatEdit;
    FEdRange:        TFlatEdit;
    FCmbGridStep:    TFlatComboBox;

    // ---- General display tab controls ----
    FChkShowSpectrum:    TFlatCheckBox;
    FChkShowWaterfall:   TFlatCheckBox;
    FChkShowWideband:    TFlatCheckBox;
    FChkWidebandFill:    TFlatCheckBox;
    FChkSpectrumFill:    TFlatCheckBox;
    FCmbFPS:             TFlatComboBox;
    FChkLightTheme:      TFlatCheckBox;
    FCmbFreqMhzDigits:   TFlatComboBox;

    // ---- PA Settings tab controls ----
    FEdMaxPower:     TFlatSpinEdit;
    FEdPlutoTxMaxAtt: TFlatFloatSpinEdit;   // Pluto TX att на drive=100%
    FEdBandCal:      array[0..10] of TFlatFloatSpinEdit;
    FEdVHFCal:       array[0..CFG_XVTR_COUNT-1] of TFlatFloatSpinEdit;
    FVHFCalLabels:   array[0..CFG_XVTR_COUNT-1] of TLabel;
    // Раскладку страницы PA ставит RelayoutPATab под активное железо: у AD936x
    // нет ни моста детектора, ни drive-байта — мощность там задаётся
    // аттенюацией, поэтому из всей страницы остаётся один потолок.
    FPAGrpPower:     TPanel;
    FPAGrpVHF:       TPanel;
    FLblPAHint:      TLabel;
    FLblPAMaxPower:  TLabel;
    FLblPlutoTxAtt:  TLabel;
    FLblPABandHdr:   TLabel;
    FPABandLabels:   array[0..10] of TLabel;
    FPAHasPA:        Boolean;

    // ---- Calibration tab controls ----
    // Таблицы «на диапазон» строятся на 12 слотов (максимум плана); подписи,
    // видимость и раскладка задаются железом в RelayoutCalTab: у openHPSDR
    // виден HF-план, у Pluto — VHF/UHF-план, слоты трансвертеров — только
    // включённые. Значения пишутся в per-device секцию JSON (ключ = MAC),
    // поэтому таблицы разных аппаратов не пересекаются.
    FPageCalib:        TScrollBox;
    FNavCalib:         TFlatButton;
    FCalGrpDet:        TPanel;                              // Power / SWR detector
    FCalGrpVA:         TPanel;                              // Supply voltage / current
    FCalGrpSM:         TPanel;                              // S-meter
    FCmbPwrDetModel:   TFlatComboBox;                       // модель детектора Alex-моста
    FEdPADetCal:       array[0..CFG_BAND_COUNT-1] of TFlatFloatSpinEdit;  // множитель FWD/REV на банд
    FLblPADetCal:      array[0..CFG_BAND_COUNT-1] of TLabel;
    FEdSupplyVoltCal:  TFlatFloatSpinEdit;                  // множитель напряжения питания
    FEdAmpVoff:        TFlatFloatSpinEdit;                  // датчик тока: сдвиг нуля, мВ
    FEdAmpSens:        TFlatFloatSpinEdit;                  // датчик тока: мВ/А
    FEdSMBand:         array[0..CFG_BAND_COUNT-1] of TFlatFloatSpinEdit;  // офсет S-метра на диапазон
    FLblSMBand:        array[0..CFG_BAND_COUNT-1] of TLabel;
    FEdSMXvtr:         array[0..CFG_XVTR_COUNT-1] of TFlatFloatSpinEdit;  // офсет S-метра на трансвертер
    FLblSMXvtr:        array[0..CFG_XVTR_COUNT-1] of TLabel;
    FLblSMBandHdr:     TLabel;
    FLblSMXvtrHdr:     TLabel;
    FCalGrpDisp:       TPanel;                              // Spectrum / waterfall
    FEdDispBand:       array[0..CFG_BAND_COUNT-1] of TFlatFloatSpinEdit;  // офсет спектра на диапазон
    FLblDispBand:      array[0..CFG_BAND_COUNT-1] of TLabel;
    FEdDispXvtr:       array[0..CFG_XVTR_COUNT-1] of TFlatFloatSpinEdit;  // офсет спектра на трансвертер
    FLblDispXvtr:      array[0..CFG_XVTR_COUNT-1] of TLabel;
    FLblDispBandHdr:   TLabel;
    FLblDispXvtrHdr:   TLabel;
    FBtnDispFromSM:    TFlatButton;                         // «= S-meter» — копия таблицы
    FLblCalHint:     TLabel;   // подзаголовок страницы, зависит от железа
    FCalGrpFreq:       TPanel;                              // Reference frequency
    FEdFreqCalPPM:     TFlatFloatSpinEdit;                  // калибровка опорника, ppm
    FCalIsPluto:       Boolean;                             // активное железо — AD936x
    FCalXvtrOn:        array[0..CFG_XVTR_COUNT-1] of Boolean;  // слот трансвертера включён
    FCalXvtrName:      array[0..CFG_XVTR_COUNT-1] of string;   // подпись слота
    FCalConfig:        TCalibration;
    FOnCalibrationChange: TOnCalibrationChange;

    // ---- Transmit tab controls ----
    // Текущее состояние, отдаётся в OnTXChange целиком при любой правке.
    FTX: TTXSettings;
    // Подвкладки страницы Transmit: Profile / EQ / Hardware / Display.
    // Первые две — то, что хранится в TX-профиле, вторые две — свойства
    // устройства и экрана (от профиля не зависят). См. TTXProfile в Settings.
    FTXSubPage:        array[0..TXTAB_COUNT-1] of TPanel;
    FTXSubBtn:         array[0..TXTAB_COUNT-1] of TFlatButton;
    FTXSubIdx:         Integer;
    // Полоса профиля наверху страницы
    FCmbTXProfile:     TFlatComboBox;
    FBtnTXProfNew:     TFlatButton;
    FBtnTXProfRen:     TFlatButton;
    FBtnTXProfDel:     TFlatButton;
    FBtnTXProfFactory: TFlatButton;
    FLblTXProfDrive:   TLabel;             // «Drive профиля: N %» (ползунок — в главном окне)
    FCmbTXMicJack:     TFlatComboBox;       // "Mic In" / "Line In"
    FChkTXMicBoost:    TFlatCheckBox;       // +20 dB (только Mic In)
    FLblLineInGain:    TLabel;          // метка "Line In gain" (только Line In)
    FEdLineInGain:     TFlatFloatSpinEdit;  // -34.5..+12 dB, шаг 1.5 (только Line In)
    FChkTXMicBias:     TFlatCheckBox;
    FChkTXMicPTT:      TFlatCheckBox;
    FCmbTXMicTR:       TFlatComboBox;       // "Tip" / "Ring" (только Orion)
    FLblTipRing:       TLabel;          // метка (только Orion)
    FLblMicJack:       TLabel;          // метка "Mic jack"
    FLblMicGain:       TLabel;          // метка "Mic gain (dB)"
    FEdTXMicGain:      TFlatFloatSpinEdit;
    // Группы подвкладки Profile — их двигает RelayoutTXProfileSub, когда у
    // железа нет микрофонного разъёма (AD936x: звук только с ПК).
    FTXGrpMic:         TPanel;
    FTXGrpFilt:        TPanel;
    FTXGrpDyn:         TPanel;
    FTXGrpPwr:         TPanel;
    FTXHasHWMic:       Boolean;
    FEdTXAttOnTX:      TFlatSpinEdit;
    // Tune (TUN button)
    FEdTUNLevel:       TFlatSpinEdit;
    FEdTUNFreq:        TFlatSpinEdit;
    // Filter
    FEdTXFiltLow:      TFlatSpinEdit;
    FEdTXFiltHigh:     TFlatSpinEdit;
    FCmbTXFiltNC:      TFlatComboBox;
    FCmbTXFiltMP:      TFlatComboBox;
    FCmbTXFiltWindow:  TFlatComboBox;
    // Compressor
    FChkTXCompOn:      TFlatCheckBox;
    FEdTXCompGain:     TFlatFloatSpinEdit;
    // Leveler
    FChkTXLevOn:       TFlatCheckBox;
    FEdTXLevTop:       TFlatFloatSpinEdit;
    FEdTXLevDecay:     TFlatSpinEdit;
    // ALC
    FChkTXALCOn:       TFlatCheckBox;
    FEdTXALCMaxGain:   TFlatFloatSpinEdit;
    FEdTXALCDecay:     TFlatSpinEdit;
    // Phase Rotator
    FChkTXPHROn:       TFlatCheckBox;
    FEdTXPHRStages:    TFlatSpinEdit;
    FEdTXPHRFreq:      TFlatFloatSpinEdit;
    // EQ
    FTXEQControl:      TEqualizerControl;
    // AM
    FEdTXAMCarrier:    TFlatFloatSpinEdit;
    // FM
    FEdTXFMDev:        TFlatFloatSpinEdit;
    FEdTXFMLow:        TFlatSpinEdit;
    FEdTXFMHigh:       TFlatSpinEdit;
    FCmbTXFMEmph:      TFlatComboBox;
    // CTCSS
    FChkTXCTCSSOn:     TFlatCheckBox;
    FEdTXCTCSSFreq:    TFlatFloatSpinEdit;
    // CW (телеграф) — свойство устройства, профилем не переключается
    FCW:               TCWSettings;
    // Кто рисует точки: прошивка / программа / никто. Одна развилка — один
    // список (две галки на неё разъезжались во взаимоисключающих состояниях).
    FCmbCWKeyerSrc:    TFlatComboBox;
    FEdCWPitch:        TFlatSpinEdit;
    FEdCWSpeed:        TFlatSpinEdit;
    FEdCWWeight:       TFlatSpinEdit;
    FCmbCWKeyerMode:   TFlatComboBox;
    FChkCWReverse:     TFlatCheckBox;
    FChkCWStrict:      TFlatCheckBox;
    FChkCWBreakIn:     TFlatCheckBox;
    FEdCWHang:         TFlatSpinEdit;
    FEdCWRamp:         TFlatSpinEdit;
    FEdCWRFDelay:      TFlatSpinEdit;
    FChkCWSidetoneHW:  TFlatCheckBox;
    FEdCWSidetoneHWLv: TFlatSpinEdit;
    FChkCWSidetoneSW:  TFlatCheckBox;
    FEdCWSidetoneSWLv: TFlatSpinEdit;
    // Локальный кейер: манипуляцию рисует сам EWSDR (обязателен на AD936x)
    FEdCWOffset:       TFlatSpinEdit;
    FChkCWKeyPort:     TFlatCheckBox;
    FEdCWKeyPort:      TFlatEdit;
    FCmbCWDotLine:     TFlatComboBox;
    FCmbCWDashLine:    TFlatComboBox;
    FChkCWDecoder:     TFlatCheckBox;
    FChkCWDecStrip:    TFlatCheckBox;
    FChkCWKeyInvert:   TFlatCheckBox;
    FChkCWKeyDTR:      TFlatCheckBox;
    FChkCWKeyRTS:      TFlatCheckBox;
    FCWHasFWKeyer:     Boolean;                 // у железа есть кейер в прошивке
    // TX Display (отдельный analyzer)
    FCmbTXFFT:         TFlatComboBox;
    FCmbTXWindow:      TFlatComboBox;
    FCmbTXSpecDet:     TFlatComboBox;
    FCmbTXSpecAvg:     TFlatComboBox;
    FEdTXSpecAvgTime:  TFlatFloatSpinEdit;
    FCmbTXWfDet:       TFlatComboBox;
    FCmbTXWfAvg:       TFlatComboBox;
    FEdTXWfAvgTime:    TFlatFloatSpinEdit;
    // TX Grid (отдельные от RX grid в Display)
    FEdTXRefLevel:     TFlatEdit;
    FEdTXRange:        TFlatEdit;
    FCmbTXGridStep:    TFlatComboBox;

    // ---- Alex / Antenna tab controls ----
    FPageAlex:          TScrollBox;
    FNavAlex:           TFlatButton;
    // Per-band antenna radio buttons (bands 0..10 = 160m..6m)
    FAlexRxAnt:         array[0..10, 1..3] of TFlatRadioButton;
    FAlexTxAnt:         array[0..10, 1..3] of TFlatRadioButton;
    // Per-band secondary RX (взаимно исключающие checkboxes)
    FAlexRxByps:        array[0..10] of TFlatCheckBox;  // BYPS: Ext1+Bypass
    FAlexRxExt1:        array[0..10] of TFlatCheckBox;  // EXT1: Ext2+Bypass
    FAlexRxXvtr:        array[0..10] of TFlatCheckBox;  // XVTR: XvtrDDC+Bypass
    // Per-band Do Not TX
    FAlexDoNotTx:       array[0..10] of TFlatCheckBox;
    // Global TX routing flags
    FAlexRxBypassOnTx:  TFlatCheckBox;
    FAlexExt1OnTx:      TFlatCheckBox;
    FAlexGndBpf2OnTx:   TFlatCheckBox;
    FAlexEnableXvtrHf:  TFlatCheckBox;
    // Группы вкладки Antenna: Alex-таблица (openHPSDR) vs выбор приёмного входа
    // AD936x (Pluto/LibreSDR) — показывается одна из двух (SetAntennaBackend).
    FAlexSubtitle:      TLabel;
    FAlexGrpAnt:        TPanel;
    FAlexGrpTX:         TPanel;
    FPlutoAntGrp:       TPanel;
    // На диапазон (VU-план, 12 слотов) и на слот трансвертера — по паре радио-
    // кнопок RX (RX1/RX2) и TX (TX1/TX2).
    FAntRx:      array[0..CFG_BAND_COUNT-1, 0..1] of TFlatRadioButton;
    FAntTx:      array[0..CFG_BAND_COUNT-1, 0..1] of TFlatRadioButton;
    FAntXvtrRx:  array[0..CFG_XVTR_COUNT-1, 0..1] of TFlatRadioButton;
    FAntXvtrTx:  array[0..CFG_XVTR_COUNT-1, 0..1] of TFlatRadioButton;
    FAntXvtrRow: array[0..CFG_XVTR_COUNT-1] of TPanel;   // строка слота (скрыта, если выкл.)
    FAntXvtrLbl: array[0..CFG_XVTR_COUNT-1] of TLabel;
    FAntXvtrHdr: TLabel;   // «Transverters» — виден при хотя бы одном включённом
    FAnt936x:    TAD936xAnt;
    // Current Alex config state
    FAlexConfig:        TAlexSettings;
    FAlexBoardType:     Integer;
    // Callback
    FOnAlexChange:      TOnAlexChange;

    // ---- XVTR (Transverter) tab controls ----
    FPageXvtr:          TScrollBox;
    FNavXvtr:           TFlatButton;
    FXvtrEn:            array[0..CFG_XVTR_COUNT-1] of TFlatCheckBox;
    FXvtrName:          array[0..CFG_XVTR_COUNT-1] of TFlatEdit;
    FXvtrBegin:         array[0..CFG_XVTR_COUNT-1] of TFlatFloatSpinEdit;  // MHz
    FXvtrEnd:           array[0..CFG_XVTR_COUNT-1] of TFlatFloatSpinEdit;  // MHz
    FXvtrLOOff:         array[0..CFG_XVTR_COUNT-1] of TFlatFloatSpinEdit;  // MHz
    FXvtrLOErr:         array[0..CFG_XVTR_COUNT-1] of TFlatFloatSpinEdit;  // Hz (целые)
    FXvtrRXGain:        array[0..CFG_XVTR_COUNT-1] of TFlatFloatSpinEdit;  // dB
    FXvtrRXOnly:        array[0..CFG_XVTR_COUNT-1] of TFlatCheckBox;
    FXvtrPower:         array[0..CFG_XVTR_COUNT-1] of TFlatSpinEdit;   // 0..100
    FXvtrDisPA:         array[0..CFG_XVTR_COUNT-1] of TFlatCheckBox;
    FXvtrRXAnt:         array[0..CFG_XVTR_COUNT-1] of TFlatComboBox;       // 0=def..3=ANT3
    FChkXvtrTunPwr:     TFlatCheckBox;
    FXvtrConfig:        TXvtrSettings;
    FOnXvtrChange:      TOnXvtrChange;

    // ---- OC Control tab (Open Collector выходы Penny/Alex, openHPSDR-only) ----
    FPageOC:            TScrollBox;
    FNavOC:             TFlatButton;
    FOCSubtitle:        TLabel;
    FOCEnabled:         TFlatCheckBox;
    // HF-группа: 11 диапазонов (0=160m..10=6m) × 7 пинов, RX/TX-маски
    FOCHFRx:            array[0..10, 0..OC_PIN_COUNT-1] of TFlatCheckBox;
    FOCHFTx:            array[0..10, 0..OC_PIN_COUNT-1] of TFlatCheckBox;
    FOCHFAction:        array[0..OC_PIN_COUNT-1] of TFlatComboBox;
    FOCHFGrp:           TPanel;
    FOCHFActGrp:        TPanel;
    // VHF-группа = 8 слотов XVTR × 7 пинов, RX/TX-маски
    FOCXvtrRx:          array[0..CFG_XVTR_COUNT-1, 0..OC_PIN_COUNT-1] of TFlatCheckBox;
    FOCXvtrTx:          array[0..CFG_XVTR_COUNT-1, 0..OC_PIN_COUNT-1] of TFlatCheckBox;
    FOCXvtrAction:      array[0..OC_PIN_COUNT-1] of TFlatComboBox;
    FOCXvtrGrp:         TPanel;
    FOCXvtrActGrp:      TPanel;
    FOCConfig:          TOCSettings;
    FOnOCChange:        TOnOCChange;

    // ---- QO-100 tab (редактирует зарезервированный слот QO100_SLOT) ----
    FPageQO:            TScrollBox;
    FNavQO:             TFlatButton;
    FQoEn:              TFlatCheckBox;
    FQoLnbLO:           TFlatFloatSpinEdit;   // МГц — LNB LO (= LOOffset, RX)
    FQoLoErr:           TFlatFloatSpinEdit;   // Гц — LO Error (= LOError, живая калибровка LNB / beacon-lock)
    FQoTxLO:            TFlatFloatSpinEdit;   // МГц — транспондер-offset (= TxLOOffset)
    FQoTxLOErr:         TFlatFloatSpinEdit;   // Гц  — поправка offset'а (TO Error)
    FQoBegin:           TFlatFloatSpinEdit;   // МГц — downlink begin
    FQoEnd:             TFlatFloatSpinEdit;   // МГц — downlink end
    FQoRXGain:          TFlatFloatSpinEdit;   // dB
    FQoPower:           TFlatSpinEdit;        // 0..100 TX drive ceiling
    FQoInfo:            TLabel;               // расчёт RX IF / TX freq

    // ---- CAT tab controls ----
    FCATSerialEn:       array[0..3] of TFlatCheckBox;
    FCATSerialPort:     array[0..3] of TFlatEdit;
    FCATSerialBaud:     array[0..3] of TFlatComboBox;
    FCATSerialDataBits: array[0..3] of TFlatComboBox;
    FCATSerialStopBits: array[0..3] of TFlatComboBox;
    FCATSerialParity:   array[0..3] of TFlatComboBox;
    FCATSerialAndromeda:array[0..3] of TFlatCheckBox;
    FCATTcpEn:          TFlatCheckBox;
    FCATTcpPort:        TFlatSpinEdit;
    // ---- Slices tab controls (CAT доп. слайсов; индекс = слот, 0 = слайс B) ----
    FSliceCATEn:     array[0..CFG_CAT_SLICE_COUNT-1] of TFlatCheckBox;
    FSliceCATPort:   array[0..CFG_CAT_SLICE_COUNT-1] of TFlatSpinEdit;
    FSliceCATAutoTx: array[0..CFG_CAT_SLICE_COUNT-1] of TFlatCheckBox;
    // Аудио слота: списки — общие с главными комбо (RefreshAudioDevices),
    // первый пункт «Default»/«None» = общий выход/вход приложения.
    FSliceOutDev:    array[0..CFG_CAT_SLICE_COUNT-1] of TFlatComboBox;
    FSliceInDev:     array[0..CFG_CAT_SLICE_COUNT-1] of TFlatComboBox;

    // ---- Advanced tab controls ----
    FChkDither:      TFlatCheckBox;
    FChkRandom:      TFlatCheckBox;
    // Группа ADC прячется целиком у AD936x (SetADCBackend), Web Server встаёт
    // на её место — иначе страница начиналась бы с пустоты.
    FAdvGrpADC:      TPanel;
    FAdvGrpWeb:      TPanel;
    FLblAdvHint:     TLabel;
    FAdvHasADC:      Boolean;
    // ★Перекладка страницы возможна только когда страница ВИДИМА: сброс
    // прокрутки не доходит до спрятанного виджета, а сохранённое смещение
    // возвращается при показе — и блоки разъезжаются. Поэтому перекладка
    // спрятанной страницы лишь взводит свой флаг, а исполняется из SelectPage
    // в момент показа (RunDueRelayout).
    FPARelayoutDue:  Boolean;
    FTXRelayoutDue:  Boolean;
    FCalRelayoutDue: Boolean;
    FAdvRelayoutDue: Boolean;
    FChkWebEnabled:  TFlatCheckBox;
    FEdWebPort:      TFlatSpinEdit;
    FEdWebBind:      TFlatEdit;
    FEdWebUser:      TFlatEdit;
    FEdWebPass:      TFlatEdit;
    FCmbWebRes:      TFlatComboBox;
    FChkTCIEnabled:  TFlatCheckBox;
    FEdTCIPort:      TFlatSpinEdit;
    FEdTCIBind:      TFlatEdit;
    // Последнее применённое: обработчик висит на уходе фокуса и на Close,
    // а перезапускать сервер без правки нельзя (порвёт клиентов).
    FTCILastEnabled: Boolean;
    FTCILastPort:    Integer;
    FTCILastBind:    string;

    // ---- Close button ----
    FBtnClose:       TFlatButton;

    // ---- Callbacks ----
    FOnCATChange:        TOnCATChange;
    FOnSliceCATChange:   TOnSliceCATChange;
    FOnPAChange:         TOnPASettingsChange;
    FOnPlutoTxChange:    TOnPlutoTxChange;
    FOnAnt936xChange:    TOnAnt936xChange;
    FOnVHFCalChange:     TOnVHFCalChange;
    FOnADCChange:        TOnADCChange;
    FOnWebSettingsChange: TOnWebSettingsChange;
    FOnTCISettingsChange: TOnTCISettingsChange;
    FOnDXClusterChange:   TOnDXClusterChange;
    FOnDisplayChange:    TOnDisplayParamChange;
    FOnWaterfallChange:  TOnWaterfallParamChange;
    FOnWfRenderChange:   TOnWfRenderChange;
    FOnSpecMSAAChange:   TOnSpecMSAAChange;
    FOnGridChange:       TOnGridParamChange;
    FOnAudioDevChange:   TOnAudioDevChange;
    FOnAudioInDevChange: TOnAudioInDevChange;
    FOnAudioBufferChange: TOnAudioBufferChange;
    FOnSendAudioToRadioChange: TOnSendAudioToRadioChange;
    FOnVisibilityChange: TOnVisibilityChange;
    FOnFPSChange:        TOnFPSChange;
    FOnThemeChange:            TOnThemeChange;
    FOnWfAGCNFChange:          TOnWfAGCNFChange;
    FOnFreqMhzDigitsChange:    TOnFreqMhzDigitsChange;
    FOnTXChange:               TOnTXSettingsChange;
    FOnCWChange:               TOnCWSettingsChange;
    FOnCWMessagesOpen:         TOnCWMessagesOpen;
    FOnCWTerminalOpen:         TOnCWTerminalOpen;
    FOnTXProfileSelect:        TOnTXProfileIdx;
    FOnTXProfileAdd:           TOnTXProfileName;
    FOnTXProfileRename:        TOnTXProfileIdxName;
    FOnTXProfileDelete:        TOnTXProfileIdx;
    FOnTXProfileFactory:       TNotifyEvent;

    FLoading: Boolean;

    procedure BuildUI;
    procedure BuildAudioTab;
    procedure BuildDisplayTab;
    procedure BuildRX1Tab;
    procedure BuildPATab;
    procedure BuildCalibrationTab;
    function  GroupTitle(Grp: TPanel): TLabel;
    procedure RunDueRelayout(APage: TWinControl);
    procedure RelayoutCalTab;
    procedure RelayoutPATab;
    procedure RelayoutTXProfileSub;
    procedure RelayoutAdvancedTab;
    // Раскладка блока офсетов «на диапазон + на трансвертер» внутри группы
    // (общая для S-метра и спектра). Возвращает нужную высоту группы.
    function  LayoutCalOffsets(const BandLbl: array of TLabel;
                               const BandEd:  array of TFlatFloatSpinEdit;
                               XvtrHdr: TLabel;
                               const XvtrLbl: array of TLabel;
                               const XvtrEd:  array of TFlatFloatSpinEdit;
                               Y0: Integer): Integer;
    procedure OnCalDispFromSMClick(Sender: TObject);
    procedure CollectCalFromUI;
    procedure OnCalAnyChange(Sender: TObject);
    procedure BuildCATTab;
    procedure BuildSlicesTab;
    procedure BuildTransmitTab;
    procedure BuildAlexTab;
    procedure BuildAdvancedTab;
    procedure OnADCChkChange(Sender: TObject);
    procedure OnWebAnyChange(Sender: TObject);
    procedure OnTCIAnyChange(Sender: TObject);
    procedure CollectAlexFromUI;
    procedure FireAlexChange;
    procedure OnAlexAnyChange(Sender: TObject);
    procedure OnAlexRxOnlyChange(Sender: TObject);
    procedure BuildXvtrTab;
    procedure BuildQOTab;
    procedure CollectQOFromUI;       // QO-виджеты → FXvtrConfig[QO100_SLOT]
    procedure LoadQOFromConfig;      // FXvtrConfig[QO100_SLOT] → QO-виджеты
    procedure UpdateQOInfo;          // пересчёт RX IF / TX freq в инфо-метке
    procedure OnQOAnyChange(Sender: TObject);
    procedure CollectXvtrFromUI;
    procedure FireXvtrChange;
    procedure OnXvtrAnyChange(Sender: TObject);
    procedure UpdateXvtrRowEnabled(Idx: Integer);
    procedure BuildOCTab;
    procedure CollectOCFromUI;
    procedure FireOCChange;
    procedure OnOCAnyChange(Sender: TObject);
    procedure CollectTXFromUI;
    procedure FireTXChange;
    procedure OnTXAnyChange(Sender: TObject);
    procedure CollectCWFromUI;
    procedure FireCWChange;
    procedure OnCWAnyChange(Sender: TObject);
    procedure BtnCWMessagesClick(Sender: TObject);
    procedure BtnCWTerminalClick(Sender: TObject);
    procedure OnMicJackChange(Sender: TObject);
    procedure UpdateMicJackVisibility;
    // Подвкладки и полоса профиля на странице Transmit
    procedure SelectTXSubPage(Idx: Integer);
    procedure TXSubTabClick(Sender: TObject);
    procedure OnTXProfileComboChange(Sender: TObject);
    procedure BtnTXProfNewClick(Sender: TObject);
    procedure BtnTXProfRenClick(Sender: TObject);
    procedure BtnTXProfDelClick(Sender: TObject);
    procedure BtnTXProfFactoryClick(Sender: TObject);
    procedure ApplyTheme(const T: TAppTheme);
    procedure SettingsResize(Sender: TObject);
    procedure SettingsScrollChanged(Sender: TObject);
    procedure SettingsPageMouseWheel(Sender: TObject; Shift: TShiftState;
      WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean);
    procedure UpdateSettingsScrollBar;
    procedure RelayoutResponsiveCards;
    procedure NavClick(Sender: TObject);
    procedure SelectPage(APage: TWinControl; ANav: TFlatButton);

    function MakeLbl(AParent: TWinControl; const Cap: string;
      ALeft, ATop, AW: Integer): TLabel;
    function MakeTextBlock(AParent: TWinControl; const Cap: string;
      ALeft, ATop, AW, AH: Integer): TLabel;
    procedure MakePageHeader(AParent: TWinControl; const ATitle, ASubtitle: string;
      ASubtitleHeight: Integer = 20);
    function MakeCombo(AParent: TWinControl; ALeft, ATop, AW: Integer;
      OnChange: TNotifyEvent): TFlatComboBox;
    function MakeGroupPanel(AParent: TWinControl; const Cap: string;
      ALeft, ATop, AW, AH: Integer): TPanel;
    procedure BuildDXClusterTab;
    procedure OnDXAnyChange(Sender: TObject);
    function MakeScrollPage: TScrollBox;
    procedure AddPageBottomSpace(APage: TScrollBox);
    procedure UpdatePageBottomSpace(APage: TScrollBox);
    procedure AddNavItem(AItem: TControl);
    procedure MakeNavSection(const Cap: string);
    function MakeNavButton(const Cap: string): TFlatButton;
    procedure RelayoutNav;

    procedure OnFFTSizeChange(Sender: TObject);
    procedure OnWindowChange(Sender: TObject);
    procedure OnSpecDetChange(Sender: TObject);
    procedure OnSpecAvgModeChange(Sender: TObject);
    procedure OnSpecAvgTimeChange(Sender: TObject);
    procedure OnWfDetChange(Sender: TObject);
    procedure OnWfAvgModeChange(Sender: TObject);
    procedure OnWfAvgTimeChange(Sender: TObject);
    procedure OnWfThresholdChange(Sender: TObject);
    procedure OnWfAGCNFChkChange(Sender: TObject);
    procedure OnRefLevelChange(Sender: TObject);
    procedure OnRangeChange(Sender: TObject);
    procedure OnGridStepChange(Sender: TObject);
    procedure OnRXDevChange(Sender: TObject);
    procedure OnTXDevChange(Sender: TObject);
    procedure OnAudioBufferCmbChange(Sender: TObject);
    procedure OnSendAudioToRadioChkChange(Sender: TObject);
    procedure OnVisibilityChkChange(Sender: TObject);
    procedure OnFPSCmbChange(Sender: TObject);
    procedure OnLightThemeChkChange(Sender: TObject);
    procedure OnFreqMhzDigitsCmbChange(Sender: TObject);
    procedure OnMaxPowerChange(Sender: TObject);
    procedure OnPlutoTxMaxAttChange(Sender: TObject);
    procedure Ant936xUIChange(Sender: TObject);
    procedure OnBandCalChange(Sender: TObject);
    procedure OnVHFCalEdit(Sender: TObject);
    procedure OnCATSerialChange(Sender: TObject);
    procedure OnCATTcpChange(Sender: TObject);
    procedure FireCATChange;
    procedure OnSliceCATCtrlChange(Sender: TObject);
    procedure SelectDevInCombo(Cmb: TFlatComboBox; const DevName: string);
    procedure FillSliceDevCombos(AudioOut: TAudioOutput; AudioIn: TAudioInput);
    procedure FireSliceCATChange;
    procedure BtnCloseClick(Sender: TObject);

    procedure FireDisplayChange;
    procedure FireWaterfallChange;
    procedure FireWfRenderChange;
    procedure OnWfRenderCtlChange(Sender: TObject);
    procedure OnSpecMSAACtlChange(Sender: TObject);
    procedure FireGridChange;

    function  GetSpecAvgTimeMS: Double;
    function  GetWfAvgTimeMS:   Double;
    function  GetWfHigh:        Double;
    function  GetWfLow:         Double;
    function  GetWfAGCOffset:   Double;
    function  GetRefLevel:     Double;
    function  GetRange:        Double;
    function  GetGridStep:     Double;
    function  GetFFTSize:      Integer;

  public
    constructor Create(AOwner: TComponent); override;

    procedure LoadValues(
      FFTSize, WinType, SpecDet, SpecAvgMode: Integer;
      SpecAvgTimeMS: Double;
      WfDet, WfAvgMode: Integer;
      WfAvgTimeMS, WfHigh, WfLow, WfAGCOffset: Double;
      RefLevel, Range, GridStep: Double;
      const AudioOutDevName: string;
      const AudioInDevName: string);

    procedure RefreshAudioDevices(AudioOut: TAudioOutput; AudioIn: TAudioInput);
    procedure LoadAudioBufferSize(BufferSize: Integer);
    procedure LoadSendAudioToRadio(Enabled: Boolean);
    procedure LoadVisibility(ShowSpectrum, ShowWaterfall, ShowWideband,
      WidebandFill, SpectrumFill: Boolean);
    procedure LoadFPS(FPS: Integer);
    procedure LoadLightTheme(ALight: Boolean);
    procedure LoadFreqMhzDigits(Digits: Integer);
    procedure LoadPASettings(MaxPower: Double; const BandCal: array of Double);
    procedure LoadPlutoTxMaxAtt(MaxAttDb: Double);
    procedure LoadCalibration(const C: TCalibration);
    // Железо вкладки Calibration: band-план и состав групп (Pluto/openHPSDR).
    procedure SetCalibrationBackend(IsPluto: Boolean);
    // Мост детектора мощности и drive-байт есть только у openHPSDR: у AD936x
    // мощность = железная аттенюация, и вся калибровка по диапазонам мертва.
    procedure SetPABackend(HasPA: Boolean);
    // Вкладка Antenna: Alex-таблица (openHPSDR) либо таблица разъёмов AD936x.
    procedure SetAntennaBackend(IsAD936x, HasAntSwitch: Boolean);
    // Таблица разъёмов + имена/видимость слотов трансвертеров.
    procedure LoadAnt936x(const A: TAD936xAnt; const X: TXvtrSettings);
    procedure LoadVHFCalSettings(const VHFCal: array of Double);
    // Слайс-CAT: enable/порт/AutoTX по слотам (0 = слайс B).
    procedure LoadSliceCATSettings(
      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);
    procedure LoadCATSettings(
      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 LoadTXSettings(const T: TTXSettings; BoardType: Integer = 0);
    procedure LoadCWSettings(const C: TCWSettings);
    // Есть ли у активного железа кейер в прошивке (openHPSDR — да, AD936x — нет).
    procedure SetCWBackend(HasFWKeyer: Boolean);
    // Микрофонный разъём трансивера (DUC Specific байт 50) — только openHPSDR.
    procedure SetTXMicBackend(HasHWMic: Boolean);
    // Широкополосный АЦП и dither/random — тоже свойства openHPSDR.
    procedure SetWidebandBackend(HasWideband: Boolean);
    procedure SetADCBackend(HasDitherRandom: Boolean);
    // Список TX-профилей + активный + drive активного (ползунок DRV живёт в
    // главном окне, здесь показываем его значение как часть профиля).
    procedure LoadTXProfiles(const Names: array of string;
                             ActiveIdx, DrivePct: Integer);
    // Живое обновление подписи «Drive профиля» (ползунок DRV в главном окне).
    procedure SetTXProfileDrive(DrivePct: Integer);
    // Открыть диалог сразу на странице Transmit (ПКМ по кнопке профиля).
    // SubIdx: 0=Profile, 1=EQ, 2=Hardware, 3=Display.
    procedure ShowTransmitPage(SubIdx: Integer = 0);
    procedure LoadAlexSettings(const A: TAlexSettings; BoardType: Integer);
    procedure LoadXvtrSettings(const X: TXvtrSettings);
    procedure LoadOCSettings(const O: TOCSettings);
    // Вкладка OC Control — только openHPSDR (у Pluto/AD936x нет такого выхода).
    procedure SetOCBackend(IsPluto: Boolean);
    // Вкладка QO-100 — наоборот, только AD936x (см. TRadioController.InQO100).
    procedure SetQOBackend(IsPluto: Boolean);
    procedure LoadWfAGCNF(AGC, NF: Boolean);
    procedure LoadWfRender(Palette: Integer; Gamma: Double;
      ResLimit, SpeedDiv: Integer);
    procedure LoadSpecMSAA(Samples: Integer);
    procedure LoadADCSettings(Dither, Random: Boolean);
    procedure LoadDXClusterSettings(const D: TDXClusterSettings);
    procedure LoadWebSettings(Enabled: Boolean; Port: Integer;
      const BindAddr, User, Pass: string; SpecPixels: Integer);
    procedure LoadTCISettings(Enabled: Boolean; Port: Integer;
      const BindAddr: string);

    property OnCATChange:        TOnCATChange            read FOnCATChange       write FOnCATChange;
    property OnSliceCATChange:   TOnSliceCATChange       read FOnSliceCATChange  write FOnSliceCATChange;
    property OnAlexChange:       TOnAlexChange           read FOnAlexChange      write FOnAlexChange;
    property OnXvtrChange:       TOnXvtrChange           read FOnXvtrChange      write FOnXvtrChange;
    property OnOCChange:         TOnOCChange             read FOnOCChange        write FOnOCChange;
    property OnPAChange:         TOnPASettingsChange     read FOnPAChange        write FOnPAChange;
    property OnPlutoTxChange:    TOnPlutoTxChange        read FOnPlutoTxChange   write FOnPlutoTxChange;
    property OnAnt936xChange:    TOnAnt936xChange        read FOnAnt936xChange   write FOnAnt936xChange;
    property OnVHFCalChange:     TOnVHFCalChange         read FOnVHFCalChange    write FOnVHFCalChange;
    property OnCalibrationChange: TOnCalibrationChange   read FOnCalibrationChange write FOnCalibrationChange;
    property OnDisplayChange:    TOnDisplayParamChange   read FOnDisplayChange   write FOnDisplayChange;
    property OnWaterfallChange:  TOnWaterfallParamChange read FOnWaterfallChange write FOnWaterfallChange;
    property OnWfRenderChange:   TOnWfRenderChange       read FOnWfRenderChange  write FOnWfRenderChange;
    property OnSpecMSAAChange:   TOnSpecMSAAChange       read FOnSpecMSAAChange  write FOnSpecMSAAChange;
    property OnGridChange:       TOnGridParamChange      read FOnGridChange      write FOnGridChange;
    property OnAudioDevChange:   TOnAudioDevChange       read FOnAudioDevChange  write FOnAudioDevChange;
    property OnAudioInDevChange: TOnAudioInDevChange     read FOnAudioInDevChange write FOnAudioInDevChange;
    property OnAudioBufferChange: TOnAudioBufferChange    read FOnAudioBufferChange write FOnAudioBufferChange;
    property OnSendAudioToRadioChange: TOnSendAudioToRadioChange read FOnSendAudioToRadioChange write FOnSendAudioToRadioChange;
    property OnVisibilityChange: TOnVisibilityChange     read FOnVisibilityChange write FOnVisibilityChange;
    property OnFPSChange:        TOnFPSChange            read FOnFPSChange       write FOnFPSChange;
    property OnThemeChange:      TOnThemeChange          read FOnThemeChange     write FOnThemeChange;
    property OnFreqMhzDigitsChange: TOnFreqMhzDigitsChange read FOnFreqMhzDigitsChange write FOnFreqMhzDigitsChange;
    property OnTXChange:         TOnTXSettingsChange     read FOnTXChange        write FOnTXChange;
    property OnCWChange:         TOnCWSettingsChange     read FOnCWChange        write FOnCWChange;
    property OnCWMessagesOpen:   TOnCWMessagesOpen       read FOnCWMessagesOpen  write FOnCWMessagesOpen;
    property OnCWTerminalOpen:   TOnCWTerminalOpen       read FOnCWTerminalOpen  write FOnCWTerminalOpen;
    property OnTXProfileSelect:  TOnTXProfileIdx         read FOnTXProfileSelect write FOnTXProfileSelect;
    property OnTXProfileAdd:     TOnTXProfileName        read FOnTXProfileAdd    write FOnTXProfileAdd;
    property OnTXProfileRename:  TOnTXProfileIdxName     read FOnTXProfileRename write FOnTXProfileRename;
    property OnTXProfileDelete:  TOnTXProfileIdx         read FOnTXProfileDelete write FOnTXProfileDelete;
    property OnTXProfileFactory: TNotifyEvent            read FOnTXProfileFactory write FOnTXProfileFactory;
    property OnWfAGCNFChange:    TOnWfAGCNFChange        read FOnWfAGCNFChange   write FOnWfAGCNFChange;
    property OnADCChange:        TOnADCChange            read FOnADCChange       write FOnADCChange;
    property OnWebSettingsChange: TOnWebSettingsChange   read FOnWebSettingsChange write FOnWebSettingsChange;
    property OnTCISettingsChange: TOnTCISettingsChange   read FOnTCISettingsChange write FOnTCISettingsChange;
    property OnDXClusterChange: TOnDXClusterChange       read FOnDXClusterChange   write FOnDXClusterChange;
  end;

implementation

const
  CLR_BG      = TColor($00121212);
  CLR_PANEL   = TColor($001A1A1A);
  CLR_TEXT    = TColor($00E0E0E0);
  CLR_TEXTDIM = TColor($00888888);
  CLR_BORDER  = TColor($00303030);
  CLR_ACCENT  = TColor($0040FF80);
  CLR_INPUT   = TColor($00FFFFFF);
  CLR_INPUT_TEXT = TColor($00202020);
  BTN_H       = 24;
  GRP_TITLE_H = 22;  // высота заголовка в group-панелях (title label + divider)
  SETTINGS_MARGIN = 22;
  SETTINGS_CARD_W = 760;
  SETTINGS_CARD_MAX_W = 934;
  SETTINGS_FOOTER_H = 52;
  SETTINGS_BOTTOM_SPACE = 24;

  // Левая колонка навигации: раскладку считает RelayoutNav, а не call-site —
  // пункт умеет прятаться, и захардкоженные Top оставляли на его месте дыру.
  NAV_Y0       = 68;   // верх первого заголовка секции
  NAV_BTN_H    = 30;   // высота кнопки пункта
  NAV_STEP     = 32;   // шаг между кнопками (высота + зазор)
  NAV_SECT_H   = 18;   // высота заголовка секции
  NAV_SECT_GAP = 8;    // отступ перед заголовком следующей секции

  // Первый пункт комбо аудио слайса = «как у приложения» (пустое имя в конфиге).
  SLICE_DEV_DEFAULT = 'Default (app output)';
  SLICE_DEV_NONE    = 'None (app input)';
  CAT_BAUD_VALS: array[0..7] of Integer = (1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200);
  CAT_BAUD_NAMES: array[0..7] of string = ('1200','2400','4800','9600','19200','38400','57600','115200');

  FFT_SIZES: array[0..6] of Integer = (4096, 8192, 16384, 32768, 65536, 131072, 262144);
  FFT_NAMES: array[0..6] of string  = ('4096','8192','16384','32768','65536','131072','262144');

  WINDOW_NAMES: array[0..6] of string = (
    'Rectangular', '4-term Blackman-Harris', 'Hann',
    'Flat-top', 'Hamming', 'Kaiser', '7-term Blackman-Harris');

  DETECTOR_NAMES: array[0..4] of string = (
    'Peak', 'Rosenfell', 'Average', 'Sample', 'RMS');

  AVGMODE_NAMES: array[0..3] of string = (
    'None', 'Recursive', 'Time Window', 'Log Recursive');
  AVGMODE_VALUES: array[0..3] of Integer = (0, 1, 2, 3);
  WF_AVGMODE_NAMES: array[0..3] of string = (
    'None', 'Recursive', 'Time Window', 'Log Recursive');
  WF_AVGMODE_VALUES: array[0..3] of Integer = (0, 1, 2, 3);

  GRID_STEPS:  array[0..4] of Double = (5.0, 10.0, 20.0, 30.0, 40.0);
  GRID_STEP_NAMES: array[0..4] of string = ('5 dB','10 dB','20 dB','30 dB','40 dB');

  WF_PALETTE_NAMES: array[0..2] of string = ('Classic', 'Inferno', 'Turbo');
  DISPLAY_RES_VALUES: array[0..2] of Integer = (1024, 2048, 4096);
  DISPLAY_RES_NAMES: array[0..2] of string = ('1024 px', '2048 px', '4096 px');
  WF_SPEED_VALUES: array[0..3] of Integer = (1, 2, 4, 8);
  WF_SPEED_NAMES: array[0..3] of string = ('Full rate', '1/2', '1/4', '1/8');
  MSAA_VALUES: array[0..3] of Integer = (1, 2, 4, 8);
  MSAA_NAMES: array[0..3] of string = ('Off', '2x', '4x', '8x');

  FPS_VALUES: array[0..12] of Integer = (5, 10, 15, 20, 25, 30, 40, 50, 60, 75, 80, 90, 100);
  FPS_NAMES:  array[0..12] of string  = ('5 fps','10 fps','15 fps','20 fps','25 fps','30 fps',
                                         '40 fps','50 fps','60 fps','75 fps','80 fps','90 fps','100 fps');

  PA_BAND_NAMES: array[0..10] of string =
    ('160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m');

  // TX pin action (OC Control) — имена в UI, значения = OCA_* из Settings.pas.
  OC_ACTION_NAMES: array[0..6] of string = (
    'MOX', 'TUNE', '2TON', 'MOX+TUNE', 'MOX+2TON', 'TUNE+2TON', 'MOX+TUNE+2TON');

  // Раскладка вкладки Calibration — общая для построения (BuildCalibrationTab)
  // и перекладки под активное железо (RelayoutCalTab).
  CAL_MARGIN   = 22;
  CAL_PAD      = 18;
  CAL_ROW_H    = 32;
  CAL_R1       = 42;
  CAL_ED_W     = 72;
  CAL_LBL_W    = 54;
  CAL_SM_LBL_W = 68;   // подписи трансвертеров длиннее ('70cm', 'QO-100')
  CAL_COLS     = 4;
  CAL_GRP_W    = SETTINGS_CARD_W;
  CAL_GRP1_H   = 236;  // Power / SWR detector
  CAL_GRP2_H   = 156;  // Supply voltage / current
  CAL_VA_LBL_W = 220;

{ TSettingsForm }

constructor TSettingsForm.Create(AOwner: TComponent);
var
  WorkArea: TRect;
  MaxW, MaxH: Integer;
begin
  inherited CreateNew(AOwner);
  // Вся геометрия этой формы строится кодом и уже переводится из 96-DPI
  // координат через DpiScale. Если оставить TCustomForm.Scaled=True, LCL
  // после конструктора повторно применит monitor DPI ко всей форме:
  // при Windows 125% получится 1.25 * 1.25 = 1.5625. Кроме общего раздувания,
  // это смешивает два масштаба с RelayoutCalTab, который вызывается позднее,
  // и строки Calibration начинают наезжать на поясняющий текст.
  Scaled       := False;
  Caption      := 'Settings';
  if AOwner is TCustomForm then
  begin
    WorkArea := TCustomForm(AOwner).BoundsRect;
    WorkArea := Screen.MonitorFromRect(WorkArea).WorkareaRect;
    Position := poOwnerFormCenter;
  end
  else
  begin
    WorkArea := Screen.PrimaryMonitor.WorkareaRect;
    Position := poScreenCenter;
  end;
  MaxW         := WorkArea.Right - WorkArea.Left - DpiScale(48);
  MaxH         := WorkArea.Bottom - WorkArea.Top - DpiScale(48);
  Width        := Min(DpiScale(1180), MaxW);
  Height       := Min(DpiScale(720), MaxH);
  Constraints.MinWidth  := Min(DpiScale(760), Width);
  Constraints.MinHeight := Min(DpiScale(620), Height);
  BorderStyle  := bsSizeable;
  Color        := CLR_BG;
  Font.Color   := CLR_TEXT;
  FLoading     := False;
  FRXDevCount  := 0;
  BuildUI;
  OnResize := SettingsResize;
  OnMouseWheel := SettingsPageMouseWheel;
  RelayoutResponsiveCards;
  ApplyTheme(DarkTheme);
end;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function TSettingsForm.MakeLbl(AParent: TWinControl; const Cap: string;
  ALeft, ATop, AW: Integer): TLabel;
begin
  Result           := TLabel.Create(Self);
  Result.Parent    := AParent;
  Result.Caption   := Cap;
  Result.AutoSize  := AW <= 0;
  if AW > 0 then
  begin
    Result.SetBounds(DpiScale(ALeft), DpiScale(ATop),
      DpiScale(AW), DpiScale(18));
    Result.Layout := tlCenter;
  end
  else
  begin
    Result.Left := DpiScale(ALeft);
    Result.Top  := DpiScale(ATop);
  end;
  Result.Font.Color := CLR_TEXTDIM;
  Result.Font.Size  := 9;
end;

function TSettingsForm.MakeTextBlock(AParent: TWinControl; const Cap: string;
  ALeft, ATop, AW, AH: Integer): TLabel;
begin
  Result            := TLabel.Create(Self);
  Result.Parent     := AParent;
  Result.Caption    := Cap;
  Result.AutoSize   := False;
  Result.SetBounds(DpiScale(ALeft), DpiScale(ATop),
    DpiScale(AW), DpiScale(AH));
  Result.Layout     := tlTop;
  Result.WordWrap   := True;
  Result.Font.Size  := 9;
  Result.Font.Color := CLR_TEXTDIM;
end;

procedure TSettingsForm.MakePageHeader(AParent: TWinControl;
  const ATitle, ASubtitle: string; ASubtitleHeight: Integer);
var
  L: TLabel;
begin
  L := TLabel.Create(Self);
  L.Parent     := AParent;
  L.Caption    := ATitle;
  L.AutoSize   := False;
  L.SetBounds(DpiScale(SETTINGS_MARGIN), DpiScale(16),
    DpiScale(SETTINGS_CARD_W), DpiScale(32));
  L.Tag        := TAG_PAGE_HEADER;
  L.Layout     := tlCenter;
  L.Font.Size  := 16;
  L.Font.Style := [fsBold];
  L.Font.Color := CLR_TEXT;

  if ASubtitle = '' then Exit;
  L := MakeTextBlock(AParent, ASubtitle, SETTINGS_MARGIN, 48,
    SETTINGS_CARD_W, ASubtitleHeight);
  L.Tag := TAG_PAGE_HEADER;
end;

function TSettingsForm.MakeCombo(AParent: TWinControl; ALeft, ATop, AW: Integer;
  OnChange: TNotifyEvent): TFlatComboBox;
begin
  Result          := TFlatComboBox.Create(Self);
  Result.Parent   := AParent;
  Result.Left     := DpiScale(ALeft);
  Result.Top      := DpiScale(ATop);
  Result.Width    := DpiScale(AW);
  Result.Height   := DpiScale(BTN_H);
  Result.Color    := CLR_INPUT;
  Result.Font.Color := CLR_INPUT_TEXT;
  Result.Font.Size  := 9;
  Result.OnChange := OnChange;
end;

// Создаёт панель-группу с заголовком и разделителем (вместо TGroupBox).
// На Windows TGroupBox рисует фрейм системными цветами, TPanel — нет.
function TSettingsForm.MakeGroupPanel(AParent: TWinControl; const Cap: string;
  ALeft, ATop, AW, AH: Integer): TPanel;
var
  TitleLbl: TLabel;
  Divider:  TPanel;
  SAW: Integer;   // AW уже пересчитан в реальные пиксели — используем везде ниже
begin
  SAW := DpiScale(AW);
  Result := TPanel.Create(Self);
  Result.Parent := AParent;
  Result.SetBounds(DpiScale(ALeft), DpiScale(ATop), SAW, DpiScale(AH));
  Result.BevelOuter := bvNone;
  Result.Color := CLR_PANEL;
  if AW = SETTINGS_CARD_W then
    Result.Tag := TAG_RESPONSIVE_CARD;

  TitleLbl := TLabel.Create(Self);
  TitleLbl.Parent := Result;
  TitleLbl.Caption := Cap;
  TitleLbl.AutoSize := False;
  TitleLbl.SetBounds(DpiScale(10), DpiScale(2),
    SAW - DpiScale(20), DpiScale(GRP_TITLE_H - 3));
  TitleLbl.Anchors := [akLeft, akTop, akRight];
  TitleLbl.Layout := tlCenter;
  TitleLbl.Font.Color := CLR_ACCENT;
  TitleLbl.Font.Size  := 9;
  TitleLbl.Font.Style := [fsBold];

  Divider := TPanel.Create(Self);
  Divider.Parent := Result;
  Divider.SetBounds(0, DpiScale(GRP_TITLE_H), SAW, 1);
  Divider.Anchors := [akLeft, akTop, akRight];
  Divider.BevelOuter := bvNone;
  Divider.Color := CLR_BORDER;
end;

procedure TSettingsForm.RunDueRelayout(APage: TWinControl);
// Зовётся из SelectPage сразу после того, как страница стала видимой: только
// теперь у её TScrollBox осмысленная прокрутка, и перекладку можно исполнить.
begin
  if      (APage = FPagePA)       and FPARelayoutDue  then RelayoutPATab
  else if (APage = FPageTransmit) and FTXRelayoutDue  then RelayoutTXProfileSub
  else if (APage = FPageCalib)    and FCalRelayoutDue then RelayoutCalTab
  else if (APage = FPageAdvanced) and FAdvRelayoutDue then RelayoutAdvancedTab;
end;

function TSettingsForm.GroupTitle(Grp: TPanel): TLabel;
// Подпись группы MakeGroupPanel создаёт первой — до разделителя и до всего
// содержимого, — поэтому она всегда Controls[0]. Нужна там, где заголовок
// зависит от железа (страница PA).
begin
  Result := nil;
  if (Grp <> nil) and (Grp.ControlCount > 0) and (Grp.Controls[0] is TLabel) then
    Result := TLabel(Grp.Controls[0]);
end;

function TSettingsForm.MakeScrollPage: TScrollBox;
begin
  Result := TScrollBox.Create(Self);
  Result.Parent      := FContentPanel;
  Result.Align       := alClient;
  Result.BorderStyle := bsNone;
  Result.Color       := CLR_BG;
  Result.AutoScroll  := True;
  Result.OnMouseWheel := SettingsPageMouseWheel;
  Result.Visible     := False;
end;

procedure TSettingsForm.AddPageBottomSpace(APage: TScrollBox);
var
  Spacer: TPanel;
begin
  if APage = nil then Exit;
  Spacer := TPanel.Create(Self);
  Spacer.Parent     := APage;
  Spacer.Tag        := TAG_BOTTOM_SPACER;
  Spacer.BevelOuter := bvNone;
  Spacer.Color      := CLR_BG;
  Spacer.Height     := DpiScale(SETTINGS_BOTTOM_SPACE);
  UpdatePageBottomSpace(APage);
end;

procedure TSettingsForm.UpdatePageBottomSpace(APage: TScrollBox);
var
  i, ContentBottom: Integer;
  Ctrl: TControl;
  Spacer: TPanel;
begin
  if APage = nil then Exit;
  Spacer := nil;
  ContentBottom := 0;
  for i := 0 to APage.ControlCount - 1 do
  begin
    Ctrl := APage.Controls[i];
    if Ctrl.Tag = TAG_BOTTOM_SPACER then
      Spacer := TPanel(Ctrl)
    else if Ctrl.Visible then
      ContentBottom := Max(ContentBottom, Ctrl.Top + Ctrl.Height);
  end;
  if Spacer = nil then Exit;
  Spacer.SetBounds(0, ContentBottom, Max(APage.ClientWidth, DpiScale(804)),
    DpiScale(SETTINGS_BOTTOM_SPACE));
end;

procedure TSettingsForm.AddNavItem(AItem: TControl);
begin
  SetLength(FNavItems, Length(FNavItems) + 1);
  FNavItems[High(FNavItems)] := AItem;
end;

procedure TSettingsForm.MakeNavSection(const Cap: string);
var
  L: TLabel;
begin
  L := TLabel.Create(Self);
  L.Parent     := FNavPanel;
  L.Caption    := UpperCase(Cap);
  L.AutoSize   := False;
  L.SetBounds(DpiScale(14), 0, DpiScale(160), DpiScale(NAV_SECT_H));
  L.Layout     := tlCenter;
  L.Font.Size  := 8;
  L.Font.Style := [fsBold];
  L.Font.Color := CLR_TEXTDIM;
  AddNavItem(L);
end;

function TSettingsForm.MakeNavButton(const Cap: string): TFlatButton;
begin
  Result := TFlatButton.Create(Self);
  Result.Parent      := FNavPanel;
  Result.Caption     := Cap;
  Result.SetBounds(DpiScale(10), 0, DpiScale(166), DpiScale(NAV_BTN_H));
  Result.Anchors     := [akLeft, akTop, akRight];
  Result.OnClick     := NavClick;
  Result.ClrNorm     := CLR_PANEL;
  Result.ClrBorder   := CLR_PANEL;
  Result.ClrHot      := TColor($00242424);
  Result.ClrActive   := TColor($00204030);
  Result.ClrText     := CLR_TEXT;
  Result.ClrTextAct  := CLR_ACCENT;
  Result.Font.Size   := 9;
  Result.Font.Style  := [];
  AddNavItem(Result);
end;

procedure TSettingsForm.RelayoutNav;
// Раскладывает левую колонку сверху вниз, пропуская скрытые пункты: иначе на
// месте спрятанной кнопки (OC Control у Pluto) остаётся дыра в списке.
// Заголовок секции живёт ровно столько, сколько в ней есть видимая кнопка.
var
  I, J, Y: Integer;
  HasVisible: Boolean;
begin
  Y := NAV_Y0;
  for I := 0 to High(FNavItems) do
    if FNavItems[I] is TLabel then
    begin
      // Пустая секция заголовка не заслуживает.
      HasVisible := False;
      for J := I + 1 to High(FNavItems) do
      begin
        if FNavItems[J] is TLabel then Break;
        if FNavItems[J].Visible then begin HasVisible := True; Break; end;
      end;
      FNavItems[I].Visible := HasVisible;
      if not HasVisible then Continue;
      if Y > NAV_Y0 then Inc(Y, NAV_SECT_GAP);
      FNavItems[I].Top := DpiScale(Y);
      Inc(Y, NAV_SECT_H + 2);
    end
    else if FNavItems[I].Visible then
    begin
      FNavItems[I].Top := DpiScale(Y);
      Inc(Y, NAV_STEP);
    end;
end;

// ---------------------------------------------------------------------------
// BuildUI
// ---------------------------------------------------------------------------

procedure TSettingsForm.BuildUI;
begin
  FNavPanel := TPanel.Create(Self);
  FNavPanel.Parent := Self;
  FNavPanel.Align := alLeft;
  FNavPanel.Width := DpiScale(188);
  FNavPanel.BevelOuter := bvNone;
  FNavPanel.Color := TColor($00151515);

  with TLabel.Create(Self) do
  begin
    Parent := FNavPanel;
    Caption := 'Settings';
    AutoSize := False;
    SetBounds(DpiScale(14), DpiScale(14), DpiScale(160), DpiScale(24));
    Layout := tlCenter;
    Font.Size := 14;
    Font.Style := [fsBold];
    Font.Color := CLR_TEXT;
  end;

  with TLabel.Create(Self) do
  begin
    Parent := FNavPanel;
    Caption := 'EWSDR';
    AutoSize := False;
    SetBounds(DpiScale(14), DpiScale(39), DpiScale(160), DpiScale(18));
    Layout := tlCenter;
    Font.Size := 9;
    Font.Color := CLR_TEXTDIM;
  end;

  MakeNavSection('General');
  FNavAudio     := MakeNavButton('Audio');
  FNavDisplay   := MakeNavButton('Display');
  FNavSpectrum  := MakeNavButton('Spectrum');
  FNavWaterfall := MakeNavButton('Waterfall');

  MakeNavSection('Radio');
  FNavTransmit  := MakeNavButton('Transmit');
  FNavPA        := MakeNavButton('Power Amplifier');
  FNavCalib     := MakeNavButton('Calibration');
  FNavAlex      := MakeNavButton('Antenna / Alex');
  FNavXvtr      := MakeNavButton('Transverter');
  FNavOC        := MakeNavButton('OC Control');
  FNavQO        := MakeNavButton('QO-100');

  MakeNavSection('Integration');
  FNavCAT       := MakeNavButton('CAT');
  FNavSlices    := MakeNavButton('Slices');
  FNavAdvanced  := MakeNavButton('Advanced');
  FNavDXCluster := MakeNavButton('DX Cluster');

  RelayoutNav;

  FContentPanel := TPanel.Create(Self);
  FContentPanel.Parent := Self;
  FContentPanel.Align := alClient;
  FContentPanel.BevelOuter := bvNone;
  FContentPanel.Color := CLR_BG;

  FFooterPanel := TPanel.Create(Self);
  FFooterPanel.Parent := FContentPanel;
  FFooterPanel.Align := alBottom;
  FFooterPanel.Height := DpiScale(SETTINGS_FOOTER_H);
  FFooterPanel.BevelOuter := bvNone;
  FFooterPanel.Color := CLR_BG;

  FPageAudio     := MakeScrollPage;
  FPageDisplay   := MakeScrollPage;
  FPageSpectrum  := MakeScrollPage;
  FPageWaterfall := MakeScrollPage;
  FPageTransmit  := MakeScrollPage;
  FPagePA        := MakeScrollPage;
  FPageCalib     := MakeScrollPage;
  FPageAdvanced  := MakeScrollPage;
  FPageDXCluster := MakeScrollPage;
  FPageCAT       := MakeScrollPage;
  FPageSlices    := MakeScrollPage;
  FPageAlex      := MakeScrollPage;
  FPageXvtr      := MakeScrollPage;
  FPageOC        := MakeScrollPage;
  FPageQO        := MakeScrollPage;
  FAlexBoardType := 0;
  TSettingsManager.DefaultAlex(FAlexConfig);
  TSettingsManager.DefaultXvtr(FXvtrConfig);
  TSettingsManager.DefaultCalibration(FCalConfig);
  TSettingsManager.DefaultOC(FOCConfig);

  BuildAudioTab;
  BuildDisplayTab;
  BuildRX1Tab;
  BuildTransmitTab;
  BuildPATab;
  BuildCalibrationTab;
  BuildAdvancedTab;
  BuildDXClusterTab;
  BuildCATTab;
  BuildSlicesTab;
  BuildAlexTab;
  BuildXvtrTab;
  BuildOCTab;
  BuildQOTab;

  // Страницы, заканчивающиеся карточкой, получают небольшой участок фона
  // после последнего блока. Иначе при прокрутке блок визуально слипается
  // с закреплённой нижней панелью Close.
  AddPageBottomSpace(FPageSpectrum);
  AddPageBottomSpace(FPageWaterfall);
  AddPageBottomSpace(FPageCalib);
  AddPageBottomSpace(FPageCAT);
  AddPageBottomSpace(FPageDXCluster);
  AddPageBottomSpace(FPageOC);

  FBtnClose          := TFlatButton.Create(Self);
  FBtnClose.Parent   := FFooterPanel;
  FBtnClose.Caption  := 'Close';
  FBtnClose.SetBounds(FFooterPanel.ClientWidth - DpiScale(110), DpiScale(10),
    DpiScale(92), DpiScale(30));
  FBtnClose.Anchors  := [akRight, akBottom];
  FBtnClose.OnClick  := BtnCloseClick;
  FBtnClose.ClrNorm   := CLR_PANEL;
  FBtnClose.ClrBorder := TColor($00404040);
  FBtnClose.ClrHot    := TColor($00303030);
  FBtnClose.ClrActive := TColor($00003300);
  FBtnClose.ClrText   := CLR_TEXT;
  FBtnClose.ClrTextAct:= CLR_ACCENT;
  FBtnClose.Font.Size := 9;

  // Оконный контейнер перекрывает нативный scrollbar TScrollBox. Сам
  // TOverlayScrollBar остаётся тонким, а системная прокрутка продолжает
  // надёжно двигать всё содержимое страницы.
  FScrollHost := TPanel.Create(Self);
  FScrollHost.Parent := FContentPanel;
  FScrollHost.BevelOuter := bvNone;
  FScrollHost.Color := CLR_BG;
  FScrollHost.Tag := TAG_PAGE_BG;

  FScrollBar := TOverlayScrollBar.Create(Self);
  FScrollBar.Parent := FScrollHost;
  FScrollBar.Align := alClient;
  FScrollBar.OnPositionChange := SettingsScrollChanged;

  SelectPage(FPageAudio, FNavAudio);
end;

procedure TSettingsForm.SettingsResize(Sender: TObject);
begin
  RelayoutResponsiveCards;
  UpdateSettingsScrollBar;
end;

procedure TSettingsForm.SettingsScrollChanged(Sender: TObject);
begin
  if (FActivePage = nil) or (FScrollBar = nil) then Exit;
  FActivePage.VertScrollBar.Position := FScrollBar.Position;
end;

procedure TSettingsForm.SettingsPageMouseWheel(Sender: TObject;
  Shift: TShiftState; WheelDelta: Integer; MousePos: TPoint;
  var Handled: Boolean);
var
  P: TPoint;
begin
  if (FActivePage = nil) or (FScrollBar = nil) then Exit;
  P := FActivePage.ScreenToClient(Mouse.CursorPos);
  if (P.X < 0) or (P.Y < 0) or
     (P.X >= FActivePage.Width) or (P.Y >= FActivePage.Height) then Exit;

  UpdateSettingsScrollBar;
  if FScrollBar.Maximum <= 0 then Exit;

  if WheelDelta > 0 then
    FScrollBar.ScrollBy(-DpiScale(64))
  else if WheelDelta < 0 then
    FScrollBar.ScrollBy(DpiScale(64));
  Handled := WheelDelta <> 0;
end;

procedure TSettingsForm.UpdateSettingsScrollBar;
var
  GutterW, ViewH, MaxPos: Integer;
begin
  if (FActivePage = nil) or (FScrollHost = nil) or
     (FScrollBar = nil) then Exit;

  FActivePage.UpdateScrollbars;
  ViewH := FActivePage.ClientHeight;
  MaxPos := Max(0, FActivePage.VertScrollBar.Range
    - FActivePage.VertScrollBar.Page);

  FScrollBar.HoverTarget := FActivePage;
  FScrollBar.SetRange(MaxPos, ViewH);
  FScrollBar.Position := FActivePage.VertScrollBar.Position;

  // На Windows gutter должен полностью закрывать нативную полосу. Бегунок
  // внутри него всё равно рисуется шириной 4 design px.
  GutterW := Max(DpiScale(8), FActivePage.VertScrollBar.Size);
  FScrollHost.SetBounds(FContentPanel.ClientWidth - GutterW, 0, GutterW,
    Max(0, FContentPanel.ClientHeight - FFooterPanel.Height));
  FScrollHost.BringToFront;
end;

procedure TSettingsForm.RelayoutResponsiveCards;

  procedure ResizeTree(AParent: TWinControl; ACardWidth: Integer);
  var
    i, HalfWidth: Integer;
    Ctrl: TControl;
  begin
    HalfWidth := (ACardWidth - DpiScale(16)) div 2;
    for i := 0 to AParent.ControlCount - 1 do
    begin
      Ctrl := AParent.Controls[i];
      if Ctrl.Tag = TAG_PAGE_HEADER then
        Ctrl.Width := ACardWidth
      else if Ctrl.Tag = TAG_RESPONSIVE_INSET then
        Ctrl.Width := ACardWidth - DpiScale(36)
      else if (Ctrl is TPanel) and (Ctrl.Tag = TAG_RESPONSIVE_CARD) then
        Ctrl.Width := ACardWidth
      else if (Ctrl is TPanel) and (Ctrl.Tag = TAG_RESPONSIVE_HALF_LEFT) then
      begin
        Ctrl.Left  := DpiScale(SETTINGS_MARGIN);
        Ctrl.Width := HalfWidth;
      end
      else if (Ctrl is TPanel) and (Ctrl.Tag = TAG_RESPONSIVE_HALF_RIGHT) then
      begin
        Ctrl.Left  := DpiScale(SETTINGS_MARGIN) + HalfWidth + DpiScale(16);
        Ctrl.Width := HalfWidth;
      end
      else if (Ctrl is TPanel) and (Ctrl.Tag = TAG_PAGE_BG) then
        Ctrl.Width := ACardWidth + DpiScale(2 * SETTINGS_MARGIN);

      if Ctrl is TWinControl then
        ResizeTree(TWinControl(Ctrl), ACardWidth);
    end;
  end;

  procedure ResizeTransmit(ACardWidth: Integer);
  var
    ActionW, ActionGap, ActionLeft, ComboLeft, TabGap, TabW, i: Integer;
  begin
    ActionW   := DpiScale(100);
    ActionGap := DpiScale(6);
    ActionLeft := ACardWidth - DpiScale(18) - ActionW;

    if FBtnTXProfFactory <> nil then
      FBtnTXProfFactory.SetBounds(ActionLeft, FBtnTXProfFactory.Top,
        ActionW, FBtnTXProfFactory.Height);
    Dec(ActionLeft, ActionW + ActionGap);
    if FBtnTXProfDel <> nil then
      FBtnTXProfDel.SetBounds(ActionLeft, FBtnTXProfDel.Top,
        ActionW, FBtnTXProfDel.Height);
    Dec(ActionLeft, ActionW + ActionGap);
    if FBtnTXProfRen <> nil then
      FBtnTXProfRen.SetBounds(ActionLeft, FBtnTXProfRen.Top,
        ActionW, FBtnTXProfRen.Height);
    Dec(ActionLeft, ActionW + ActionGap);
    if FBtnTXProfNew <> nil then
      FBtnTXProfNew.SetBounds(ActionLeft, FBtnTXProfNew.Top,
        ActionW, FBtnTXProfNew.Height);

    if FCmbTXProfile <> nil then
    begin
      ComboLeft := DpiScale(78);
      FCmbTXProfile.Width := Max(DpiScale(230),
        ActionLeft - DpiScale(12) - ComboLeft);
    end;

    TabGap := DpiScale(4);
    TabW   := (ACardWidth - 3 * TabGap) div TXTAB_COUNT;
    for i := 0 to TXTAB_COUNT - 1 do
      if FTXSubBtn[i] <> nil then
        FTXSubBtn[i].SetBounds(DpiScale(SETTINGS_MARGIN)
          + i * (TabW + TabGap), FTXSubBtn[i].Top,
          TabW, FTXSubBtn[i].Height);

    if FTXEQControl <> nil then
      FTXEQControl.Width := ACardWidth - DpiScale(36);
  end;

  procedure ResizeOC(ACardWidth: Integer);
  var
    AsideWidth, Gap, MainWidth, MainLeft, AsideLeft: Integer;
  begin
    AsideWidth := DpiScale(260);
    Gap        := DpiScale(14);
    MainLeft   := DpiScale(SETTINGS_MARGIN);
    MainWidth  := ACardWidth - AsideWidth - Gap;
    AsideLeft  := MainLeft + MainWidth + Gap;

    if FOCHFGrp <> nil then
      FOCHFGrp.SetBounds(MainLeft, FOCHFGrp.Top, MainWidth, FOCHFGrp.Height);
    if FOCHFActGrp <> nil then
      FOCHFActGrp.SetBounds(AsideLeft, FOCHFActGrp.Top,
        AsideWidth, FOCHFActGrp.Height);
    if FOCXvtrGrp <> nil then
      FOCXvtrGrp.SetBounds(MainLeft, FOCXvtrGrp.Top,
        MainWidth, FOCXvtrGrp.Height);
    if FOCXvtrActGrp <> nil then
      FOCXvtrActGrp.SetBounds(AsideLeft, FOCXvtrActGrp.Top,
        AsideWidth, FOCXvtrActGrp.Height);
  end;

  procedure ResizePage(APage: TScrollBox; ACardWidth: Integer);
  begin
    if (APage = nil) or (APage.ClientWidth <= 0) then Exit;
    ResizeTree(APage, ACardWidth);
    if APage = FPageTransmit then
      ResizeTransmit(ACardWidth);
    if APage = FPageOC then
      ResizeOC(ACardWidth);
    UpdatePageBottomSpace(APage);
  end;

var
  CardWidth: Integer;
begin
  if FContentPanel = nil then Exit;
  // Ширина берётся от общего контейнера, а не от отдельной страницы:
  // появившийся на длинной вкладке scrollbar не должен сужать её карточки.
  CardWidth := FContentPanel.ClientWidth
    - DpiScale(2 * SETTINGS_MARGIN) - DpiScale(20);
  CardWidth := EnsureRange(CardWidth, DpiScale(SETTINGS_CARD_W),
    DpiScale(SETTINGS_CARD_MAX_W));

  ResizePage(FPageAudio, CardWidth);
  ResizePage(FPageDisplay, CardWidth);
  ResizePage(FPageSpectrum, CardWidth);
  ResizePage(FPageWaterfall, CardWidth);
  ResizePage(FPageTransmit, CardWidth);
  ResizePage(FPagePA, CardWidth);
  ResizePage(FPageCalib, CardWidth);
  ResizePage(FPageAdvanced, CardWidth);
  ResizePage(FPageDXCluster, CardWidth);
  ResizePage(FPageCAT, CardWidth);
  ResizePage(FPageSlices, CardWidth);
  ResizePage(FPageAlex, CardWidth);
  ResizePage(FPageXvtr, CardWidth);
  ResizePage(FPageOC, CardWidth);
  ResizePage(FPageQO, CardWidth);
end;

procedure TSettingsForm.NavClick(Sender: TObject);
begin
  if Sender = FNavAudio then SelectPage(FPageAudio, FNavAudio)
  else if Sender = FNavDisplay then SelectPage(FPageDisplay, FNavDisplay)
  else if Sender = FNavSpectrum then SelectPage(FPageSpectrum, FNavSpectrum)
  else if Sender = FNavWaterfall then SelectPage(FPageWaterfall, FNavWaterfall)
  else if Sender = FNavTransmit then SelectPage(FPageTransmit, FNavTransmit)
  else if Sender = FNavPA then SelectPage(FPagePA, FNavPA)
  else if Sender = FNavCalib then SelectPage(FPageCalib, FNavCalib)
  else if Sender = FNavAdvanced then SelectPage(FPageAdvanced, FNavAdvanced)
  else if Sender = FNavDXCluster then SelectPage(FPageDXCluster, FNavDXCluster)
  else if Sender = FNavCAT then SelectPage(FPageCAT, FNavCAT)
  else if Sender = FNavSlices then SelectPage(FPageSlices, FNavSlices)
  else if Sender = FNavAlex then SelectPage(FPageAlex, FNavAlex)
  else if Sender = FNavXvtr then SelectPage(FPageXvtr, FNavXvtr)
  else if Sender = FNavOC then SelectPage(FPageOC, FNavOC)
  else if Sender = FNavQO then SelectPage(FPageQO, FNavQO);
end;

procedure TSettingsForm.SelectPage(APage: TWinControl; ANav: TFlatButton);
begin
  if APage is TScrollBox then
    FActivePage := TScrollBox(APage);

  FPageAudio.Visible    := APage = FPageAudio;
  FPageDisplay.Visible  := APage = FPageDisplay;
  FPageSpectrum.Visible := APage = FPageSpectrum;
  FPageWaterfall.Visible:= APage = FPageWaterfall;
  FPageTransmit.Visible := APage = FPageTransmit;
  FPagePA.Visible       := APage = FPagePA;
  FPageCalib.Visible    := APage = FPageCalib;
  FPageAdvanced.Visible := APage = FPageAdvanced;
  FPageDXCluster.Visible := APage = FPageDXCluster;
  FPageCAT.Visible      := APage = FPageCAT;
  FPageSlices.Visible   := APage = FPageSlices;
  FPageAlex.Visible     := APage = FPageAlex;
  FPageXvtr.Visible     := APage = FPageXvtr;
  FPageOC.Visible       := APage = FPageOC;
  FPageQO.Visible       := APage = FPageQO;

  FNavAudio.Active    := ANav = FNavAudio;
  FNavDisplay.Active  := ANav = FNavDisplay;
  FNavSpectrum.Active := ANav = FNavSpectrum;
  FNavWaterfall.Active:= ANav = FNavWaterfall;
  FNavTransmit.Active := ANav = FNavTransmit;
  FNavPA.Active       := ANav = FNavPA;
  FNavCalib.Active    := ANav = FNavCalib;
  FNavAdvanced.Active := ANav = FNavAdvanced;
  FNavDXCluster.Active := ANav = FNavDXCluster;
  FNavCAT.Active      := ANav = FNavCAT;
  FNavSlices.Active   := ANav = FNavSlices;
  FNavAlex.Active     := ANav = FNavAlex;
  FNavXvtr.Active     := ANav = FNavXvtr;
  FNavOC.Active       := ANav = FNavOC;
  FNavQO.Active       := ANav = FNavQO;

  // Скрытые alClient-страницы LCL не всегда перекладывает до показа.
  // После переключения берём уже актуальный ClientWidth выбранной страницы.
  RelayoutResponsiveCards;
  // Отложенная перекладка под железо — теперь страница видима, и сброс
  // прокрутки внутри неё наконец доходит до виджета.
  RunDueRelayout(APage);
  UpdateSettingsScrollBar;
end;

// ---------------------------------------------------------------------------
// BuildAudioTab
// ---------------------------------------------------------------------------

procedure TSettingsForm.BuildAudioTab;
const
  MARGIN = 22;
  PAD    = 18;
  R1     = 42;
  STEP   = 42;
  LW     = 150;
  CX     = PAD + LW + 12;
  CMB_W  = 460;

var
  Grp: TPanel;
begin
  MakePageHeader(FPageAudio, 'Audio',
    'Input and output devices used by the receiver and transmitter.');

  Grp := MakeGroupPanel(FPageAudio, 'Audio Devices', MARGIN, 86, SETTINGS_CARD_W, 184);

  MakeLbl(Grp, 'TX input device', PAD, R1 + 5, LW);
  FCmbTXDev := MakeCombo(Grp, CX, R1, CMB_W, OnTXDevChange);
  FCmbTXDev.Items.Add('None');

  MakeLbl(Grp, 'RX output device', PAD, R1 + STEP + 5, LW);
  FCmbRXDev := MakeCombo(Grp, CX, R1 + STEP, CMB_W, OnRXDevChange);
  FCmbRXDev.Items.Add('Default');

  MakeLbl(Grp, 'Output buffer', PAD, R1 + STEP * 2 + 5, LW);
  FCmbAudioBuffer := MakeCombo(Grp, CX, R1 + STEP * 2, 160, OnAudioBufferCmbChange);
  FCmbAudioBuffer.Items.Add('128 frames');
  FCmbAudioBuffer.Items.Add('256 frames');
  FCmbAudioBuffer.Items.Add('512 frames');
  FCmbAudioBuffer.ItemIndex := 0;

  Grp := MakeGroupPanel(FPageAudio, 'Transceiver Audio', MARGIN, 290, SETTINGS_CARD_W, 92);

  FChkSendAudioToRadio := TFlatCheckBox.Create(Self);
  FChkSendAudioToRadio.Parent := Grp;
  FChkSendAudioToRadio.Caption := 'Send audio stream to radio (built-in speaker)';
  FChkSendAudioToRadio.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(420), DpiScale(22));
  FChkSendAudioToRadio.Checked := False;
  FChkSendAudioToRadio.Font.Color := CLR_TEXT;
  FChkSendAudioToRadio.Font.Size := 9;
  FChkSendAudioToRadio.OnChange := OnSendAudioToRadioChkChange;
end;

// ---------------------------------------------------------------------------
// BuildDisplayTab
// ---------------------------------------------------------------------------

procedure TSettingsForm.BuildDisplayTab;
const
  MARGIN = 22;
  PAD    = 18;
  R1     = 42;
  CHKW   = 220;
  LW     = 150;
  CX     = PAD + LW + 12;

var
  i:   Integer;
  Grp: TPanel;
begin
  MakePageHeader(FPageDisplay, 'Display',
    'General visual options. Spectrum and waterfall processing are split into their own pages.');

  Grp := MakeGroupPanel(FPageDisplay, 'Visible Panes', MARGIN, 86, SETTINGS_CARD_W, 112);

  FChkShowSpectrum := TFlatCheckBox.Create(Self);
  FChkShowSpectrum.Parent := Grp;
  FChkShowSpectrum.Caption := 'Spectrum';
  FChkShowSpectrum.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(CHKW), DpiScale(22));
  FChkShowSpectrum.Checked := True;
  FChkShowSpectrum.Font.Color := CLR_TEXT;
  FChkShowSpectrum.Font.Size := 9;
  FChkShowSpectrum.OnChange := OnVisibilityChkChange;

  FChkShowWaterfall := TFlatCheckBox.Create(Self);
  FChkShowWaterfall.Parent := Grp;
  FChkShowWaterfall.Caption := 'Waterfall';
  FChkShowWaterfall.SetBounds(DpiScale(PAD + CHKW + 20), DpiScale(R1), DpiScale(CHKW), DpiScale(22));
  FChkShowWaterfall.Checked := True;
  FChkShowWaterfall.Font.Color := CLR_TEXT;
  FChkShowWaterfall.Font.Size := 9;
  FChkShowWaterfall.OnChange := OnVisibilityChkChange;

  FChkShowWideband := TFlatCheckBox.Create(Self);
  FChkShowWideband.Parent := Grp;
  FChkShowWideband.Caption := 'Wideband';
  FChkShowWideband.SetBounds(DpiScale(PAD + (CHKW + 20) * 2), DpiScale(R1), DpiScale(CHKW), DpiScale(22));
  FChkShowWideband.Checked := False;
  FChkShowWideband.Font.Color := CLR_TEXT;
  FChkShowWideband.Font.Size := 9;
  FChkShowWideband.OnChange := OnVisibilityChkChange;

  FChkWidebandFill := TFlatCheckBox.Create(Self);
  FChkWidebandFill.Parent := Grp;
  FChkWidebandFill.Caption := 'Wideband gradient fill';
  FChkWidebandFill.SetBounds(DpiScale(PAD + (CHKW + 20) * 2), DpiScale(R1 + 28), DpiScale(CHKW), DpiScale(22));
  FChkWidebandFill.Checked := False;
  FChkWidebandFill.Font.Color := CLR_TEXT;
  FChkWidebandFill.Font.Size := 9;
  FChkWidebandFill.OnChange := OnVisibilityChkChange;

  FChkSpectrumFill := TFlatCheckBox.Create(Self);
  FChkSpectrumFill.Parent := Grp;
  FChkSpectrumFill.Caption := 'Spectrum gradient fill';
  FChkSpectrumFill.SetBounds(DpiScale(PAD), DpiScale(R1 + 28), DpiScale(CHKW), DpiScale(22));
  FChkSpectrumFill.Checked := True;
  FChkSpectrumFill.Font.Color := CLR_TEXT;
  FChkSpectrumFill.Font.Size := 9;
  FChkSpectrumFill.OnChange := OnVisibilityChkChange;

  Grp := MakeGroupPanel(FPageDisplay, 'Performance', MARGIN, 212, SETTINGS_CARD_W, 92);

  MakeLbl(Grp, 'Refresh rate', PAD, R1 + 5, LW);
  FCmbFPS := MakeCombo(Grp, CX, R1, 140, OnFPSCmbChange);
  for i := 0 to High(FPS_NAMES) do
    FCmbFPS.Items.Add(FPS_NAMES[i]);
  FCmbFPS.ItemIndex := 8;

  Grp := MakeGroupPanel(FPageDisplay, 'Appearance', MARGIN, 318, SETTINGS_CARD_W, 68);

  FChkLightTheme := TFlatCheckBox.Create(Self);
  FChkLightTheme.Parent := Grp;
  FChkLightTheme.Caption := 'Light theme';
  FChkLightTheme.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(CHKW), DpiScale(22));
  FChkLightTheme.Checked := False;
  FChkLightTheme.Font.Color := CLR_TEXT;
  FChkLightTheme.Font.Size := 9;
  FChkLightTheme.OnChange := OnLightThemeChkChange;

  Grp := MakeGroupPanel(FPageDisplay, 'VFO Display', MARGIN, 404, SETTINGS_CARD_W, 92);

  MakeLbl(Grp, 'Frequency range', PAD, R1 + 5, LW);
  FCmbFreqMhzDigits := MakeCombo(Grp, CX, R1, 240, OnFreqMhzDigitsCmbChange);
  FCmbFreqMhzDigits.Items.Add('up to 999 MHz  (3 digits)');
  FCmbFreqMhzDigits.Items.Add('up to 9.999 GHz  (4 digits)');
  FCmbFreqMhzDigits.Items.Add('up to 99.999 GHz  (5 digits)');
  FCmbFreqMhzDigits.ItemIndex := 0;
end;

// ---------------------------------------------------------------------------
// BuildRX1Tab
// ---------------------------------------------------------------------------

procedure TSettingsForm.BuildRX1Tab;
const
  MARGIN = 22;
  PAD    = 18;
  LW     = 150;
  CX     = PAD + LW + 12;
  CMB_W  = 230;
  CMB_W_LONG = 300;
  SPW    = 96;
  EDT_W  = 96;
  STEP   = 42;
  R1     = 42;

var
  Grp:    TPanel;
  i:      Integer;

  function MkFloat(AX, AY, AW: Integer; AValue, AMin, AMax, AInc: Double;
                   AOnChange: TNotifyEvent): TFlatFloatSpinEdit;
  begin
    Result := TFlatFloatSpinEdit.Create(Self);
    Result.Parent := Grp;
    Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(BTN_H));
    Result.Value := AValue; Result.MinValue := AMin;
    Result.MaxValue := AMax; Result.Increment := AInc;
    Result.DecimalPlaces := 1;
    Result.Color := CLR_INPUT; Result.Font.Color := CLR_INPUT_TEXT;
    Result.Font.Size := 9;
    Result.OnChange := AOnChange;
  end;

  function MkEdit(AX, AY, AW: Integer; const AText: string;
                  AOnExit: TNotifyEvent): TFlatEdit;
  begin
    Result := TFlatEdit.Create(Self);
    Result.Parent := Grp;
    Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(BTN_H));
    Result.Color := CLR_INPUT; Result.Font.Color := CLR_INPUT_TEXT;
    Result.Font.Size := 9;
    Result.Text := AText; Result.OnExit := AOnExit;
  end;

begin
  MakePageHeader(FPageSpectrum, 'Spectrum',
    'Analyzer, detector, averaging and grid parameters for RX1.');

  Grp := MakeGroupPanel(FPageSpectrum, 'Analyzer', MARGIN, 86, SETTINGS_CARD_W, 128);
  MakeLbl(Grp, 'FFT Size:', PAD, R1 + 5, LW);
  FCmbFFTSize := MakeCombo(Grp, CX, R1, CMB_W, OnFFTSizeChange);
  for i := 0 to High(FFT_NAMES) do FCmbFFTSize.Items.Add(FFT_NAMES[i]);
  MakeLbl(Grp, 'Window:', PAD, R1 + STEP + 5, LW);
  FCmbWindow := MakeCombo(Grp, CX, R1 + STEP, CMB_W_LONG, OnWindowChange);
  for i := 0 to High(WINDOW_NAMES) do FCmbWindow.Items.Add(WINDOW_NAMES[i]);

  Grp := MakeGroupPanel(FPageSpectrum, 'Detection && Averaging', MARGIN, 230, SETTINGS_CARD_W, 170);
  MakeLbl(Grp, 'Detector:', PAD, R1 + 5, LW);
  FCmbSpecDet := MakeCombo(Grp, CX, R1, CMB_W, OnSpecDetChange);
  for i := 0 to High(DETECTOR_NAMES) do FCmbSpecDet.Items.Add(DETECTOR_NAMES[i]);
  MakeLbl(Grp, 'Avg Mode:', PAD, R1 + STEP + 5, LW);
  FCmbSpecAvg := MakeCombo(Grp, CX, R1 + STEP, CMB_W, OnSpecAvgModeChange);
  for i := 0 to High(AVGMODE_NAMES) do FCmbSpecAvg.Items.Add(AVGMODE_NAMES[i]);
  MakeLbl(Grp, 'Avg Time (ms):', PAD, R1 + 2 * STEP + 5, LW);
  FSpecAvgTime := MkFloat(CX, R1 + 2 * STEP, SPW, 30.0, 0.0, 5000.0, 10.0, OnSpecAvgTimeChange);

  Grp := MakeGroupPanel(FPageSpectrum, 'Rendering', MARGIN, 602, SETTINGS_CARD_W, 128);
  MakeLbl(Grp, 'Anti-aliasing:', PAD, R1 + 5, LW);
  FCmbSpecMSAA := MakeCombo(Grp, CX, R1, 130, OnSpecMSAACtlChange);
  for i := 0 to High(MSAA_NAMES) do FCmbSpecMSAA.Items.Add(MSAA_NAMES[i]);
  FCmbSpecMSAA.ItemIndex := 2;   // 4x
  MakeLbl(Grp, '(GL spectrum curve smoothing, MSAA)', CX + 140, R1 + 5, 300);

  Grp := MakeGroupPanel(FPageSpectrum, 'Grid', MARGIN, 416, SETTINGS_CARD_W, 170);
  MakeLbl(Grp, 'Reference level:', PAD, R1 + 5, LW);
  FEdRefLevel := MkEdit(CX, R1, EDT_W, '-20', OnRefLevelChange);
  MakeLbl(Grp, 'Range (dB):', PAD, R1 + STEP + 5, LW);
  FEdRange := MkEdit(CX, R1 + STEP, EDT_W, '110', OnRangeChange);
  MakeLbl(Grp, 'Grid step:', PAD, R1 + 2 * STEP + 5, LW);
  FCmbGridStep := MakeCombo(Grp, CX, R1 + 2 * STEP, 130, OnGridStepChange);
  for i := 0 to High(GRID_STEP_NAMES) do FCmbGridStep.Items.Add(GRID_STEP_NAMES[i]);
  FCmbGridStep.ItemIndex := 1;

  MakePageHeader(FPageWaterfall, 'Waterfall',
    'Waterfall detector, smoothing and color scale thresholds.');

  Grp := MakeGroupPanel(FPageWaterfall, 'Detection && Averaging', MARGIN, 86, SETTINGS_CARD_W, 170);
  MakeLbl(Grp, 'Detector:', PAD, R1 + 5, LW);
  FCmbWfDet := MakeCombo(Grp, CX, R1, CMB_W, OnWfDetChange);
  for i := 0 to High(DETECTOR_NAMES) do FCmbWfDet.Items.Add(DETECTOR_NAMES[i]);
  MakeLbl(Grp, 'Avg Mode:', PAD, R1 + STEP + 5, LW);
  FCmbWfAvg := MakeCombo(Grp, CX, R1 + STEP, CMB_W, OnWfAvgModeChange);
  for i := 0 to High(WF_AVGMODE_NAMES) do FCmbWfAvg.Items.Add(WF_AVGMODE_NAMES[i]);
  MakeLbl(Grp, 'Avg Time (ms):', PAD, R1 + 2 * STEP + 5, LW);
  FWfAvgTime := MkFloat(CX, R1 + 2 * STEP, SPW, 120.0, 0.0, 5000.0, 10.0, OnWfAvgTimeChange);

  Grp := MakeGroupPanel(FPageWaterfall, 'Color Scale', MARGIN, 272, SETTINGS_CARD_W, 170);
  MakeLbl(Grp, 'High (dBm):', PAD, R1 + 5, LW);
  FWfHigh := MkFloat(CX, R1, SPW, -80.0, -160.0, 20.0, 1.0, OnWfThresholdChange);
  MakeLbl(Grp, 'Low (dBm):', PAD, R1 + STEP + 5, LW);
  FWfLow := MkFloat(CX, R1 + STEP, SPW, -130.0, -180.0, 0.0, 1.0, OnWfThresholdChange);
  MakeLbl(Grp, 'AGC Offset (dB):', PAD, R1 + 2 * STEP + 5, LW);
  FWfAGCOffset := MkFloat(CX, R1 + 2 * STEP, SPW, 0.0, -40.0, 40.0, 0.5, OnWfThresholdChange);

  Grp := MakeGroupPanel(FPageWaterfall, 'AGC && Noise Floor', MARGIN, 458, SETTINGS_CARD_W, 140);
  FChkWfAGC := TFlatCheckBox.Create(Self);
  FChkWfAGC.Parent  := Grp;
  FChkWfAGC.Caption := 'Waterfall AGC';
  FChkWfAGC.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(200), DpiScale(22));
  FChkWfAGC.Checked := True;
  FChkWfAGC.Font.Color := CLR_TEXT;
  FChkWfAGC.Font.Size  := 9;
  FChkWfAGC.OnChange   := OnWfAGCNFChkChange;
  FChkWfNF := TFlatCheckBox.Create(Self);
  FChkWfNF.Parent  := Grp;
  FChkWfNF.Caption := 'Waterfall NF';
  FChkWfNF.SetBounds(DpiScale(PAD), DpiScale(R1 + STEP), DpiScale(200), DpiScale(22));
  FChkWfNF.Checked := False;
  FChkWfNF.Font.Color := CLR_TEXT;
  FChkWfNF.Font.Size  := 9;
  FChkWfNF.OnChange   := OnWfAGCNFChkChange;

  Grp := MakeGroupPanel(FPageWaterfall, 'Rendering', MARGIN, 614, SETTINGS_CARD_W, 212);
  MakeLbl(Grp, 'Palette:', PAD, R1 + 5, LW);
  FCmbWfPalette := MakeCombo(Grp, CX, R1, CMB_W, OnWfRenderCtlChange);
  for i := 0 to High(WF_PALETTE_NAMES) do FCmbWfPalette.Items.Add(WF_PALETTE_NAMES[i]);
  FCmbWfPalette.ItemIndex := 0;
  MakeLbl(Grp, 'Gamma:', PAD, R1 + STEP + 5, LW);
  FWfGamma := MkFloat(CX, R1 + STEP, SPW, 1.0, 0.3, 3.0, 0.05, OnWfRenderCtlChange);
  FWfGamma.DecimalPlaces := 2;
  MakeLbl(Grp, 'Resolution:', PAD, R1 + 2 * STEP + 5, LW);
  FCmbWfRes := MakeCombo(Grp, CX, R1 + 2 * STEP, 130, OnWfRenderCtlChange);
  for i := 0 to High(DISPLAY_RES_NAMES) do FCmbWfRes.Items.Add(DISPLAY_RES_NAMES[i]);
  FCmbWfRes.ItemIndex := High(DISPLAY_RES_NAMES);
  MakeLbl(Grp, 'Speed:', PAD, R1 + 3 * STEP + 5, LW);
  FCmbWfSpeed := MakeCombo(Grp, CX, R1 + 3 * STEP, 130, OnWfRenderCtlChange);
  for i := 0 to High(WF_SPEED_NAMES) do FCmbWfSpeed.Items.Add(WF_SPEED_NAMES[i]);
  FCmbWfSpeed.ItemIndex := 0;
end;

// ---------------------------------------------------------------------------
// BuildTransmitTab — все настройки TX-тракта.
// ---------------------------------------------------------------------------

procedure TSettingsForm.BuildTransmitTab;
const
  MARGIN  = 22;
  PAD     = 18;
  LW      = 150;
  CX      = PAD + LW + 12;
  STEP    = 42;
  R1      = 42;
  SPW     = 96;
  GRP_W   = 760;

var
  Grp: TPanel;
  Y, i: Integer;
  SubTop: Integer;   // общий (немасштабированный) Top всех четырёх подвкладок

  // Подложка подвкладки: страница Transmit — четыре независимых экрана,
  // переключаемых сегментами (свиток на 2000 px не листают, в нём ищут).
  function SubPage(Idx: Integer; ATop: Integer): TPanel;
  begin
    Result := TPanel.Create(Self);
    Result.Parent     := FPageTransmit;
    Result.BevelOuter := bvNone;
    Result.Color      := CLR_BG;
    Result.Tag        := TAG_PAGE_BG;    // ApplyTheme: фон страницы, не группа
    Result.SetBounds(0, DpiScale(ATop),
                     DpiScale(GRP_W + 2 * MARGIN), DpiScale(400));
    Result.Visible    := False;
    FTXSubPage[Idx]   := Result;
  end;

  function MkBtn(AParent: TWinControl; AX, AY, AW: Integer;
                 const ACap: string; AClick: TNotifyEvent): TFlatButton;
  begin
    Result := TFlatButton.Create(Self);
    Result.Parent  := AParent;
    Result.Caption := ACap;
    Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(BTN_H));
    Result.ClrNorm    := CLR_PANEL;
    Result.ClrBorder  := CLR_BORDER;
    Result.ClrHot     := TColor($00242424);
    Result.ClrActive  := TColor($00204030);
    Result.ClrText    := CLR_TEXT;
    Result.ClrTextAct := CLR_ACCENT;
    Result.Font.Size  := 9;
    Result.OnClick    := AClick;
  end;

  function MkSpin(AParent: TWinControl; AX, AY: Integer; AMin, AMax, AVal: Integer): TFlatSpinEdit;
  begin
    Result := TFlatSpinEdit.Create(Self);
    Result.Parent := AParent;
    Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(SPW), DpiScale(BTN_H));
    Result.MinValue := AMin;
    Result.MaxValue := AMax;
    Result.Value    := AVal;
    Result.Color := CLR_INPUT;
    Result.Font.Color := CLR_INPUT_TEXT;
    Result.Font.Size  := 9;
    Result.OnChange := OnTXAnyChange;
  end;

  function MkFloat(AParent: TWinControl; AX, AY: Integer;
                   AVal, AMin, AMax, AInc: Double): TFlatFloatSpinEdit;
  begin
    Result := TFlatFloatSpinEdit.Create(Self);
    Result.Parent := AParent;
    Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(SPW), DpiScale(BTN_H));
    Result.MinValue  := AMin;
    Result.MaxValue  := AMax;
    Result.Increment := AInc;
    Result.Value     := AVal;
    Result.DecimalPlaces := 2;
    Result.Color := CLR_INPUT;
    Result.Font.Color := CLR_INPUT_TEXT;
    Result.Font.Size  := 9;
    Result.OnChange := OnTXAnyChange;
  end;

  function MkChk(AParent: TWinControl; AX, AY, AW: Integer; const ACap: string): TFlatCheckBox;
  begin
    Result := TFlatCheckBox.Create(Self);
    Result.Parent := AParent;
    Result.Caption := ACap;
    Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(22));
    Result.Font.Color := CLR_TEXT;
    Result.Font.Size := 9;
    Result.OnChange := OnTXAnyChange;
  end;

  function MkCmb(AParent: TWinControl; AX, AY, AW: Integer): TFlatComboBox;
  begin
    Result := MakeCombo(AParent, AX, AY, AW, OnTXAnyChange);
  end;

  // Телеграф едет отдельной записью (TCWSettings) и своим событием — иначе
  // правка скорости кейера дёргала бы пересборку всей TX-цепи WDSP.
  function MkSpinCW(AParent: TWinControl; AX, AY: Integer;
                    AMin, AMax, AVal: Integer): TFlatSpinEdit;
  begin
    Result := MkSpin(AParent, AX, AY, AMin, AMax, AVal);
    Result.OnChange := OnCWAnyChange;
  end;

  function MkChkCW(AParent: TWinControl; AX, AY, AW: Integer;
                   const ACap: string): TFlatCheckBox;
  begin
    Result := MkChk(AParent, AX, AY, AW, ACap);
    Result.OnChange := OnCWAnyChange;
  end;

  function MkCmbCW(AParent: TWinControl; AX, AY, AW: Integer): TFlatComboBox;
  begin
    Result := MakeCombo(AParent, AX, AY, AW, OnCWAnyChange);
  end;

  procedure TxNote(AParent: TWinControl; const AText: string;
    ATop, AHeight: Integer);
  var
    Note: TLabel;
  begin
    Note := MakeTextBlock(AParent, AText, PAD, ATop,
      GRP_W - 2 * PAD, AHeight);
    Note.Tag := TAG_RESPONSIVE_INSET;
  end;

begin
  MakePageHeader(FPageTransmit, 'Transmit',
    'Profile and EQ hold the active TX profile; Hardware and Display are '
    + 'radio properties and do not follow it.');

  // ---- Полоса профиля (над подвкладками, видна всегда) --------------------
  Grp := MakeGroupPanel(FPageTransmit, 'TX Profile', MARGIN, 78, GRP_W, 124);

  MakeLbl(Grp, 'Active', PAD, R1 + 5, 60);
  FCmbTXProfile := MakeCombo(Grp, PAD + 60, R1, 230, OnTXProfileComboChange);
  FBtnTXProfNew := MkBtn(Grp, PAD + 302, R1, 100, 'New...',    BtnTXProfNewClick);
  FBtnTXProfRen := MkBtn(Grp, PAD + 408, R1, 100, 'Rename...', BtnTXProfRenClick);
  FBtnTXProfDel := MkBtn(Grp, PAD + 514, R1, 100, 'Delete',    BtnTXProfDelClick);
  FBtnTXProfFactory := MkBtn(Grp, PAD + 620, R1, 100, 'Factory', BtnTXProfFactoryClick);

  TxNote(Grp, 'Edits on the Profile and EQ tabs go into the active profile '
    + 'immediately — there is no separate Save button.', R1 + 30, 18);
  TxNote(Grp, 'New... forks the current sound under a new name. Factory rebuilds '
    + 'the stock set; it keeps microphone wiring and drive, not the sound.',
    R1 + 48, 18);

  // ---- Сегменты подвкладок ------------------------------------------------
  Y := 78 + 124 + 12;
  FTXSubBtn[0] := MkBtn(FPageTransmit, MARGIN,       Y, 132, 'Profile',  TXSubTabClick);
  FTXSubBtn[1] := MkBtn(FPageTransmit, MARGIN + 136, Y, 132, 'EQ',       TXSubTabClick);
  FTXSubBtn[2] := MkBtn(FPageTransmit, MARGIN + 272, Y, 132, 'Hardware', TXSubTabClick);
  FTXSubBtn[3] := MkBtn(FPageTransmit, MARGIN + 408, Y, 132, 'Display',  TXSubTabClick);
  for i := 0 to TXTAB_COUNT - 1 do FTXSubBtn[i].Tag := i;

  Inc(Y, BTN_H + 14);
  SubTop := Y;       // ★ немасштабированный: FTXSubPage[].Top уже в пикселях

  // =========================================================================
  //  Подвкладка 0 — Profile: всё, что определяет звук в эфире
  // =========================================================================
  SubPage(0, SubTop);

  // ---- Микрофонный вход (часть профиля: гарнитура vs микшер-ESSB) ----
  // Две строки, обе по общей сетке страницы: подпись в колонке PAD (ширина LW),
  // поле в колонке CX, вторая пара — от PAD+320. Тумблеры справа с равным
  // шагом. Connector скрыт на не-Orion и потому делит строку с Mic gain —
  // спрятанная пара не оставляет дыры.
  Grp := MakeGroupPanel(FTXSubPage[0], 'Microphone', MARGIN, 0, GRP_W, 134);
  FTXGrpMic := Grp;

  // Row 0: Mic jack | Boost (Mic In) либо Line In gain (Line In) | Bias | PTT
  FLblMicJack := MakeLbl(Grp, 'Mic jack', PAD, R1 + 5, LW);
  FCmbTXMicJack := MkCmb(Grp, CX, R1, 110);
  FCmbTXMicJack.Items.Add('Mic In');
  FCmbTXMicJack.Items.Add('Line In');
  FCmbTXMicJack.ItemIndex := 0;
  FCmbTXMicJack.OnChange := OnMicJackChange;

  // Boost и пара «Line In gain» занимают одно и то же место (видима одна из них).
  FChkTXMicBoost := MkChk(Grp, PAD + 302, R1, 170, 'Mic boost +20 dB');
  FLblLineInGain := MakeLbl(Grp, 'Line In gain', PAD + 302, R1 + 5, 76);
  FEdLineInGain  := MkFloat(Grp, PAD + 382, R1, 0.0, -34.5, 12.0, 1.5);
  FEdLineInGain.DecimalPlaces := 1;
  FChkTXMicBias  := MkChk(Grp, PAD + 490, R1, 100, 'Mic bias');
  FChkTXMicPTT   := MkChk(Grp, PAD + 596, R1, 130, 'Mic-jack PTT');

  // Row 1: Mic gain | Connector Tip/Ring (только Orion boards 4 и 5)
  // ★Mic gain — единственное здесь, что не про разъём: он идёт в WDSP
  // (SetMicGain) и работает с любым источником, включая звук с ПК. При
  // скрытом разъёме RelayoutTXProfileSub поднимает его в первую строку.
  FLblMicGain := MakeLbl(Grp, 'Mic gain (dB)', PAD, R1 + STEP + 5, LW);
  FEdTXMicGain := MkFloat(Grp, CX, R1 + STEP, 10.0, -20.0, 60.0, 0.5);

  FLblTipRing := MakeLbl(Grp, 'Connector', PAD + 302, R1 + STEP + 5, 76);
  FCmbTXMicTR := MkCmb(Grp, PAD + 382, R1 + STEP, 96);
  FCmbTXMicTR.Items.Add('Tip');   // MicTipRing=False, bit3=0
  FCmbTXMicTR.Items.Add('Ring');  // MicTipRing=True,  bit3=1
  FCmbTXMicTR.ItemIndex := 0;
  FLblTipRing.Visible := False;
  FCmbTXMicTR.Visible := False;

  // ---- Кромки TX-фильтра (FIR/фаза/окно — на вкладке Hardware) ----
  Y := 134 + 12;
  Grp := MakeGroupPanel(FTXSubPage[0], 'TX Filter (bandpass)', MARGIN, Y, GRP_W, 108);
  FTXGrpFilt := Grp;

  MakeLbl(Grp, 'Low cut (Hz)',  PAD,            R1 + 5, LW);
  FEdTXFiltLow  := MkSpin(Grp, CX,              R1, 0, 8000, 200);
  MakeLbl(Grp, 'High cut (Hz)', PAD + 320,      R1 + 5, 90);
  FEdTXFiltHigh := MkSpin(Grp, PAD + 320 + 100, R1, 100, 12000, 3100);
  TxNote(Grp, 'Voice bandwidth. ESSB is simply a high cut of 3500..5000 Hz; '
    + 'sideband sign is applied by the engine, these are magnitudes.',
    R1 + 32, 26);

  // ---- Динамическая обработка ----
  Inc(Y, 108 + 12);
  Grp := MakeGroupPanel(FTXSubPage[0], 'Dynamic processing', MARGIN, Y, GRP_W, 220);
  FTXGrpDyn := Grp;

  FChkTXCompOn := MkChk(Grp, PAD, R1, 220, 'Compressor');
  MakeLbl(Grp, 'Gain (dB)', PAD + 230, R1 + 5, 80);
  FEdTXCompGain := MkFloat(Grp, PAD + 320, R1, 5.0, 0.0, 20.0, 0.5);

  FChkTXLevOn := MkChk(Grp, PAD, R1 + STEP, 220, 'Leveler');
  MakeLbl(Grp, 'Top (dB)', PAD + 230, R1 + STEP + 5, 80);
  FEdTXLevTop := MkFloat(Grp, PAD + 320, R1 + STEP, 5.0, 0.0, 20.0, 0.5);
  MakeLbl(Grp, 'Decay (ms)', PAD + 430, R1 + STEP + 5, 80);
  FEdTXLevDecay := MkSpin(Grp, PAD + 520, R1 + STEP, 1, 5000, 500);

  FChkTXALCOn := MkChk(Grp, PAD, R1 + 2 * STEP, 220, 'ALC');
  MakeLbl(Grp, 'Max gain', PAD + 230, R1 + 2 * STEP + 5, 80);
  FEdTXALCMaxGain := MkFloat(Grp, PAD + 320, R1 + 2 * STEP, 0.0, -20.0, 20.0, 0.5);
  MakeLbl(Grp, 'Decay (ms)', PAD + 430, R1 + 2 * STEP + 5, 80);
  FEdTXALCDecay := MkSpin(Grp, PAD + 520, R1 + 2 * STEP, 1, 5000, 10);

  FChkTXPHROn := MkChk(Grp, PAD, R1 + 3 * STEP, 220, 'Phase Rotator');
  MakeLbl(Grp, 'Stages', PAD + 230, R1 + 3 * STEP + 5, 80);
  FEdTXPHRStages := MkSpin(Grp, PAD + 320, R1 + 3 * STEP, 2, 16, 8);
  MakeLbl(Grp, 'Corner (Hz)', PAD + 430, R1 + 3 * STEP + 5, 80);
  FEdTXPHRFreq := MkFloat(Grp, PAD + 520, R1 + 3 * STEP, 338.0, 50.0, 3000.0, 1.0);

  // ---- Мощность и несущая AM (тоже часть профиля) ----
  Inc(Y, 220 + 12);
  Grp := MakeGroupPanel(FTXSubPage[0], 'Power / AM carrier', MARGIN, Y, GRP_W, 148);
  FTXGrpPwr := Grp;

  MakeLbl(Grp, 'Tune level (%)', PAD, R1 + 5, LW);
  FEdTUNLevel := MkSpin(Grp, CX, R1, 0, 100, 10);
  MakeLbl(Grp, 'AM carrier level', PAD + 320, R1 + 5, 110);
  FEdTXAMCarrier := MkFloat(Grp, PAD + 320 + 120, R1, 0.5, 0.0, 1.0, 0.05);

  FLblTXProfDrive := MakeLbl(Grp, 'Profile drive: —', PAD, R1 + STEP + 5, 300);
  TxNote(Grp, 'Drive belongs to the profile, but you set it with the DRV slider '
    + 'in the main window — the active profile follows it.',
    R1 + STEP + 28, 26);

  FTXSubPage[0].Height := DpiScale(Y + 148 + 12);
  FTXHasHWMic := True;   // до подключения показываем полный (openHPSDR) вид

  // =========================================================================
  //  Подвкладка 1 — EQ (тоже профильный, но занимает целый экран)
  // =========================================================================
  SubPage(1, SubTop);

  Grp := MakeGroupPanel(FTXSubPage[1], 'TX Equalizer', MARGIN, 0, GRP_W, 434);

  FTXEQControl := TEqualizerControl.Create(Self);
  FTXEQControl.Parent := Grp;
  FTXEQControl.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(GRP_W - 2 * PAD), DpiScale(326));
  FTXEQControl.Font.Size := 9;
  FTXEQControl.SetThemeColors(CLR_PANEL, TColor($00202020), CLR_BORDER,
    TColor($00383838), CLR_TEXT, CLR_TEXTDIM, CLR_ACCENT, TColor($0055DDEE));
  FTXEQControl.OnChange := OnTXAnyChange;

  // Главное, что надо знать про этот EQ: точки — не «полосы», а узлы кривой,
  // и за крайними узлами WDSP обрывает АЧХ (см. WDSPEngine.PushTXEQProfile).
  TxNote(Grp, 'Points are curve nodes: response is interpolated between them and '
    + 'cut beyond the outer nodes.' + LineEnding
    + 'Keep the outer nodes beyond the TX passband. In 3-band mode WDSP fixes '
    + 'frequencies at 150 / 1500 / 6000 Hz.', R1 + 332, 58);

  FTXSubPage[1].Height := DpiScale(434 + 12);

  // =========================================================================
  //  Подвкладка 2 — Hardware: свойства устройства, профилем не переключаются
  // =========================================================================
  SubPage(2, SubTop);

  Grp := MakeGroupPanel(FTXSubPage[2], 'Radio', MARGIN, 0, GRP_W, 108);

  MakeLbl(Grp, 'ATT on TX (dB)', PAD, R1 + 5, LW);
  FEdTXAttOnTX := MkSpin(Grp, CX, R1, 0, 31, 31);
  MakeLbl(Grp, 'Tune tone (Hz)', PAD + 320, R1 + 5, 110);
  FEdTUNFreq  := MkSpin(Grp, PAD + 320 + 120, R1, 100, 5000, 750);
  TxNote(Grp, 'ATT on TX attenuates the RX ADC while transmitting (input '
    + 'protection) — not to be confused with output power.', R1 + 32, 26);

  Y := 108 + 12;
  Grp := MakeGroupPanel(FTXSubPage[2], 'TX Filter (DSP)', MARGIN, Y, GRP_W, 190);

  MakeLbl(Grp, 'FIR length',    PAD,            R1 + 5, LW);
  FCmbTXFiltNC  := MkCmb(Grp, CX,               R1, 140);
  FCmbTXFiltNC.Items.AddStrings(['256','512','1024','2048','4096']);
  FCmbTXFiltNC.ItemIndex := 2;

  MakeLbl(Grp, 'Phase mode',    PAD,            R1 + STEP + 5, LW);
  FCmbTXFiltMP  := MkCmb(Grp, CX,               R1 + STEP, 200);
  FCmbTXFiltMP.Items.AddStrings(['Linear Phase','Low Latency']);
  FCmbTXFiltMP.ItemIndex := 1;

  MakeLbl(Grp, 'Window',        PAD,            R1 + 2 * STEP + 5, LW);
  FCmbTXFiltWindow := MkCmb(Grp, CX,            R1 + 2 * STEP, 240);
  for i := 0 to High(WINDOW_NAMES) do
    FCmbTXFiltWindow.Items.Add(WINDOW_NAMES[i]);
  FCmbTXFiltWindow.ItemIndex := 1;

  TxNote(Grp, 'Skirt steepness and path latency, not timbre — hence outside '
    + 'the profile.', R1 + 3 * STEP - 2, 22);

  // ---- CW: телеграфом в openHPSDR P2 управляет прошивка -------------------
  Inc(Y, 190 + 12);
  Grp := MakeGroupPanel(FTXSubPage[2], 'CW (telegraphy)', MARGIN, Y, GRP_W, 502);

  MakeLbl(Grp, 'Keyer', PAD, R1 - 1, 50);
  FCmbCWKeyerSrc := MkCmbCW(Grp, PAD + 54, R1 - 6, 194);
  FCmbCWKeyerSrc.Items.AddStrings(['Radio firmware','Software (PC)','Off (MOX only)']);
  FCmbCWKeyerSrc.ItemIndex := 0;
  MkBtn(Grp, PAD + 260, R1 - 6, 100, 'Messages...', BtnCWMessagesClick);
  MkBtn(Grp, PAD + 366, R1 - 6, 100, 'Terminal...', BtnCWTerminalClick);
  MakeLbl(Grp, 'Pitch (Hz)', PAD + 480, R1 - 1, 90);
  FEdCWPitch := MkSpinCW(Grp, PAD + 574, R1 - 6, 200, 1200, 600);

  MakeLbl(Grp, 'Keyer mode',  PAD,       R1 + STEP + 5, LW);
  FCmbCWKeyerMode := MkCmbCW(Grp, CX,    R1 + STEP, 200);
  FCmbCWKeyerMode.Items.AddStrings(['Straight key','Iambic A','Iambic B']);
  FCmbCWKeyerMode.ItemIndex := 2;
  FChkCWReverse := MkChkCW(Grp, PAD + 400, R1 + STEP, 150, 'Reverse paddles');
  FChkCWStrict  := MkChkCW(Grp, PAD + 560, R1 + STEP, 160, 'Strict spacing');

  MakeLbl(Grp, 'Speed (WPM)', PAD,       R1 + 2 * STEP + 5, LW);
  FEdCWSpeed  := MkSpinCW(Grp, CX,       R1 + 2 * STEP, 5, 60, 20);
  MakeLbl(Grp, 'Weight',      PAD + 320, R1 + 2 * STEP + 5, 70);
  FEdCWWeight := MkSpinCW(Grp, PAD + 400, R1 + 2 * STEP, 33, 66, 50);
  MakeLbl(Grp, 'Edge (ms)',   PAD + 520, R1 + 2 * STEP + 5, 80);
  FEdCWRamp   := MkSpinCW(Grp, PAD + 604, R1 + 2 * STEP, 0, 20, 9);

  FChkCWBreakIn := MkChkCW(Grp, PAD,     R1 + 3 * STEP, 150, 'Break-in');
  MakeLbl(Grp, 'Hang (ms)',   PAD + 320, R1 + 3 * STEP + 5, 80);
  FEdCWHang     := MkSpinCW(Grp, PAD + 400, R1 + 3 * STEP, 0, 2000, 300);
  MakeLbl(Grp, 'RF delay',    PAD + 520, R1 + 3 * STEP + 5, 80);
  FEdCWRFDelay  := MkSpinCW(Grp, PAD + 604, R1 + 3 * STEP, 0, 255, 0);

  FChkCWSidetoneHW  := MkChkCW(Grp, PAD, R1 + 4 * STEP, 190, 'Sidetone in radio');
  MakeLbl(Grp, 'Level',       PAD + 196, R1 + 4 * STEP + 5, 44);
  FEdCWSidetoneHWLv := MkSpinCW(Grp, PAD + 244, R1 + 4 * STEP, 0, 127, 50);
  FChkCWSidetoneSW  := MkChkCW(Grp, PAD + 362, R1 + 4 * STEP, 200, 'Sidetone in PC audio');
  MakeLbl(Grp, 'Level',       PAD + 568, R1 + 4 * STEP + 5, 44);
  FEdCWSidetoneSWLv := MkSpinCW(Grp, PAD + 616, R1 + 4 * STEP, 0, 100, 30);

  // ---- Программный кейер: манипуляцию рисует сам EWSDR --------------------
  MakeLbl(Grp, 'Carrier offset (Hz)', PAD, R1 + 5 * STEP + 5, LW);
  FEdCWOffset := MkSpinCW(Grp, CX, R1 + 5 * STEP, -1, 48000, 0);
  MakeLbl(Grp, '0 = auto, -1 = none (software keyer only)', PAD + 320, R1 + 5 * STEP + 5, 260);
  FChkCWDecoder  := MkChkCW(Grp, PAD,       R1 + 6 * STEP, 160, 'Decode RX');
  FChkCWDecStrip := MkChkCW(Grp, PAD + 176, R1 + 6 * STEP, 210, 'Text under spectrum');

  FChkCWKeyPort := MkChkCW(Grp, PAD, R1 + 7 * STEP, 250, 'Paddle on serial port');
  FEdCWKeyPort := TFlatEdit.Create(Self);
  FEdCWKeyPort.Parent := Grp;
  FEdCWKeyPort.SetBounds(DpiScale(PAD + 260), DpiScale(R1 + 7 * STEP),
                         DpiScale(220), DpiScale(BTN_H));
  FEdCWKeyPort.Color := CLR_INPUT;
  FEdCWKeyPort.Font.Color := CLR_INPUT_TEXT;
  FEdCWKeyPort.Font.Size := 9;
  FEdCWKeyPort.OnExit := OnCWAnyChange;
  MakeLbl(Grp, SerPortNameHint, PAD + 492, R1 + 7 * STEP + 5, 200);

  MakeLbl(Grp, 'Dot line', PAD, R1 + 8 * STEP + 5, 70);
  FCmbCWDotLine := MkCmbCW(Grp, PAD + 74, R1 + 8 * STEP, 90);
  FCmbCWDotLine.Items.AddStrings(['CTS','DSR','DCD','RI']);
  FCmbCWDotLine.ItemIndex := 0;
  MakeLbl(Grp, 'Dash line', PAD + 176, R1 + 8 * STEP + 5, 74);
  FCmbCWDashLine := MkCmbCW(Grp, PAD + 254, R1 + 8 * STEP, 90);
  FCmbCWDashLine.Items.AddStrings(['CTS','DSR','DCD','RI']);
  FCmbCWDashLine.ItemIndex := 1;
  FChkCWKeyInvert := MkChkCW(Grp, PAD + 360, R1 + 8 * STEP, 90, 'Invert');
  FChkCWKeyDTR    := MkChkCW(Grp, PAD + 456, R1 + 8 * STEP, 100, 'DTR power');
  FChkCWKeyRTS    := MkChkCW(Grp, PAD + 562, R1 + 8 * STEP, 100, 'RTS power');

  TxNote(Grp, 'Firmware keyer: the radio produces dot and dash timing, so PC '
    + 'load never affects it — the default where the hardware has one, and the '
    + 'paddle plugs into the radio. Software keyer: EWSDR draws the keyed '
    + 'carrier into the IQ stream itself (the only option on Pluto/LibreSDR) '
    + 'and the paddle comes in over a serial port; element lengths are counted '
    + 'in samples, so only the paddle input jitters, never the rhythm. Carrier '
    + 'offset 0 means auto: zero on openHPSDR, 12 kHz on zero-IF hardware, '
    + 'where LO leakage would otherwise sit right on the working frequency '
    + 'between elements. The voice transmit chain is not started in CW at all.',
    R1 + 9 * STEP - 6, 92);

  Inc(Y, 502 + 12);
  Grp := MakeGroupPanel(FTXSubPage[2], 'FM / CTCSS', MARGIN, Y, GRP_W, 250);

  MakeLbl(Grp, 'FM deviation (Hz)', PAD, R1 + 5, LW);
  FEdTXFMDev := MkFloat(Grp, CX, R1, 5000.0, 1000.0, 25000.0, 100.0);

  MakeLbl(Grp, 'FM AF low cut',   PAD,        R1 + STEP + 5, LW);
  FEdTXFMLow  := MkSpin(Grp, CX,              R1 + STEP, 0, 5000, 300);
  MakeLbl(Grp, 'FM AF high cut',  PAD + 320,  R1 + STEP + 5, 100);
  FEdTXFMHigh := MkSpin(Grp, PAD + 320 + 110, R1 + STEP, 100, 8000, 3000);

  MakeLbl(Grp, 'FM emphasis',     PAD,        R1 + 2 * STEP + 5, LW);
  FCmbTXFMEmph := MkCmb(Grp, CX,              R1 + 2 * STEP, 180);
  FCmbTXFMEmph.Items.AddStrings(['Pre-EQ','Post-EQ']);
  FCmbTXFMEmph.ItemIndex := 1;

  FChkTXCTCSSOn := MkChk(Grp, PAD,            R1 + 3 * STEP, 220, 'CTCSS tone');
  MakeLbl(Grp, 'Freq (Hz)',       PAD + 230,  R1 + 3 * STEP + 5, 80);
  FEdTXCTCSSFreq := MkFloat(Grp, PAD + 320,   R1 + 3 * STEP, 100.0, 50.0, 300.0, 0.1);

  TxNote(Grp, 'A property of the channel or repeater — a voice profile does '
    + 'not switch it.', R1 + 4 * STEP + 5, 26);

  FTXSubPage[2].Height := DpiScale(Y + 250 + 12);

  // =========================================================================
  //  Подвкладка 3 — Display: анализатор и сетка TX-спектра
  // =========================================================================
  SubPage(3, SubTop);

  Grp := MakeGroupPanel(FTXSubPage[3], 'TX Display (Analyzer)', MARGIN, 0, GRP_W, 318);

  MakeLbl(Grp, 'FFT size',     PAD,        R1 + 5, LW);
  FCmbTXFFT := MkCmb(Grp, CX,              R1, 140);
  for i := 0 to High(FFT_NAMES) do FCmbTXFFT.Items.Add(FFT_NAMES[i]);
  FCmbTXFFT.ItemIndex := 2;

  MakeLbl(Grp, 'Window',       PAD,        R1 + STEP + 5, LW);
  FCmbTXWindow := MkCmb(Grp, CX,           R1 + STEP, 240);
  for i := 0 to High(WINDOW_NAMES) do FCmbTXWindow.Items.Add(WINDOW_NAMES[i]);
  FCmbTXWindow.ItemIndex := 2;

  MakeLbl(Grp, 'Spec detector',PAD,        R1 + 2 * STEP + 5, LW);
  FCmbTXSpecDet := MkCmb(Grp, CX,          R1 + 2 * STEP, 200);
  for i := 0 to High(DETECTOR_NAMES) do FCmbTXSpecDet.Items.Add(DETECTOR_NAMES[i]);
  FCmbTXSpecDet.ItemIndex := 0;

  MakeLbl(Grp, 'Spec average', PAD,        R1 + 3 * STEP + 5, LW);
  FCmbTXSpecAvg := MkCmb(Grp, CX,          R1 + 3 * STEP, 200);
  for i := 0 to High(AVGMODE_NAMES) do FCmbTXSpecAvg.Items.Add(AVGMODE_NAMES[i]);
  FCmbTXSpecAvg.ItemIndex := 3;
  MakeLbl(Grp, 'Avg time (ms)', PAD + 430, R1 + 3 * STEP + 5, 100);
  FEdTXSpecAvgTime := MkFloat(Grp, PAD + 540, R1 + 3 * STEP, 30.0, 1.0, 5000.0, 1.0);

  MakeLbl(Grp, 'WF detector',  PAD,        R1 + 4 * STEP + 5, LW);
  FCmbTXWfDet := MkCmb(Grp, CX,            R1 + 4 * STEP, 200);
  for i := 0 to High(DETECTOR_NAMES) do FCmbTXWfDet.Items.Add(DETECTOR_NAMES[i]);
  FCmbTXWfDet.ItemIndex := 0;

  MakeLbl(Grp, 'WF average',   PAD,        R1 + 5 * STEP + 5, LW);
  FCmbTXWfAvg := MkCmb(Grp, CX,            R1 + 5 * STEP, 200);
  for i := 0 to High(WF_AVGMODE_NAMES) do FCmbTXWfAvg.Items.Add(WF_AVGMODE_NAMES[i]);
  FCmbTXWfAvg.ItemIndex := 3;
  MakeLbl(Grp, 'Avg time (ms)', PAD + 430, R1 + 5 * STEP + 5, 100);
  FEdTXWfAvgTime := MkFloat(Grp, PAD + 540, R1 + 5 * STEP, 120.0, 1.0, 5000.0, 1.0);

  // ---- TX Grid (RefLevel / Range / GridStep — отдельные от RX) ----
  Y := 318 + 12;
  Grp := MakeGroupPanel(FTXSubPage[3], 'TX Grid', MARGIN, Y, GRP_W, 170);

  MakeLbl(Grp, 'Reference level', PAD, R1 + 5, LW);
  FEdTXRefLevel := TFlatEdit.Create(Self);
  FEdTXRefLevel.Parent := Grp;
  FEdTXRefLevel.SetBounds(DpiScale(CX), DpiScale(R1), DpiScale(96), DpiScale(BTN_H));
  FEdTXRefLevel.Color := CLR_INPUT;
  FEdTXRefLevel.Font.Color := CLR_INPUT_TEXT;
  FEdTXRefLevel.Font.Size  := 9;
  FEdTXRefLevel.Text       := '0';
  FEdTXRefLevel.OnExit     := OnTXAnyChange;

  MakeLbl(Grp, 'Range (dB)', PAD, R1 + STEP + 5, LW);
  FEdTXRange := TFlatEdit.Create(Self);
  FEdTXRange.Parent := Grp;
  FEdTXRange.SetBounds(DpiScale(CX), DpiScale(R1 + STEP), DpiScale(96), DpiScale(BTN_H));
  FEdTXRange.Color := CLR_INPUT;
  FEdTXRange.Font.Color := CLR_INPUT_TEXT;
  FEdTXRange.Font.Size  := 9;
  FEdTXRange.Text       := '80';
  FEdTXRange.OnExit     := OnTXAnyChange;

  MakeLbl(Grp, 'Grid step', PAD, R1 + 2 * STEP + 5, LW);
  FCmbTXGridStep := MkCmb(Grp, CX, R1 + 2 * STEP, 130);
  for i := 0 to High(GRID_STEP_NAMES) do FCmbTXGridStep.Items.Add(GRID_STEP_NAMES[i]);
  FCmbTXGridStep.ItemIndex := 1;

  FTXSubPage[3].Height := DpiScale(Y + 170 + 12);

  FTXSubIdx := -1;
  SelectTXSubPage(0);
  UpdateMicJackVisibility;
end;
procedure TSettingsForm.CollectTXFromUI;
var i: Integer;
begin
  if FCmbTXMicJack  <> nil then FTX.MicLineIn      := FCmbTXMicJack.ItemIndex = 1;
  if FChkTXMicBoost <> nil then FTX.MicBoost       := FChkTXMicBoost.Checked;
  if FEdLineInGain  <> nil then FTX.LineInGainDB   := FEdLineInGain.Value;
  if FChkTXMicBias  <> nil then FTX.MicBias        := FChkTXMicBias.Checked;
  if FChkTXMicPTT    <> nil then FTX.MicPTTEnabled := FChkTXMicPTT.Checked;
  if FCmbTXMicTR     <> nil then FTX.MicTipRing    := FCmbTXMicTR.ItemIndex = 1;
  if FEdTXMicGain    <> nil then FTX.MicGainDB     := FEdTXMicGain.Value;
  if FEdTXAttOnTX    <> nil then FTX.AttOnTX       := FEdTXAttOnTX.Value;
  if FEdTUNLevel     <> nil then FTX.TUNLevel      := FEdTUNLevel.Value;
  if FEdTUNFreq      <> nil then FTX.TUNFreq       := FEdTUNFreq.Value;
  if FEdTXFiltLow    <> nil then FTX.FilterLow     := FEdTXFiltLow.Value;
  if FEdTXFiltHigh   <> nil then FTX.FilterHigh    := FEdTXFiltHigh.Value;
  if FCmbTXFiltNC    <> nil then
    FTX.FilterNC := StrToIntDef(FCmbTXFiltNC.Text, 1024);
  if FCmbTXFiltMP    <> nil then FTX.FilterMP     := Max(0, FCmbTXFiltMP.ItemIndex);
  if FCmbTXFiltWindow<> nil then FTX.FilterWindow := Max(0, FCmbTXFiltWindow.ItemIndex);
  if FChkTXCompOn    <> nil then FTX.CompressorOn   := FChkTXCompOn.Checked;
  if FEdTXCompGain   <> nil then FTX.CompressorGain := FEdTXCompGain.Value;
  if FChkTXLevOn     <> nil then FTX.LevelerOn    := FChkTXLevOn.Checked;
  if FEdTXLevTop     <> nil then FTX.LevelerTop   := FEdTXLevTop.Value;
  if FEdTXLevDecay   <> nil then FTX.LevelerDecay := FEdTXLevDecay.Value;
  if FChkTXALCOn     <> nil then FTX.ALCOn        := FChkTXALCOn.Checked;
  if FEdTXALCMaxGain <> nil then FTX.ALCMaxGain   := FEdTXALCMaxGain.Value;
  if FEdTXALCDecay   <> nil then FTX.ALCDecay     := FEdTXALCDecay.Value;
  if FChkTXPHROn     <> nil then FTX.PhaseRotOn     := FChkTXPHROn.Checked;
  if FEdTXPHRStages  <> nil then FTX.PhaseRotStages := FEdTXPHRStages.Value;
  if FEdTXPHRFreq    <> nil then FTX.PhaseRotFreq   := FEdTXPHRFreq.Value;
  if FTXEQControl <> nil then
  begin
    FTX.EQOn := FTXEQControl.EQEnabled;
    FTX.EQNumBands := FTXEQControl.BandCount;
    FTX.EQGains[0] := FTXEQControl.PreampGain;
    for i := 1 to 10 do
    begin
      FTX.EQGains[i] := FTXEQControl.BandGain[i];
      FTX.EQFreqs[i] := FTXEQControl.BandFreq[i];
    end;
  end;
  if FEdTXAMCarrier <> nil then FTX.AMCarrierLevel := FEdTXAMCarrier.Value;
  if FEdTXFMDev     <> nil then FTX.FMDeviation    := FEdTXFMDev.Value;
  if FEdTXFMLow     <> nil then FTX.FMLowCut       := FEdTXFMLow.Value;
  if FEdTXFMHigh    <> nil then FTX.FMHighCut      := FEdTXFMHigh.Value;
  if FCmbTXFMEmph   <> nil then FTX.FMEmphPosition := Max(0, FCmbTXFMEmph.ItemIndex);
  if FChkTXCTCSSOn  <> nil then FTX.CTCSSOn        := FChkTXCTCSSOn.Checked;
  if FEdTXCTCSSFreq <> nil then FTX.CTCSSFreq      := FEdTXCTCSSFreq.Value;
  if (FCmbTXFFT <> nil) and (FCmbTXFFT.ItemIndex >= 0) then
    FTX.TXFFTSize := FFT_SIZES[FCmbTXFFT.ItemIndex];
  if FCmbTXWindow <> nil then FTX.TXWindowType := Max(0, FCmbTXWindow.ItemIndex);
  if FCmbTXSpecDet <> nil then FTX.TXSpecDetector := Max(0, FCmbTXSpecDet.ItemIndex);
  if (FCmbTXSpecAvg <> nil) and (FCmbTXSpecAvg.ItemIndex >= 0) then
    FTX.TXSpecAvgMode := AVGMODE_VALUES[FCmbTXSpecAvg.ItemIndex];
  if FEdTXSpecAvgTime <> nil then FTX.TXSpecAvgTimeMS := FEdTXSpecAvgTime.Value;
  if FCmbTXWfDet <> nil then FTX.TXWfDetector := Max(0, FCmbTXWfDet.ItemIndex);
  if (FCmbTXWfAvg <> nil) and (FCmbTXWfAvg.ItemIndex >= 0) then
    FTX.TXWfAvgMode := WF_AVGMODE_VALUES[FCmbTXWfAvg.ItemIndex];
  if FEdTXWfAvgTime <> nil then FTX.TXWfAvgTimeMS := FEdTXWfAvgTime.Value;
  if FEdTXRefLevel <> nil then
  begin
    Val(FEdTXRefLevel.Text, FTX.TXSpecRefLevel, i);
    if i <> 0 then FTX.TXSpecRefLevel := 0.0;
  end;
  if FEdTXRange <> nil then
  begin
    Val(FEdTXRange.Text, FTX.TXSpecRange, i);
    if i <> 0 then FTX.TXSpecRange := 80.0;
  end;
  if (FCmbTXGridStep <> nil) and (FCmbTXGridStep.ItemIndex >= 0)
                              and (FCmbTXGridStep.ItemIndex <= High(GRID_STEPS)) then
    FTX.TXSpecGridStep := GRID_STEPS[FCmbTXGridStep.ItemIndex];
end;

procedure TSettingsForm.FireTXChange;
begin
  if FLoading then Exit;
  CollectTXFromUI;
  if Assigned(FOnTXChange) then
    FOnTXChange(FTX);
end;

procedure TSettingsForm.OnTXAnyChange(Sender: TObject);
begin
  FireTXChange;
end;

procedure TSettingsForm.CollectCWFromUI;
var Src: Integer;
begin
  // Список «кто рисует точки» → пара флагов. На железе без кейера в прошивке
  // первого пункта в списке нет вовсе, поэтому индексы сдвинуты на один.
  if FCmbCWKeyerSrc <> nil then
  begin
    Src := FCmbCWKeyerSrc.ItemIndex;
    if Src < 0 then Src := 0;
    if not FCWHasFWKeyer then Inc(Src);
    case Src of
      0: begin FCW.FWKeyer := True;  FCW.LocalKeyer := False; end;   // прошивка
      1: begin FCW.FWKeyer := True;  FCW.LocalKeyer := True;  end;   // программа
    else begin FCW.FWKeyer := False; FCW.LocalKeyer := False; end;   // выключен
    end;
  end;
  if FEdCWPitch        <> nil then FCW.Pitch           := FEdCWPitch.Value;
  if FCmbCWKeyerMode   <> nil then FCW.KeyerMode       := EnsureRange(FCmbCWKeyerMode.ItemIndex, 0, 2);
  if FChkCWReverse     <> nil then FCW.ReverseKeys     := FChkCWReverse.Checked;
  if FChkCWStrict      <> nil then FCW.StrictSpacing   := FChkCWStrict.Checked;
  if FEdCWSpeed        <> nil then FCW.Speed           := FEdCWSpeed.Value;
  if FEdCWWeight       <> nil then FCW.Weight          := FEdCWWeight.Value;
  if FChkCWBreakIn     <> nil then FCW.BreakIn         := FChkCWBreakIn.Checked;
  if FEdCWHang         <> nil then FCW.HangTimeMS      := FEdCWHang.Value;
  if FEdCWRamp         <> nil then FCW.RampMS          := FEdCWRamp.Value;
  if FEdCWRFDelay      <> nil then FCW.RFDelayMS       := FEdCWRFDelay.Value;
  if FChkCWSidetoneHW  <> nil then FCW.SidetoneHW      := FChkCWSidetoneHW.Checked;
  if FEdCWSidetoneHWLv <> nil then FCW.SidetoneHWLevel := FEdCWSidetoneHWLv.Value;
  if FChkCWSidetoneSW  <> nil then FCW.SidetoneSW      := FChkCWSidetoneSW.Checked;
  if FEdCWSidetoneSWLv <> nil then FCW.SidetoneSWLevel := FEdCWSidetoneSWLv.Value;
  if FEdCWOffset       <> nil then FCW.CarrierOffsetHz := FEdCWOffset.Value;
  if FChkCWKeyPort     <> nil then FCW.KeyPortEnabled  := FChkCWKeyPort.Checked;
  if FEdCWKeyPort      <> nil then FCW.KeyPort         := Copy(FEdCWKeyPort.Text, 1, 63);
  if FCmbCWDotLine     <> nil then FCW.KeyDotLine      := EnsureRange(FCmbCWDotLine.ItemIndex, 0, 3);
  if FCmbCWDashLine    <> nil then FCW.KeyDashLine     := EnsureRange(FCmbCWDashLine.ItemIndex, 0, 3);
  if FChkCWDecoder     <> nil then FCW.Decoder         := FChkCWDecoder.Checked;
  if FChkCWDecStrip    <> nil then FCW.DecoderStrip    := FChkCWDecStrip.Checked;
  if FChkCWKeyInvert   <> nil then FCW.KeyInvert       := FChkCWKeyInvert.Checked;
  if FChkCWKeyDTR      <> nil then FCW.KeyPowerDTR     := FChkCWKeyDTR.Checked;
  if FChkCWKeyRTS      <> nil then FCW.KeyPowerRTS     := FChkCWKeyRTS.Checked;
end;

procedure TSettingsForm.SetCWBackend(HasFWKeyer: Boolean);
// Гейт по железу: у AD936x кейера в прошивке нет вовсе, значит нет ни этого
// пункта в списке, ни аппаратного сайдтона (он живёт в наушниках самого
// трансивера) — телеграф там может быть только программным.
// ★Обязан отработать ДО LoadCWSettings: тот считает индекс списка по FCWHasFWKeyer.
begin
  FCWHasFWKeyer := HasFWKeyer;
  if FChkCWSidetoneHW  <> nil then FChkCWSidetoneHW.Enabled  := HasFWKeyer;
  if FEdCWSidetoneHWLv <> nil then FEdCWSidetoneHWLv.Enabled := HasFWKeyer;
  if FCmbCWKeyerSrc = nil then Exit;
  FLoading := True;   // правка Items дёргает OnChange — настройки трогать нельзя
  try
    FCmbCWKeyerSrc.Items.Clear;
    if HasFWKeyer then FCmbCWKeyerSrc.Items.Add('Radio firmware');
    FCmbCWKeyerSrc.Items.Add('Software (PC)');
    FCmbCWKeyerSrc.Items.Add('Off (MOX only)');
    FCmbCWKeyerSrc.ItemIndex := 0;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.FireCWChange;
begin
  if FLoading then Exit;
  CollectCWFromUI;
  if Assigned(FOnCWChange) then FOnCWChange(FCW);
end;

procedure TSettingsForm.OnCWAnyChange(Sender: TObject);
begin
  FireCWChange;
end;

procedure TSettingsForm.BtnCWMessagesClick(Sender: TObject);
begin
  if Assigned(FOnCWMessagesOpen) then FOnCWMessagesOpen;
end;

procedure TSettingsForm.BtnCWTerminalClick(Sender: TObject);
begin
  if Assigned(FOnCWTerminalOpen) then FOnCWTerminalOpen;
end;

procedure TSettingsForm.LoadCWSettings(const C: TCWSettings);
var Src: Integer;
begin
  FLoading := True;
  try
    FCW := C;
    if FCmbCWKeyerSrc <> nil then
    begin
      // Пара флагов → пункт списка. «Прошивка» на железе без кейера означает
      // программный кейер: другого пути там нет, и врать в списке незачем.
      if (not C.FWKeyer) and (not C.LocalKeyer) then Src := 2
      else if C.LocalKeyer or (not FCWHasFWKeyer) then Src := 1
      else Src := 0;
      if not FCWHasFWKeyer then Dec(Src);
      FCmbCWKeyerSrc.ItemIndex := EnsureRange(Src, 0, FCmbCWKeyerSrc.Items.Count - 1);
    end;
    if FEdCWPitch        <> nil then FEdCWPitch.Value          := EnsureRange(C.Pitch, 200, 1200);
    if FCmbCWKeyerMode   <> nil then FCmbCWKeyerMode.ItemIndex := EnsureRange(C.KeyerMode, 0, 2);
    if FChkCWReverse     <> nil then FChkCWReverse.Checked     := C.ReverseKeys;
    if FChkCWStrict      <> nil then FChkCWStrict.Checked      := C.StrictSpacing;
    if FEdCWSpeed        <> nil then FEdCWSpeed.Value          := EnsureRange(C.Speed, 5, 60);
    if FEdCWWeight       <> nil then FEdCWWeight.Value         := EnsureRange(C.Weight, 33, 66);
    if FChkCWBreakIn     <> nil then FChkCWBreakIn.Checked     := C.BreakIn;
    if FEdCWHang         <> nil then FEdCWHang.Value           := EnsureRange(C.HangTimeMS, 0, 2000);
    if FEdCWRamp         <> nil then FEdCWRamp.Value           := EnsureRange(C.RampMS, 0, 20);
    if FEdCWRFDelay      <> nil then FEdCWRFDelay.Value        := EnsureRange(C.RFDelayMS, 0, 255);
    if FChkCWSidetoneHW  <> nil then FChkCWSidetoneHW.Checked  := C.SidetoneHW;
    if FEdCWSidetoneHWLv <> nil then FEdCWSidetoneHWLv.Value   := EnsureRange(C.SidetoneHWLevel, 0, 127);
    if FChkCWSidetoneSW  <> nil then FChkCWSidetoneSW.Checked  := C.SidetoneSW;
    if FEdCWSidetoneSWLv <> nil then FEdCWSidetoneSWLv.Value   := EnsureRange(C.SidetoneSWLevel, 0, 100);
    if FEdCWOffset       <> nil then FEdCWOffset.Value         := EnsureRange(C.CarrierOffsetHz, -1, 48000);
    if FChkCWKeyPort     <> nil then FChkCWKeyPort.Checked     := C.KeyPortEnabled;
    if FEdCWKeyPort      <> nil then FEdCWKeyPort.Text         := C.KeyPort;
    if FCmbCWDotLine     <> nil then FCmbCWDotLine.ItemIndex   := EnsureRange(C.KeyDotLine,  0, 3);
    if FCmbCWDashLine    <> nil then FCmbCWDashLine.ItemIndex  := EnsureRange(C.KeyDashLine, 0, 3);
    if FChkCWDecoder     <> nil then FChkCWDecoder.Checked     := C.Decoder;
    if FChkCWDecStrip    <> nil then FChkCWDecStrip.Checked    := C.DecoderStrip;
    if FChkCWKeyInvert   <> nil then FChkCWKeyInvert.Checked   := C.KeyInvert;
    if FChkCWKeyDTR      <> nil then FChkCWKeyDTR.Checked      := C.KeyPowerDTR;
    if FChkCWKeyRTS      <> nil then FChkCWKeyRTS.Checked      := C.KeyPowerRTS;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.UpdateMicJackVisibility;
var LineIn: Boolean;
begin
  if FCmbTXMicJack = nil then Exit;
  // Без разъёма прятать нечего и показывать нечего: вся строка снята
  // RelayoutTXProfileSub, и вернуть её отсюда было бы возвратом дыры.
  if not FTXHasHWMic then Exit;
  LineIn := FCmbTXMicJack.ItemIndex = 1;
  if FChkTXMicBoost  <> nil then FChkTXMicBoost.Visible  := not LineIn;
  if FLblLineInGain  <> nil then FLblLineInGain.Visible  := LineIn;
  if FEdLineInGain   <> nil then FEdLineInGain.Visible   := LineIn;
end;

procedure TSettingsForm.SetTXMicBackend(HasHWMic: Boolean);
// HasHWMic — Caps.HasHWMic: микрофонный разъём самого трансивера. Всё, что
// стоит в строке разъёма, уезжает в DUC Specific байт 50 (openHPSDR); у AD936x
// такого разъёма нет, звук идёт только с ПК.
begin
  if FTXHasHWMic = HasHWMic then Exit;
  FTXHasHWMic := HasHWMic;
  RelayoutTXProfileSub;
end;

procedure TSettingsForm.RelayoutTXProfileSub;
// Подвкладка Profile: без микрофонного разъёма группа Microphone теряет целую
// строку, Mic gain поднимается в неё, а три группы ниже подтягиваются — иначе
// на месте снятой строки осталась бы дыра.
const
  TX_MARGIN = 22;
  TX_STEP   = 42;
  TX_R1     = 42;
  TX_MIC_H  = 134;
  TX_FILT_H = 108;
  TX_DYN_H  = 220;
  TX_PWR_H  = 148;
var
  MicH, GainY, Y: Integer;

  procedure Vis(C: TControl; V: Boolean);
  begin
    if C <> nil then C.Visible := V;
  end;

begin
  if (FTXGrpMic = nil) or (FTXGrpFilt = nil) or (FTXGrpDyn = nil)
     or (FTXGrpPwr = nil) or (FTXSubPage[0] = nil) then Exit;
  if not FPageTransmit.Visible then begin FTXRelayoutDue := True; Exit; end;
  FTXRelayoutDue := False;

  // ★Как и на вкладке Calibration: TScrollBox двигает детей при прокрутке, и
  // SetBounds ниже задаёт координаты ОТНОСИТЕЛЬНО прокрученного клиента.
  // Перекладка случается при смене трансивера на живой программе, то есть
  // страницу к этому моменту уже успели пролистать, — и без сброса группы
  // разъезжались: всё уезжало вверх, а Microphone оставался на месте.
  FPageTransmit.VertScrollBar.Position := 0;
  FPageTransmit.HorzScrollBar.Position := 0;

  Vis(FLblMicJack,    FTXHasHWMic);
  Vis(FCmbTXMicJack,  FTXHasHWMic);
  Vis(FChkTXMicBias,  FTXHasHWMic);
  Vis(FChkTXMicPTT,   FTXHasHWMic);
  if not FTXHasHWMic then
  begin
    // Пара «Boost ↔ Line In gain» обычно переключается UpdateMicJackVisibility;
    // здесь снимаем обе, а тот выше уже знает, что возвращать их не должен.
    Vis(FChkTXMicBoost, False);
    Vis(FLblLineInGain, False);
    Vis(FEdLineInGain,  False);
    Vis(FLblTipRing,    False);
    Vis(FCmbTXMicTR,    False);
  end
  else
    UpdateMicJackVisibility;   // вернуть ту из пары, что положена по разъёму

  if FTXHasHWMic then
  begin
    GainY := TX_R1 + TX_STEP;
    MicH  := TX_MIC_H;
  end
  else
  begin
    GainY := TX_R1;
    MicH  := TX_MIC_H - TX_STEP;
  end;
  if FLblMicGain  <> nil then FLblMicGain.Top  := DpiScale(GainY + 5);
  if FEdTXMicGain <> nil then FEdTXMicGain.Top := DpiScale(GainY);

  // Ширину групп ведёт RelayoutResponsiveCards — здесь только высота и Top.
  FTXGrpMic.SetBounds(DpiScale(TX_MARGIN), 0, FTXGrpMic.Width, DpiScale(MicH));
  Y := MicH + 12;
  FTXGrpFilt.Top := DpiScale(Y);
  Inc(Y, TX_FILT_H + 12);
  FTXGrpDyn.Top := DpiScale(Y);
  Inc(Y, TX_DYN_H + 12);
  FTXGrpPwr.Top := DpiScale(Y);
  FTXSubPage[0].Height := DpiScale(Y + TX_PWR_H + 12);
  UpdatePageBottomSpace(FPageTransmit);
  if FActivePage = FPageTransmit then UpdateSettingsScrollBar;
end;

procedure TSettingsForm.SetWidebandBackend(HasWideband: Boolean);
// Кадры широкополосного АЦП приходят только от openHPSDR. У AD936x галка
// открывала бы пустую панель, поэтому её здесь нет вовсе.
begin
  if FChkShowWideband <> nil then FChkShowWideband.Visible := HasWideband;
  if FChkWidebandFill <> nil then FChkWidebandFill.Visible := HasWideband;
end;

procedure TSettingsForm.SetADCBackend(HasDitherRandom: Boolean);
// Dither/random — свойства АЦП openHPSDR; у AD936x такого понятия нет.
begin
  if FAdvHasADC = HasDitherRandom then Exit;   // не сбрасывать прокрутку зря
  FAdvHasADC := HasDitherRandom;
  RelayoutAdvancedTab;
end;

procedure TSettingsForm.RelayoutAdvancedTab;
// Без dither/random группа ADC пуста — прячем её целиком, а Web Server
// поднимается на её место, иначе страница начиналась бы с пустоты.
const
  ADV_ADC_H = 128;
begin
  if (FAdvGrpADC = nil) or (FAdvGrpWeb = nil) or (FLblAdvHint = nil) then Exit;
  if not FPageAdvanced.Visible then begin FAdvRelayoutDue := True; Exit; end;
  FAdvRelayoutDue := False;
  FPageAdvanced.VertScrollBar.Position := 0;
  FPageAdvanced.HorzScrollBar.Position := 0;
  if FAdvHasADC then
    FLblAdvHint.Caption := 'ADC processing and built-in web server settings.'
  else
    FLblAdvHint.Caption := 'Built-in web server settings.';
  FAdvGrpADC.Visible := FAdvHasADC;
  if FAdvHasADC then
    FAdvGrpWeb.Top := DpiScale(86 + ADV_ADC_H + 16)
  else
    FAdvGrpWeb.Top := DpiScale(86);
  UpdatePageBottomSpace(FPageAdvanced);
  if FActivePage = FPageAdvanced then UpdateSettingsScrollBar;
end;

procedure TSettingsForm.OnMicJackChange(Sender: TObject);
begin
  UpdateMicJackVisibility;
  FireTXChange;
end;

// ---------------------------------------------------------------------------
// Transmit: подвкладки и полоса TX-профиля
// ---------------------------------------------------------------------------

procedure TSettingsForm.SelectTXSubPage(Idx: Integer);
var i: Integer;
begin
  if (Idx < 0) or (Idx >= TXTAB_COUNT) then Exit;
  FTXSubIdx := Idx;
  for i := 0 to TXTAB_COUNT - 1 do
  begin
    if FTXSubPage[i] <> nil then FTXSubPage[i].Visible := (i = Idx);
    if FTXSubBtn[i]  <> nil then FTXSubBtn[i].Active   := (i = Idx);
  end;
end;

procedure TSettingsForm.TXSubTabClick(Sender: TObject);
begin
  if Sender is TFlatButton then SelectTXSubPage(TFlatButton(Sender).Tag);
end;

procedure TSettingsForm.OnTXProfileComboChange(Sender: TObject);
begin
  if FLoading then Exit;
  if (FCmbTXProfile = nil) or (FCmbTXProfile.ItemIndex < 0) then Exit;
  // Применение снимка в эфир — снаружи (контроллер); назад придёт LoadTXSettings
  // с уже новыми значениями, форма перерисуется целиком.
  if Assigned(FOnTXProfileSelect) then FOnTXProfileSelect(FCmbTXProfile.ItemIndex);
end;

procedure TSettingsForm.BtnTXProfNewClick(Sender: TObject);
var S: string;
begin
  if FCmbTXProfile = nil then Exit;
  if FCmbTXProfile.Items.Count >= TXPROF_MAX then
  begin
    MessageDlg('TX Profile',
      Format('No more than %d profiles per radio.', [TXPROF_MAX]),
      mtInformation, [mbOK], 0);
    Exit;
  end;
  S := 'Profile ' + IntToStr(FCmbTXProfile.Items.Count + 1);
  if not InputQuery('TX Profile', 'Name for the new profile (copy of the current sound):', S) then Exit;
  if Trim(S) = '' then Exit;
  if Assigned(FOnTXProfileAdd) then FOnTXProfileAdd(Trim(S));
end;

procedure TSettingsForm.BtnTXProfRenClick(Sender: TObject);
var S: string; Idx: Integer;
begin
  if FCmbTXProfile = nil then Exit;
  Idx := FCmbTXProfile.ItemIndex;
  if (Idx < 0) or (Idx >= FCmbTXProfile.Items.Count) then Exit;
  S := FCmbTXProfile.Items[Idx];
  if not InputQuery('TX Profile', 'New profile name:', S) then Exit;
  if Trim(S) = '' then Exit;
  if Assigned(FOnTXProfileRename) then FOnTXProfileRename(Idx, Trim(S));
end;

procedure TSettingsForm.BtnTXProfDelClick(Sender: TObject);
var Idx: Integer;
begin
  if FCmbTXProfile = nil then Exit;
  Idx := FCmbTXProfile.ItemIndex;
  // Последний профиль не удаляем — активному профилю всегда есть куда писать.
  if (Idx < 0) or (FCmbTXProfile.Items.Count <= 1) then Exit;
  if MessageDlg('TX Profile',
                'Delete profile "' + FCmbTXProfile.Items[Idx] + '"?',
                mtConfirmation, [mbYes, mbNo], 0) <> mrYes then Exit;
  if Assigned(FOnTXProfileDelete) then FOnTXProfileDelete(Idx);
end;

procedure TSettingsForm.BtnTXProfFactoryClick(Sender: TObject);
// Пересоздать заводской набор. Нужен не только «на случай чего»: заводские
// профили пишутся в конфиг один раз при первом подключении устройства, и без
// этой кнопки обновлённые заводские настройки (например кривые EQ у ESSB)
// не доехали бы до тех, у кого секция tx_profiles уже записана.
begin
  if MessageDlg('TX Profile',
                'Replace the whole profile list with the factory set?'#13#10
              + 'Your own profiles and edits to the stock ones will be lost.'#13#10
              + '"Default" becomes active and restores the stock sound: '
              + 'bandwidth, dynamics and a flat equalizer. Microphone wiring, '
              + 'its gain and drive are kept as they are.',
                mtConfirmation, [mbYes, mbNo], 0) <> mrYes then Exit;
  if Assigned(FOnTXProfileFactory) then FOnTXProfileFactory(Self);
end;

procedure TSettingsForm.LoadTXProfiles(const Names: array of string;
                                        ActiveIdx, DrivePct: Integer);
var
  i: Integer;
  WasLoading: Boolean;
begin
  if FCmbTXProfile = nil then Exit;
  WasLoading := FLoading;
  FLoading := True;                       // заполнение списка не должно шибать OnChange
  try
    FCmbTXProfile.Items.Clear;
    for i := 0 to High(Names) do FCmbTXProfile.Items.Add(Names[i]);
    if (ActiveIdx >= 0) and (ActiveIdx < FCmbTXProfile.Items.Count) then
      FCmbTXProfile.ItemIndex := ActiveIdx;
    if FBtnTXProfDel <> nil then
      FBtnTXProfDel.Enabled := FCmbTXProfile.Items.Count > 1;
    if FLblTXProfDrive <> nil then
      FLblTXProfDrive.Caption := Format('Profile drive: %d %%', [DrivePct]);
  finally
    FLoading := WasLoading;
  end;
end;

procedure TSettingsForm.SetTXProfileDrive(DrivePct: Integer);
begin
  if FLblTXProfDrive <> nil then
    FLblTXProfDrive.Caption := Format('Profile drive: %d %%', [DrivePct]);
end;

procedure TSettingsForm.ShowTransmitPage(SubIdx: Integer = 0);
begin
  SelectPage(FPageTransmit, FNavTransmit);
  SelectTXSubPage(SubIdx);
  Show;
  BringToFront;
end;

procedure TSettingsForm.LoadTXSettings(const T: TTXSettings; BoardType: Integer = 0);
var i: Integer; IsOrion: Boolean;
begin
  FLoading := True;
  try
    FTX := T;
    if FCmbTXMicJack  <> nil then
      FCmbTXMicJack.ItemIndex := IfThen(T.MicLineIn, 1, 0);
    UpdateMicJackVisibility;
    if FChkTXMicBoost  <> nil then FChkTXMicBoost.Checked := T.MicBoost;
    if FEdLineInGain   <> nil then FEdLineInGain.Value     := EnsureRange(T.LineInGainDB, -34.5, 12.0);
    if FChkTXMicBias   <> nil then FChkTXMicBias.Checked  := T.MicBias;
    if FChkTXMicPTT    <> nil then FChkTXMicPTT.Checked   := T.MicPTTEnabled;
    // Tip/Ring (Orion boards 4 и 5). ★Без микрофонного разъёма строку уже снял
    // RelayoutTXProfileSub, и вернуть её отсюда значило бы вернуть дыру: этот
    // Load зовётся при каждом открытии окна, а SetTXMicBackend — только на
    // смене железа.
    IsOrion := FTXHasHWMic and (BoardType in [4, 5]);
    if FLblTipRing <> nil then FLblTipRing.Visible := IsOrion;
    if FCmbTXMicTR <> nil then
    begin
      FCmbTXMicTR.Visible := IsOrion;
      FCmbTXMicTR.ItemIndex := IfThen(T.MicTipRing, 1, 0);
    end;
    if FEdTXMicGain    <> nil then FEdTXMicGain.Value := T.MicGainDB;
    if FEdTXAttOnTX    <> nil then FEdTXAttOnTX.Value := EnsureRange(T.AttOnTX, 0, 31);
    if FEdTUNLevel     <> nil then FEdTUNLevel.Value           := EnsureRange(T.TUNLevel, 0, 100);
    if FEdTUNFreq      <> nil then FEdTUNFreq.Value            := EnsureRange(T.TUNFreq, 100, 5000);
    if FEdTXFiltLow    <> nil then FEdTXFiltLow.Value          := T.FilterLow;
    if FEdTXFiltHigh   <> nil then FEdTXFiltHigh.Value         := T.FilterHigh;
    if FCmbTXFiltNC    <> nil then FCmbTXFiltNC.Text           := IntToStr(T.FilterNC);
    if FCmbTXFiltMP    <> nil then
      FCmbTXFiltMP.ItemIndex := EnsureRange(T.FilterMP, 0, FCmbTXFiltMP.Items.Count - 1);
    if FCmbTXFiltWindow<> nil then
      FCmbTXFiltWindow.ItemIndex := EnsureRange(T.FilterWindow, 0, FCmbTXFiltWindow.Items.Count - 1);
    if FChkTXCompOn    <> nil then FChkTXCompOn.Checked        := T.CompressorOn;
    if FEdTXCompGain   <> nil then FEdTXCompGain.Value         := T.CompressorGain;
    if FChkTXLevOn     <> nil then FChkTXLevOn.Checked         := T.LevelerOn;
    if FEdTXLevTop     <> nil then FEdTXLevTop.Value           := T.LevelerTop;
    if FEdTXLevDecay   <> nil then FEdTXLevDecay.Value         := T.LevelerDecay;
    if FChkTXALCOn     <> nil then FChkTXALCOn.Checked         := T.ALCOn;
    if FEdTXALCMaxGain <> nil then FEdTXALCMaxGain.Value       := T.ALCMaxGain;
    if FEdTXALCDecay   <> nil then FEdTXALCDecay.Value         := T.ALCDecay;
    if FChkTXPHROn     <> nil then FChkTXPHROn.Checked         := T.PhaseRotOn;
    if FEdTXPHRStages  <> nil then FEdTXPHRStages.Value        := T.PhaseRotStages;
    if FEdTXPHRFreq    <> nil then FEdTXPHRFreq.Value          := T.PhaseRotFreq;
    if FTXEQControl <> nil then
    begin
      FTXEQControl.EQEnabled := T.EQOn;
      FTXEQControl.BandCount := T.EQNumBands;
      FTXEQControl.PreampGain := T.EQGains[0];
      for i := 1 to 10 do
        FTXEQControl.SetBand(i, T.EQFreqs[i], T.EQGains[i]);
    end;
    if FEdTXAMCarrier  <> nil then FEdTXAMCarrier.Value  := T.AMCarrierLevel;
    if FEdTXFMDev      <> nil then FEdTXFMDev.Value      := T.FMDeviation;
    if FEdTXFMLow      <> nil then FEdTXFMLow.Value      := T.FMLowCut;
    if FEdTXFMHigh     <> nil then FEdTXFMHigh.Value     := T.FMHighCut;
    if FCmbTXFMEmph    <> nil then
      FCmbTXFMEmph.ItemIndex := EnsureRange(T.FMEmphPosition, 0, FCmbTXFMEmph.Items.Count - 1);
    if FChkTXCTCSSOn   <> nil then FChkTXCTCSSOn.Checked := T.CTCSSOn;
    if FEdTXCTCSSFreq  <> nil then FEdTXCTCSSFreq.Value  := T.CTCSSFreq;

    if FCmbTXFFT <> nil then
    begin
      FCmbTXFFT.ItemIndex := 2;
      for i := 0 to High(FFT_SIZES) do
        if FFT_SIZES[i] = T.TXFFTSize then
        begin
          FCmbTXFFT.ItemIndex := i;
          Break;
        end;
    end;
    if FCmbTXWindow  <> nil then
      FCmbTXWindow.ItemIndex  := EnsureRange(T.TXWindowType, 0, FCmbTXWindow.Items.Count - 1);
    if FCmbTXSpecDet <> nil then
      FCmbTXSpecDet.ItemIndex := EnsureRange(T.TXSpecDetector, 0, FCmbTXSpecDet.Items.Count - 1);
    if FCmbTXSpecAvg <> nil then
    begin
      FCmbTXSpecAvg.ItemIndex := 3;
      for i := 0 to High(AVGMODE_VALUES) do
        if AVGMODE_VALUES[i] = T.TXSpecAvgMode then
        begin
          FCmbTXSpecAvg.ItemIndex := i;
          Break;
        end;
    end;
    if FEdTXSpecAvgTime <> nil then FEdTXSpecAvgTime.Value := T.TXSpecAvgTimeMS;
    if FCmbTXWfDet <> nil then
      FCmbTXWfDet.ItemIndex := EnsureRange(T.TXWfDetector, 0, FCmbTXWfDet.Items.Count - 1);
    if FCmbTXWfAvg <> nil then
    begin
      FCmbTXWfAvg.ItemIndex := 3;
      for i := 0 to High(WF_AVGMODE_VALUES) do
        if WF_AVGMODE_VALUES[i] = T.TXWfAvgMode then
        begin
          FCmbTXWfAvg.ItemIndex := i;
          Break;
        end;
    end;
    if FEdTXWfAvgTime <> nil then FEdTXWfAvgTime.Value := T.TXWfAvgTimeMS;
    if FEdTXRefLevel <> nil then FEdTXRefLevel.Text := FormatFloat('0.#', T.TXSpecRefLevel);
    if FEdTXRange    <> nil then FEdTXRange.Text    := FormatFloat('0.#', T.TXSpecRange);
    if FCmbTXGridStep <> nil then
    begin
      FCmbTXGridStep.ItemIndex := 1;  // дефолт 10 dB
      for i := 0 to High(GRID_STEPS) do
        if Abs(GRID_STEPS[i] - T.TXSpecGridStep) < 0.5 then
        begin
          FCmbTXGridStep.ItemIndex := i;
          Break;
        end;
    end;
  finally
    FLoading := False;
  end;
end;

// ---------------------------------------------------------------------------
// BuildPATab
// ---------------------------------------------------------------------------

procedure TSettingsForm.BuildPATab;
const
  MARGIN  = 22;
  LBL_W   = 54;
  ED_W    = 72;
  COL_W   = LBL_W + ED_W + 14;
  COLS    = 4;
  VHF_LBL_W = 170;
  VHF_COL_W = VHF_LBL_W + ED_W + 48;
  VHF_COLS  = 2;
  ROW_H   = 32;
  GRP_PAD = 18;
  R1      = 42;
  GRP_H   = 222;

  function MakeFloatSpin(AParent: TWinControl; ALeft, ATop: Integer;
                         AChange: TNotifyEvent): TFlatFloatSpinEdit;
  var E: TFlatFloatSpinEdit;
  begin
    E               := TFlatFloatSpinEdit.Create(Self);
    E.Parent        := AParent;
    E.SetBounds(DpiScale(ALeft), DpiScale(ATop), DpiScale(ED_W), DpiScale(BTN_H + 2));
    E.Color         := CLR_INPUT;
    E.Font.Color    := CLR_INPUT_TEXT;
    E.Font.Size     := 9;
    E.MinValue      := 38.8;
    E.MaxValue      := 100.0;
    E.Increment     := 0.1;
    E.DecimalPlaces := 1;
    E.Value         := 100.0;
    E.OnChange      := AChange;
    Result          := E;
  end;

var
  Grp:      TPanel;
  Lbl:      TLabel;
  Spin:     TFlatSpinEdit;
  i, r, c, X, Y: Integer;
begin
  // Подпись под заголовком зависит от железа (RelayoutPATab): у AD936x на
  // странице остаётся один потолок аттенюации, и обещать «per-band calibration»
  // там нечестно. Поэтому создаём её отдельно, а не через ASubtitle.
  MakePageHeader(FPagePA, 'Power Amplifier', '');
  FLblPAHint := MakeTextBlock(FPagePA, '', SETTINGS_MARGIN, 48,
                              SETTINGS_CARD_W, 20);
  FLblPAHint.Tag := TAG_PAGE_HEADER;

  Grp := MakeGroupPanel(FPagePA, 'Power && Calibration', MARGIN, 86, SETTINGS_CARD_W, GRP_H);
  FPAGrpPower := Grp;

  // Max Power
  Lbl := MakeLbl(Grp, 'Max power (W)', GRP_PAD, R1 + 6, 132);
  FLblPAMaxPower := Lbl;

  Spin              := TFlatSpinEdit.Create(Self);
  Spin.Parent       := Grp;
  Spin.SetBounds(DpiScale(GRP_PAD + 150), DpiScale(R1), DpiScale(ED_W), DpiScale(BTN_H + 2));
  Spin.Color        := CLR_INPUT;
  Spin.Font.Color   := CLR_INPUT_TEXT;
  Spin.Font.Size    := 9;
  Spin.MinValue     := 5;
  Spin.MaxValue     := 200;
  Spin.Increment    := 1;
  Spin.Value        := 100;
  Spin.OnChange     := OnMaxPowerChange;
  FEdMaxPower       := Spin;

  // Pluto TX max attenuation @ drive 100% (потолок под внешний усилитель), дБ<0
  Lbl := MakeLbl(Grp, 'TX att @100% drive (dB)',
    GRP_PAD + 320, R1 + 6, 180);
  FLblPlutoTxAtt := Lbl;

  FEdPlutoTxMaxAtt := TFlatFloatSpinEdit.Create(Self);
  FEdPlutoTxMaxAtt.Parent     := Grp;
  FEdPlutoTxMaxAtt.SetBounds(DpiScale(GRP_PAD + 510), DpiScale(R1), DpiScale(ED_W), DpiScale(BTN_H + 2));
  FEdPlutoTxMaxAtt.Color      := CLR_INPUT;
  FEdPlutoTxMaxAtt.Font.Color := CLR_INPUT_TEXT;
  FEdPlutoTxMaxAtt.Font.Size  := 9;
  FEdPlutoTxMaxAtt.DecimalPlaces := 2;
  FEdPlutoTxMaxAtt.MinValue   := -50;
  FEdPlutoTxMaxAtt.MaxValue   := 0;
  FEdPlutoTxMaxAtt.Increment  := 0.25;
  FEdPlutoTxMaxAtt.Value      := -10;
  FEdPlutoTxMaxAtt.OnChange   := OnPlutoTxMaxAttChange;

  // Calibration label
  Lbl := MakeLbl(Grp, 'Calibration per band, 38.8 ... 100.0',
    GRP_PAD, R1 + ROW_H + 12, 320);
  FLblPABandHdr := Lbl;

  // Band grid 4 columns × 3 rows
  for i := 0 to 10 do
  begin
    r := i div COLS;
    c := i mod COLS;
    X := GRP_PAD + c * (COL_W + 4);
    Y := R1 + ROW_H + 34 + r * ROW_H;

    Lbl := MakeLbl(Grp, PA_BAND_NAMES[i] + ':', X, Y + 5, LBL_W);
    FPABandLabels[i] := Lbl;

    FEdBandCal[i]     := MakeFloatSpin(Grp, X + LBL_W, Y, OnBandCalChange);
    FEdBandCal[i].Tag := i;
  end;

  // ── VHF Calibration (Transverters) ──────────────────────────────────────
  Grp := MakeGroupPanel(FPagePA, 'VHF Calibration (Transverters)',
                        MARGIN, 86 + GRP_H + 14, SETTINGS_CARD_W, R1 + 4 * ROW_H + 44);
  FPAGrpVHF := Grp;

  Lbl := MakeLbl(Grp, 'Calibration per transverter slot, 38.8 ... 100.0',
    GRP_PAD, R1, 420);

  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    r := i div VHF_COLS;
    c := i mod VHF_COLS;
    X := GRP_PAD + c * VHF_COL_W;
    Y := R1 + 24 + r * ROW_H;

    Lbl            := TLabel.Create(Self);
    Lbl.Parent     := Grp;
    Lbl.Caption    := 'VHF' + IntToStr(i) + ':';
    Lbl.AutoSize   := False;
    Lbl.SetBounds(DpiScale(X), DpiScale(Y + 5), DpiScale(VHF_LBL_W), DpiScale(18));
    Lbl.Font.Color := CLR_TEXTDIM;
    Lbl.Font.Size  := 9;
    Lbl.ShowHint   := True;
    FVHFCalLabels[i] := Lbl;

    FEdVHFCal[i]     := MakeFloatSpin(Grp, X + VHF_LBL_W + 8, Y, OnVHFCalEdit);
    FEdVHFCal[i].Tag := i;
  end;

  FPAHasPA := True;   // до подключения показываем полный (openHPSDR) вид
  RelayoutPATab;
end;

procedure TSettingsForm.SetPABackend(HasPA: Boolean);
// HasPA — Caps.HasPA активного бэкенда: мост детектора мощности, drive-байт и
// телеметрия питания. У AD936x ничего этого нет.
begin
  if FPAHasPA = HasPA then Exit;
  FPAHasPA := HasPA;
  RelayoutPATab;
end;

procedure TSettingsForm.RelayoutPATab;
// openHPSDR: потолок мощности + калибровка драйва по диапазонам и слотам
// трансвертера (всё это кормит CalcDriveByte). AD936x: мощность задаётся
// железной аттенюацией, drive-байта нет вовсе — на странице остаётся один
// потолок аттенюации, остальное прячется вместе со своей группой.
const
  PA_MARGIN = 22;
  PA_R1     = 42;
  PA_ROW_H  = 32;
  PA_GRP_H  = 222;
  PA_GRP_PAD = 18;
  PA_VHF_H  = PA_R1 + 4 * PA_ROW_H + 44;
var
  i, H: Integer;
  Lbl:  TLabel;
begin
  if (FPAGrpPower = nil) or (FPAGrpVHF = nil) or (FLblPAHint = nil) then Exit;
  if not FPagePA.Visible then begin FPARelayoutDue := True; Exit; end;
  FPARelayoutDue := False;
  // Как и на вкладке Calibration: SetBounds ниже задаёт координаты относительно
  // прокрученного клиента, поэтому сперва возвращаем страницу в ноль.
  FPagePA.VertScrollBar.Position := 0;
  FPagePA.HorzScrollBar.Position := 0;

  if FPAHasPA then
    FLblPAHint.Caption := 'Output limit and per-band calibration values.'
  else
    FLblPAHint.Caption := 'Transmit power is set by hardware attenuation; '
                        + 'there is no power detector to calibrate.';

  // Заголовок группы — по её содержимому: калибровки у AD936x не остаётся.
  Lbl := GroupTitle(FPAGrpPower);
  if Lbl <> nil then
    if FPAHasPA then Lbl.Caption := 'Power && Calibration'
    else             Lbl.Caption := 'Transmit power';

  if FLblPAMaxPower <> nil then FLblPAMaxPower.Visible := FPAHasPA;
  if FEdMaxPower    <> nil then FEdMaxPower.Visible    := FPAHasPA;
  if FLblPABandHdr  <> nil then FLblPABandHdr.Visible  := FPAHasPA;
  for i := 0 to High(FPABandLabels) do
  begin
    if FPABandLabels[i] <> nil then FPABandLabels[i].Visible := FPAHasPA;
    if FEdBandCal[i]    <> nil then FEdBandCal[i].Visible    := FPAHasPA;
  end;
  FPAGrpVHF.Visible := FPAHasPA;

  // Аттенюация — свойство AD936x. У openHPSDR мощность идёт drive-байтом, и
  // поле было бы мёртвым; у AD936x оно единственное и переезжает в первую
  // колонку, чтобы не висеть посреди пустой группы.
  if (FLblPlutoTxAtt <> nil) and (FEdPlutoTxMaxAtt <> nil) then
  begin
    FLblPlutoTxAtt.Visible   := not FPAHasPA;
    FEdPlutoTxMaxAtt.Visible := not FPAHasPA;
    if FPAHasPA then
    begin
      FLblPlutoTxAtt.Left   := DpiScale(PA_GRP_PAD + 320);
      FEdPlutoTxMaxAtt.Left := DpiScale(PA_GRP_PAD + 510);
    end
    else
    begin
      FLblPlutoTxAtt.Left   := DpiScale(PA_GRP_PAD);
      FEdPlutoTxMaxAtt.Left := DpiScale(PA_GRP_PAD + 190);
    end;
  end;

  if FPAHasPA then H := PA_GRP_H
  else             H := PA_R1 + BTN_H + 2 + 18;   // ровно одна строка
  // Ширину не трогаем: карточки тянет по окну RelayoutResponsiveCards, и
  // фиксированная ширина здесь схлопнула бы их до базовой.
  FPAGrpPower.SetBounds(DpiScale(PA_MARGIN), DpiScale(86),
                        FPAGrpPower.Width, DpiScale(H));
  if FPAHasPA then
    FPAGrpVHF.SetBounds(DpiScale(PA_MARGIN), DpiScale(86 + H + 14),
                        FPAGrpVHF.Width, DpiScale(PA_VHF_H));
  UpdatePageBottomSpace(FPagePA);
  if FActivePage = FPagePA then UpdateSettingsScrollBar;
end;

procedure TSettingsForm.BuildCalibrationTab;
// Вкладка Calibration: модель детектора мощности
// Alex-моста + множитель FWD/REV на банд (Det. Cal.), калибровка напряжения/
// тока питания (PAVoltCal/AmpVoff/AmpSens) и офсет S-метра на диапазон и на
// слот трансвертера. Позиции/подписи/видимость band-строк ставит RelayoutCalTab
// под активное железо — здесь только создание контролов.
const
  GRP3_H0 = 200;   // стартовая высота S-метра, уточняется в RelayoutCalTab

  // Длинные подсказки не влезают в ширину группы — переводим на несколько
  // строк (TLabel по умолчанию AutoSize=True и режется по краю).
  procedure WrapLbl(L: TLabel; AW, AH: Integer);
  begin
    if L = nil then Exit;
    L.AutoSize := False;
    L.WordWrap := True;
    L.Width    := DpiScale(AW);
    L.Height   := DpiScale(AH);
  end;

  function MakeCalSpin(AParent: TWinControl; ALeft, ATop: Integer;
                       AMin, AMax, AInc: Double; ADec: Integer;
                       ADefault: Double): TFlatFloatSpinEdit;
  var E: TFlatFloatSpinEdit;
  begin
    E               := TFlatFloatSpinEdit.Create(Self);
    E.Parent        := AParent;
    E.SetBounds(DpiScale(ALeft), DpiScale(ATop), DpiScale(CAL_ED_W), DpiScale(BTN_H + 2));
    E.Color         := CLR_INPUT;
    E.Font.Color    := CLR_INPUT_TEXT;
    E.Font.Size     := 9;
    E.MinValue      := AMin;
    E.MaxValue      := AMax;
    E.Increment     := AInc;
    E.DecimalPlaces := ADec;
    E.Value         := ADefault;
    E.OnChange      := OnCalAnyChange;
    Result          := E;
  end;

var
  Grp:  TPanel;
  Lbl:  TLabel;
  i, GTop: Integer;
begin
  // Гард: SetItemIndex у TFlatComboBox стреляет OnChange и при программном
  // присвоении — без гарда OnCalAnyChange дёрнется из «ItemIndex := 0» ниже,
  // когда остальные контролы вкладки ещё nil → AV в CollectCalFromUI.
  FLoading := True;
  FCalIsPluto := False;
  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    FCalXvtrOn[i]   := False;
    FCalXvtrName[i] := '';
  end;

  // Подпись зависит от железа (RelayoutCalTab): у AD936x детектора мощности и
  // телеметрии питания нет, и обещать их калибровку в подзаголовке нечестно.
  MakePageHeader(FPageCalib, 'Calibration', '');
  FLblCalHint := MakeTextBlock(FPageCalib, '', SETTINGS_MARGIN, 48,
                               SETTINGS_CARD_W, 20);
  FLblCalHint.Tag := TAG_PAGE_HEADER;

  // ── Power / SWR detector ────────────────────────────────────────────────
  GTop := 86;
  FCalGrpDet := MakeGroupPanel(FPageCalib, 'Power / SWR detector',
                               CAL_MARGIN, GTop, CAL_GRP_W, CAL_GRP1_H);
  Grp := FCalGrpDet;

  Lbl            := MakeLbl(Grp, 'Detector model', CAL_PAD, CAL_R1 + 6, 132);
  FCmbPwrDetModel := MakeCombo(Grp, CAL_PAD + 150, CAL_R1, 280, OnCalAnyChange);
  FCmbPwrDetModel.Items.Add('ANAN-10/100/100B/100D (3.3V)');
  FCmbPwrDetModel.Items.Add('ANAN-200D / Orion');
  FCmbPwrDetModel.Items.Add('ANAN-7000DLE / Anvelina Pro 3 / G2');
  FCmbPwrDetModel.Items.Add('ANAN-8000DLE / Orion MkII');
  FCmbPwrDetModel.ItemIndex := 0;

  Lbl := MakeLbl(Grp,
    'FWD/REV multiplier per band: 1.00 = no correction, >1 if meter under-reads '
    + '(e.g. 88 W shown at real 100 W -> 1.14). SWR is not affected.',
    CAL_PAD, CAL_R1 + CAL_ROW_H + 8, 650);
  WrapLbl(Lbl, 650, 34);

  for i := 0 to CFG_BAND_COUNT - 1 do
  begin
    FLblPADetCal[i]    := MakeLbl(Grp, '', 0, 0, CAL_LBL_W);
    FEdPADetCal[i]     := MakeCalSpin(Grp, 0, 0, 0.50, 2.00, 0.01, 2, 1.00);
    FEdPADetCal[i].Tag := i;
  end;

  // ── Supply voltage / current ────────────────────────────────────────────
  GTop := GTop + CAL_GRP1_H + 14;
  FCalGrpVA := MakeGroupPanel(FPageCalib, 'Supply voltage / current',
                              CAL_MARGIN, GTop, CAL_GRP_W, CAL_GRP2_H);
  Grp := FCalGrpVA;

  Lbl := MakeLbl(Grp, 'Voltage multiplier (1.000 = no correction)',
                 CAL_PAD, CAL_R1 + 6, CAL_VA_LBL_W);
  FEdSupplyVoltCal := MakeCalSpin(Grp, CAL_PAD + CAL_VA_LBL_W + 10, CAL_R1,
                                  0.100, 5.000, 0.001, 3, 1.000);

  Lbl := MakeLbl(Grp, 'Current sensor zero offset (mV)',
                 CAL_PAD, CAL_R1 + CAL_ROW_H + 6, CAL_VA_LBL_W);
  FEdAmpVoff := MakeCalSpin(Grp, CAL_PAD + CAL_VA_LBL_W + 10, CAL_R1 + CAL_ROW_H,
                            0.0, 2000.0, 1.0, 0, 360.0);

  Lbl := MakeLbl(Grp, 'Current sensor sensitivity (mV/A)',
                 CAL_PAD, CAL_R1 + 2 * CAL_ROW_H + 6, CAL_VA_LBL_W);
  FEdAmpSens := MakeCalSpin(Grp, CAL_PAD + CAL_VA_LBL_W + 10, CAL_R1 + 2 * CAL_ROW_H,
                            1.0, 1000.0, 0.1, 1, 120.0);

  Lbl := MakeLbl(Grp, 'Defaults 360 mV / 120 mV/A (ACS712-style, Orion MkII)',
                 CAL_PAD + CAL_VA_LBL_W + 10 + CAL_ED_W + 14,
                 CAL_R1 + CAL_ROW_H + 6, 320);
  WrapLbl(Lbl, 320, 34);

  // ── S-meter ─────────────────────────────────────────────────────────────
  GTop := GTop + CAL_GRP2_H + 14;
  FCalGrpSM := MakeGroupPanel(FPageCalib, 'S-meter',
                              CAL_MARGIN, GTop, CAL_GRP_W, GRP3_H0);
  Grp := FCalGrpSM;

  FLblSMBandHdr := MakeLbl(Grp,
    'Offset (dB) added to S-meter readings — per band (+ = meter reads higher).',
    CAL_PAD, 30, 540);
  WrapLbl(FLblSMBandHdr, 540, 18);

  for i := 0 to CFG_BAND_COUNT - 1 do
  begin
    FLblSMBand[i]    := MakeLbl(Grp, '', 0, 0, CAL_SM_LBL_W);
    FEdSMBand[i]     := MakeCalSpin(Grp, 0, 0, -30.0, 30.0, 0.1, 1, 0.0);
    FEdSMBand[i].Tag := i;
  end;

  FLblSMXvtrHdr := MakeLbl(Grp, 'Per transverter slot', CAL_PAD, 0, 300);

  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    FLblSMXvtr[i]          := MakeLbl(Grp, '', 0, 0, CAL_SM_LBL_W);
    FLblSMXvtr[i].AutoSize := False;
    FLblSMXvtr[i].ShowHint := True;
    FEdSMXvtr[i]           := MakeCalSpin(Grp, 0, 0, -30.0, 30.0, 0.1, 1, 0.0);
    FEdSMXvtr[i].Tag       := i;
  end;

  // ── Spectrum / waterfall ────────────────────────────────────────────────
  // Отдельная от S-метра калибровка Display Cal Offset: у display-
  // тракта своё окно/детектор, поэтому уровень трассы и показания стрелки
  // расходятся на пару дБ. Сдвиг двигает ВСЮ трассу (и шумовую полку, и
  // сигналы) — это калибровка уровня, а не подавление шума.
  FCalGrpDisp := MakeGroupPanel(FPageCalib, 'Spectrum / waterfall',
                                CAL_MARGIN, GTop, CAL_GRP_W, GRP3_H0);
  Grp := FCalGrpDisp;

  FLblDispBandHdr := MakeLbl(Grp,
    'Offset (dB) added to spectrum/waterfall — per band (moves the whole trace).',
    CAL_PAD, 30, 540);
  WrapLbl(FLblDispBandHdr, 540, 18);

  FBtnDispFromSM             := TFlatButton.Create(Self);
  FBtnDispFromSM.Parent      := Grp;
  FBtnDispFromSM.Caption     := 'Copy all from S-meter';
  FBtnDispFromSM.SetBounds(DpiScale(CAL_PAD), DpiScale(54),
                            DpiScale(190), DpiScale(BTN_H));
  FBtnDispFromSM.OnClick     := OnCalDispFromSMClick;
  FBtnDispFromSM.ClrNorm     := CLR_PANEL;
  FBtnDispFromSM.ClrBorder   := TColor($00404040);
  FBtnDispFromSM.ClrHot      := TColor($00303030);
  FBtnDispFromSM.ClrActive   := TColor($00003300);
  FBtnDispFromSM.ClrText     := CLR_TEXT;
  FBtnDispFromSM.ClrTextAct  := CLR_ACCENT;
  FBtnDispFromSM.Font.Size   := 9;
  FBtnDispFromSM.Hint        :=
    'Copy all band and transverter offsets from the S-meter table above';
  FBtnDispFromSM.ShowHint    := True;

  for i := 0 to CFG_BAND_COUNT - 1 do
  begin
    FLblDispBand[i]    := MakeLbl(Grp, '', 0, 0, CAL_SM_LBL_W);
    FEdDispBand[i]     := MakeCalSpin(Grp, 0, 0, -30.0, 30.0, 0.1, 1, 0.0);
    FEdDispBand[i].Tag := i;
  end;

  FLblDispXvtrHdr := MakeLbl(Grp, 'Per transverter slot', CAL_PAD, 0, 300);

  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    FLblDispXvtr[i]          := MakeLbl(Grp, '', 0, 0, CAL_SM_LBL_W);
    FLblDispXvtr[i].AutoSize := False;
    FLblDispXvtr[i].ShowHint := True;
    FEdDispXvtr[i]           := MakeCalSpin(Grp, 0, 0, -30.0, 30.0, 0.1, 1, 0.0);
    FEdDispXvtr[i].Tag       := i;
  end;

  // ── Reference frequency ─────────────────────────────────────────────────
  // Одна поправка опорника на всё железо:
  // у openHPSDR правится частотное слово DDC/DUC, у AD936x — xo_correction.
  // Ручные LO Error / TO Error трансвертеров остаются сверху: тракт тракту
  // рознь, и где-то уход больше, где-то меньше.
  FCalGrpFreq := MakeGroupPanel(FPageCalib, 'Reference frequency',
                                CAL_MARGIN, GTop, CAL_GRP_W, CAL_GRP2_H);
  Grp := FCalGrpFreq;

  Lbl := MakeLbl(Grp, 'Oscillator error (ppm)', CAL_PAD, CAL_R1 + 6, CAL_VA_LBL_W);
  FEdFreqCalPPM := MakeCalSpin(Grp, CAL_PAD + CAL_VA_LBL_W + 10, CAL_R1,
                               -100.0, 100.0, 0.01, 3, 0.0);

  Lbl := MakeLbl(Grp,
    'Positive value raises displayed frequencies. Measure against a known '
    + 'reference (beacon, WWV, GPSDO): if a 10 MHz signal reads 10 Hz low, '
    + 'that is +1.0 ppm. Applies to RX and TX on every band; per-transverter '
    + 'LO Error / TO Error still apply on top.',
    CAL_PAD, CAL_R1 + CAL_ROW_H + 6, 650);
  WrapLbl(Lbl, 650, 52);

  RelayoutCalTab;   // стартовая раскладка (HF-план, трансвертеры скрыты)

  FLoading := False;
end;

procedure TSettingsForm.RelayoutCalTab;
// Показывает только то, что есть у активного железа: band-план (HF у openHPSDR,
// VHF/UHF у Pluto), из трансвертеров — включённые слоты. Детектор мощности и
// датчики питания — свойства Alex-моста openHPSDR, у AD936x такой телеметрии
// нет, поэтому обе группы прячутся, а S-метр поднимается наверх.
var
  i, r, c, X, Y, GTop, HSM, HDisp: Integer;
  Nm: string;
begin
  // Гард: вкладка ещё строится (или LoadXvtrSettings пришёл раньше неё).
  if (FCalGrpDet = nil) or (FCalGrpVA = nil) or (FCalGrpSM = nil)
     or (FCalGrpDisp = nil) or (FLblDispXvtrHdr = nil)
     or (FCalGrpFreq = nil) then Exit;
  if not FPageCalib.Visible then begin FCalRelayoutDue := True; Exit; end;
  FCalRelayoutDue := False;

  // TScrollBox двигает детей при прокрутке, и SetBounds ниже задаёт координаты
  // ОТНОСИТЕЛЬНО прокрученного клиента. Если страницу успели прокрутить (у
  // openHPSDR она длиннее на две группы), после перекладки прокрутка
  // схлопывается — и группы остаются смещёнными вниз ровно на прежний скролл.
  // Поэтому сначала возвращаем страницу в ноль.
  FPageCalib.VertScrollBar.Position := 0;
  FPageCalib.HorzScrollBar.Position := 0;

  if FLblCalHint <> nil then
    if FCalIsPluto then
      FLblCalHint.Caption := 'S-meter, spectrum and reference frequency '
                           + 'calibration.'
    else
      FLblCalHint.Caption := 'Power/SWR detector, supply voltage/current and '
                           + 'S-meter calibration.';

  // ---- подписи/видимость строк Det. Cal. (своя сетка, уже с подсказкой) ----
  for i := 0 to CFG_BAND_COUNT - 1 do
  begin
    Nm := BandName(i, FCalIsPluto);
    r  := i div CAL_COLS;
    c  := i mod CAL_COLS;
    X  := CAL_PAD + c * (CAL_LBL_W + CAL_ED_W + 18);
    Y  := CAL_R1 + CAL_ROW_H + 46 + r * CAL_ROW_H;
    if FLblPADetCal[i] <> nil then
    begin
      FLblPADetCal[i].Caption := Nm + ':';
      FLblPADetCal[i].SetBounds(DpiScale(X), DpiScale(Y + 5), DpiScale(CAL_LBL_W), DpiScale(18));
      FLblPADetCal[i].Visible := Nm <> '';
    end;
    if FEdPADetCal[i] <> nil then
    begin
      FEdPADetCal[i].SetBounds(DpiScale(X + CAL_LBL_W), DpiScale(Y), DpiScale(CAL_ED_W), DpiScale(BTN_H + 2));
      FEdPADetCal[i].Visible := Nm <> '';
    end;
  end;

  // ---- таблицы офсетов (S-метр и спектр) — одинаковая геометрия ----
  HSM   := LayoutCalOffsets(FLblSMBand, FEdSMBand, FLblSMXvtrHdr,
                            FLblSMXvtr, FEdSMXvtr, 52);
  HDisp := LayoutCalOffsets(FLblDispBand, FEdDispBand, FLblDispXvtrHdr,
                            FLblDispXvtr, FEdDispXvtr, 90);

  // ---- вертикальная укладка групп ----
  GTop := 86;
  FCalGrpDet.Visible := not FCalIsPluto;
  FCalGrpVA.Visible  := not FCalIsPluto;
  if not FCalIsPluto then
  begin
    FCalGrpDet.SetBounds(DpiScale(CAL_MARGIN), DpiScale(GTop), DpiScale(CAL_GRP_W), DpiScale(CAL_GRP1_H));
    GTop := GTop + CAL_GRP1_H + 14;
    FCalGrpVA.SetBounds(DpiScale(CAL_MARGIN), DpiScale(GTop), DpiScale(CAL_GRP_W), DpiScale(CAL_GRP2_H));
    GTop := GTop + CAL_GRP2_H + 14;
  end;
  FCalGrpSM.SetBounds(DpiScale(CAL_MARGIN), DpiScale(GTop), DpiScale(CAL_GRP_W), DpiScale(HSM));
  GTop := GTop + HSM + 14;
  FCalGrpDisp.SetBounds(DpiScale(CAL_MARGIN), DpiScale(GTop), DpiScale(CAL_GRP_W), DpiScale(HDisp));
  GTop := GTop + HDisp + 14;
  FCalGrpFreq.SetBounds(DpiScale(CAL_MARGIN), DpiScale(GTop), DpiScale(CAL_GRP_W), DpiScale(CAL_GRP2_H));
  UpdatePageBottomSpace(FPageCalib);
  if FActivePage = FPageCalib then UpdateSettingsScrollBar;
end;

function TSettingsForm.LayoutCalOffsets(const BandLbl: array of TLabel;
  const BandEd: array of TFlatFloatSpinEdit; XvtrHdr: TLabel;
  const XvtrLbl: array of TLabel; const XvtrEd: array of TFlatFloatSpinEdit;
  Y0: Integer): Integer;
// Строки диапазонов стоят по индексу плана (пустой слот просто скрыт),
// слоты трансвертеров — только включённые и без дырок.
var
  i, r, c, X, Y, Slot, BandRows, XvtrRows: Integer;
  Nm: string;
begin
  BandRows := 0;
  for i := 0 to CFG_BAND_COUNT - 1 do
  begin
    Nm := BandName(i, FCalIsPluto);
    r  := i div CAL_COLS;
    c  := i mod CAL_COLS;
    X  := CAL_PAD + c * (CAL_SM_LBL_W + CAL_ED_W + 14);
    Y  := Y0 + r * CAL_ROW_H;
    if BandLbl[i] <> nil then
    begin
      BandLbl[i].Caption := Nm + ':';
      BandLbl[i].SetBounds(DpiScale(X), DpiScale(Y + 5), DpiScale(CAL_SM_LBL_W), DpiScale(18));
      BandLbl[i].Visible := Nm <> '';
    end;
    if BandEd[i] <> nil then
    begin
      BandEd[i].SetBounds(DpiScale(X + CAL_SM_LBL_W), DpiScale(Y), DpiScale(CAL_ED_W), DpiScale(BTN_H + 2));
      BandEd[i].Visible := Nm <> '';
    end;
    if Nm <> '' then BandRows := r + 1;
  end;

  Y := Y0 + BandRows * CAL_ROW_H + 6;
  XvtrHdr.SetBounds(DpiScale(CAL_PAD), DpiScale(Y), DpiScale(300), DpiScale(18));
  Slot := 0;
  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    if FCalXvtrOn[i] then
    begin
      r := Slot div CAL_COLS;
      c := Slot mod CAL_COLS;
      X := CAL_PAD + c * (CAL_SM_LBL_W + CAL_ED_W + 14);
      if FCalXvtrName[i] <> '' then Nm := FCalXvtrName[i]
      else                          Nm := 'XVTR' + IntToStr(i);
      XvtrLbl[i].Caption := Nm + ':';
      XvtrLbl[i].Hint    := 'XVTR' + IntToStr(i) + ' (' + Nm + ')';
      XvtrLbl[i].SetBounds(DpiScale(X), DpiScale(Y + 24 + r * CAL_ROW_H + 5), DpiScale(CAL_SM_LBL_W), DpiScale(18));
      XvtrEd[i].SetBounds(DpiScale(X + CAL_SM_LBL_W), DpiScale(Y + 24 + r * CAL_ROW_H), DpiScale(CAL_ED_W), DpiScale(BTN_H + 2));
      Inc(Slot);
    end;
    XvtrLbl[i].Visible := FCalXvtrOn[i];
    XvtrEd[i].Visible  := FCalXvtrOn[i];
  end;
  XvtrRows    := (Slot + CAL_COLS - 1) div CAL_COLS;
  XvtrHdr.Visible := Slot > 0;

  if Slot > 0 then Result := Y + 24 + XvtrRows * CAL_ROW_H + 12
  else             Result := Y + 12;
end;

procedure TSettingsForm.OnCalDispFromSMClick(Sender: TObject);
// Стартовая точка калибровки дисплея: те же цифры, что у стрелки,
// дальше доводятся руками по шумовой полке/эталонному генератору.
var i: Integer;
begin
  FLoading := True;
  try
    for i := 0 to CFG_BAND_COUNT - 1 do
      if (FEdDispBand[i] <> nil) and (FEdSMBand[i] <> nil) then
        FEdDispBand[i].Value := FEdSMBand[i].Value;
    for i := 0 to CFG_XVTR_COUNT - 1 do
      if (FEdDispXvtr[i] <> nil) and (FEdSMXvtr[i] <> nil) then
        FEdDispXvtr[i].Value := FEdSMXvtr[i].Value;
  finally
    FLoading := False;
  end;
  OnCalAnyChange(FBtnDispFromSM);
end;

procedure TSettingsForm.SetCalibrationBackend(IsPluto: Boolean);
begin
  FCalIsPluto := IsPluto;
  RelayoutCalTab;
end;

procedure TSettingsForm.CollectCalFromUI;
var i: Integer;
begin
  FCalConfig.PwrDetModel := FCmbPwrDetModel.ItemIndex;
  for i := 0 to CFG_BAND_COUNT - 1 do
  begin
    if FEdPADetCal[i]  <> nil then FCalConfig.PADetCal[i]     := FEdPADetCal[i].Value;
    if FEdSMBand[i]    <> nil then FCalConfig.SMeterBandDB[i] := FEdSMBand[i].Value;
    if FEdDispBand[i]  <> nil then FCalConfig.DispBandDB[i]   := FEdDispBand[i].Value;
  end;
  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    if FEdSMXvtr[i]   <> nil then FCalConfig.SMeterXvtrDB[i] := FEdSMXvtr[i].Value;
    if FEdDispXvtr[i] <> nil then FCalConfig.DispXvtrDB[i]   := FEdDispXvtr[i].Value;
  end;
  FCalConfig.FreqCalPPM     := FEdFreqCalPPM.Value;
  FCalConfig.SupplyVoltCal  := FEdSupplyVoltCal.Value;
  FCalConfig.AmpVoffMV      := FEdAmpVoff.Value;
  FCalConfig.AmpSensMVA     := FEdAmpSens.Value;
end;

procedure TSettingsForm.OnCalAnyChange(Sender: TObject);
begin
  if FLoading then Exit;
  // Смена модели детектора подставляет модельные дефолты датчика тока
  // ANAN-7000DLE/Anvelina Pro 3 — 340/88,
  // остальные — 360/120. Правится руками после, если датчик нестандартный.
  if Sender = FCmbPwrDetModel then
  begin
    FLoading := True;
    try
      if FCmbPwrDetModel.ItemIndex = 2 then
      begin
        FEdAmpVoff.Value := 340.0;
        FEdAmpSens.Value := 88.0;
      end else
      begin
        FEdAmpVoff.Value := 360.0;
        FEdAmpSens.Value := 120.0;
      end;
    finally
      FLoading := False;
    end;
  end;
  CollectCalFromUI;
  if Assigned(FOnCalibrationChange) then
    FOnCalibrationChange(FCalConfig);
end;

procedure TSettingsForm.LoadCalibration(const C: TCalibration);
var i: Integer;
begin
  FLoading := True;
  try
    FCalConfig := C;
    FCmbPwrDetModel.ItemIndex := EnsureRange(C.PwrDetModel, 0, 3);
    for i := 0 to CFG_BAND_COUNT - 1 do
    begin
      if FEdPADetCal[i] <> nil then FEdPADetCal[i].Value := C.PADetCal[i];
      if FEdSMBand[i]   <> nil then FEdSMBand[i].Value   := C.SMeterBandDB[i];
      if FEdDispBand[i] <> nil then FEdDispBand[i].Value := C.DispBandDB[i];
    end;
    for i := 0 to CFG_XVTR_COUNT - 1 do
    begin
      if FEdSMXvtr[i]   <> nil then FEdSMXvtr[i].Value   := C.SMeterXvtrDB[i];
      if FEdDispXvtr[i] <> nil then FEdDispXvtr[i].Value := C.DispXvtrDB[i];
    end;
    FEdFreqCalPPM.Value    := EnsureRange(C.FreqCalPPM, -100.0, 100.0);
    FEdSupplyVoltCal.Value := C.SupplyVoltCal;
    FEdAmpVoff.Value       := C.AmpVoffMV;
    FEdAmpSens.Value       := C.AmpSensMVA;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.BuildAdvancedTab;
const
  MARGIN  = 22;
  GRP_PAD = 18;
  LBL_W   = 110;
  ED_W    = 200;
  ROW_H   = 42;
  R1      = 42;
var
  Grp:  TPanel;
  Chk:  TFlatCheckBox;
  Ed:   TFlatEdit;
  Spin: TFlatSpinEdit;
  Lbl:  TLabel;
  Y, i: Integer;
begin
  MakePageHeader(FPageAdvanced, 'Advanced', '');
  FLblAdvHint := MakeTextBlock(FPageAdvanced, '', SETTINGS_MARGIN, 48,
                               SETTINGS_CARD_W, 20);
  FLblAdvHint.Tag := TAG_PAGE_HEADER;

  // ── ADC ───────────────────────────────────────────────────────────────────
  Grp := MakeGroupPanel(FPageAdvanced, 'ADC', MARGIN, 86, SETTINGS_CARD_W, 128);
  FAdvGrpADC := Grp;
  FAdvHasADC := True;   // до подключения показываем полный (openHPSDR) вид

  Chk                := TFlatCheckBox.Create(Self);
  Chk.Parent         := Grp;
  Chk.Caption        := 'Dither Enabled';
  Chk.SetBounds(DpiScale(GRP_PAD), DpiScale(R1), DpiScale(260), DpiScale(22));
  Chk.Font.Size      := 9;
  Chk.Font.Color     := CLR_TEXT;
  Chk.Checked        := True;
  Chk.OnChange       := OnADCChkChange;
  FChkDither         := Chk;

  Chk                := TFlatCheckBox.Create(Self);
  Chk.Parent         := Grp;
  Chk.Caption        := 'Random Enabled';
  Chk.SetBounds(DpiScale(GRP_PAD), DpiScale(R1 + ROW_H), DpiScale(260), DpiScale(22));
  Chk.Font.Size      := 9;
  Chk.Font.Color     := CLR_TEXT;
  Chk.Checked        := True;
  Chk.OnChange       := OnADCChkChange;
  FChkRandom         := Chk;

  // ── Web Server ────────────────────────────────────────────────────────────
  Grp := MakeGroupPanel(FPageAdvanced, 'Web Server', MARGIN, 230, SETTINGS_CARD_W, 318);
  FAdvGrpWeb := Grp;

  Y := R1;
  Chk               := TFlatCheckBox.Create(Self);
  Chk.Parent        := Grp;
  Chk.Caption       := 'Enabled';
  Chk.SetBounds(DpiScale(GRP_PAD), DpiScale(Y), DpiScale(260), DpiScale(22));
  Chk.Font.Size     := 9;
  Chk.Font.Color    := CLR_TEXT;
  Chk.Checked       := True;
  Chk.OnChange      := OnWebAnyChange;
  FChkWebEnabled    := Chk;

  Y := Y + ROW_H;
  MakeLbl(Grp, 'Port:', GRP_PAD, Y + 4, LBL_W);
  Spin              := TFlatSpinEdit.Create(Self);
  Spin.Parent       := Grp;
  Spin.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(90), DpiScale(BTN_H));
  Spin.Color        := CLR_INPUT;
  Spin.Font.Color   := CLR_INPUT_TEXT;
  Spin.Font.Size    := 9;
  Spin.MinValue     := 1;
  Spin.MaxValue     := 65535;
  Spin.Value        := 8080;
  Spin.OnChange     := OnWebAnyChange;
  FEdWebPort        := Spin;

  Y := Y + ROW_H;
  MakeLbl(Grp, 'Interface:', GRP_PAD, Y + 4, LBL_W);
  Ed                := TFlatEdit.Create(Self);
  Ed.Parent         := Grp;
  Ed.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(ED_W), DpiScale(BTN_H));
  Ed.Color          := CLR_INPUT;
  Ed.Font.Color     := CLR_INPUT_TEXT;
  Ed.Font.Size      := 9;
  Ed.Text           := '0.0.0.0';
  Ed.OnChange       := OnWebAnyChange;
  FEdWebBind        := Ed;

  Y := Y + ROW_H;
  MakeLbl(Grp, 'Username:', GRP_PAD, Y + 4, LBL_W);
  Ed                := TFlatEdit.Create(Self);
  Ed.Parent         := Grp;
  Ed.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(ED_W), DpiScale(BTN_H));
  Ed.Color          := CLR_INPUT;
  Ed.Font.Color     := CLR_INPUT_TEXT;
  Ed.Font.Size      := 9;
  Ed.Text           := 'admin';
  Ed.OnChange       := OnWebAnyChange;
  FEdWebUser        := Ed;

  Y := Y + ROW_H;
  MakeLbl(Grp, 'Password:', GRP_PAD, Y + 4, LBL_W);
  Ed                := TFlatEdit.Create(Self);
  Ed.Parent         := Grp;
  Ed.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(ED_W), DpiScale(BTN_H));
  Ed.Color          := CLR_INPUT;
  Ed.Font.Color     := CLR_INPUT_TEXT;
  Ed.Font.Size      := 9;
  Ed.PasswordChar   := '*';
  Ed.Text           := 'hpsdr';
  Ed.OnChange       := OnWebAnyChange;
  FEdWebPass        := Ed;

  // Потолок точек в кадре спектра/водопада, уходящем веб-клиенту. Реально
  // отправляется min(этот потолок, разрешение анализатора = ширина
  // панадаптера); выше — децимация max-детектором.
  Y := Y + ROW_H;
  MakeLbl(Grp, 'Spectrum:', GRP_PAD, Y + 4, LBL_W);
  FCmbWebRes := MakeCombo(Grp, GRP_PAD + LBL_W + 12, Y, 130, OnWebAnyChange);
  for i := 0 to High(DISPLAY_RES_NAMES) do FCmbWebRes.Items.Add(DISPLAY_RES_NAMES[i]);
  FCmbWebRes.ItemIndex := 0;
  Lbl := MakeLbl(Grp, '1024 px ~160 KB/s, 4096 px ~650 KB/s per client',
                 GRP_PAD + LBL_W + 12, Y + ROW_H - 8, 320);
  Lbl.Font.Size := 8;

  // TCI Server живёт на вкладке CAT (см. BuildCATTab): это такой же канал
  // внешнего управления трансивером, что и CAT, и оператор ищет его там.

  RelayoutAdvancedTab;   // стартовая раскладка (полный, openHPSDR-вид)
end;

procedure TSettingsForm.BuildDXClusterTab;
const
  MARGIN  = 22;
  GRP_PAD = 18;
  LBL_W   = 110;
  ED_W    = 220;
  ROW_H   = 42;
  R1      = 42;
  // Подписи для чекбоксов фильтра мод. Порядок = Ord(TDXMode), включая
  // dxmUnknown («без моды») — иначе фильтр молча съедал бы такие споты.
  MODE_CAPS: array[0..9] of string =
    ('no mode', 'CW', 'SSB', 'DIGI', 'FT8', 'FT4', 'RTTY', 'PSK', 'FM', 'SSTV');
var
  Grp:  TPanel;
  Chk:  TFlatCheckBox;
  Ed:   TFlatEdit;
  Spin: TFlatSpinEdit;
  Lbl:  TLabel;
  Y, i, CX, CY: Integer;
begin
  TSettingsManager.DefaultDXCluster(FDXCfg);

  MakePageHeader(FPageDXCluster, 'DX Cluster',
    'Telnet connection to a DX cluster and how spots look on the panadapter.');

  // ── Соединение ────────────────────────────────────────────────────────────
  Grp := MakeGroupPanel(FPageDXCluster, 'Connection', MARGIN, 86, SETTINGS_CARD_W, 366);

  Y := R1;
  Chk            := TFlatCheckBox.Create(Self);
  Chk.Parent     := Grp;
  Chk.Caption    := 'Connect at startup';
  Chk.SetBounds(DpiScale(GRP_PAD), DpiScale(Y), DpiScale(300), DpiScale(22));
  Chk.Font.Size  := 9;
  Chk.Font.Color := CLR_TEXT;
  Chk.OnChange   := OnDXAnyChange;
  FChkDXEnabled  := Chk;

  Y := Y + ROW_H;
  MakeLbl(Grp, 'Host:', GRP_PAD, Y + 4, LBL_W);
  Ed             := TFlatEdit.Create(Self);
  Ed.Parent      := Grp;
  Ed.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(ED_W), DpiScale(BTN_H));
  Ed.Color       := CLR_INPUT;
  Ed.Font.Color  := CLR_INPUT_TEXT;
  Ed.Font.Size   := 9;
  Ed.Text        := FDXCfg.Host;
  Ed.OnChange    := OnDXAnyChange;
  FEdDXHost      := Ed;

  Y := Y + ROW_H;
  MakeLbl(Grp, 'Port:', GRP_PAD, Y + 4, LBL_W);
  Spin           := TFlatSpinEdit.Create(Self);
  Spin.Parent    := Grp;
  Spin.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(90), DpiScale(BTN_H));
  Spin.Color     := CLR_INPUT;
  Spin.Font.Color := CLR_INPUT_TEXT;
  Spin.Font.Size := 9;
  Spin.MinValue  := 1;
  Spin.MaxValue  := 65535;
  Spin.Value     := FDXCfg.Port;
  Spin.OnChange  := OnDXAnyChange;
  FEdDXPort      := Spin;

  Y := Y + ROW_H;
  MakeLbl(Grp, 'Callsign:', GRP_PAD, Y + 4, LBL_W);
  Ed             := TFlatEdit.Create(Self);
  Ed.Parent      := Grp;
  Ed.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(140), DpiScale(BTN_H));
  Ed.Color       := CLR_INPUT;
  Ed.Font.Color  := CLR_INPUT_TEXT;
  Ed.Font.Size   := 9;
  Ed.OnChange    := OnDXAnyChange;
  FEdDXLogin     := Ed;
  Lbl := MakeLbl(Grp, 'used to log in; spots for this call are highlighted',
                 GRP_PAD + LBL_W + 12 + 150, Y + 4, 340);
  Lbl.Font.Size := 8;

  Y := Y + ROW_H;
  MakeLbl(Grp, 'Password:', GRP_PAD, Y + 4, LBL_W);
  Ed             := TFlatEdit.Create(Self);
  Ed.Parent      := Grp;
  Ed.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(140), DpiScale(BTN_H));
  Ed.Color       := CLR_INPUT;
  Ed.Font.Color  := CLR_INPUT_TEXT;
  Ed.Font.Size   := 9;
  Ed.PasswordChar := '*';
  Ed.OnChange    := OnDXAnyChange;
  FEdDXPass      := Ed;
  Lbl := MakeLbl(Grp, 'usually not needed', GRP_PAD + LBL_W + 12 + 150, Y + 4, 200);
  Lbl.Font.Size := 8;

  Y := Y + ROW_H;
  MakeLbl(Grp, 'After login:', GRP_PAD, Y + 4, LBL_W);
  FEdDXPostLogin := TFlatMemo.Create(Self);
  FEdDXPostLogin.Parent := Grp;
  FEdDXPostLogin.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y),
    DpiScale(ED_W + 120), DpiScale(70));
  FEdDXPostLogin.Color      := CLR_INPUT;
  FEdDXPostLogin.Font.Color := CLR_INPUT_TEXT;
  FEdDXPostLogin.Font.Size  := 9;
  FEdDXPostLogin.WordWrap   := False;
  FEdDXPostLogin.OnChange   := OnDXAnyChange;
  Lbl := MakeLbl(Grp, 'one command per line (set/filter, sh/dx …) — dialects differ between clusters',
                 GRP_PAD, Y + 76, 520);
  Lbl.Font.Size := 8;

  // ── Отображение ───────────────────────────────────────────────────────────
  Grp := MakeGroupPanel(FPageDXCluster, 'Spots on panadapter', MARGIN, 468,
                        SETTINGS_CARD_W, 292);

  Y := R1;
  Chk            := TFlatCheckBox.Create(Self);
  Chk.Parent     := Grp;
  Chk.Caption    := 'Show spots on the spectrum';
  Chk.SetBounds(DpiScale(GRP_PAD), DpiScale(Y), DpiScale(300), DpiScale(22));
  Chk.Font.Size  := 9;
  Chk.Font.Color := CLR_TEXT;
  Chk.OnChange   := OnDXAnyChange;
  FChkDXShow     := Chk;

  Y := Y + ROW_H;
  MakeLbl(Grp, 'Keep for, min:', GRP_PAD, Y + 4, LBL_W);
  Spin           := TFlatSpinEdit.Create(Self);
  Spin.Parent    := Grp;
  Spin.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(90), DpiScale(BTN_H));
  Spin.Color     := CLR_INPUT;
  Spin.Font.Color := CLR_INPUT_TEXT;
  Spin.Font.Size := 9;
  Spin.MinValue  := 1;
  Spin.MaxValue  := 1440;
  Spin.Value     := FDXCfg.TTLMinutes;
  Spin.OnChange  := OnDXAnyChange;
  FEdDXTTL       := Spin;
  Lbl := MakeLbl(Grp, 'also the fade horizon: a spot dims and then disappears',
                 GRP_PAD + LBL_W + 114, Y + 4, 360);
  Lbl.Font.Size := 8;

  Y := Y + ROW_H;
  MakeLbl(Grp, 'Label rows:', GRP_PAD, Y + 4, LBL_W);
  Spin           := TFlatSpinEdit.Create(Self);
  Spin.Parent    := Grp;
  Spin.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(90), DpiScale(BTN_H));
  Spin.Color     := CLR_INPUT;
  Spin.Font.Color := CLR_INPUT_TEXT;
  Spin.Font.Size := 9;
  Spin.MinValue  := 1;
  Spin.MaxValue  := 6;
  Spin.Value     := FDXCfg.MaxRows;
  Spin.OnChange  := OnDXAnyChange;
  FEdDXRows      := Spin;
  Lbl := MakeLbl(Grp, 'ladder height: more rows means fewer hidden spots',
                 GRP_PAD + LBL_W + 114, Y + 4, 360);
  Lbl.Font.Size := 8;

  Y := Y + ROW_H;
  MakeLbl(Grp, 'Modes:', GRP_PAD, Y + 4, LBL_W);
  CX := GRP_PAD + LBL_W + 12;
  CY := Y;
  for i := 0 to High(FChkDXMode) do
  begin
    Chk            := TFlatCheckBox.Create(Self);
    Chk.Parent     := Grp;
    Chk.Caption    := MODE_CAPS[i];
    Chk.SetBounds(DpiScale(CX), DpiScale(CY), DpiScale(90), DpiScale(22));
    Chk.Font.Size  := 9;
    Chk.Font.Color := CLR_TEXT;
    Chk.OnChange   := OnDXAnyChange;
    FChkDXMode[i]  := Chk;
    CX := CX + 96;
    if CX > 480 then begin CX := GRP_PAD + LBL_W + 12; CY := CY + 26; end;
  end;
  Lbl := MakeLbl(Grp, 'none checked — every spot is shown',
                 GRP_PAD + LBL_W + 12, CY + 28, 360);
  Lbl.Font.Size := 8;
end;

procedure TSettingsForm.OnDXAnyChange(Sender: TObject);
var i: Integer;
begin
  if FLoading then Exit;
  if not Assigned(FOnDXClusterChange) then Exit;
  FDXCfg.Enabled    := FChkDXEnabled.Checked;
  FDXCfg.ShowSpots  := FChkDXShow.Checked;
  FDXCfg.Host       := Trim(FEdDXHost.Text);
  FDXCfg.Port       := FEdDXPort.Value;
  FDXCfg.Login      := Trim(FEdDXLogin.Text);
  FDXCfg.Password   := FEdDXPass.Text;
  FDXCfg.PostLogin  := FEdDXPostLogin.Text;
  FDXCfg.TTLMinutes := FEdDXTTL.Value;
  FDXCfg.MaxRows    := FEdDXRows.Value;
  FDXCfg.ModeMask   := 0;
  for i := 0 to High(FChkDXMode) do
    if (FChkDXMode[i] <> nil) and FChkDXMode[i].Checked then
      FDXCfg.ModeMask := FDXCfg.ModeMask or (LongWord(1) shl i);
  FOnDXClusterChange(FDXCfg);
end;

procedure TSettingsForm.LoadDXClusterSettings(const D: TDXClusterSettings);
var i: Integer;
begin
  FLoading := True;
  try
    FDXCfg := D;
    FChkDXEnabled.Checked  := D.Enabled;
    FChkDXShow.Checked     := D.ShowSpots;
    FEdDXHost.Text         := D.Host;
    FEdDXPort.Value        := EnsureRange(D.Port, 1, 65535);
    FEdDXLogin.Text        := D.Login;
    FEdDXPass.Text         := D.Password;
    FEdDXPostLogin.Text    := D.PostLogin;
    FEdDXTTL.Value         := EnsureRange(D.TTLMinutes, 1, 1440);
    FEdDXRows.Value        := EnsureRange(D.MaxRows, 1, 6);
    for i := 0 to High(FChkDXMode) do
      if FChkDXMode[i] <> nil then
        FChkDXMode[i].Checked := (D.ModeMask and (LongWord(1) shl i)) <> 0;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.OnADCChkChange(Sender: TObject);
begin
  if FLoading then Exit;
  if Assigned(FOnADCChange) then
    FOnADCChange(FChkDither.Checked, FChkRandom.Checked);
end;

procedure TSettingsForm.OnWebAnyChange(Sender: TObject);
var Res: Integer;
begin
  if FLoading then Exit;
  if not Assigned(FOnWebSettingsChange) then Exit;
  if (FCmbWebRes.ItemIndex >= 0) and (FCmbWebRes.ItemIndex <= High(DISPLAY_RES_VALUES)) then
    Res := DISPLAY_RES_VALUES[FCmbWebRes.ItemIndex]
  else
    Res := DISPLAY_RES_VALUES[0];
  FOnWebSettingsChange(
    FChkWebEnabled.Checked,
    FEdWebPort.Value,
    FEdWebBind.Text,
    FEdWebUser.Text,
    FEdWebPass.Text,
    Res);
end;

procedure TSettingsForm.OnTCIAnyChange(Sender: TObject);
begin
  if FLoading then Exit;
  if not Assigned(FOnTCISettingsChange) then Exit;
  // Применение перезапускает сервер и рвёт соединения клиентов, поэтому зовём
  // только на РЕАЛЬНОЕ изменение: обработчик висит и на уходе фокуса, и на
  // кнопке Close, а они срабатывают и без правок.
  if (FChkTCIEnabled.Checked = FTCILastEnabled) and
     (FEdTCIPort.Value = FTCILastPort) and
     (FEdTCIBind.Text = FTCILastBind) then Exit;
  FTCILastEnabled := FChkTCIEnabled.Checked;
  FTCILastPort    := FEdTCIPort.Value;
  FTCILastBind    := FEdTCIBind.Text;
  FOnTCISettingsChange(FTCILastEnabled, FTCILastPort, FTCILastBind);
end;

procedure TSettingsForm.LoadTCISettings(Enabled: Boolean; Port: Integer;
  const BindAddr: string);
begin
  FLoading := True;
  try
    FChkTCIEnabled.Checked := Enabled;
    FEdTCIPort.Value       := EnsureRange(Port, 1, 65535);
    FEdTCIBind.Text        := BindAddr;
    FTCILastEnabled        := FChkTCIEnabled.Checked;
    FTCILastPort           := FEdTCIPort.Value;
    FTCILastBind           := FEdTCIBind.Text;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.LoadADCSettings(Dither, Random: Boolean);
begin
  FLoading := True;
  try
    FChkDither.Checked := Dither;
    FChkRandom.Checked := Random;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.LoadWebSettings(Enabled: Boolean; Port: Integer;
  const BindAddr, User, Pass: string; SpecPixels: Integer);
var i: Integer;
begin
  FLoading := True;
  try
    FChkWebEnabled.Checked := Enabled;
    FEdWebPort.Value       := EnsureRange(Port, 1, 65535);
    FEdWebBind.Text        := BindAddr;
    FEdWebUser.Text        := User;
    FEdWebPass.Text        := Pass;
    FCmbWebRes.ItemIndex   := 0;
    for i := 0 to High(DISPLAY_RES_VALUES) do
      if DISPLAY_RES_VALUES[i] = SpecPixels then
      begin FCmbWebRes.ItemIndex := i; Break; end;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.ApplyTheme(const T: TAppTheme);

  procedure StyleBtn(B: TFlatButton);
  begin
    if B.Parent = FNavPanel then
    begin
      B.ClrNorm    := T.Panel;
      B.ClrBorder  := T.Panel;
      B.ClrHot     := T.BtnHot;
      B.ClrActive  := T.BtnActive;
      B.ClrText    := T.Text;
      B.ClrTextAct := T.BtnTextActive;
    end
    else
    begin
      B.ClrNorm    := T.BtnNorm;
      B.ClrBorder  := T.BtnBorderNorm;
      B.ClrHot     := T.BtnHot;
      B.ClrActive  := T.BtnActive;
      B.ClrText    := T.BtnText;
      B.ClrTextAct := T.BtnTextActive;
    end;
    B.Invalidate;
  end;

  procedure WalkControls(C: TWinControl);
  var i: Integer; Ctrl: TControl;
  begin
    for i := 0 to C.ControlCount - 1 do
    begin
      Ctrl := C.Controls[i];
      if Ctrl is TEqualizerControl then
        TEqualizerControl(Ctrl).SetAppTheme(T)
      else if Ctrl is TFlatButton then
        StyleBtn(TFlatButton(Ctrl))
      else if Ctrl is TScrollBox then
      begin
        TScrollBox(Ctrl).Color := T.BG;
        WalkControls(TScrollBox(Ctrl));
      end
      else if Ctrl is TPanel then
      begin
        if TPanel(Ctrl).Height = 1 then
          TPanel(Ctrl).Color := T.Border
        else if (TPanel(Ctrl).Tag = TAG_PAGE_BG) or
                (TPanel(Ctrl).Tag = TAG_BOTTOM_SPACER) then
          TPanel(Ctrl).Color := T.BG          // подложка подвкладки, не группа
        else
          TPanel(Ctrl).Color := T.Panel;
        WalkControls(TPanel(Ctrl));
      end
      else if Ctrl is TLabel then
      begin
        if Ctrl.Font.Size >= 12 then
          TLabel(Ctrl).Font.Color := T.Text
        else if fsBold in Ctrl.Font.Style then
          TLabel(Ctrl).Font.Color := T.FreqActive
        else
          TLabel(Ctrl).Font.Color := T.TextDim;
      end
      else if Ctrl is TFlatCheckBox then
        TFlatCheckBox(Ctrl).SetAppTheme(T)
      else if Ctrl is TFlatComboBox then
        TFlatComboBox(Ctrl).SetAppTheme(T)
      else if Ctrl is TFlatEdit then
        TFlatEdit(Ctrl).SetAppTheme(T)
      else if Ctrl is TFlatSpinEdit then
        TFlatSpinEdit(Ctrl).SetAppTheme(T)
      else if Ctrl is TFlatFloatSpinEdit then
        TFlatFloatSpinEdit(Ctrl).SetAppTheme(T)
      else if Ctrl is TFlatRadioButton then
        TFlatRadioButton(Ctrl).SetAppTheme(T)
      // TFlatMemo — обёртка над TMemo со своим scrollbar: тему знает сама,
      // рекурсивный обход внутрь ей только навредил бы (внутренний TMemo не
      // подходит ни под одну ветку и остался бы серым).
      else if Ctrl is TFlatMemo then
        TFlatMemo(Ctrl).SetAppTheme(T)
      else if Ctrl is TListBox then
      begin
        TListBox(Ctrl).Color      := T.BG;
        TListBox(Ctrl).Font.Color := T.Text;
      end
      else if Ctrl is TWinControl then
        WalkControls(TWinControl(Ctrl));
    end;
  end;

begin
  Color := T.BG;
  Font.Color := T.Text;
  if FScrollHost <> nil then FScrollHost.Color := T.BG;
  if FScrollBar <> nil then
    FScrollBar.SetColors(T.BG, T.Border,
      T.SliderThumbHot, T.SliderThumbDrag);
  WalkControls(Self);
  if FScrollHost <> nil then FScrollHost.BringToFront;
  Invalidate;
end;

// ---------------------------------------------------------------------------
// LoadValues
// ---------------------------------------------------------------------------

procedure TSettingsForm.LoadValues(
  FFTSize, WinType, SpecDet, SpecAvgMode: Integer;
  SpecAvgTimeMS: Double;
  WfDet, WfAvgMode: Integer;
  WfAvgTimeMS, WfHigh, WfLow, WfAGCOffset: Double;
  RefLevel, Range, GridStep: Double;
  const AudioOutDevName: string;
  const AudioInDevName: string);
var
  i, Idx: Integer;
begin
  FLoading := True;
  try
    Idx := 5;
    for i := 0 to High(FFT_SIZES) do
      if FFT_SIZES[i] = FFTSize then begin Idx := i; Break; end;
    FCmbFFTSize.ItemIndex := Idx;

    if (WinType >= 0) and (WinType < FCmbWindow.Items.Count) then
      FCmbWindow.ItemIndex := WinType
    else
      FCmbWindow.ItemIndex := 2;

    if (SpecDet >= 0) and (SpecDet < FCmbSpecDet.Items.Count) then
      FCmbSpecDet.ItemIndex := SpecDet
    else
      FCmbSpecDet.ItemIndex := 0;

    Idx := 3;
    for i := 0 to High(AVGMODE_VALUES) do
      if AVGMODE_VALUES[i] = SpecAvgMode then begin Idx := i; Break; end;
    FCmbSpecAvg.ItemIndex := Idx;
    FSpecAvgTime.Value := EnsureRange(SpecAvgTimeMS, 0.0, 5000.0);

    if (WfDet >= 0) and (WfDet < FCmbWfDet.Items.Count) then
      FCmbWfDet.ItemIndex := WfDet
    else
      FCmbWfDet.ItemIndex := 0;

    Idx := 3;
    for i := 0 to High(WF_AVGMODE_VALUES) do
      if WF_AVGMODE_VALUES[i] = WfAvgMode then begin Idx := i; Break; end;
    FCmbWfAvg.ItemIndex := Idx;
    FWfAvgTime.Value   := EnsureRange(WfAvgTimeMS, 0.0, 5000.0);
    FWfHigh.Value      := EnsureRange(WfHigh, -160.0, 20.0);
    FWfLow.Value       := EnsureRange(WfLow, -180.0, 0.0);
    FWfAGCOffset.Value := EnsureRange(WfAGCOffset, -40.0, 40.0);

    FEdRefLevel.Text := FormatFloat('0.#', RefLevel);
    FEdRange.Text    := FormatFloat('0.#', Range);
    Idx := 1;
    for i := 0 to High(GRID_STEPS) do
      if Abs(GRID_STEPS[i] - GridStep) < 0.5 then begin Idx := i; Break; end;
    FCmbGridStep.ItemIndex := Idx;

    if AudioOutDevName = '' then
      FCmbRXDev.ItemIndex := 0
    else
    begin
      Idx := FCmbRXDev.Items.IndexOf(AudioOutDevName);
      if Idx >= 0 then FCmbRXDev.ItemIndex := Idx
      else FCmbRXDev.ItemIndex := 0;
    end;

    if AudioInDevName = '' then
      FCmbTXDev.ItemIndex := 0
    else
    begin
      Idx := FCmbTXDev.Items.IndexOf(AudioInDevName);
      if Idx >= 0 then FCmbTXDev.ItemIndex := Idx
      else FCmbTXDev.ItemIndex := 0;
    end;
  finally
    FLoading := False;
  end;
end;

// ---------------------------------------------------------------------------
// LoadAudioBufferSize
// ---------------------------------------------------------------------------

procedure TSettingsForm.LoadAudioBufferSize(BufferSize: Integer);
begin
  FLoading := True;
  try
    case BufferSize of
      256: FCmbAudioBuffer.ItemIndex := 1;
      512: FCmbAudioBuffer.ItemIndex := 2;
    else
      FCmbAudioBuffer.ItemIndex := 0;
    end;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.LoadSendAudioToRadio(Enabled: Boolean);
begin
  FLoading := True;
  try
    FChkSendAudioToRadio.Checked := Enabled;
  finally
    FLoading := False;
  end;
end;

// ---------------------------------------------------------------------------
// RefreshAudioDevices
// ---------------------------------------------------------------------------

procedure TSettingsForm.RefreshAudioDevices(AudioOut: TAudioOutput; AudioIn: TAudioInput);
var
  Names:    TStringList;
  Cnt, i:   Integer;
  OldRXSel: string;
  OldTXSel: string;
begin
  Names := TStringList.Create;
  FLoading := True;
  try
    if AudioOut <> nil then
    begin
      OldRXSel := FCmbRXDev.Text;
      FCmbRXDev.Items.Clear;
      FCmbRXDev.Items.Add('Default');
      FRXDevCount := 0;

      Cnt := 0;
      Names.Clear;
      if AudioOut.EnumOutputDevices(Names, FRXDevIndices, Cnt) then
      begin
        FRXDevCount := Cnt;
        for i := 0 to Cnt - 1 do
          FCmbRXDev.Items.Add(Names[i]);
      end;
      if (FRXDevCount = 0) and (AudioOut.LastError <> '') then
        FCmbRXDev.Items.Add('PortAudio: ' + AudioOut.LastError);

      i := FCmbRXDev.Items.IndexOf(OldRXSel);
      if i >= 0 then FCmbRXDev.ItemIndex := i
      else FCmbRXDev.ItemIndex := 0;
    end;

    if AudioIn <> nil then
    begin
      OldTXSel := FCmbTXDev.Text;
      FCmbTXDev.Items.Clear;
      FCmbTXDev.Items.Add('None');
      FTXDevCount := 0;

      Cnt := 0;
      Names.Clear;
      if AudioIn.EnumInputDevices(Names, FTXDevIndices, Cnt) then
      begin
        FTXDevCount := Cnt;
        for i := 0 to Cnt - 1 do
          FCmbTXDev.Items.Add(Names[i]);
      end;
      if (FTXDevCount = 0) and (AudioIn.LastError <> '') then
        FCmbTXDev.Items.Add('PortAudio: ' + AudioIn.LastError);

      i := FCmbTXDev.Items.IndexOf(OldTXSel);
      if i >= 0 then FCmbTXDev.ItemIndex := i
      else FCmbTXDev.ItemIndex := 0;
    end;
  finally
    FLoading := False;
    Names.Free;
  end;
  FillSliceDevCombos(AudioOut, AudioIn);
end;

procedure TSettingsForm.FillSliceDevCombos(AudioOut: TAudioOutput; AudioIn: TAudioInput);
// Списки устройств в комбо вкладки Slices — те же, что у главных комбо. Текущий
// выбор держим по ИМЕНИ: порядок перечисления PortAudio между запусками плавает.
var
  Names: TStringList;
  Idx:   array[0..63] of Integer;
  Cnt, i, k: Integer;
  Cur:   string;
begin
  if FSliceOutDev[0] = nil then Exit;   // вкладка ещё не построена
  Names := TStringList.Create;
  FLoading := True;
  try
    if AudioOut <> nil then
    begin
      Cnt := 0; Names.Clear;
      AudioOut.EnumOutputDevices(Names, Idx, Cnt);
      for i := 0 to CFG_CAT_SLICE_COUNT - 1 do
      begin
        Cur := FSliceOutDev[i].Text;
        if FSliceOutDev[i].ItemIndex = 0 then Cur := '';
        FSliceOutDev[i].Items.Clear;
        FSliceOutDev[i].Items.Add(SLICE_DEV_DEFAULT);
        for k := 0 to Cnt - 1 do FSliceOutDev[i].Items.Add(Names[k]);
        SelectDevInCombo(FSliceOutDev[i], Cur);
      end;
    end;
    if AudioIn <> nil then
    begin
      Cnt := 0; Names.Clear;
      AudioIn.EnumInputDevices(Names, Idx, Cnt);
      for i := 0 to CFG_CAT_SLICE_COUNT - 1 do
      begin
        Cur := FSliceInDev[i].Text;
        if FSliceInDev[i].ItemIndex = 0 then Cur := '';
        FSliceInDev[i].Items.Clear;
        FSliceInDev[i].Items.Add(SLICE_DEV_NONE);
        for k := 0 to Cnt - 1 do FSliceInDev[i].Items.Add(Names[k]);
        SelectDevInCombo(FSliceInDev[i], Cur);
      end;
    end;
  finally
    FLoading := False;
    Names.Free;
  end;
end;

// ---------------------------------------------------------------------------
// Value accessors
// ---------------------------------------------------------------------------

function TSettingsForm.GetFFTSize: Integer;
var Idx: Integer;
begin
  Idx := FCmbFFTSize.ItemIndex;
  if (Idx >= 0) and (Idx <= High(FFT_SIZES)) then
    Result := FFT_SIZES[Idx]
  else
    Result := 4096;
end;

function TSettingsForm.GetSpecAvgTimeMS: Double;
begin
  Result := EnsureRange(FSpecAvgTime.Value, 0.0, 5000.0);
end;

function TSettingsForm.GetWfAvgTimeMS: Double;
begin
  Result := EnsureRange(FWfAvgTime.Value, 0.0, 5000.0);
end;

function TSettingsForm.GetWfHigh: Double;
begin
  Result := EnsureRange(FWfHigh.Value, -160.0, 20.0);
end;

function TSettingsForm.GetWfLow: Double;
begin
  Result := EnsureRange(FWfLow.Value, -180.0, 0.0);
end;

function TSettingsForm.GetWfAGCOffset: Double;
begin
  Result := EnsureRange(FWfAGCOffset.Value, -40.0, 40.0);
end;

function TSettingsForm.GetRefLevel: Double;
var V: Double; Err: Integer;
begin
  Val(FEdRefLevel.Text, V, Err);
  if Err <> 0 then V := -20.0;
  Result := EnsureRange(V, -200.0, 0.0);
end;

function TSettingsForm.GetRange: Double;
var V: Double; Err: Integer;
begin
  Val(FEdRange.Text, V, Err);
  if Err <> 0 then V := 110.0;
  Result := EnsureRange(V, 10.0, 200.0);
end;

function TSettingsForm.GetGridStep: Double;
var Idx: Integer;
begin
  Idx := FCmbGridStep.ItemIndex;
  if (Idx >= 0) and (Idx <= High(GRID_STEPS)) then
    Result := GRID_STEPS[Idx]
  else
    Result := 10.0;
end;

// ---------------------------------------------------------------------------
// Fire helpers
// ---------------------------------------------------------------------------

procedure TSettingsForm.FireDisplayChange;
var
  AvgIdx: Integer;
  AvgVal: Integer;
begin
  if FLoading then Exit;
  if not Assigned(FOnDisplayChange) then Exit;
  AvgIdx := FCmbSpecAvg.ItemIndex;
  if (AvgIdx >= 0) and (AvgIdx <= High(AVGMODE_VALUES)) then
    AvgVal := AVGMODE_VALUES[AvgIdx]
  else
    AvgVal := 3;
  FOnDisplayChange(
    GetFFTSize,
    FCmbWindow.ItemIndex,
    FCmbSpecDet.ItemIndex,
    AvgVal,
    GetSpecAvgTimeMS);
end;

procedure TSettingsForm.FireWaterfallChange;
var
  AvgIdx: Integer;
  AvgVal: Integer;
begin
  if FLoading then Exit;
  if not Assigned(FOnWaterfallChange) then Exit;
  AvgIdx := FCmbWfAvg.ItemIndex;
  if (AvgIdx >= 0) and (AvgIdx <= High(WF_AVGMODE_VALUES)) then
    AvgVal := WF_AVGMODE_VALUES[AvgIdx]
  else
    AvgVal := 1;
  FOnWaterfallChange(
    FCmbWfDet.ItemIndex,
    AvgVal,
    GetWfAvgTimeMS,
    GetWfHigh,
    GetWfLow,
    GetWfAGCOffset);
end;

procedure TSettingsForm.FireWfRenderChange;
var
  ResV, SpeedV: Integer;
begin
  if FLoading then Exit;
  if not Assigned(FOnWfRenderChange) then Exit;
  if (FCmbWfRes.ItemIndex >= 0) and (FCmbWfRes.ItemIndex <= High(DISPLAY_RES_VALUES)) then
    ResV := DISPLAY_RES_VALUES[FCmbWfRes.ItemIndex]
  else
    ResV := DISPLAY_RES_VALUES[High(DISPLAY_RES_VALUES)];
  if (FCmbWfSpeed.ItemIndex >= 0) and (FCmbWfSpeed.ItemIndex <= High(WF_SPEED_VALUES)) then
    SpeedV := WF_SPEED_VALUES[FCmbWfSpeed.ItemIndex]
  else
    SpeedV := 1;
  FOnWfRenderChange(Max(0, FCmbWfPalette.ItemIndex), FWfGamma.Value, ResV, SpeedV);
end;

procedure TSettingsForm.OnWfRenderCtlChange(Sender: TObject);
begin FireWfRenderChange; end;

procedure TSettingsForm.OnSpecMSAACtlChange(Sender: TObject);
begin
  if FLoading then Exit;
  if not Assigned(FOnSpecMSAAChange) then Exit;
  if (FCmbSpecMSAA.ItemIndex >= 0) and (FCmbSpecMSAA.ItemIndex <= High(MSAA_VALUES)) then
    FOnSpecMSAAChange(MSAA_VALUES[FCmbSpecMSAA.ItemIndex]);
end;

procedure TSettingsForm.LoadWfRender(Palette: Integer; Gamma: Double;
  ResLimit, SpeedDiv: Integer);
var i: Integer;
begin
  FLoading := True;
  try
    if (Palette >= 0) and (Palette < FCmbWfPalette.Items.Count) then
      FCmbWfPalette.ItemIndex := Palette
    else
      FCmbWfPalette.ItemIndex := 0;
    FWfGamma.Value := EnsureRange(Gamma, 0.3, 3.0);
    FCmbWfRes.ItemIndex := High(DISPLAY_RES_VALUES);
    for i := 0 to High(DISPLAY_RES_VALUES) do
      if DISPLAY_RES_VALUES[i] = ResLimit then
      begin FCmbWfRes.ItemIndex := i; Break; end;
    FCmbWfSpeed.ItemIndex := 0;
    for i := 0 to High(WF_SPEED_VALUES) do
      if WF_SPEED_VALUES[i] = SpeedDiv then
      begin FCmbWfSpeed.ItemIndex := i; Break; end;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.LoadSpecMSAA(Samples: Integer);
var i: Integer;
begin
  FLoading := True;
  try
    FCmbSpecMSAA.ItemIndex := 0;
    for i := 0 to High(MSAA_VALUES) do
      if MSAA_VALUES[i] = Samples then
      begin FCmbSpecMSAA.ItemIndex := i; Break; end;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.FireGridChange;
begin
  if FLoading then Exit;
  if not Assigned(FOnGridChange) then Exit;
  FOnGridChange(GetRefLevel, GetRange, GetGridStep);
end;

// ---------------------------------------------------------------------------
// Event handlers
// ---------------------------------------------------------------------------

procedure TSettingsForm.OnFFTSizeChange(Sender: TObject);
begin FireDisplayChange; end;

procedure TSettingsForm.OnWindowChange(Sender: TObject);
begin FireDisplayChange; end;

procedure TSettingsForm.OnSpecDetChange(Sender: TObject);
begin FireDisplayChange; end;

procedure TSettingsForm.OnSpecAvgModeChange(Sender: TObject);
begin FireDisplayChange; end;

procedure TSettingsForm.OnSpecAvgTimeChange(Sender: TObject);
begin FireDisplayChange; end;

procedure TSettingsForm.OnWfDetChange(Sender: TObject);
begin FireWaterfallChange; end;

procedure TSettingsForm.OnWfAvgModeChange(Sender: TObject);
begin FireWaterfallChange; end;

procedure TSettingsForm.OnWfAvgTimeChange(Sender: TObject);
begin FireWaterfallChange; end;

procedure TSettingsForm.OnWfThresholdChange(Sender: TObject);
begin FireWaterfallChange; end;

procedure TSettingsForm.OnWfAGCNFChkChange(Sender: TObject);
begin
  if FLoading then Exit;
  if not Assigned(FOnWfAGCNFChange) then Exit;
  FOnWfAGCNFChange(FChkWfAGC.Checked, FChkWfNF.Checked);
end;

procedure TSettingsForm.OnRefLevelChange(Sender: TObject);
begin FireGridChange; end;

procedure TSettingsForm.OnRangeChange(Sender: TObject);
begin FireGridChange; end;

procedure TSettingsForm.OnGridStepChange(Sender: TObject);
begin FireGridChange; end;

procedure TSettingsForm.OnRXDevChange(Sender: TObject);
var
  Idx:     Integer;
  DevIdx:  Integer;
  DevName: string;
begin
  if FLoading then Exit;
  if not Assigned(FOnAudioDevChange) then Exit;
  Idx := FCmbRXDev.ItemIndex;
  if Idx <= 0 then
    FOnAudioDevChange(-1, '')
  else
  begin
    DevName := FCmbRXDev.Items[Idx];
    DevIdx := Idx - 1;
    if (DevIdx >= 0) and (DevIdx < FRXDevCount) then
      FOnAudioDevChange(FRXDevIndices[DevIdx], DevName)
    else
      FOnAudioDevChange(-1, '');
  end;
end;

procedure TSettingsForm.OnTXDevChange(Sender: TObject);
var
  Idx:     Integer;
  DevIdx:  Integer;
  DevName: string;
begin
  if FLoading then Exit;
  if not Assigned(FOnAudioInDevChange) then Exit;
  Idx := FCmbTXDev.ItemIndex;
  if Idx <= 0 then
    FOnAudioInDevChange(-1, '')
  else
  begin
    DevName := FCmbTXDev.Items[Idx];
    DevIdx := Idx - 1;
    if (DevIdx >= 0) and (DevIdx < FTXDevCount) then
      FOnAudioInDevChange(FTXDevIndices[DevIdx], DevName)
    else
      FOnAudioInDevChange(-1, '');
  end;
end;

procedure TSettingsForm.OnAudioBufferCmbChange(Sender: TObject);
var
  BufferSize: Integer;
begin
  if FLoading then Exit;
  if not Assigned(FOnAudioBufferChange) then Exit;
  case FCmbAudioBuffer.ItemIndex of
    1: BufferSize := 256;
    2: BufferSize := 512;
  else
    BufferSize := 128;
  end;
  FOnAudioBufferChange(BufferSize);
end;

procedure TSettingsForm.OnSendAudioToRadioChkChange(Sender: TObject);
begin
  if FLoading then Exit;
  if Assigned(FOnSendAudioToRadioChange) then
    FOnSendAudioToRadioChange(FChkSendAudioToRadio.Checked);
end;

procedure TSettingsForm.LoadVisibility(ShowSpectrum, ShowWaterfall,
  ShowWideband, WidebandFill, SpectrumFill: Boolean);
begin
  FLoading := True;
  try
    FChkShowSpectrum.Checked  := ShowSpectrum;
    FChkShowWaterfall.Checked := ShowWaterfall;
    FChkShowWideband.Checked  := ShowWideband;
    FChkWidebandFill.Checked  := WidebandFill;
    FChkSpectrumFill.Checked  := SpectrumFill;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.LoadWfAGCNF(AGC, NF: Boolean);
begin
  FLoading := True;
  try
    FChkWfAGC.Checked := AGC;
    FChkWfNF.Checked  := NF;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.OnVisibilityChkChange(Sender: TObject);
begin
  if FLoading then Exit;
  if not Assigned(FOnVisibilityChange) then Exit;
  FOnVisibilityChange(FChkShowSpectrum.Checked, FChkShowWaterfall.Checked,
    FChkShowWideband.Checked, FChkWidebandFill.Checked, FChkSpectrumFill.Checked);
end;

procedure TSettingsForm.OnLightThemeChkChange(Sender: TObject);
begin
  if FLoading then Exit;
  if FChkLightTheme.Checked then ApplyTheme(LightTheme)
  else                            ApplyTheme(DarkTheme);
  if Assigned(FOnThemeChange) then
    FOnThemeChange(FChkLightTheme.Checked);
end;

procedure TSettingsForm.OnFPSCmbChange(Sender: TObject);
var
  Idx: Integer;
begin
  if FLoading then Exit;
  if not Assigned(FOnFPSChange) then Exit;
  Idx := FCmbFPS.ItemIndex;
  if (Idx >= 0) and (Idx <= High(FPS_VALUES)) then
    FOnFPSChange(FPS_VALUES[Idx]);
end;

procedure TSettingsForm.LoadFPS(FPS: Integer);
var
  i, Idx: Integer;
begin
  FLoading := True;
  try
    Idx := 8;
    for i := 0 to High(FPS_VALUES) do
      if FPS_VALUES[i] = FPS then begin Idx := i; Break; end;
    FCmbFPS.ItemIndex := Idx;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.LoadLightTheme(ALight: Boolean);
begin
  FLoading := True;
  try
    FChkLightTheme.Checked := ALight;
  finally
    FLoading := False;
  end;
  if ALight then ApplyTheme(LightTheme)
  else            ApplyTheme(DarkTheme);
end;

procedure TSettingsForm.LoadFreqMhzDigits(Digits: Integer);
begin
  FLoading := True;
  try
    FCmbFreqMhzDigits.ItemIndex := EnsureRange(Digits - 3, 0, 2);
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.OnFreqMhzDigitsCmbChange(Sender: TObject);
begin
  if FLoading then Exit;
  if Assigned(FOnFreqMhzDigitsChange) then
    FOnFreqMhzDigitsChange(FCmbFreqMhzDigits.ItemIndex + 3);
end;

procedure TSettingsForm.LoadPASettings(MaxPower: Double;
  const BandCal: array of Double);
var i, n: Integer;
begin
  FLoading := True;
  try
    FEdMaxPower.Value := Round(EnsureRange(MaxPower, 5.0, 200.0));
    n := Min(10, High(BandCal));
    for i := 0 to n do
      FEdBandCal[i].Value := EnsureRange(BandCal[i], 38.8, 100.0);
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.LoadPlutoTxMaxAtt(MaxAttDb: Double);
begin
  FLoading := True;
  try
    FEdPlutoTxMaxAtt.Value := EnsureRange(MaxAttDb, -50.0, 0.0);
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.OnMaxPowerChange(Sender: TObject);
var
  BandCal: array[0..10] of Double;
  i: Integer;
begin
  if FLoading then Exit;
  if not Assigned(FOnPAChange) then Exit;
  for i := 0 to 10 do
    BandCal[i] := FEdBandCal[i].Value;
  FOnPAChange(FEdMaxPower.Value, BandCal);
end;

procedure TSettingsForm.OnPlutoTxMaxAttChange(Sender: TObject);
begin
  if FLoading then Exit;
  if Assigned(FOnPlutoTxChange) then FOnPlutoTxChange(FEdPlutoTxMaxAtt.Value);
end;

procedure TSettingsForm.OnBandCalChange(Sender: TObject);
var
  BandCal: array[0..10] of Double;
  i: Integer;
begin
  if FLoading then Exit;
  if not Assigned(FOnPAChange) then Exit;
  for i := 0 to 10 do
    BandCal[i] := FEdBandCal[i].Value;
  FOnPAChange(FEdMaxPower.Value, BandCal);
end;

procedure TSettingsForm.LoadVHFCalSettings(const VHFCal: array of Double);
var i, n: Integer;
begin
  FLoading := True;
  try
    n := Min(CFG_XVTR_COUNT - 1, High(VHFCal));
    for i := 0 to n do
      FEdVHFCal[i].Value := EnsureRange(VHFCal[i], 38.8, 100.0);
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.OnVHFCalEdit(Sender: TObject);
var
  VHFCal: array[0..CFG_XVTR_COUNT-1] of Double;
  i: Integer;
begin
  if FLoading then Exit;
  if not Assigned(FOnVHFCalChange) then Exit;
  for i := 0 to CFG_XVTR_COUNT - 1 do
    VHFCal[i] := FEdVHFCal[i].Value;
  FOnVHFCalChange(VHFCal);
end;

procedure TSettingsForm.BtnCloseClick(Sender: TObject);
begin
  // Порт и адрес TCI применяются по уходу фокуса (см. BuildAdvancedTab), а из
  // поля, в котором стоит курсор, при закрытии окна фокус может и не уйти —
  // дожимаем правку здесь, иначе она молча пропадёт.
  OnTCIAnyChange(nil);
  Close;
end;

// ---------------------------------------------------------------------------
// BuildCATTab
// ---------------------------------------------------------------------------

procedure TSettingsForm.BuildCATTab;
const
  MARGIN  = 22;
  PAD     = 18;
  LW      = 120;
  CX      = PAD + LW + 8;
  GRP_GAP = 16;
  GRP_W   = (SETTINGS_CARD_W - GRP_GAP) div 2;
  GRP_H   = 272;
  CMB_W   = 140;
  EDT_W   = 160;
  R1      = 42;
  STEP    = 32;
  // Высота сетевых карточек (TCP CAT и TCI) — общая: стоят они в одной строке.
  NET_GRP_H = 176;
var
  i, col, row, gx, gy, j: Integer;
  Grp:  TPanel;
  Chk:  TFlatCheckBox;
  Ed:   TFlatEdit;
  Cmb:  TFlatComboBox;
  Spin: TFlatSpinEdit;
  Lbl:  TLabel;
begin
  MakePageHeader(FPageCAT, 'CAT',
    'External control: serial CAT ports, TCP CAT server and TCI server.');

  for i := 0 to 3 do
  begin
    row := i div 2;
    col := i mod 2;
    gx  := MARGIN + col * (GRP_W + GRP_GAP);
    gy  := 86 + row * (GRP_H + GRP_GAP);

    Grp := MakeGroupPanel(FPageCAT, 'Serial Port ' + IntToStr(i + 1), gx, gy, GRP_W, GRP_H);
    if col = 0 then
      Grp.Tag := TAG_RESPONSIVE_HALF_LEFT
    else
      Grp.Tag := TAG_RESPONSIVE_HALF_RIGHT;

    Chk            := TFlatCheckBox.Create(Self);
    Chk.Parent     := Grp;
    Chk.Caption    := 'Enable';
    Chk.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(GRP_W - PAD * 2), DpiScale(22));
    Chk.Font.Color := CLR_TEXT;
    Chk.Font.Size := 9;
    Chk.Tag        := i;
    Chk.OnChange   := OnCATSerialChange;
    FCATSerialEn[i] := Chk;

    MakeLbl(Grp, 'Port:', PAD, R1 + STEP + 6, LW);
    Ed            := TFlatEdit.Create(Self);
    Ed.Parent     := Grp;
    Ed.SetBounds(DpiScale(CX), DpiScale(R1 + STEP), DpiScale(EDT_W), DpiScale(BTN_H));
    Ed.Color      := CLR_INPUT; Ed.Font.Color := CLR_INPUT_TEXT;
    Ed.Font.Size := 9;
    Ed.Tag        := i;
    Ed.OnChange   := OnCATSerialChange;
    Ed.Text := SerDefaultPortName(i);
    FCATSerialPort[i] := Ed;

    MakeLbl(Grp, 'Baud rate:', PAD, R1 + 2 * STEP + 6, LW);
    Cmb := MakeCombo(Grp, CX, R1 + 2 * STEP, CMB_W, OnCATSerialChange);
    Cmb.Tag := i;
    for j := 0 to High(CAT_BAUD_NAMES) do Cmb.Items.Add(CAT_BAUD_NAMES[j]);
    Cmb.ItemIndex := 3; // 9600
    FCATSerialBaud[i] := Cmb;

    MakeLbl(Grp, 'Data bits:', PAD, R1 + 3 * STEP + 6, LW);
    Cmb := MakeCombo(Grp, CX, R1 + 3 * STEP, CMB_W, OnCATSerialChange);
    Cmb.Tag := i;
    Cmb.Items.Add('7'); Cmb.Items.Add('8');
    Cmb.ItemIndex := 1; // 8
    FCATSerialDataBits[i] := Cmb;

    MakeLbl(Grp, 'Stop bits:', PAD, R1 + 4 * STEP + 6, LW);
    Cmb := MakeCombo(Grp, CX, R1 + 4 * STEP, CMB_W, OnCATSerialChange);
    Cmb.Tag := i;
    Cmb.Items.Add('1'); Cmb.Items.Add('2');
    Cmb.ItemIndex := 0; // 1
    FCATSerialStopBits[i] := Cmb;

    MakeLbl(Grp, 'Parity:', PAD, R1 + 5 * STEP + 6, LW);
    Cmb := MakeCombo(Grp, CX, R1 + 5 * STEP, CMB_W, OnCATSerialChange);
    Cmb.Tag := i;
    Cmb.Items.Add('None'); Cmb.Items.Add('Odd'); Cmb.Items.Add('Even');
    Cmb.ItemIndex := 0; // None
    FCATSerialParity[i] := Cmb;

    Chk            := TFlatCheckBox.Create(Self);
    Chk.Parent     := Grp;
    Chk.Caption    := 'Andromeda / G2 front panel';
    Chk.SetBounds(DpiScale(PAD), DpiScale(R1 + 6 * STEP), DpiScale(GRP_W - PAD * 2), DpiScale(22));
    Chk.Font.Color := CLR_TEXT;
    Chk.Font.Size := 9;
    Chk.Tag        := i;
    Chk.OnChange   := OnCATSerialChange;
    FCATSerialAndromeda[i] := Chk;
  end;

  // Два сетевых канала управления — рядом, одной строкой под последовательными
  // портами: TCP CAT слева, TCI справа. Высота у обоих одна, иначе нижняя
  // кромка страницы получается рваной.
  gy := 86 + 2 * (GRP_H + GRP_GAP);
  Grp := MakeGroupPanel(FPageCAT, 'TCP CAT Server', MARGIN, gy, GRP_W, NET_GRP_H);
  Grp.Tag := TAG_RESPONSIVE_HALF_LEFT;

  FCATTcpEn            := TFlatCheckBox.Create(Self);
  FCATTcpEn.Parent     := Grp;
  FCATTcpEn.Caption    := 'Enable TCP server (default port 19090)';
  FCATTcpEn.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(300), DpiScale(22));
  FCATTcpEn.Font.Color := CLR_TEXT;
  FCATTcpEn.Font.Size := 9;
  FCATTcpEn.OnChange   := OnCATTcpChange;

  MakeLbl(Grp, 'Port:', PAD, R1 + STEP + 6, LW);
  Spin            := TFlatSpinEdit.Create(Self);
  Spin.Parent     := Grp;
  Spin.SetBounds(DpiScale(CX), DpiScale(R1 + STEP), DpiScale(100), DpiScale(BTN_H + 2));
  Spin.Color      := CLR_INPUT; Spin.Font.Color := CLR_INPUT_TEXT;
  Spin.Font.Size := 9;
  Spin.MinValue   := 1; Spin.MaxValue := 65535;
  Spin.Value      := 19090;
  Spin.OnChange   := OnCATTcpChange;
  FCATTcpPort := Spin;

  // ── TCI Server ────────────────────────────────────────────────────────────
  // TCI поверх WebSocket: логгеры, скиммеры, цифровые виды связи.
  Grp := MakeGroupPanel(FPageCAT, 'TCI Server',
                        MARGIN + GRP_W + GRP_GAP, gy, GRP_W, NET_GRP_H);
  Grp.Tag := TAG_RESPONSIVE_HALF_RIGHT;

  Chk            := TFlatCheckBox.Create(Self);
  Chk.Parent     := Grp;
  Chk.Caption    := 'Enable TCI server (default port 40001)';
  Chk.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(GRP_W - PAD * 2), DpiScale(22));
  Chk.Font.Color := CLR_TEXT;
  Chk.Font.Size  := 9;
  Chk.Checked    := False;
  Chk.OnChange   := OnTCIAnyChange;
  FChkTCIEnabled := Chk;

  MakeLbl(Grp, 'Port:', PAD, R1 + STEP + 6, LW);
  Spin            := TFlatSpinEdit.Create(Self);
  Spin.Parent     := Grp;
  Spin.SetBounds(DpiScale(CX), DpiScale(R1 + STEP), DpiScale(100), DpiScale(BTN_H + 2));
  Spin.Color      := CLR_INPUT; Spin.Font.Color := CLR_INPUT_TEXT;
  Spin.Font.Size  := 9;
  Spin.MinValue   := 1; Spin.MaxValue := 65535;
  Spin.Value      := 40001;
  // Применяем по уходу фокуса, а не на каждое нажатие: набирая «40001», через
  // OnChange мы бы подряд перезапустили сервер на портах 4, 40, 400, 4000.
  Spin.OnExit     := OnTCIAnyChange;
  FEdTCIPort      := Spin;

  MakeLbl(Grp, 'Interface:', PAD, R1 + 2 * STEP + 6, LW);
  Ed            := TFlatEdit.Create(Self);
  Ed.Parent     := Grp;
  Ed.SetBounds(DpiScale(CX), DpiScale(R1 + 2 * STEP), DpiScale(EDT_W), DpiScale(BTN_H));
  Ed.Color      := CLR_INPUT; Ed.Font.Color := CLR_INPUT_TEXT;
  Ed.Font.Size  := 9;
  Ed.Text       := '127.0.0.1';
  // Тем более адрес: промежуточное «127.0.0.» — не адрес, и раньше это молча
  // означало «слушать на всех интерфейсах». Применяем по уходу фокуса.
  Ed.OnExit     := OnTCIAnyChange;
  FEdTCIBind    := Ed;

  // Авторизации в протоколе нет: открытый наружу порт = полный доступ к
  // трансиверу. В половинную карточку строка не влезает — режем на две.
  Lbl := MakeLbl(Grp, 'No authentication in TCI: keep 127.0.0.1',
                 PAD, R1 + 3 * STEP + 2, GRP_W - PAD * 2);
  Lbl.Font.Size := 8;
  Lbl := MakeLbl(Grp, 'unless the network is trusted.',
                 PAD, R1 + 3 * STEP + 16, GRP_W - PAD * 2);
  Lbl.Font.Size := 8;
end;

// ---------------------------------------------------------------------------
// CAT event handlers
// ---------------------------------------------------------------------------

procedure TSettingsForm.FireCATChange;
var
  SerEn:   array[0..3] of Boolean;
  SerPort: array[0..3] of string;
  SerBaud, SerData, SerStop, SerPar: array[0..3] of Integer;
  SerAndr: array[0..3] of Boolean;
  i, Idx: Integer;
begin
  if FLoading then Exit;
  if not Assigned(FOnCATChange) then Exit;
  for i := 0 to 3 do
  begin
    SerEn[i]   := FCATSerialEn[i].Checked;
    SerPort[i] := FCATSerialPort[i].Text;
    Idx := FCATSerialBaud[i].ItemIndex;
    if (Idx >= 0) and (Idx <= High(CAT_BAUD_VALS)) then
      SerBaud[i] := CAT_BAUD_VALS[Idx]
    else
      SerBaud[i] := 9600;
    if FCATSerialDataBits[i].ItemIndex = 0 then SerData[i] := 7 else SerData[i] := 8;
    if FCATSerialStopBits[i].ItemIndex = 1 then SerStop[i] := 2 else SerStop[i] := 1;
    SerPar[i]  := FCATSerialParity[i].ItemIndex;
    SerAndr[i] := FCATSerialAndromeda[i].Checked;
  end;
  FOnCATChange(SerEn, SerPort, SerBaud, SerData, SerStop, SerPar, SerAndr,
    FCATTcpEn.Checked, FCATTcpPort.Value);
end;

procedure TSettingsForm.OnCATSerialChange(Sender: TObject);
begin FireCATChange; end;

procedure TSettingsForm.OnCATTcpChange(Sender: TObject);
begin FireCATChange; end;

// ---------------------------------------------------------------------------
// LoadCATSettings
// ---------------------------------------------------------------------------

procedure TSettingsForm.LoadCATSettings(
  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, j, Idx: Integer;
begin
  FLoading := True;
  try
    for i := 0 to 3 do
    begin
      if i <= High(SerEnabled) then FCATSerialEn[i].Checked := SerEnabled[i];
      if i <= High(SerPort)    then FCATSerialPort[i].Text  := SerPort[i];
      if i <= High(SerBaud) then
      begin
        Idx := 3;
        for j := 0 to High(CAT_BAUD_VALS) do
          if CAT_BAUD_VALS[j] = SerBaud[i] then begin Idx := j; Break; end;
        FCATSerialBaud[i].ItemIndex := Idx;
      end;
      if i <= High(SerDataBits) then
      begin
        if SerDataBits[i] = 7 then FCATSerialDataBits[i].ItemIndex := 0
        else FCATSerialDataBits[i].ItemIndex := 1;
      end;
      if i <= High(SerStopBits) then
      begin
        if SerStopBits[i] = 2 then FCATSerialStopBits[i].ItemIndex := 1
        else FCATSerialStopBits[i].ItemIndex := 0;
      end;
      if i <= High(SerParity) then
        FCATSerialParity[i].ItemIndex := EnsureRange(SerParity[i], 0, 2);
      if i <= High(SerAndromeda) then
        FCATSerialAndromeda[i].Checked := SerAndromeda[i];
    end;
    FCATTcpEn.Checked := TcpEnabled;
    FCATTcpPort.Value  := EnsureRange(TcpPort, 1, 65535);
  finally
    FLoading := False;
  end;
end;


// ---------------------------------------------------------------------------
// BuildSlicesTab — CAT дополнительных слайсов (B..): свой TCP-порт на слайс.
// ---------------------------------------------------------------------------

procedure TSettingsForm.BuildSlicesTab;
const
  MARGIN  = 22;
  PAD     = 18;
  LW      = 120;
  CX      = PAD + LW + 8;
  GRP_W   = 380;
  GRP_H   = 240;
  GRP_GAP = 16;
  CMB_W   = 210;
  R1      = 42;
  STEP    = 32;
var
  i, col, row, gx, gy: Integer;
  Grp:  TPanel;
  Chk:  TFlatCheckBox;
  Spin: TFlatSpinEdit;
  Cmb:  TFlatComboBox;
begin
  MakePageHeader(FPageSlices, 'Slices',
    'A separate CAT TCP port per additional slice — external software controls '
    + 'its own slice as a standalone transceiver.' + LineEnding
    + 'Slice A is the main receiver and is configured on the CAT page.', 38);

  for i := 0 to CFG_CAT_SLICE_COUNT - 1 do
  begin
    row := i div 2;
    col := i mod 2;
    gx  := MARGIN + col * (GRP_W + GRP_GAP);
    gy  := 96 + row * (GRP_H + GRP_GAP);

    Grp := MakeGroupPanel(FPageSlices, 'Slice ' + Chr(Ord('B') + i), gx, gy, GRP_W, GRP_H);
    if col = 0 then
      Grp.Tag := TAG_RESPONSIVE_HALF_LEFT
    else
      Grp.Tag := TAG_RESPONSIVE_HALF_RIGHT;

    Chk            := TFlatCheckBox.Create(Self);
    Chk.Parent     := Grp;
    Chk.Caption    := 'Enable CAT TCP server';
    Chk.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(GRP_W - PAD * 2), DpiScale(22));
    Chk.Font.Color := CLR_TEXT;
    Chk.Font.Size := 9;
    Chk.Tag        := i;
    Chk.OnChange   := OnSliceCATCtrlChange;
    FSliceCATEn[i] := Chk;

    MakeLbl(Grp, 'Port:', PAD, R1 + STEP + 6, LW);
    Spin            := TFlatSpinEdit.Create(Self);
    Spin.Parent     := Grp;
    Spin.SetBounds(DpiScale(CX), DpiScale(R1 + STEP), DpiScale(100), DpiScale(BTN_H + 2));
    Spin.Color      := CLR_INPUT; Spin.Font.Color := CLR_INPUT_TEXT;
    Spin.Font.Size := 9;
    Spin.MinValue   := 1; Spin.MaxValue := 65535;
    Spin.Value      := CFG_CAT_SLICE_PORT0 + i;
    Spin.Tag        := i;
    Spin.OnChange   := OnSliceCATCtrlChange;
    FSliceCATPort[i] := Spin;

    // Auto TX: команда TX по этому порту сама делает слайс источником
    // передачи. Пока кто-то уже в эфире,
    // заявка игнорируется — передаёт всегда только один слайс. Подпись короткая
    // (в ширину группы), пояснение — строкой ниже.
    Chk            := TFlatCheckBox.Create(Self);
    Chk.Parent     := Grp;
    Chk.Caption    := 'Auto TX';
    Chk.SetBounds(DpiScale(PAD), DpiScale(R1 + 2 * STEP), DpiScale(GRP_W - PAD * 2), DpiScale(22));
    Chk.Font.Color := CLR_TEXT;
    Chk.Font.Size := 9;
    Chk.Tag        := i;
    Chk.OnChange   := OnSliceCATCtrlChange;
    FSliceCATAutoTx[i] := Chk;

    MakeLbl(Grp, 'CAT TX takes transmit to this slice',
            PAD + 2, R1 + 2 * STEP + 24, GRP_W - PAD * 2 - 2);

    // Аудио слота: не шаблон, а привязка буквы — применяется к слайсу, который
    // стоит на слоте сейчас, и достаётся следующему.
    MakeLbl(Grp, 'Audio out:', PAD, R1 + 3 * STEP + 26, LW);
    Cmb := MakeCombo(Grp, CX, R1 + 3 * STEP + 20, CMB_W, OnSliceCATCtrlChange);
    Cmb.Tag := i;
    Cmb.Items.Add(SLICE_DEV_DEFAULT);
    Cmb.ItemIndex := 0;
    FSliceOutDev[i] := Cmb;

    MakeLbl(Grp, 'Audio in:', PAD, R1 + 4 * STEP + 26, LW);
    Cmb := MakeCombo(Grp, CX, R1 + 4 * STEP + 20, CMB_W, OnSliceCATCtrlChange);
    Cmb.Tag := i;
    Cmb.Items.Add(SLICE_DEV_NONE);
    Cmb.ItemIndex := 0;
    FSliceInDev[i] := Cmb;
  end;

  gy := 96 + ((CFG_CAT_SLICE_COUNT + 1) div 2) * (GRP_H + GRP_GAP);
  MakeTextBlock(FPageSlices,
    'Audio routing belongs to the slice letter and is inherited by the next '
    + 'receiver placed in that slot.' + LineEnding
    + 'Only one slice can transmit. Auto TX selects this slice as the TX source; '
    + 'without it, CAT can key only the current TX slice.' + LineEnding
    + 'The CAT port stays open before the slice exists and replies with radio-off status.',
    MARGIN, gy, SETTINGS_CARD_W, 62);
end;

procedure TSettingsForm.FireSliceCATChange;
var
  En, Auto: array[0..CFG_CAT_SLICE_COUNT-1] of Boolean;
  Prt: array[0..CFG_CAT_SLICE_COUNT-1] of Integer;
  OutD, InD: array[0..CFG_CAT_SLICE_COUNT-1] of string;
  i: Integer;
begin
  if FLoading then Exit;
  if not Assigned(FOnSliceCATChange) then Exit;
  for i := 0 to CFG_CAT_SLICE_COUNT - 1 do
  begin
    En[i]   := FSliceCATEn[i].Checked;
    Prt[i]  := FSliceCATPort[i].Value;
    Auto[i] := FSliceCATAutoTx[i].Checked;
    // Первый пункт списка — «общий выход/вход приложения» (пустое имя).
    if FSliceOutDev[i].ItemIndex <= 0 then OutD[i] := ''
    else OutD[i] := FSliceOutDev[i].Text;
    if FSliceInDev[i].ItemIndex <= 0 then InD[i] := ''
    else InD[i] := FSliceInDev[i].Text;
  end;
  FOnSliceCATChange(En, Prt, Auto, OutD, InD);
end;

procedure TSettingsForm.OnSliceCATCtrlChange(Sender: TObject);
begin FireSliceCATChange; end;

procedure TSettingsForm.LoadSliceCATSettings(
  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);
var i: Integer;
begin
  FLoading := True;
  try
    for i := 0 to CFG_CAT_SLICE_COUNT - 1 do
    begin
      if i <= High(Enabled) then FSliceCATEn[i].Checked := Enabled[i];
      if (i <= High(Port)) and (Port[i] > 0) then
        FSliceCATPort[i].Value := EnsureRange(Port[i], 1, 65535);
      if i <= High(AutoTX) then FSliceCATAutoTx[i].Checked := AutoTX[i];
      if i <= High(OutDev) then SelectDevInCombo(FSliceOutDev[i], OutDev[i]);
      if i <= High(InDev)  then SelectDevInCombo(FSliceInDev[i],  InDev[i]);
    end;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.SelectDevInCombo(Cmb: TFlatComboBox; const DevName: string);
// Выбор устройства по ИМЕНИ. Сохранённого устройства может не быть в системе
// (кабель не поднят, гарнитура не воткнута) — тогда добавляем имя в список,
// чтобы привязка слота не потерялась молча при первом же открытии настроек.
var i: Integer;
begin
  if Cmb = nil then Exit;
  if DevName = '' then begin Cmb.ItemIndex := 0; Exit; end;
  i := Cmb.Items.IndexOf(DevName);
  if i < 0 then i := Cmb.Items.Add(DevName);
  Cmb.ItemIndex := i;
end;

// ---------------------------------------------------------------------------
// BuildAlexTab
// ---------------------------------------------------------------------------

procedure TSettingsForm.BuildAlexTab;
const
  MARGIN   = 22;
  PAD      = 12;
  HDR1_H   = 22;   // height of "RX Ant / TX Ant" header row
  HDR2_H   = 20;   // height of "1 2 3 / BYPS EXT1 XVTR" sub-header row
  ROW_H    = 24;
  BANDS    = 11;
  BAND_X   = PAD;
  RX1_X    = PAD + 50;
  RX2_X    = PAD + 76;
  RX3_X    = PAD + 102;
  BYPS_X   = PAD + 136;
  EXT1_X   = PAD + 192;
  XVTR_X   = PAD + 248;
  TX1_X    = PAD + 308;
  TX2_X    = PAD + 334;
  TX3_X    = PAD + 360;
  DNTX_X   = PAD + 398;
  GRP_W    = DNTX_X + 80;
  // Таблица разъёмов AD936x (Pluto/LibreSDR): диапазон | RX 1/2 | TX 1/2.
  ANT_ROW_H = 26;
  ANT_HDR_H = 34;   // строка «RX / TX» + подстрока «1 2» под ней
  ANT_RX1_X = PAD + 96;
  ANT_RX2_X = ANT_RX1_X + 34;
  ANT_TX1_X = ANT_RX2_X + 52;
  ANT_TX2_X = ANT_TX1_X + 34;
  ANT_GRP_W = ANT_TX2_X + 42;
  GRP_H    = GRP_TITLE_H + HDR1_H + HDR2_H + BANDS * ROW_H + PAD;
  DATA_Y0  = GRP_TITLE_H + HDR1_H + HDR2_H + 4;
  TXGRP_H  = GRP_TITLE_H + 4 * 28 + PAD;

  BAND_NAMES: array[0..10] of string =
    ('160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m');

var
  Grp, GrpTX: TPanel;
  b:       Integer;
  Y:       Integer;
  Chk:     TFlatCheckBox;
  RxGrp, TxGrp: TPanel;

  // In Lazarus, TRadioButton has no GroupIndex; buttons are grouped by parent.
  // We use small invisible panels as parents so each band's RX and TX groups
  // are independent of each other.
  function MkRbGroup(AParent: TWinControl; AX, AY, AW: Integer): TPanel;
  begin
    Result            := TPanel.Create(Self);
    Result.Parent     := AParent;
    Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(20));
    Result.BevelOuter := bvNone;
    Result.Color      := CLR_PANEL;
    Result.Caption    := '';
  end;

  function MkRb(AParent: TWinControl; AX: Integer): TFlatRadioButton;
  begin
    Result            := TFlatRadioButton.Create(Self);
    Result.Parent     := AParent;
    Result.Caption    := '';
    Result.SetBounds(DpiScale(AX), DpiScale(0), DpiScale(22), DpiScale(20));
    Result.Font.Color := CLR_TEXT;
    Result.Font.Size  := 9;
    Result.OnChange   := OnAlexAnyChange;
  end;

  function MkCh(AParent: TWinControl; const ACap: string;
    AX, AY, AW: Integer): TFlatCheckBox;
  begin
    Result            := TFlatCheckBox.Create(Self);
    Result.Parent     := AParent;
    Result.Caption    := ACap;
    Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(20));
    Result.Font.Color := CLR_TEXT;
    Result.Font.Size  := 9;
    Result.OnChange   := OnAlexAnyChange;
  end;

  // ---- Таблица разъёмов AD936x ----
  function MkAntRb(AParent: TWinControl; AX: Integer): TFlatRadioButton;
  begin
    Result            := TFlatRadioButton.Create(Self);
    Result.Parent     := AParent;
    Result.Caption    := '';
    Result.SetBounds(DpiScale(AX), DpiScale(0), DpiScale(22), DpiScale(20));
    Result.Font.Color := CLR_TEXT;
    Result.Font.Size  := 9;
    Result.OnChange   := Ant936xUIChange;
  end;

  function MkAntLbl(AParent: TWinControl; const ACap: string;
    AX, AY: Integer): TLabel;
  begin
    Result            := TLabel.Create(Self);
    Result.Parent     := AParent;
    Result.Caption    := ACap;
    Result.AutoSize   := False;
    Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(84), DpiScale(18));
    Result.Layout     := tlCenter;
    Result.Font.Size  := 9;
    Result.Font.Color := CLR_TEXT;
  end;

  procedure MkAntHdr(AParent: TWinControl; const ACap: string; AX, AY: Integer);
  begin
    with TLabel.Create(Self) do
    begin
      Parent     := AParent;
      Caption    := ACap;
      AutoSize   := False;
      SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(30), DpiScale(16));
      Alignment  := taCenter;
      Layout     := tlCenter;
      Font.Size  := 9;
      Font.Color := CLR_TEXTDIM;
    end;
  end;

begin
  FLoading := True;
  try
    MakePageHeader(FPageAlex, 'Antenna / Alex', '');
    FAlexSubtitle := TLabel.Create(Self);
    FAlexSubtitle.Parent     := FPageAlex;
    FAlexSubtitle.Caption    :=
      'Per-band RX/TX antenna and secondary path routing for the Alex filter board.';
    FAlexSubtitle.AutoSize   := False;
    FAlexSubtitle.SetBounds(DpiScale(MARGIN), DpiScale(48),
      DpiScale(SETTINGS_CARD_W), DpiScale(20));
    FAlexSubtitle.Layout     := tlTop;
    FAlexSubtitle.WordWrap   := True;
    FAlexSubtitle.Font.Size  := 9;
    FAlexSubtitle.Font.Color := CLR_TEXTDIM;

    // ---- Antenna grid group ----
    Grp := MakeGroupPanel(FPageAlex, 'Antenna Selection', MARGIN, 86, GRP_W, GRP_H);
    Grp.Tag := TAG_RESPONSIVE_CARD;

    // Header row 1 — group labels
    MakeLbl(Grp, 'Band',         BAND_X, GRP_TITLE_H + 2,           44);
    MakeLbl(Grp, 'RX Antenna',   RX1_X,  GRP_TITLE_H + 2,           80);
    MakeLbl(Grp, 'Secondary RX', BYPS_X, GRP_TITLE_H + 2,          170);
    MakeLbl(Grp, 'TX Antenna',   TX1_X,  GRP_TITLE_H + 2,           80);
    MakeLbl(Grp, 'No TX',        DNTX_X, GRP_TITLE_H + 2,           56);

    // Header row 2 — sub-column labels
    MakeLbl(Grp, '1',    RX1_X  + 3, GRP_TITLE_H + HDR1_H + 2, 20);
    MakeLbl(Grp, '2',    RX2_X  + 3, GRP_TITLE_H + HDR1_H + 2, 20);
    MakeLbl(Grp, '3',    RX3_X  + 3, GRP_TITLE_H + HDR1_H + 2, 20);
    MakeLbl(Grp, 'BYPS', BYPS_X,     GRP_TITLE_H + HDR1_H + 2, 52);
    MakeLbl(Grp, 'EXT1', EXT1_X,     GRP_TITLE_H + HDR1_H + 2, 52);
    MakeLbl(Grp, 'XVTR', XVTR_X,     GRP_TITLE_H + HDR1_H + 2, 52);
    MakeLbl(Grp, '1',    TX1_X  + 3, GRP_TITLE_H + HDR1_H + 2, 20);
    MakeLbl(Grp, '2',    TX2_X  + 3, GRP_TITLE_H + HDR1_H + 2, 20);
    MakeLbl(Grp, '3',    TX3_X  + 3, GRP_TITLE_H + HDR1_H + 2, 20);

    // Data rows (bands 0..10 = 160m..6m)
    for b := 0 to BANDS - 1 do
    begin
      Y := DATA_Y0 + b * ROW_H;
      MakeLbl(Grp, BAND_NAMES[b], BAND_X, Y + 2, 44);

      // RX antenna — 3 radio buttons in a dedicated panel so they are mutually
      // exclusive only within that band row (Lazarus groups by parent, not GroupIndex)
      RxGrp := MkRbGroup(Grp, RX1_X, Y, RX3_X - RX1_X + 26);
      FAlexRxAnt[b, 1] := MkRb(RxGrp, 0);
      FAlexRxAnt[b, 2] := MkRb(RxGrp, RX2_X - RX1_X);
      FAlexRxAnt[b, 3] := MkRb(RxGrp, RX3_X - RX1_X);
      FAlexRxAnt[b, 1].Checked := True;

      // Secondary RX checkboxes — mutually exclusive, handled in OnAlexRxOnlyChange
      // Tag encodes: b=BYPS, b+100=EXT1, b+200=XVTR
      Chk := MkCh(Grp, '', BYPS_X, Y, 22);
      Chk.Tag := b;
      Chk.OnChange := OnAlexRxOnlyChange;
      FAlexRxByps[b] := Chk;

      Chk := MkCh(Grp, '', EXT1_X, Y, 22);
      Chk.Tag := b + 100;
      Chk.OnChange := OnAlexRxOnlyChange;
      FAlexRxExt1[b] := Chk;

      Chk := MkCh(Grp, '', XVTR_X, Y, 22);
      Chk.Tag := b + 200;
      Chk.OnChange := OnAlexRxOnlyChange;
      FAlexRxXvtr[b] := Chk;

      // TX antenna — same pattern: dedicated panel per band row
      TxGrp := MkRbGroup(Grp, TX1_X, Y, TX3_X - TX1_X + 26);
      FAlexTxAnt[b, 1] := MkRb(TxGrp, 0);
      FAlexTxAnt[b, 2] := MkRb(TxGrp, TX2_X - TX1_X);
      FAlexTxAnt[b, 3] := MkRb(TxGrp, TX3_X - TX1_X);
      FAlexTxAnt[b, 1].Checked := True;

      // Do Not TX checkbox
      FAlexDoNotTx[b] := MkCh(Grp, '', DNTX_X, Y, 22);
    end;

    // ---- TX Routing flags group ----
    GrpTX := MakeGroupPanel(FPageAlex, 'TX Routing',
      MARGIN, 86 + GRP_H + 12, 480, TXGRP_H);
    GrpTX.Tag := TAG_RESPONSIVE_CARD;

    FAlexRxBypassOnTx := MkCh(GrpTX, 'Rx BYPASS on TX',
      PAD, GRP_TITLE_H + 8, 440);
    FAlexExt1OnTx     := MkCh(GrpTX, 'Ext 1 on TX',
      PAD, GRP_TITLE_H + 8 + 28, 440);
    FAlexGndBpf2OnTx  := MkCh(GrpTX, 'Ground BPF2 on TX  (ANAN-8000DLE)',
      PAD, GRP_TITLE_H + 8 + 56, 440);
    FAlexEnableXvtrHf := MkCh(GrpTX, 'Enable XVTR HF',
      PAD, GRP_TITLE_H + 8 + 84, 440);
    FAlexGrpAnt := Grp;
    FAlexGrpTX  := GrpTX;

    // ---- Антенные разъёмы AD936x (Pluto/LibreSDR) ----
    // Alex-таблицы у таких плат нет; «антенна» = какой из физических разъёмов
    // (RX1/RX2, TX1/TX2) используется на диапазоне. Строки — VU-план + слоты
    // трансвертеров (QO-100 и пр.). Группа занимает место Alex-групп и
    // показывается вместо них (SetAntennaBackend).
    FPlutoAntGrp := MakeGroupPanel(FPageAlex, 'Antenna Ports',
      MARGIN, 86, ANT_GRP_W,
      GRP_TITLE_H + ANT_HDR_H + (CFG_BAND_COUNT + 1 + CFG_XVTR_COUNT) * ANT_ROW_H + PAD);
    FPlutoAntGrp.Tag := TAG_RESPONSIVE_CARD;
    FPlutoAntGrp.Visible := False;

    // Шапка колонок: «RX»/«TX» над своей парой, под ними номера разъёмов.
    Y := GRP_TITLE_H + 4;
    MkAntHdr(FPlutoAntGrp, 'RX', (ANT_RX1_X + ANT_RX2_X) div 2 - 2, Y);
    MkAntHdr(FPlutoAntGrp, 'TX', (ANT_TX1_X + ANT_TX2_X) div 2 - 2, Y);
    MkAntHdr(FPlutoAntGrp, '1',  ANT_RX1_X + 5, Y + 16);
    MkAntHdr(FPlutoAntGrp, '2',  ANT_RX2_X + 5, Y + 16);
    MkAntHdr(FPlutoAntGrp, '1',  ANT_TX1_X + 5, Y + 16);
    MkAntHdr(FPlutoAntGrp, '2',  ANT_TX2_X + 5, Y + 16);

    Y := GRP_TITLE_H + ANT_HDR_H;
    for b := 0 to CFG_BAND_COUNT - 1 do
    begin
      if BandName(b, True) = '' then Continue;   // пустой слот плана — не строка
      MkAntLbl(FPlutoAntGrp, BandName(b, True), BAND_X, Y + 3);
      RxGrp := MkRbGroup(FPlutoAntGrp, ANT_RX1_X, Y, ANT_RX2_X - ANT_RX1_X + 24);
      FAntRx[b, 0] := MkAntRb(RxGrp, 0);
      FAntRx[b, 1] := MkAntRb(RxGrp, ANT_RX2_X - ANT_RX1_X);
      TxGrp := MkRbGroup(FPlutoAntGrp, ANT_TX1_X, Y, ANT_TX2_X - ANT_TX1_X + 24);
      FAntTx[b, 0] := MkAntRb(TxGrp, 0);
      FAntTx[b, 1] := MkAntRb(TxGrp, ANT_TX2_X - ANT_TX1_X);
      Inc(Y, ANT_ROW_H);
    end;

    // Секция трансвертеров: заголовок + строка на слот. Видимость и вертикальную
    // упаковку (только включённые слоты, без дыр) делает LoadAnt936x.
    FAntXvtrHdr := MkAntLbl(FPlutoAntGrp, 'Transverters', BAND_X, Y + 6);
    FAntXvtrHdr.Font.Color := CLR_TEXTDIM;
    FAntXvtrHdr.Visible    := False;
    Inc(Y, ANT_ROW_H);

    for b := 0 to CFG_XVTR_COUNT - 1 do
    begin
      FAntXvtrRow[b] := TPanel.Create(Self);
      FAntXvtrRow[b].Parent     := FPlutoAntGrp;
      FAntXvtrRow[b].SetBounds(DpiScale(0), DpiScale(Y), DpiScale(ANT_GRP_W), DpiScale(ANT_ROW_H));
      FAntXvtrRow[b].BevelOuter := bvNone;
      FAntXvtrRow[b].Color      := CLR_PANEL;
      FAntXvtrRow[b].Caption    := '';
      FAntXvtrRow[b].Visible    := False;
      FAntXvtrLbl[b] := MkAntLbl(FAntXvtrRow[b], '', BAND_X, 3);
      RxGrp := MkRbGroup(FAntXvtrRow[b], ANT_RX1_X, 0, ANT_RX2_X - ANT_RX1_X + 24);
      FAntXvtrRx[b, 0] := MkAntRb(RxGrp, 0);
      FAntXvtrRx[b, 1] := MkAntRb(RxGrp, ANT_RX2_X - ANT_RX1_X);
      TxGrp := MkRbGroup(FAntXvtrRow[b], ANT_TX1_X, 0, ANT_TX2_X - ANT_TX1_X + 24);
      FAntXvtrTx[b, 0] := MkAntRb(TxGrp, 0);
      FAntXvtrTx[b, 1] := MkAntRb(TxGrp, ANT_TX2_X - ANT_TX1_X);
      Inc(Y, ANT_ROW_H);
    end;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.SetAntennaBackend(IsAD936x, HasAntSwitch: Boolean);
// Переключает вкладку Antenna: Alex-таблица (openHPSDR) либо таблица разъёмов
// AD936x. HasAntSwitch=False (прошивка не даёт доступа к мультиплексору) —
// Alex-таблица тоже не нужна: показываем пустую вкладку с поясняющим подзаголовком.
begin
  FLoading := True;
  try
    if FAlexGrpAnt <> nil then FAlexGrpAnt.Visible := not IsAD936x;
    if FAlexGrpTX  <> nil then FAlexGrpTX.Visible  := not IsAD936x;
    if FPlutoAntGrp <> nil then FPlutoAntGrp.Visible := IsAD936x and HasAntSwitch;
    if FAlexSubtitle <> nil then
    begin
      if IsAD936x and HasAntSwitch then
        FAlexSubtitle.Caption :=
          'Which physical connector (RX1/RX2, TX1/TX2) is used on each band.'
      else if IsAD936x then
        FAlexSubtitle.Caption := 'This board has a single antenna connector pair.'
      else
        FAlexSubtitle.Caption :=
          'Per-band RX/TX antenna and secondary path routing for the Alex filter board.';
    end;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.LoadAnt936x(const A: TAD936xAnt; const X: TXvtrSettings);
// Таблица разъёмов + секция трансвертеров. Строки выключенных слотов не просто
// прячутся, а исключаются из раскладки (иначе между диапазонами и QO-100 зияла
// дыра в 8 строк); высота группы считается по фактическому числу строк.
const
  ANT_ROW_H = 26;
  PAD       = 12;
var
  b, Y, NVis, Bands: Integer;
begin
  FLoading := True;
  try
    FAnt936x := A;
    for b := 0 to CFG_BAND_COUNT - 1 do
    begin
      if FAntRx[b, 0] = nil then Continue;
      FAntRx[b, 0].Checked := A.RxAnt[b] = 0;
      FAntRx[b, 1].Checked := A.RxAnt[b] = 1;
      FAntTx[b, 0].Checked := A.TxAnt[b] = 0;
      FAntTx[b, 1].Checked := A.TxAnt[b] = 1;
    end;

    // Первая строка трансвертеров — сразу под заголовком секции.
    if FAntXvtrHdr <> nil then Y := FAntXvtrHdr.Top + DpiScale(ANT_ROW_H) - DpiScale(6)
    else                       Y := 0;
    NVis := 0;
    for b := 0 to CFG_XVTR_COUNT - 1 do
    begin
      if FAntXvtrRow[b] = nil then Continue;
      FAntXvtrRow[b].Visible := X.Entries[b].Enabled;
      if not X.Entries[b].Enabled then Continue;
      if X.Entries[b].ButtonText <> '' then
        FAntXvtrLbl[b].Caption := string(X.Entries[b].ButtonText)
      else
        FAntXvtrLbl[b].Caption := Format('XVTR %d', [b + 1]);
      FAntXvtrRx[b, 0].Checked := A.XvtrRxAnt[b] = 0;
      FAntXvtrRx[b, 1].Checked := A.XvtrRxAnt[b] = 1;
      FAntXvtrTx[b, 0].Checked := A.XvtrTxAnt[b] = 0;
      FAntXvtrTx[b, 1].Checked := A.XvtrTxAnt[b] = 1;
      FAntXvtrRow[b].Top := Y;   // упаковка подряд, без дыр от выключенных слотов
      Inc(Y, DpiScale(ANT_ROW_H));
      Inc(NVis);
    end;
    if FAntXvtrHdr <> nil then FAntXvtrHdr.Visible := NVis > 0;

    // Высота группы — по последней видимой строке (иначе внизу пустое поле).
    if FPlutoAntGrp <> nil then
    begin
      if NVis = 0 then
      begin
        Bands := 0;
        for b := 0 to CFG_BAND_COUNT - 1 do
          if FAntRx[b, 0] <> nil then Inc(Bands);
        Y := FPlutoAntGrp.Height - DpiScale(PAD);   // fallback, если заголовка нет
        if (FAntXvtrHdr <> nil) and (Bands > 0) then Y := FAntXvtrHdr.Top - DpiScale(6);
      end;
      FPlutoAntGrp.Height := Y + DpiScale(PAD);
    end;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.Ant936xUIChange(Sender: TObject);
// Любая радиокнопка таблицы разъёмов: собрать состояние и отдать наружу.
var b: Integer;
begin
  if FLoading then Exit;
  for b := 0 to CFG_BAND_COUNT - 1 do
  begin
    if FAntRx[b, 0] = nil then Continue;
    if FAntRx[b, 1].Checked then FAnt936x.RxAnt[b] := 1 else FAnt936x.RxAnt[b] := 0;
    if FAntTx[b, 1].Checked then FAnt936x.TxAnt[b] := 1 else FAnt936x.TxAnt[b] := 0;
  end;
  for b := 0 to CFG_XVTR_COUNT - 1 do
  begin
    if FAntXvtrRx[b, 0] = nil then Continue;
    if FAntXvtrRx[b, 1].Checked then FAnt936x.XvtrRxAnt[b] := 1
    else                             FAnt936x.XvtrRxAnt[b] := 0;
    if FAntXvtrTx[b, 1].Checked then FAnt936x.XvtrTxAnt[b] := 1
    else                             FAnt936x.XvtrTxAnt[b] := 0;
  end;
  if Assigned(FOnAnt936xChange) then FOnAnt936xChange(FAnt936x);
end;

// ---------------------------------------------------------------------------
// Alex event handlers and data collectors
// ---------------------------------------------------------------------------

procedure TSettingsForm.CollectAlexFromUI;
var b, a: Integer;
begin
  for b := 0 to 10 do
  begin
    FAlexConfig.RxAnt[b] := 1;
    for a := 1 to 3 do
      if (FAlexRxAnt[b, a] <> nil) and FAlexRxAnt[b, a].Checked then
      begin
        FAlexConfig.RxAnt[b] := a;
        Break;
      end;
    FAlexConfig.TxAnt[b] := 1;
    for a := 1 to 3 do
      if (FAlexTxAnt[b, a] <> nil) and FAlexTxAnt[b, a].Checked then
      begin
        FAlexConfig.TxAnt[b] := a;
        Break;
      end;
    if (FAlexRxXvtr[b] <> nil) and FAlexRxXvtr[b].Checked then
      FAlexConfig.RxOnly[b] := 3
    else if (FAlexRxExt1[b] <> nil) and FAlexRxExt1[b].Checked then
      FAlexConfig.RxOnly[b] := 2
    else if (FAlexRxByps[b] <> nil) and FAlexRxByps[b].Checked then
      FAlexConfig.RxOnly[b] := 1
    else
      FAlexConfig.RxOnly[b] := 0;
    FAlexConfig.DoNotTx[b] := (FAlexDoNotTx[b] <> nil) and FAlexDoNotTx[b].Checked;
  end;
  FAlexConfig.RxBypassOnTx := (FAlexRxBypassOnTx <> nil) and FAlexRxBypassOnTx.Checked;
  FAlexConfig.Ext1OnTx     := (FAlexExt1OnTx     <> nil) and FAlexExt1OnTx.Checked;
  FAlexConfig.GndBpf2OnTx  := (FAlexGndBpf2OnTx  <> nil) and FAlexGndBpf2OnTx.Checked;
  FAlexConfig.EnableXvtrHf := (FAlexEnableXvtrHf <> nil) and FAlexEnableXvtrHf.Checked;
end;

procedure TSettingsForm.FireAlexChange;
begin
  if FLoading then Exit;
  CollectAlexFromUI;
  if Assigned(FOnAlexChange) then
    FOnAlexChange(FAlexConfig);
end;

procedure TSettingsForm.OnAlexAnyChange(Sender: TObject);
begin
  FireAlexChange;
end;

procedure TSettingsForm.OnAlexRxOnlyChange(Sender: TObject);
var
  Tag, b: Integer;
begin
  if FLoading then Exit;
  Tag := TFlatCheckBox(Sender).Tag;
  if Tag >= 200 then
  begin
    b := Tag - 200;  // XVTR
    if TFlatCheckBox(Sender).Checked then
    begin
      FLoading := True;
      FAlexRxByps[b].Checked := False;
      FAlexRxExt1[b].Checked := False;
      FLoading := False;
    end;
  end
  else if Tag >= 100 then
  begin
    b := Tag - 100;  // EXT1
    if TFlatCheckBox(Sender).Checked then
    begin
      FLoading := True;
      FAlexRxByps[b].Checked := False;
      FAlexRxXvtr[b].Checked := False;
      FLoading := False;
    end;
  end
  else
  begin
    b := Tag;        // BYPS
    if TFlatCheckBox(Sender).Checked then
    begin
      FLoading := True;
      FAlexRxExt1[b].Checked := False;
      FAlexRxXvtr[b].Checked := False;
      FLoading := False;
    end;
  end;
  FireAlexChange;
end;

procedure TSettingsForm.LoadAlexSettings(const A: TAlexSettings; BoardType: Integer);
var b, ant: Integer;
begin
  FLoading := True;
  try
    FAlexConfig    := A;
    FAlexBoardType := BoardType;
    for b := 0 to 10 do
    begin
      for ant := 1 to 3 do
        if FAlexRxAnt[b, ant] <> nil then
          FAlexRxAnt[b, ant].Checked := (A.RxAnt[b] = ant);
      for ant := 1 to 3 do
        if FAlexTxAnt[b, ant] <> nil then
          FAlexTxAnt[b, ant].Checked := (A.TxAnt[b] = ant);
      if FAlexRxByps[b] <> nil then FAlexRxByps[b].Checked := (A.RxOnly[b] = 1);
      if FAlexRxExt1[b] <> nil then FAlexRxExt1[b].Checked := (A.RxOnly[b] = 2);
      if FAlexRxXvtr[b] <> nil then FAlexRxXvtr[b].Checked := (A.RxOnly[b] = 3);
      if FAlexDoNotTx[b] <> nil then FAlexDoNotTx[b].Checked := A.DoNotTx[b];
    end;
    if FAlexRxBypassOnTx <> nil then FAlexRxBypassOnTx.Checked := A.RxBypassOnTx;
    if FAlexExt1OnTx     <> nil then FAlexExt1OnTx.Checked     := A.Ext1OnTx;
    if FAlexGndBpf2OnTx  <> nil then FAlexGndBpf2OnTx.Checked  := A.GndBpf2OnTx;
    if FAlexEnableXvtrHf <> nil then FAlexEnableXvtrHf.Checked  := A.EnableXvtrHf;
  finally
    FLoading := False;
  end;
end;

// ===========================================================================
// XVTR (Transverter) tab
// ===========================================================================

procedure TSettingsForm.BuildXvtrTab;
const
  MARGIN  = 22;
  ROW_H   = 30;
  HDR_H   = 38;     // место под двухстрочные заголовки столбцов
  // X-координаты колонок (внутри Grp). Подобраны так чтобы помещаться
  // в content-area формы (~880px при ширине 1080).
  EN_X    = 14;
  NAME_X  = 40;     NAME_W   = 104;    // подпись на band-кнопке (макс. 15 символов)
  BEG_X   = 152;    BEG_W    = 80;     // 3 десятичных, "144.000"
  END_X   = 240;    END_W    = 80;
  LO_X    = 328;    LO_W     = 90;     // 6 десятичных, "116.000000"
  ERR_X   = 426;    ERR_W    = 70;     // целое Hz
  GAIN_X  = 504;    GAIN_W   = 62;     // 1 десятичная, dB
  RXO_X   = 574;    RXO_W    = 56;     // chk центрирован
  PWR_X   = 638;    PWR_W    = 58;     // целое %
  PA_X    = 704;    PA_W     = 66;
  ANT_X   = 778;    ANT_W    = 78;     // combo default/ANT1/ANT2/ANT3
  GRP_W   = ANT_X + ANT_W + 14;        // 934
var
  Grp, GrpOpts: TPanel;
  i, Y, GrpY:    Integer;
  L:       TLabel;

  function MakeFlt(AParent: TWinControl; AX, AY, AW: Integer;
                   AMin, AMax, AStep: Double; ADec: Integer): TFlatFloatSpinEdit;
  begin
    Result := TFlatFloatSpinEdit.Create(Self);
    Result.Parent := AParent;
    Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(BTN_H));
    Result.MinValue := AMin;
    Result.MaxValue := AMax;
    Result.Increment := AStep;
    Result.DecimalPlaces := ADec;
    Result.Color := CLR_INPUT;
    Result.Font.Color := CLR_INPUT_TEXT;
    Result.Font.Size := 9;
    Result.OnChange := OnXvtrAnyChange;
  end;

  function MakeInt(AParent: TWinControl; AX, AY, AW: Integer;
                   AMin, AMax: Integer): TFlatSpinEdit;
  begin
    Result := TFlatSpinEdit.Create(Self);
    Result.Parent := AParent;
    Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(BTN_H));
    Result.MinValue := AMin;
    Result.MaxValue := AMax;
    Result.Color := CLR_INPUT;
    Result.Font.Color := CLR_INPUT_TEXT;
    Result.Font.Size := 9;
    Result.OnChange := OnXvtrAnyChange;
  end;

  // Чекбокс без подписи (только квадрат). Подпись пишем отдельно над колонкой.
  function MakeChk(AParent: TWinControl; AX, AY: Integer): TFlatCheckBox;
  begin
    Result := TFlatCheckBox.Create(Self);
    Result.Parent := AParent;
    Result.Caption := '';
    Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(22), DpiScale(20));
    Result.Font.Color := CLR_TEXT;
    Result.Font.Size := 9;
    Result.OnChange := OnXvtrAnyChange;
  end;

  function MakeEdt(AParent: TWinControl; AX, AY, AW: Integer): TFlatEdit;
  begin
    Result := TFlatEdit.Create(Self);
    Result.Parent := AParent;
    Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(BTN_H));
    Result.Color := CLR_INPUT;
    Result.Font.Color := CLR_INPUT_TEXT;
    Result.Font.Size := 9;
    Result.MaxLength := 15;
    Result.OnChange := OnXvtrAnyChange;
  end;

  function MakeCmb(AParent: TWinControl; AX, AY, AW: Integer): TFlatComboBox;
  begin
    Result := TFlatComboBox.Create(Self);
    Result.Parent := AParent;
    Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(BTN_H));
    Result.Color := CLR_INPUT;
    Result.Font.Color := CLR_INPUT_TEXT;
    Result.Font.Size := 9;
    Result.OnChange := OnXvtrAnyChange;
  end;

  // Заголовок столбца, центрирован по ширине AW от AX.
  function MakeHdr(AParent: TWinControl; const Cap: string;
                   AX, AY, AW: Integer): TLabel;
  begin
    Result := TLabel.Create(Self);
    Result.Parent     := AParent;
    Result.Caption    := Cap;
    Result.AutoSize   := False;
    Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(28));
    Result.Alignment  := taCenter;
    Result.Layout     := tlCenter;
    Result.WordWrap   := True;
    Result.Font.Color := CLR_TEXTDIM;
    Result.Font.Size  := 9;
  end;

begin
  FLoading := True;
  try
    MakePageHeader(FPageXvtr, 'Transverters',
      'Configure up to 8 transverter (XVTR) bands. The VFO shows RF frequency; '
      + 'the radio tunes to IF: f_IF = f_displayed - LO + LO Error.', 38);

    // --- Group "Slots" --- (последний слот зарезервирован под QO-100 — не строим)
    GrpY := 100;
    Grp := MakeGroupPanel(FPageXvtr, 'Slots',
      MARGIN, GrpY, GRP_W, GRP_TITLE_H + HDR_H + QO100_SLOT * ROW_H + 14);
    Grp.Tag := TAG_RESPONSIVE_CARD;

    // Заголовки колонок (под GRP_TITLE_H + небольшой отступ)
    Y := GRP_TITLE_H + 6;
    MakeHdr(Grp, 'EN',         EN_X + 2,   Y, 28);
    MakeHdr(Grp, 'Label',      NAME_X,     Y, NAME_W);
    MakeHdr(Grp, 'Begin' + LineEnding + '(MHz)', BEG_X,  Y, BEG_W);
    MakeHdr(Grp, 'End' + LineEnding + '(MHz)',   END_X,  Y, END_W);
    MakeHdr(Grp, 'LO' + LineEnding + '(MHz)',    LO_X,   Y, LO_W);
    MakeHdr(Grp, 'LO Error' + LineEnding + 'Hz', ERR_X,  Y, ERR_W);
    MakeHdr(Grp, 'RX Gain' + LineEnding + 'dB',  GAIN_X, Y, GAIN_W);
    MakeHdr(Grp, 'RX' + LineEnding + 'Only',     RXO_X,  Y, RXO_W);
    MakeHdr(Grp, 'TX Pwr' + LineEnding + '%',    PWR_X,  Y, PWR_W);
    MakeHdr(Grp, 'Disable' + LineEnding + 'PA',  PA_X,   Y, PA_W);
    MakeHdr(Grp, 'RX' + LineEnding + 'Antenna',  ANT_X,  Y, ANT_W);

    // Ряды слотов (QO100_SLOT исключён — у него отдельная страница QO-100)
    for i := 0 to QO100_SLOT - 1 do
    begin
      Y := GRP_TITLE_H + HDR_H + i * ROW_H + 2;
      // Чекбокс EN центрируем по 24px ширине EN-колонки
      FXvtrEn[i]    := MakeChk(Grp, EN_X + 5,  Y + 2);
      FXvtrName[i]  := MakeEdt(Grp, NAME_X,    Y, NAME_W);
      FXvtrBegin[i] := MakeFlt(Grp, BEG_X,     Y, BEG_W,   0.0, 50000.0, 0.001, 3);
      FXvtrEnd[i]   := MakeFlt(Grp, END_X,     Y, END_W,   0.0, 50000.0, 0.001, 3);
      FXvtrLOOff[i] := MakeFlt(Grp, LO_X,      Y, LO_W, -50000.0, 50000.0, 0.001, 6);
      // диапазон ±10 МГц: QO-100 LNB без опоры легко уходит на сотни кГц, и
      // beacon-lock складывает свою коррекцию сюда (живая калибровка LNB).
      FXvtrLOErr[i] := MakeFlt(Grp, ERR_X,     Y, ERR_W, -9999999.0, 9999999.0, 1.0, 0);
      FXvtrRXGain[i]:= MakeFlt(Grp, GAIN_X,    Y, GAIN_W,  -60.0, 60.0, 0.1, 1);
      // Чекбокс RX Only центрируем
      FXvtrRXOnly[i]:= MakeChk(Grp, RXO_X + (RXO_W - 22) div 2,  Y + 2);
      FXvtrPower[i] := MakeInt(Grp, PWR_X,     Y, PWR_W,   0, 100);
      FXvtrDisPA[i] := MakeChk(Grp, PA_X + (PA_W - 22) div 2,    Y + 2);
      FXvtrRXAnt[i] := MakeCmb(Grp, ANT_X,     Y, ANT_W);
      FXvtrRXAnt[i].Items.Add('default');
      FXvtrRXAnt[i].Items.Add('ANT1');
      FXvtrRXAnt[i].Items.Add('ANT2');
      FXvtrRXAnt[i].Items.Add('ANT3');
      FXvtrRXAnt[i].ItemIndex := 0;
      FXvtrEn[i].Tag := i;
      FXvtrEn[i].OnClick := OnXvtrAnyChange;
    end;

    // --- Group "Options" ---
    GrpY := GrpY + GRP_TITLE_H + HDR_H + QO100_SLOT * ROW_H + 14 + 18;
    GrpOpts := MakeGroupPanel(FPageXvtr, 'Options',
      MARGIN, GrpY, GRP_W, GRP_TITLE_H + 36);
    GrpOpts.Tag := TAG_RESPONSIVE_CARD;
    FChkXvtrTunPwr := TFlatCheckBox.Create(Self);
    FChkXvtrTunPwr.Parent := GrpOpts;
    FChkXvtrTunPwr.Caption := 'Scale TUN by XVTR power  '
                            + '(TUN level from Transmit is multiplied by the per-slot TX Power)';
    FChkXvtrTunPwr.SetBounds(DpiScale(14), DpiScale(GRP_TITLE_H + 8), DpiScale(GRP_W - 28), DpiScale(20));
    FChkXvtrTunPwr.Font.Color := CLR_TEXT;
    FChkXvtrTunPwr.Font.Size  := 9;
    FChkXvtrTunPwr.OnChange   := OnXvtrAnyChange;

    // --- Подсказка про колонки внизу ---
    L := MakeTextBlock(FPageXvtr,
      'EN - enable the slot. Label - band button caption. Begin/End - displayed VFO range. ' +
      'LO - local oscillator. LO Error - fine correction in Hz.' + LineEnding +
      'RX Gain - transverter RX gain offset. RX Only - block TX. TX Pwr - drive limit. ' +
      'XVTR RF - use the main HF RF path.' + LineEnding +
      'Disable PA - keep the internal PA in RX position during TX. ' +
      'RX Antenna - override the per-band antenna for this slot.',
      MARGIN, GrpY + GRP_TITLE_H + 36 + 16, GRP_W, 52);
    L.Tag := TAG_PAGE_HEADER;

    // Применяем начальное состояние disabled-строк
    for i := 0 to CFG_XVTR_COUNT - 1 do
      UpdateXvtrRowEnabled(i);
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.UpdateXvtrRowEnabled(Idx: Integer);
var En: Boolean;
begin
  if (Idx < 0) or (Idx >= CFG_XVTR_COUNT) then Exit;
  if FXvtrEn[Idx] = nil then Exit;
  En := FXvtrEn[Idx].Checked;
  if FXvtrName[Idx]  <> nil then FXvtrName[Idx].Enabled  := En;
  if FXvtrBegin[Idx] <> nil then FXvtrBegin[Idx].Enabled := En;
  if FXvtrEnd[Idx]   <> nil then FXvtrEnd[Idx].Enabled   := En;
  if FXvtrLOOff[Idx] <> nil then FXvtrLOOff[Idx].Enabled := En;
  if FXvtrLOErr[Idx] <> nil then FXvtrLOErr[Idx].Enabled := En;
  if FXvtrRXGain[Idx]<> nil then FXvtrRXGain[Idx].Enabled:= En;
  if FXvtrRXOnly[Idx]<> nil then FXvtrRXOnly[Idx].Enabled:= En;
  if FXvtrPower[Idx] <> nil then FXvtrPower[Idx].Enabled := En;
  if FXvtrDisPA[Idx] <> nil then FXvtrDisPA[Idx].Enabled := En;
  if FXvtrRXAnt[Idx] <> nil then FXvtrRXAnt[Idx].Enabled := En;
end;

procedure TSettingsForm.CollectXvtrFromUI;
var i: Integer;
begin
  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    if FXvtrEn[i]    <> nil then FXvtrConfig.Entries[i].Enabled    := FXvtrEn[i].Checked;
    if FXvtrName[i]  <> nil then FXvtrConfig.Entries[i].ButtonText := Copy(FXvtrName[i].Text, 1, 15);
    if FXvtrBegin[i] <> nil then FXvtrConfig.Entries[i].FreqBegin  := FXvtrBegin[i].Value * 1.0e6;
    if FXvtrEnd[i]   <> nil then FXvtrConfig.Entries[i].FreqEnd    := FXvtrEnd[i].Value   * 1.0e6;
    if FXvtrLOOff[i] <> nil then FXvtrConfig.Entries[i].LOOffset   := FXvtrLOOff[i].Value * 1.0e6;
    if FXvtrLOErr[i] <> nil then FXvtrConfig.Entries[i].LOError    := FXvtrLOErr[i].Value;
    if FXvtrRXGain[i]<> nil then FXvtrConfig.Entries[i].RXGain     := FXvtrRXGain[i].Value;
    if FXvtrRXOnly[i]<> nil then FXvtrConfig.Entries[i].RXOnly     := FXvtrRXOnly[i].Checked;
    if FXvtrPower[i] <> nil then FXvtrConfig.Entries[i].TXPower    := FXvtrPower[i].Value;
    if FXvtrDisPA[i] <> nil then FXvtrConfig.Entries[i].DisablePA  := FXvtrDisPA[i].Checked;
    if (FXvtrRXAnt[i]<> nil) and (FXvtrRXAnt[i].ItemIndex >= 0) then
      FXvtrConfig.Entries[i].RXAntenna := Byte(FXvtrRXAnt[i].ItemIndex);
  end;
  if FChkXvtrTunPwr <> nil then
    FXvtrConfig.UseXVTRTunePower := FChkXvtrTunPwr.Checked;
end;

procedure TSettingsForm.FireXvtrChange;
var i: Integer;
begin
  if FLoading then Exit;
  CollectXvtrFromUI;   // слоты 0..QO100_SLOT-1 (виджеты QO-слота не созданы → skip)
  CollectQOFromUI;     // QO100_SLOT — из QO-страницы
  // Перерисовать enabled-состояние строк (если изменилась галка En)
  for i := 0 to CFG_XVTR_COUNT - 1 do
    UpdateXvtrRowEnabled(i);
  if Assigned(FOnXvtrChange) then
    FOnXvtrChange(FXvtrConfig);
end;

procedure TSettingsForm.OnXvtrAnyChange(Sender: TObject);
begin
  FireXvtrChange;
end;

procedure TSettingsForm.LoadXvtrSettings(const X: TXvtrSettings);
var i: Integer;
begin
  FLoading := True;
  try
    FXvtrConfig := X;
    for i := 0 to CFG_XVTR_COUNT - 1 do
    begin
      if FXvtrEn[i]    <> nil then FXvtrEn[i].Checked    := X.Entries[i].Enabled;
      if FXvtrName[i]  <> nil then FXvtrName[i].Text     := X.Entries[i].ButtonText;
      if FXvtrBegin[i] <> nil then FXvtrBegin[i].Value   := X.Entries[i].FreqBegin / 1.0e6;
      if FXvtrEnd[i]   <> nil then FXvtrEnd[i].Value     := X.Entries[i].FreqEnd   / 1.0e6;
      if FXvtrLOOff[i] <> nil then FXvtrLOOff[i].Value   := X.Entries[i].LOOffset  / 1.0e6;
      if FXvtrLOErr[i] <> nil then FXvtrLOErr[i].Value   := X.Entries[i].LOError;
      if FXvtrRXGain[i]<> nil then FXvtrRXGain[i].Value  := X.Entries[i].RXGain;
      if FXvtrRXOnly[i]<> nil then FXvtrRXOnly[i].Checked:= X.Entries[i].RXOnly;
      if FXvtrPower[i] <> nil then FXvtrPower[i].Value   := X.Entries[i].TXPower;
      if FXvtrDisPA[i] <> nil then FXvtrDisPA[i].Checked := X.Entries[i].DisablePA;
      if FXvtrRXAnt[i] <> nil then
        FXvtrRXAnt[i].ItemIndex := EnsureRange(X.Entries[i].RXAntenna, 0, 3);
      UpdateXvtrRowEnabled(i);
      // Sync VHF calibration label in PA tab
      if FVHFCalLabels[i] <> nil then
      begin
        if X.Entries[i].ButtonText <> '' then
          FVHFCalLabels[i].Caption := 'VHF' + IntToStr(i) + ' (' + X.Entries[i].ButtonText + '):'
        else
          FVHFCalLabels[i].Caption := 'VHF' + IntToStr(i) + ':';
        FVHFCalLabels[i].Hint := FVHFCalLabels[i].Caption;
      end;
      // Офсеты S-метра на вкладке Calibration — только по включённым слотам.
      FCalXvtrOn[i]   := X.Entries[i].Enabled;
      FCalXvtrName[i] := X.Entries[i].ButtonText;
    end;
    RelayoutCalTab;
    if FChkXvtrTunPwr <> nil then FChkXvtrTunPwr.Checked := X.UseXVTRTunePower;
    LoadQOFromConfig;   // QO100_SLOT → отдельная страница QO-100
  finally
    FLoading := False;
  end;
end;

// ===========================================================================
// OC Control tab (Open Collector выходы Penny/Alex, byte 1401 HP-пакета).
// Реализован сокращённый набор: без PA-override/hot-switching внешнего PA
// и без раздельных пинов VFO A/B. Только openHPSDR —
// вкладка целиком прячется на Pluto/AD936x (SetOCBackend).
// HF-группа = 11 диапазонов (как Alex/PA), VHF-группа = 8 слотов XVTR.
// ===========================================================================

procedure TSettingsForm.BuildOCTab;
const
  MARGIN  = 22;
  PAD     = 12;
  HDR1_H  = 22;
  HDR2_H  = 20;
  ROW_H   = 24;
  PIN_W   = 24;
  BAND_X  = PAD;
  RX0_X   = PAD + 50;
  TX0_X   = RX0_X + OC_PIN_COUNT * PIN_W + 20;
  GRP_W   = TX0_X + OC_PIN_COUNT * PIN_W + PAD;
  DATA_Y0 = GRP_TITLE_H + HDR1_H + HDR2_H + 4;
  HF_GRP_H   = GRP_TITLE_H + HDR1_H + HDR2_H + 11 * ROW_H + PAD;
  XVTR_GRP_H = GRP_TITLE_H + HDR1_H + HDR2_H + CFG_XVTR_COUNT * ROW_H + PAD;
  ACT_ROW_H  = 30;
  ACT_GRP_W  = 260;
  ACT_GRP_H  = GRP_TITLE_H + PAD + OC_PIN_COUNT * ACT_ROW_H;

  HF_BAND_NAMES: array[0..10] of string =
    ('160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m');

var
  Grp, ActGrp: TPanel;
  b, p: Integer;
  Y:    Integer;
  Cmb:  TFlatComboBox;

  function MkChk(AParent: TWinControl; AX, AY: Integer): TFlatCheckBox;
  begin
    Result            := TFlatCheckBox.Create(Self);
    Result.Parent     := AParent;
    Result.Caption    := '';
    Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(PIN_W), DpiScale(20));
    Result.Font.Color := CLR_TEXT;
    Result.Font.Size  := 9;
    Result.OnChange   := OnOCAnyChange;
  end;

begin
  FLoading := True;
  try
    MakePageHeader(FPageOC, 'OC Control', '');
    FOCSubtitle := TLabel.Create(Self);
    FOCSubtitle.Parent     := FPageOC;
    FOCSubtitle.Caption    :=
      'Open Collector outputs (Penny/Alex board), per-band and per-transverter. openHPSDR only.';
    FOCSubtitle.AutoSize   := False;
    FOCSubtitle.SetBounds(DpiScale(MARGIN), DpiScale(48),
      DpiScale(SETTINGS_CARD_W), DpiScale(20));
    FOCSubtitle.Layout     := tlTop;
    FOCSubtitle.WordWrap   := True;
    FOCSubtitle.Font.Size  := 9;
    FOCSubtitle.Font.Color := CLR_TEXTDIM;

    FOCEnabled := TFlatCheckBox.Create(Self);
    FOCEnabled.Parent  := FPageOC;
    FOCEnabled.Caption := 'Enable OC Control';
    FOCEnabled.SetBounds(DpiScale(MARGIN), DpiScale(76), DpiScale(220), DpiScale(22));
    FOCEnabled.Font.Color := CLR_TEXT;
    FOCEnabled.Font.Size  := 9;
    FOCEnabled.OnChange   := OnOCAnyChange;

    // ---- HF band grid ----
    Grp := MakeGroupPanel(FPageOC, 'HF Bands', MARGIN, 106, GRP_W, HF_GRP_H);
    FOCHFGrp := Grp;
    MakeLbl(Grp, 'Band', BAND_X, GRP_TITLE_H + 2, 44);
    MakeLbl(Grp, 'RX pins', RX0_X, GRP_TITLE_H + 2, OC_PIN_COUNT * PIN_W);
    MakeLbl(Grp, 'TX pins', TX0_X, GRP_TITLE_H + 2, OC_PIN_COUNT * PIN_W);
    for p := 0 to OC_PIN_COUNT - 1 do
    begin
      MakeLbl(Grp, IntToStr(p + 1), RX0_X + p * PIN_W + 5, GRP_TITLE_H + HDR1_H + 2, PIN_W);
      MakeLbl(Grp, IntToStr(p + 1), TX0_X + p * PIN_W + 5, GRP_TITLE_H + HDR1_H + 2, PIN_W);
    end;
    for b := 0 to 10 do
    begin
      Y := DATA_Y0 + b * ROW_H;
      MakeLbl(Grp, HF_BAND_NAMES[b], BAND_X, Y + 2, 44);
      for p := 0 to OC_PIN_COUNT - 1 do
      begin
        FOCHFRx[b, p] := MkChk(Grp, RX0_X + p * PIN_W, Y);
        FOCHFTx[b, p] := MkChk(Grp, TX0_X + p * PIN_W, Y);
      end;
    end;

    // ---- HF TX pin action ----
    ActGrp := MakeGroupPanel(FPageOC, 'HF TX Pin Action',
      MARGIN + GRP_W + 14, 106, ACT_GRP_W, ACT_GRP_H);
    FOCHFActGrp := ActGrp;
    for p := 0 to OC_PIN_COUNT - 1 do
    begin
      MakeLbl(ActGrp, 'Pin ' + IntToStr(p + 1), PAD, GRP_TITLE_H + PAD + p * ACT_ROW_H + 5, 44);
      Cmb := MakeCombo(ActGrp, PAD + 50, GRP_TITLE_H + PAD + p * ACT_ROW_H, 190, OnOCAnyChange);
      for b := 0 to 6 do Cmb.Items.Add(OC_ACTION_NAMES[b]);
      Cmb.ItemIndex   := OCA_MOX_TUNE_TWOTONE;
      FOCHFAction[p]  := Cmb;
    end;

    // ---- XVTR (VHF) slot grid ----
    Grp := MakeGroupPanel(FPageOC, 'Transverter Slots', MARGIN, 106 + HF_GRP_H + 14,
      GRP_W, XVTR_GRP_H);
    FOCXvtrGrp := Grp;
    MakeLbl(Grp, 'Slot', BAND_X, GRP_TITLE_H + 2, 44);
    MakeLbl(Grp, 'RX pins', RX0_X, GRP_TITLE_H + 2, OC_PIN_COUNT * PIN_W);
    MakeLbl(Grp, 'TX pins', TX0_X, GRP_TITLE_H + 2, OC_PIN_COUNT * PIN_W);
    for p := 0 to OC_PIN_COUNT - 1 do
    begin
      MakeLbl(Grp, IntToStr(p + 1), RX0_X + p * PIN_W + 5, GRP_TITLE_H + HDR1_H + 2, PIN_W);
      MakeLbl(Grp, IntToStr(p + 1), TX0_X + p * PIN_W + 5, GRP_TITLE_H + HDR1_H + 2, PIN_W);
    end;
    for b := 0 to CFG_XVTR_COUNT - 1 do
    begin
      Y := DATA_Y0 + b * ROW_H;
      MakeLbl(Grp, 'XVTR' + IntToStr(b + 1), BAND_X, Y + 2, 48);
      for p := 0 to OC_PIN_COUNT - 1 do
      begin
        FOCXvtrRx[b, p] := MkChk(Grp, RX0_X + p * PIN_W, Y);
        FOCXvtrTx[b, p] := MkChk(Grp, TX0_X + p * PIN_W, Y);
      end;
    end;

    // ---- XVTR TX pin action ----
    ActGrp := MakeGroupPanel(FPageOC, 'Transverter TX Pin Action',
      MARGIN + GRP_W + 14, 106 + HF_GRP_H + 14, ACT_GRP_W, ACT_GRP_H);
    FOCXvtrActGrp := ActGrp;
    for p := 0 to OC_PIN_COUNT - 1 do
    begin
      MakeLbl(ActGrp, 'Pin ' + IntToStr(p + 1), PAD, GRP_TITLE_H + PAD + p * ACT_ROW_H + 5, 44);
      Cmb := MakeCombo(ActGrp, PAD + 50, GRP_TITLE_H + PAD + p * ACT_ROW_H, 190, OnOCAnyChange);
      for b := 0 to 6 do Cmb.Items.Add(OC_ACTION_NAMES[b]);
      Cmb.ItemIndex     := OCA_MOX_TUNE_TWOTONE;
      FOCXvtrAction[p]  := Cmb;
    end;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.CollectOCFromUI;
var b, p: Integer; Mask: Byte;
begin
  FOCConfig.Enabled := (FOCEnabled <> nil) and FOCEnabled.Checked;
  for b := 0 to 10 do
  begin
    Mask := 0;
    for p := 0 to OC_PIN_COUNT - 1 do
      if (FOCHFRx[b, p] <> nil) and FOCHFRx[b, p].Checked then Mask := Mask or (1 shl p);
    FOCConfig.RxMaskHF[b] := Mask;
    Mask := 0;
    for p := 0 to OC_PIN_COUNT - 1 do
      if (FOCHFTx[b, p] <> nil) and FOCHFTx[b, p].Checked then Mask := Mask or (1 shl p);
    FOCConfig.TxMaskHF[b] := Mask;
  end;
  for b := 0 to CFG_XVTR_COUNT - 1 do
  begin
    Mask := 0;
    for p := 0 to OC_PIN_COUNT - 1 do
      if (FOCXvtrRx[b, p] <> nil) and FOCXvtrRx[b, p].Checked then Mask := Mask or (1 shl p);
    FOCConfig.RxMaskXvtr[b] := Mask;
    Mask := 0;
    for p := 0 to OC_PIN_COUNT - 1 do
      if (FOCXvtrTx[b, p] <> nil) and FOCXvtrTx[b, p].Checked then Mask := Mask or (1 shl p);
    FOCConfig.TxMaskXvtr[b] := Mask;
  end;
  for p := 0 to OC_PIN_COUNT - 1 do
  begin
    if (FOCHFAction[p] <> nil) and (FOCHFAction[p].ItemIndex >= 0) then
      FOCConfig.ActionHF[p] := Byte(FOCHFAction[p].ItemIndex);
    if (FOCXvtrAction[p] <> nil) and (FOCXvtrAction[p].ItemIndex >= 0) then
      FOCConfig.ActionXvtr[p] := Byte(FOCXvtrAction[p].ItemIndex);
  end;
end;

procedure TSettingsForm.FireOCChange;
begin
  if FLoading then Exit;
  CollectOCFromUI;
  if Assigned(FOnOCChange) then
    FOnOCChange(FOCConfig);
end;

procedure TSettingsForm.OnOCAnyChange(Sender: TObject);
begin
  FireOCChange;
end;

procedure TSettingsForm.LoadOCSettings(const O: TOCSettings);
var b, p: Integer;
begin
  FLoading := True;
  try
    FOCConfig := O;
    if FOCEnabled <> nil then FOCEnabled.Checked := O.Enabled;
    for b := 0 to 10 do
      for p := 0 to OC_PIN_COUNT - 1 do
      begin
        if FOCHFRx[b, p] <> nil then
          FOCHFRx[b, p].Checked := (O.RxMaskHF[b] and (1 shl p)) <> 0;
        if FOCHFTx[b, p] <> nil then
          FOCHFTx[b, p].Checked := (O.TxMaskHF[b] and (1 shl p)) <> 0;
      end;
    for b := 0 to CFG_XVTR_COUNT - 1 do
      for p := 0 to OC_PIN_COUNT - 1 do
      begin
        if FOCXvtrRx[b, p] <> nil then
          FOCXvtrRx[b, p].Checked := (O.RxMaskXvtr[b] and (1 shl p)) <> 0;
        if FOCXvtrTx[b, p] <> nil then
          FOCXvtrTx[b, p].Checked := (O.TxMaskXvtr[b] and (1 shl p)) <> 0;
      end;
    for p := 0 to OC_PIN_COUNT - 1 do
    begin
      if FOCHFAction[p]   <> nil then FOCHFAction[p].ItemIndex   := EnsureRange(O.ActionHF[p], 0, 6);
      if FOCXvtrAction[p] <> nil then FOCXvtrAction[p].ItemIndex := EnsureRange(O.ActionXvtr[p], 0, 6);
    end;
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.SetQOBackend(IsPluto: Boolean);
// Транспондер QO-100 у нас существует только на AD936x: InQO100 в контроллере
// требует IsPluto (полный дуплекс с раздельными RX/TX LO). На openHPSDR вся
// вкладка мертва, поэтому её нет в списке.
var
  Changed: Boolean;
begin
  Changed := FNavQO.Visible <> IsPluto;
  FNavQO.Visible := IsPluto;
  if Changed then RelayoutNav;   // иначе на её месте останется дыра
  if (not IsPluto) and FPageQO.Visible then
    SelectPage(FPageAudio, FNavAudio);
end;

procedure TSettingsForm.SetOCBackend(IsPluto: Boolean);
// У Pluto/AD936x нет Open Collector выхода — вкладка целиком скрывается,
// а не показывает пустое содержимое (в отличие от Antenna, где Pluto имеет
// свою таблицу разъёмов).
var
  Changed: Boolean;
begin
  Changed := FNavOC.Visible <> (not IsPluto);
  FNavOC.Visible := not IsPluto;
  // Пункты ниже обязаны подтянуться — иначе на месте OC Control остаётся дыра.
  if Changed then RelayoutNav;
  if IsPluto and (FPageOC.Visible) then
    SelectPage(FPageAudio, FNavAudio);
end;

// ── QO-100 page ─────────────────────────────────────────────────────────────

procedure TSettingsForm.BuildQOTab;
const
  MARGIN = 22;
  LBL_X  = 14;   LBL_W = 230;
  ED_X   = 250;  ED_W  = 130;
var
  Grp: TPanel;
  Y:   Integer;

  function QoFloat(AY: Integer; AMin, AMax, AStep: Double; ADec: Integer): TFlatFloatSpinEdit;
  begin
    Result := TFlatFloatSpinEdit.Create(Self);
    Result.Parent := Grp;
    Result.SetBounds(DpiScale(ED_X), DpiScale(AY), DpiScale(ED_W), DpiScale(BTN_H));
    Result.MinValue := AMin; Result.MaxValue := AMax;
    Result.Increment := AStep; Result.DecimalPlaces := ADec;
    Result.Color := CLR_INPUT; Result.Font.Color := CLR_INPUT_TEXT;
    Result.Font.Size := 9;
    Result.OnChange := OnQOAnyChange;
  end;

  procedure QoLabel(const Cap: string; AY: Integer);
  begin
    MakeLbl(Grp, Cap, LBL_X, AY + 4, LBL_W);
  end;

begin
  FLoading := True;
  try
    MakePageHeader(FPageQO, 'QO-100 (Es''hail-2 NB transponder)',
      'Geostationary transponder: RX downlink (~10489.5 MHz) via LNB and direct '
      + 'TX uplink (~2400 MHz). Separate LOs are used; beacon lock corrects drift.',
      38);

    Grp := MakeGroupPanel(FPageQO, 'Transponder', MARGIN, 100,
      SETTINGS_CARD_W, GRP_TITLE_H + 9 * 30 + 16);

    Y := GRP_TITLE_H + 8;
    FQoEn := TFlatCheckBox.Create(Self);
    FQoEn.Parent := Grp; FQoEn.Caption := 'Enable QO-100 band';
    FQoEn.SetBounds(DpiScale(LBL_X), DpiScale(Y), DpiScale(300), DpiScale(20));
    FQoEn.Font.Color := CLR_TEXT; FQoEn.Font.Size := 9;
    FQoEn.OnChange := OnQOAnyChange;

    Inc(Y, 32); QoLabel('LNB LO (MHz)', Y);
    FQoLnbLO := QoFloat(Y, 0.0, 50000.0, 0.001, 6);
    FQoLnbLO.Value := 9750.0;

    // LO Error (Гц): живая калибровка дрейфа LNB; beacon-lock складывает сюда
    // свою коррекцию. Диапазон ±10 МГц — LNB без опоры уходит на сотни кГц.
    Inc(Y, 30); QoLabel('LO Error (Hz)', Y);
    FQoLoErr := QoFloat(Y, -9999999.0, 9999999.0, 1.0, 0);

    Inc(Y, 30); QoLabel('Transponder offset = downlink - uplink (MHz)', Y);
    FQoTxLO := QoFloat(Y, 0.0, 50000.0, 0.001, 6);
    FQoTxLO.Value := 8089.5;

    // TO Error (Гц): сам транспондер-offset держим стандартным (8089.5 МГц),
    // а уход конкретного тракта (TCXO uplink-SDR, наводка LNB) правим здесь.
    Inc(Y, 30); QoLabel('TO Error (Hz)', Y);
    FQoTxLOErr := QoFloat(Y, -999999.0, 999999.0, 1.0, 0);

    Inc(Y, 30); QoLabel('Downlink begin (MHz)', Y);
    FQoBegin := QoFloat(Y, 0.0, 50000.0, 0.001, 3);
    FQoBegin.Value := 10489.5;

    Inc(Y, 30); QoLabel('Downlink end (MHz)', Y);
    FQoEnd := QoFloat(Y, 0.0, 50000.0, 0.001, 3);
    FQoEnd.Value := 10490.0;

    Inc(Y, 30); QoLabel('RX gain offset (dB)', Y);
    FQoRXGain := QoFloat(Y, -60.0, 60.0, 0.1, 1);

    Inc(Y, 30); QoLabel('TX drive ceiling (%)', Y);
    FQoPower := TFlatSpinEdit.Create(Self);
    FQoPower.Parent := Grp; FQoPower.SetBounds(DpiScale(ED_X), DpiScale(Y), DpiScale(ED_W), DpiScale(BTN_H));
    FQoPower.MinValue := 0; FQoPower.MaxValue := 100; FQoPower.Value := 100;
    FQoPower.Color := CLR_INPUT; FQoPower.Font.Color := CLR_INPUT_TEXT;
    FQoPower.Font.Size := 9;
    FQoPower.OnChange := OnQOAnyChange;

    FQoInfo := MakeTextBlock(FPageQO, '',
      MARGIN, 100 + GRP_TITLE_H + 9 * 30 + 28, SETTINGS_CARD_W, 52);
  finally
    FLoading := False;
  end;
end;

procedure TSettingsForm.UpdateQOInfo;
var dlMid, rxIf, txF: Double;
begin
  if FQoInfo = nil then Exit;
  dlMid := (FQoBegin.Value + FQoEnd.Value) / 2.0;     // МГц
  rxIf  := dlMid - FQoLnbLO.Value;                    // RX IF в SDR
  txF   := dlMid - FQoTxLO.Value + FQoTxLOErr.Value / 1.0e6;   // TX uplink + TO Error
  FQoInfo.Caption := Format(
    'At downlink %.3f MHz:  RX IF = %.3f MHz   TX = %.3f MHz',
    [dlMid, rxIf, txF]);
end;

procedure TSettingsForm.CollectQOFromUI;
begin
  if FQoEn = nil then Exit;
  FXvtrConfig.Entries[QO100_SLOT].Enabled      := FQoEn.Checked;
  FXvtrConfig.Entries[QO100_SLOT].FullDuplexTx := True;   // слот всегда QO-100
  FXvtrConfig.Entries[QO100_SLOT].ButtonText   := 'QO-100';
  FXvtrConfig.Entries[QO100_SLOT].LOOffset     := FQoLnbLO.Value  * 1.0e6;
  FXvtrConfig.Entries[QO100_SLOT].LOError      := FQoLoErr.Value;
  FXvtrConfig.Entries[QO100_SLOT].TxLOOffset   := FQoTxLO.Value   * 1.0e6;
  FXvtrConfig.Entries[QO100_SLOT].TxLOError    := FQoTxLOErr.Value;
  FXvtrConfig.Entries[QO100_SLOT].FreqBegin    := FQoBegin.Value  * 1.0e6;
  FXvtrConfig.Entries[QO100_SLOT].FreqEnd      := FQoEnd.Value    * 1.0e6;
  FXvtrConfig.Entries[QO100_SLOT].RXGain       := FQoRXGain.Value;
  FXvtrConfig.Entries[QO100_SLOT].TXPower      := FQoPower.Value;
end;

procedure TSettingsForm.LoadQOFromConfig;
var E: TXvtrEntry;
begin
  if FQoEn = nil then Exit;
  E := FXvtrConfig.Entries[QO100_SLOT];
  FQoEn.Checked    := E.Enabled;
  FQoLnbLO.Value   := E.LOOffset   / 1.0e6;
  FQoLoErr.Value   := E.LOError;
  FQoTxLO.Value    := E.TxLOOffset / 1.0e6;
  FQoTxLOErr.Value := E.TxLOError;
  FQoBegin.Value   := E.FreqBegin  / 1.0e6;
  FQoEnd.Value     := E.FreqEnd    / 1.0e6;
  FQoRXGain.Value  := E.RXGain;
  FQoPower.Value   := EnsureRange(E.TXPower, 0, 100);
  UpdateQOInfo;
end;

procedure TSettingsForm.OnQOAnyChange(Sender: TObject);
begin
  if FLoading then Exit;
  UpdateQOInfo;
  FireXvtrChange;
end;

end.
