{
  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 MainForm;

{
  OpenHPSDR Transceiver - Main Form
  Lazarus / FPC 3.2+  (нет inline var, нет анонимных процедур)

  ИСПРАВЛЕНИЯ:
    - (Web NR/NB/ANF fix) BtnNRClick, BtnNBClick, BtnANFClick переписаны
      без использования «Sender as TFlatButton» — теперь обращаются напрямую
      к BtnNR / BtnNB / BtnANF. Это устраняет EAccessViolation / EInvalidCast
      при вызове из SyncWebNR/SyncWebNB/SyncWebANF через TThread.Synchronize,
      где Sender передавался как nil. Исключение в UI-потоке приводило к
      разрыву WebSocket-соединения и «отключению» веб-клиента.
    - SyncWebNR / SyncWebNB / SyncWebANF: передают реальную кнопку (BtnNR /
      BtnNB / BtnANF) вместо nil — защита на случай изменения обработчиков.
}

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

interface

uses
  Classes, SysUtils, StrUtils, DateUtils, FreqDisplay, DeviceForm, FilterPopup,
  VfoOverlay, SampleRateOverlay, BandPlanOverlay,
  DXSpotStore, DXClusterClient, DXSpotOverlay, DXClusterForm,
  FlatButton, FlatSlider, FlatDropDown, OverlayScrollBar, AppTheme,
  Forms, Controls, Graphics, Dialogs,
  StdCtrls, ExtCtrls, Buttons, Menus, Math, Types, SyncObjs,
  OpenGLContextEx,
  LCLIntf, LCLType, GraphType,
  HPSDRProtocol, HPSDRNetwork,
  WDSP, WDSPEngine, AudioOutput, AudioInput,
  Settings,
  WebServer, WebAdapter,
  CATAdapter,
  TCIAdapter,
  SpectrumView, SpectrumViewOpengl,
  PanZoomBar, PanafallPanel, PanDisplayPopup, PureSignalPopup, FlatPopupMenu,
  WidebandView,
  StatusBar,
  PlatformUtils, SerialPort,
  RadioModes,
  WinFirewall,
  BoardUtils, WisdomBuilder, UISync,
  FlatEdit, FMRepeater,
  ChannelStore, ChannelsForm, CWMessagesForm, CWTerminalForm, BeaconScopeForm, DMRDecoder,
  PowerInhibit,
  DeviceStore,
  RadioBackend, PlutoBackend,
  RadioController, ChannelController, DpiUtils;

const
  // Меню шапки пана: теги ниже — rate в кГц, от DDC_MENU_TAG — выбор hw-DDC.
  DDC_MENU_TAG   = 1000;

  CLR_BG         = TColor($00101010);
  CLR_PANEL      = TColor($00181818);
  CLR_BORDER     = TColor($00303030);
  CLR_FREQ       = TColor($0000FF00);
  CLR_FREQ_DIM   = TColor($00007800);
  CLR_AMBER      = TColor($0000AAFF);
  CLR_ACTIVE     = TColor($000C3010);
  CLR_ACTIVE_BDR = TColor($00387838);
  CLR_INACTIVE   = TColor($001A1A1A);
  CLR_TEXT       = TColor($00CCCCCC);
  CLR_TEXTDIM    = TColor($00666666);
  CLR_METER_ON   = TColor($0000CC44);
  CLR_METER_OVR  = TColor($000000CC);
  CLR_SPECTRUM   = TColor($0044FF44);
  WDSP_WISDOM_FILE = 'wdspWisdom00';

  BAND_COUNT = 12;   // макс. слотов среди планов (Pluto VHF/UHF = 12, HF = 11)
  // Полосы/дефолты/подписи фильтров (FILT_COUNT, FILT_*_BW, FILT_*_DEF,
  // FILT_*_NAMES) живут в Settings.pas: из них строится заводская таблица
  // фильтров, а подписи кнопок теперь берутся из живой таблицы контроллера.
  FILT_GRID_COLS = 6;   // сетка кнопок фильтра: 2 ряда по 6 (12 = 10 + VAR1/2)
  EDGE_GRAB_PX   = 5;   // зона захвата края полосы фильтра мышью, px
  EDGE_SNAP_HZ   = 10;  // снап края при перетаскивании

  // CTCSS tones (38 standard tones, no None — toggle via CTCSS button)
  // CTCSS_COUNT и CTCSS_TONES перенесены в FMRepeater (нужны TRadioController).
  CTCSS_NAMES: array[0..37] of string = (
    '67.0', '71.9', '74.4', '77.0', '79.7', '82.5', '85.4', '88.5',
    '91.5', '94.8', '97.4', '100.0', '103.5', '107.2', '110.9', '114.8',
    '118.8', '123.0', '127.3', '131.8', '136.5', '141.3', '146.2', '151.4',
    '156.7', '162.2', '167.9', '173.8', '179.9', '186.2', '192.8', '203.5',
    '210.7', '218.1', '225.7', '233.6', '241.8', '250.3');

  // FM Step tuning
  FM_STEP_COUNT = 4;
  FM_STEP_HZ:    array[0..3] of Integer = (6250, 12500, 20000, 25000);
  FM_STEP_NAMES: array[0..3] of string  = ('6.25k', '12.5k', '20k', '25k');
  FM_STEP_DEF    = 3;   // 25 kHz

  // Стартовые подписи band-кнопок (HF-план); RelabelBands переписывает под
  // активный бэкенд. 12-й слот пуст — в HF-плане кнопка прячется.
  BAND_NAMES: array[0..BAND_COUNT-1] of string = (
    '160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','');

  MODE_NAMES: array[0..MODE_COUNT-1] of string = (
    'LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM','DMR','FMRAW');
  MODE_ROWS = 4;   // 4 колонки; последний ряд неполный

  SMETER_WIDTH_RATIO = 0.32;
  SMETER_MARGIN      = 3;
  TOP_SMETER_GAP     = 8;
  TOP_VFO_FONT       = 25;
  LEFT_SCROLL_GUTTER_W = 8;
  LEFT_PANEL_CONTENT_W = 232;
  LEFT_PANEL_BTN_PAD  = 3;
  LEFT_PANEL_BTN_GAP  = 2;

type
  { TMainForm }
  TMainForm = class(TForm)
  private
    // ---- Headless ядро (состояние + движки). Фаза 1 миграции: см.
    //      RadioController.pas. Поля состояния/движков теперь живут в
    //      FController; обращения идут как FController.FXxx. ----
    FController:  TRadioController;
    // True пока OnControllerState обновляет виджеты — чтобы программное
    // изменение слайдера/кнопки не вызывало повторно команду из OnChange.
    FSyncingFromController: Boolean;

    // ---- Network ----
    // Список устройств (saved+discovered) → FController.FDeviceStore (общий с web).
    FDeviceDialog: TDeviceDialog;
    FFilterPop: TFilterPopup;   // поповер редактора фильтров (ПКМ по кнопке FILTER)
    FPendingIP:        string;   // IP устройства для подключения
    // Флаг главного (слайс A): создаётся здесь (проводка событий), живёт в
    // раскладке/диспетчере FPan (AttachMainFlag). Флаги B+ — целиком в FPan.
    FVfoOverlay:         TVfoOverlay;
    FActiveSliceId:      Integer;             // выбранный активный слайс (0 = главный)
    // Перетаскивание края полосы фильтра: -1 левый, +1 правый, 0 не тянем.
    // Кромки на момент нажатия — чтобы неподвижный край не «плыл» от снапа.
    FEdgeDrag:           Integer;
    FEdgeDragLo:         Integer;
    FEdgeDragHi:         Integer;
    FSliceDrag:          Boolean;             // тащим маркер несущей слайса по спектру
    FSliceDragId:        Integer;             // какой слайс тащим
    FSampleRateOverlay:  TSampleRateOverlay;  // оверлей span/hide слева вверху спектра
    FBandPlanOverlay:    TBandPlanOverlay;    // полоска бэндплана QO-100
    // ── DX-кластер ────────────────────────────────────────────────────────
    FDXStore:        TDXSpotStore;      // общая база спотов (поток кластера пишет)
    FDXClient:       TDXClusterClient;  // telnet-соединение (свой поток)
    FDXSpotOverlay:  TDXSpotOverlay;    // подписи спотов на спектре
    FDXClusterForm:  TDXClusterForm;    // окно списка (ПКМ по кнопке DX)
    FDXCfg:          TDXClusterSettings;
    FDXConnCfg:      TDXClusterSettings; // параметры, с которыми поднят клиент
    FDXPendingCfg:   TDXClusterSettings; // правки SETUP, ждущие тишины
    FDXPending:      Boolean;
    FDXPendingAt:    TDateTime;
    FDXAgeTickAt:    TDateTime;         // когда последний раз гасили старые споты внизу спектра
    FPanelHidden:        Boolean;             // True = левая панель скрыта
    FLeftPanelW:         Integer;             // ширина панели до скрытия (DPI-safe restore)
    FPendingBoardType: Integer;  // BoardType устройства для подключения

    // ---- UI-local state (мышь на спектре/водопаде) ----
    FSpecDrag:      Boolean;
    FSpecDragX0:    Integer;  // X при нажатии
    FSpecDragFreq:  Double;   // FCenterFreq при нажатии
    FBeaconArming:  Boolean;  // ждём клик по маяку (после включения BEACON)
    FSpectrumDirty: Boolean;  // таймер должен перерисовать спектр/водопад
    // TX DUC-очередь (FDUCPending*) переехала в TRadioController (OnTXIQ).
    FSMeterPeak:      Double;    // верхняя граница светлой зоны
    FSMeterMin:       Double;    // нижняя граница светлой зоны
    FSMeterAvg:       Double;    // сглаженное среднее (EMA)
    // RX/seq + HP-телеметрия (FController.FLastSMeter/FwdW/SWR/SupplyV/SupplyA/PLLLock)
    // живут в TRadioController.

    // ---- Spectrum / Waterfall view ----
    // Панадаптер (спектр+линейка+сплиттер+водопад+пан/зум) — composite-объект
    // (Этап 3.0 мультислайсов). FSpecView и Pb*-поля ниже — алиасы его членов
    // (исторические имена; постепенная миграция на FPan.*). Размеры областей —
    // FPan.SpectrumWidth/SpectrumHeight/WaterfallHeight.
    FPan:            TPanafallPanel;
    // ---- Стек панов (этап 3.3): FPans[0] = FPan, FPans[N>0] — доп. паны на
    // своих DDC (nil = не создан). Сплиттер FPanSplitters[i] стоит НАД паном i.
    FPans:           array[0..MAX_PANS-1] of TPanafallPanel;
    FPanSplitters:   array[1..MAX_PANS-1] of TPanel;
    FPanShare:       array[0..MAX_PANS-1] of Double;   // доля высоты стека
    FPanSpecDirty:   array[0..MAX_PANS-1] of Boolean;  // кадр пана готов к рендеру
    FPanWfDirty:     array[0..MAX_PANS-1] of Boolean;
    FPanCbLock:      SyncObjs.TCriticalSection;  // жизнь FPans[i] vs пиксельные колбэки
    FPanDisplayPopup: TObject;         // TPanDisplayPopup (3.6), лениво создаётся
    // Мышь на пане N>0: drag слайса или drag центра пана (ретюн DDC).
    FPanSliceDrag:   Boolean;
    FPanSliceDragId: Integer;
    FPanCenterDrag:  Boolean;
    FPanCenterMoved: Boolean;           // был реальный сдвиг (иначе up = клик-тюн)
    FPanCenterDragX0: Integer;
    FPanCenterDragF0: Double;
    FPanMousePan:    TPanafallPanel;    // пан, в котором идёт drag
    // Сплиттер между панами (индекс пана, чей верхний сплиттер тащим; 0 = нет).
    // Во время drag сплиттер двигается «призраком» (клампы в FPanSplitMin/MaxTop),
    // пересчёт долей — по отпусканию.
    FPanSplitDrag:   Integer;
    FPanSplitY0:     Integer;
    FPanSplitTop0:   Integer;
    FPanSplitMinTop: Integer;
    FPanSplitMaxTop: Integer;
    // Грид-раскладка: при >=2 доп. панах — пан 0 сверху во всю ширину, доп.
    // паны колонками снизу; при ОДНОМ доп. пане — две колонки бок о бок
    // (пан 0 слева, доп. справа). Активна при FPanGridMode и >=1 доп. пане.
    FPanGridMode:     Boolean;
    FPanGridRowShare: Double;   // доля высоты верхнего ряда (пан 0), 0.15..0.85
    FPanColShare:     array[0..MAX_PANS-1] of Double;  // доли ширины колонок
    FPanVSplitters:   array[1..MAX_PANS-1] of TPanel;  // слева от колонки пана i
    FPansRestored:    Boolean;  // персист панов восстановлен в этой Run-сессии
    // Drag вертикального сплиттера (индекс пана справа; 0 = нет).
    FPanVSplitDrag:    Integer;
    FPanVSplitX0:      Integer;
    FPanVSplitLeft0:   Integer;
    FPanVSplitMinLeft: Integer;
    FPanVSplitMaxLeft: Integer;
    FPanWideH:         Integer;  // wideband-высота последнего LayoutPanStack (для PanMinHeight)
    FPendingPanClose: Integer;   // «×» шапки: PanId к закрытию в тике (0 = нет)
    // Меню rate пана («NNN kHz» в шапке панов N; создаётся лениво).
    FPanRateMenu:      TFlatPopupMenu;   // тематизированное меню (не нативное)
    FPanRateMenuPanId: Integer;
    FPanBandMenu:      TFlatPopupMenu;   // селектор диапазона пана N (3.6, КВ)
    FPanBandMenuPanId: Integer;
    // Pop-out (3.5): пан N в отдельном OS-окне. nil = пан в стеке.
    FPanFloatForm: array[1..MAX_PANS-1] of TForm;
    FSpecView:       TSpectrumView;
    FWidebandView:   TWidebandView;
    FUseOpenGLSpectrum: Boolean;
    FDisplayFPS:     Integer;
    FWaterfallDirty: Boolean;
    FWidebandDirty:  Boolean;
    // Display state (FShow*/FSpec*/FWf*/FTXSpec*/FController.FWidebandFill/FController.FSpectrumFill)
    // переехало в TRadioController (нужно headless-load в DoConnectDevice).
    // Снимок последнего кадра спектра/водопада для web переехал в TRadioController
    // (FSpectrumBuf/FWaterfallBuf) — нужен headless-демону.
    FAgcLineCounter:         Integer;
    // --- Settings ---
    FWebServer:      TWebServer;
    FWebAdapter:     TWebAdapter;   // мост web-сервер ↔ контроллер (вынесенные WebOnXxx)
    FChannelController: TChannelController;  // адаптер каналов памяти поверх контроллера
    FWebEnabled:     Boolean;
    FWebPort:        Integer;
    FWebBindAddr:    string;
    FWebUser:        string;
    FWebPass:        string;
    FWebSpecPixels:  Integer;   // потолок точек кадра спектра/водопада для web
    // --- CAT ---
    FCATAdapter:        TCATAdapter;     // адаптер CAT поверх контроллера (движок+транспорты+контекст)
    FTCIAdapter:        TTCIAdapter;     // адаптер TCI для WebSocket-сервера
    FTCICfg:            TTCISettings;    // текущие настройки TCI (UI/persist)
    FCATLastGlobal:     TGlobalSettings; // текущие CAT-настройки (UI/persist; ApplySettings → адаптер)
    // Виджеты построены (конец FormCreate). До этого рендер состояния запрещён:
    // адаптеры (CAT/web), создаваемые по ходу FormCreate, могут дёрнуть
    // Changed() раньше, чем появятся кнопки/панадаптер.
    FUIReady:           Boolean;
    // Временные поля для передачи параметров в Synchronize-методы
    FWebSyncFreq:    Double;
    FWebSyncInt:     Integer;
    FWebSyncBool:    Boolean;
    FWebSyncStr1:    string;             // для web-команд с текстом (IP/имя)
    FWebSyncStr2:    string;
    FWebSyncM:       TThreadMethod;
    FSettingsForm:   TObject;            // TSettingsForm (cast при использовании)
    FPendingDev:     THPSDRDevice;        // устройство ожидающее открытия WDSP
    FLightTheme:     Boolean;            // True = светлая тема
    FFreqMhzDigits:  Integer;            // 3=999MHz, 4=9.999GHz, 5=99.999GHz

    // ---- Timers ----
    FMeterTimer:    TTimer;
    FSpectrumTimer: TTimer;
    FAfterShowTimer:TTimer;   // однократный таймер для пост-инициализации
    FRestoreL, FRestoreT: Integer;  // позиция для отложенного восстановления
    FHasPendingRestore:   Boolean;

    // ---- Top panel (toolbar + VFO group + S-meter) ----
    PanelToolbar:  TPanel;
      BtnDiscover:  TFlatButton;
      BtnStartStop: TFlatButton;
      BtnSettings:  TFlatButton;

    // ---- Left panel ----
    PanelLeft: TPanel;
    FLeftScrollBar: TOverlayScrollBar;
    FLeftScrollOffset: Integer;

    PanelVfoA:    TPanel;
      LblVfoALabel: TLabel;
      FreqDispA:    TFreqDisplay;
      BtnTopVfoASelect: TFlatButton;
      BtnTopVfoATX: TFlatButton;

    PanelVfoB:    TPanel;
      LblVfoBLabel: TLabel;
      FreqDispB:    TFreqDisplay;
      BtnTopVfoBSelect: TFlatButton;
      BtnTopVfoBTX: TFlatButton;

    PanelVfoButtons: TPanel;
      BtnVfoSwap: TFlatButton;
      BtnVfoACopyB: TFlatButton;
      BtnVfoBCopyA: TFlatButton;
    PanelTopVfoGroup: TPanel;
    PanelTopVfoA: TPanel;
    PanelTopVfoB: TPanel;

    PanelBands:   TPanel;
      BtnBand:    array[0..BAND_COUNT-1] of TFlatButton;
      // Динамические кнопки XVTR (создаются/удаляются по мере того,
      // как пользователь enabled/disabled слоты в Settings → Transverter).
      BtnXvtrBand: array[0..CFG_XVTR_COUNT-1] of TFlatButton;

    PanelMode:    TPanel;
      BtnMode:    array[0..MODE_COUNT-1] of TFlatButton;

    PanelFilter:  TPanel;
      BtnFilter:  array[0..FILT_SLOTS-1] of TFlatButton;   // 10 пресетов + VAR1/VAR2
    BtnCTun: TFlatButton;
    BtnBeacon: TFlatButton;   // QO-100 beacon lock (видим только на Pluto в трансвертере)
    BtnDUP:  TFlatButton;
    BtnRxMute: TFlatButton;   // QO-100 self-monitor: RX-аудио на TX (видим только Pluto+full-duplex)
    PanelFMSQ:    TPanel;
      BtnFMSQ:    TFlatButton;
      TrkFMSQ:    TFlatSlider;
      LblFMSQ:    TLabel;
    PanelRawDev: TPanel;
      TrkRawDev: TFlatSlider;
      LblRawDev: TLabel;
    PanelFMCTCSS:     TPanel;
      BtnFMCTCSS:     TFlatButton;
      BtnFMCTCSSTone: TFlatButton;
    FCTCSSDropDown:   TFlatDropDown;
    PanelFMStep:     TPanel;
      BtnFMStep:     TFlatButton;
      BtnFMStepSel:  TFlatButton;
    FStepDropDown:   TFlatDropDown;
    PanelFMRpt:      TPanel;
      BtnFMRptMinus:   TFlatButton;
      BtnFMRptPlus:    TFlatButton;
      EdFMRptOffset:   TFlatEdit;

    PanelAGC:      TPanel;
      BtnAGCMode: array[0..4] of TFlatButton;  // FAST MED SLOW LONG OFF
      LblAGCTop:  TLabel;    // показывает значение уровня
      TrkAGC:     TFlatSlider;
    // RX hw-gain (Pluto) — ОТДЕЛЬНЫЙ блок под AGC (WDSP). Виден только для Pluto.
    PanelPlutoAGC:  TPanel;
      BtnPlutoGain: array[0..3] of TFlatButton;  // FAST/SLOW/HYB/MAN (Tag=режим)
      TrkRxGain:    TFlatSlider;   // manual gain 0..73 dB
      LblRxGain:    TLabel;        // значение усиления
      TrkVolume:  TFlatSlider;
      LblVolume:  TLabel;
      BtnNR: TFlatButton;
      BtnNB: TFlatButton;
      BtnSNB: TFlatButton;
      BtnANF: TFlatButton;
      BtnMute: TFlatButton;

    PanelSMeterRight: TPanel;
    PbSMeterRight:    TPaintBox;

    PanelTX:      TPanel;
      LblDrv:     TLabel;
      LblDrvVal:  TLabel;    // значение drive: % (openHPSDR) или дБ (Pluto)
      TrkDrive:   TFlatSlider;
      BtnMOX: TFlatButton;
      BtnTUN: TFlatButton;
      BtnPS:   TFlatButton;   // PureSignal: ЛКМ = вкл/выкл, ПКМ = поповер настроек
      Btn2Ton: TFlatButton;   // двухтональник (IMD/проверка PS)
    FPSPopup: TObject;        // TPureSignalPopup (cast при использовании)

    PanelRXBlock: TPanel;
      // Шаговый аттенюатор ADC (openHPSDR): плавные 0..31 дБ. На Pluto ряд
      // скрыт — там усиление тракта живёт в блоке RF AGC.
      LblAtt:     TLabel;
      LblAttVal:  TLabel;
      TrkAtt:     TFlatSlider;
      // Ряд CTUN | DX | Channel | BEACON.
      BtnDXCluster:     TFlatButton;   // ЛКМ — споты на спектре, ПКМ — окно кластера
      BtnChannels:      TFlatButton;
    FChannelsDropDown:  TFlatDropDown;
    // TX-профиль: кнопка с именем активного (блок TX, ряд DUP) + список.
    // ЛКМ — список, ПКМ — вкладка Transmit в настройках.
    BtnTXProfile:       TFlatButton;
    FTXProfileDropDown: TFlatDropDown;
    FChannelsForm:      TObject;   // TChannelsForm (cast при использовании)
    FCWMsgForm:         TObject;   // TCWMessagesForm (память телеграфа)
    FCWTermForm:        TObject;   // TCWTerminalForm (лента + набор с клавиатуры)
    FBeaconScopeForm:   TBeaconScopeForm;  // окно констелляции маяка (ПКМ по BEACON)

    // ---- Right panel ----
    PanelRight:   TPanel;
    // Контролы панадаптера — алиасы членов FPan (владелец FPan; см. выше).
    PbSpectrum:   TControl;
    PbWideband:   TControl;
    PbWidebandRuler: TPaintBox;
    PbRuler:      TPaintBox;   // полоса частотных меток между спектром и водопадом
    PanelSplitter: TPanel;     // перетаскиваемый разделитель спектр/водопад
    PbWaterfall:  TControl;
    // ---- Панель пана/зума под водопадом ----
    PbPanZoom:    TPaintBox;    // полоса-ползунок пана зума
    FPanZoomBar:  TPanZoomBar;
    BtnZoomIn:    TFlatButton;
    BtnZoomDef:   TFlatButton;
    BtnZoomOut:   TFlatButton;

    // ---- Status bar ----
    StatusPanel:  TMainStatusBar;

    // ---- Helpers ----
    procedure DoInvoke(M: TThreadMethod);   // маршалинг команд контроллера в UI-поток
    procedure OnControllerState(Sender: TObject; Field: TRadioField);  // контроллер → UI
    procedure BuildUI;
    function LeftPanelButtonLeft(PanelWidth, ColCount, ColIndex: Integer): Integer;
    function LeftPanelButtonWidth(PanelWidth, ColCount, ColIndex: Integer): Integer;
    procedure ApplyDarkTheme;
    procedure SetLightTheme(V: Boolean);
    procedure StyleButton(B: TFlatButton; Active: Boolean = False);
    procedure StyleSpanButton(B: TFlatButton; Active: Boolean = False);
    procedure SetStatusText(Index: Integer; const Text: string);
    procedure SetRadioOfflineStatus(const ConnText: string; ClearDevice: Boolean = False);
    procedure ApplyStatusTheme(const T: TAppTheme);
    procedure PositionSampleRateOverlay;
    procedure OnSampleRateOverlayInvalidate(Sender: TObject);
    procedure OnSampleRateSelect(SampleRate: Integer);
    procedure OnSampleRateHidePanel;
    procedure UpdateVfoDisplay;
    function  FormatFreq(Hz: Double): string;
    procedure LayoutTopVfoBlock;

    procedure ResizeSMeter;
    procedure ResizeSpectrumPanels;
    function  UpdateWidebandFrequencyView: Boolean;
    procedure InvalidateGridCache;
    procedure SyncSpecViewFreq;
    procedure RecreateDSPEngine(ASampleRate: Integer);
    function  EnsureWDSPWisdom: Boolean;

    // Network callbacks
    procedure OnWidebandCB(ADCIndex: Integer; const Samples: array of SmallInt;
      Count: Integer);
    procedure OnWDSPOpenDone(Success: Boolean);
    function  ResolveDevice(const IP: string): THPSDRDevice;
    procedure RelabelBands;          // подписи band-кнопок под план бэкенда
    procedure ApplyBackendCapsToUI;  // sample-rate пресеты + гейтинг полей по Caps
    procedure UpdateRxGainUI;        // рендер RX hw-gain (Pluto)
    procedure LayoutLeftPanel(Pluto: Boolean);   // показать/скрыть Pluto-блок + reflow
    procedure BtnPlutoGainClick(Sender: TObject);
    procedure DoConnectDevice(const Dev: THPSDRDevice);
    // Старт с уже выбранным устройством (preload rate + WDSP open + connect).
    // Переиспользуется десктоп-диалогом и web-оверлеем — без модального диалога.
    procedure StartWithDevice(const Dev: THPSDRDevice);
    // STOP-teardown: persistence + graceful Run=0 + Disconnect + offline-статус.
    // Общий для десктоп-STOP и web-disconnect.
    procedure DoStopAndDisconnect;

    // DSP/Audio callbacks (вызываются из рабочих потоков)
    procedure OnSpectrumReady(const Pixels: array of Single; Count: Integer);
    procedure OnWaterfallReady(const Pixels: array of Single; Count: Integer);

    // Public UI update (called from sync objects)
    procedure UpdateTXMeters;
    procedure UpdateTXControlAvailability;
    procedure UpdateStatusField7;          // поле 7: PLL (HPSDR) или beacon (Pluto)
    procedure UpdateDMRStatus;             // поле 8: только при активном DMR
    procedure UpdateStatusField3;          // поле 3: Supply (HPSDR) или Temp/RSSI (Pluto)
    function  BeaconStatusText: string;    // компактный статус QO-100 beacon lock
    function  DMRStatusText: string;       // sync/type/polarity DMR front-end
    procedure DoUpdateDDCSeq(DDCIdx: Integer; Seq: LongWord);

    // Event handlers
    procedure BtnDiscoverClick(Sender: TObject);
    procedure BtnDiscoverFromDialog(Sender: TObject);
    procedure BtnStartStopClick(Sender: TObject);
    procedure FreqDispAChanged(Sender: TObject; NewFreq: Int64);
    procedure ApplyVfoA(NewFreq: Int64);
    procedure FreqDispAClick(Sender: TObject);
    procedure FreqDispBClick(Sender: TObject);
    procedure BtnTopVfoASelectClick(Sender: TObject);
    procedure BtnTopVfoATXClick(Sender: TObject);
    procedure BtnTopVfoBSelectClick(Sender: TObject);
    procedure BtnTopVfoBTXClick(Sender: TObject);
    procedure ActivateVfo(Idx: Integer);
    function  ActiveVfoFreq: Int64;
    // Настройки
    procedure SaveCurrentBand;
    procedure RestoreBand(BandIdx: Integer);
    procedure SaveAllAndExit;
    procedure RestoreWindowBounds;
    procedure SaveWindowBounds;
    procedure FreqDispBChanged(Sender: TObject; NewFreq: Int64);
    procedure BtnBandClick(Sender: TObject);
    procedure BtnModeClick(Sender: TObject);
    procedure BtnModeMouseDown(Sender: TObject; Button: TMouseButton;
                               Shift: TShiftState; X, Y: Integer);
    procedure BtnFilterClick(Sender: TObject);
    procedure BtnFilterMouseDown(Sender: TObject; Button: TMouseButton;
                                 Shift: TShiftState; X, Y: Integer);
    procedure LayoutFilterButtons(PresetCount: Integer);
    procedure PushFilterEdgesToView;   // кромки RX/TX в SpectrumView
    procedure RefreshFilterCaptions;   // подписи кнопок без сброса выбора
    function  FilterEdgeAtPixel(X: Integer): Integer;  // -1 левый край, +1 правый, 0 мимо
    procedure DoFilterEdgeDrag(X: Integer; Shift: TShiftState);
    procedure OpenFilterEditor(SlotIdx: Integer);
    procedure OnFilterSlotEdited(Mode, Idx: Integer; const S: TFilterSlot);
    procedure OnFilterSlotReset(Mode, Idx: Integer);   // Idx < 0 = весь режим
    procedure BtnCTunClick(Sender: TObject);
    procedure BtnBeaconClick(Sender: TObject);
    procedure BtnBeaconMouseDown(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure UpdateBeaconButton;
    procedure BtnDUPClick(Sender: TObject);
    procedure ApplyDUP(Active: Boolean);
    procedure BtnRxMuteClick(Sender: TObject);
    procedure UpdateRxMuteButton;   // видимость (Pluto+full-duplex) + стиль кнопки RX MUTE
    procedure UpdateBandPlanOverlay; // вкл/выкл полоски бэндплана QO-100 по InQO100
    // ── DX-кластер ────────────────────────────────────────────────────────
    procedure InitDXCluster;                 // стор+клиент+оверлей, чтение настроек
    procedure ServiceDXCluster;              // тик затухания спотов (редкий)
    procedure ApplyDXClusterSettings(const D: TDXClusterSettings);
    procedure AttachPanDXSpots(P: TPanafallPanel);  // свой оверлей спотов на пан
    procedure ApplyDXOverlayCfg(O: TDXSpotOverlay); // настройки вида → один оверлей
    function  DXTuneSpotOnPan(P: TPanafallPanel; FreqHz: Double;
                              Mode: TDXMode): Integer;
    procedure UpdateDXClusterButton;
    procedure BtnDXClusterClick(Sender: TObject);
    procedure BtnDXClusterMouseDown(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure ShowDXClusterForm;
    procedure ApplyDXConnection(const D: TDXClusterSettings);
    procedure OnDXClusterSettingsChange(const D: TDXClusterSettings);
    procedure CommitDXClusterSettings;
    procedure OnDXTuneSpot(FreqHz: Double; Mode: TDXMode);
    procedure BtnTUNClick(Sender: TObject);
    procedure ApplyTUN(Active: Boolean);
    // PureSignal: ЛКМ по PS = вкл/выкл, ПКМ = поповер настроек.
    procedure BtnPSClick(Sender: TObject);
    procedure BtnPSMouseDown(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure Btn2TonClick(Sender: TObject);
    procedure OnPSPopupChanged(Sender: TObject);  // персист PS-параметров
    procedure UpdatePSButtons;                    // стиль/доступность PS/2TON
    procedure UpdateIMDMarkers;                   // 2TON: маркеры пиков/IMD3 на спектре
    procedure UpdateFilterButtons;
    procedure BtnFMSQClick(Sender: TObject);
    procedure TrkFMSQChange(Sender: TObject);
    procedure TrkRawDevChange(Sender: TObject);
    procedure ApplyFMSquelch;
    procedure BtnFMCTCSSClick(Sender: TObject);
    procedure BtnFMCTCSSToneClick(Sender: TObject);
    procedure SetFMCTCSSTone(Idx: Integer);
    procedure CloseCTCSSPopup;
    procedure BtnFMRptMinusClick(Sender: TObject);
    procedure BtnFMRptPlusClick(Sender: TObject);
    procedure EdFMRptOffsetChange(Sender: TObject);
    procedure UpdateRptAutoState;
    procedure SetFMStep(Idx: Integer);
    procedure CloseFMStepPopup;
    procedure BtnFMStepClick(Sender: TObject);
    procedure BtnFMStepSelClick(Sender: TObject);
    procedure OnCTCSSDropDownSelect(Sender: TObject; Idx: Integer);
    procedure OnStepDropDownSelect(Sender: TObject; Idx: Integer);
    procedure BtnChannelsClick(Sender: TObject);
    procedure BtnChannelsMouseDown(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure OnChannelsDropDownSelect(Sender: TObject; Idx: Integer);
    procedure RefreshChannelsDropDown;
    // TX-профили: рендер кнопки/списка + обработчики блока TX и вкладки Transmit.
    procedure RenderTXProfile;
    procedure PushTXProfilesToSettings;
    procedure BtnTXProfileClick(Sender: TObject);
    procedure BtnTXProfileMouseDown(Sender: TObject; Button: TMouseButton;
                                    Shift: TShiftState; X, Y: Integer);
    procedure OnTXProfileDropDownSelect(Sender: TObject; Idx: Integer);
    procedure OnTXProfileSelectFromSettings(Idx: Integer);
    procedure OnTXProfileAddFromSettings(const AName: string);
    procedure OnTXProfileRenameFromSettings(Idx: Integer; const AName: string);
    procedure OnTXProfileDeleteFromSettings(Idx: Integer);
    procedure OnTXProfileFactoryFromSettings(Sender: TObject);
    procedure OnVfoOverlayTXProfile(Idx: Integer);
    procedure OnChannelStoreChanged(Sender: TObject);
    procedure OnChannelActiveChanged(Idx: Integer; const Name: string; Active: Boolean);
    procedure PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState;
      X, Y: Integer);
    procedure PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure PbSpectrumMouseLeave(Sender: TObject);
    procedure PbWidebandMouseDown(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure PbWaterfallMouseDown(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure PbWaterfallMouseMove(Sender: TObject; Shift: TShiftState;
      X, Y: Integer);
    procedure PbWaterfallMouseUp(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure DoSpectrumClick(PixelX: Integer; PanelWidth: Integer);
    procedure DoSpectrumDrag(PixelX: Integer; PanelWidth: Integer);
    procedure DoWidebandClick(PixelX: Integer; PanelWidth: Integer);
    // ---- Пан/зум (мышь полосы/сплиттера — внутри TPanafallPanel) ----
    function  PanOfZoomButton(Sender: TObject): TPanafallPanel;
    procedure OnPanZoomWindow(Sender: TObject; AZoom, APan: Double);
    procedure BtnZoomInClick(Sender: TObject);
    procedure BtnZoomDefClick(Sender: TObject);
    procedure BtnZoomOutClick(Sender: TObject);
    procedure UpdatePanZoomBar;
    procedure BtnVfoSwapClick(Sender: TObject);
    procedure BtnVfoACopyBClick(Sender: TObject);
    procedure BtnVfoBCopyAClick(Sender: TObject);
    procedure BtnMOXClick(Sender: TObject);
    procedure BtnMuteClick(Sender: TObject);
    procedure UpdateNRButton;
    procedure UpdateNBButton;
    procedure UpdateSNBButton;
    procedure UpdateANFButton;
    procedure BtnNRClick(Sender: TObject);
    procedure BtnNBClick(Sender: TObject);
    procedure BtnSNBClick(Sender: TObject);
    procedure BtnANFClick(Sender: TObject);
    // Веб-интерфейс: host-зависимые callbacks от TWebServer (UI-обёртки).
    // Чистые-контроллерные перенесены в TWebAdapter (Mode/Filter/AGC/AGCTop/Volume/
    // WfAGC/WfNF/Mute/NR/NB/SNB/ANF/FreqB/Center/Attn/ActiveVfo/Ctun/MOX/Tun/FMStep/
    // ClientActive). Здесь только то, что дёргает UI-обёртки MainForm.
    procedure WebOnRun(On_: Boolean);
    // Discovery / connect / saved-CRUD из web (маршалятся в UI)
    procedure WebOnConnect(const IP: string);
    procedure WebOnDiscover;
    procedure WebOnDisconnect;
    procedure WebOnDevAdd(const AName, IP: string);
    procedure WebOnDevRemove(Idx: Integer);
    procedure WebOnDevAutoStart(Idx: Integer);
    procedure SyncWebConnect;
    procedure SyncWebDiscover;
    procedure SyncWebDisconnect;
    procedure SyncWebDevAdd;
    procedure SyncWebDevRemove;
    procedure SyncWebDevAutoStart;
    procedure PushDeviceListToWeb;       // store → FWebServer.SetDeviceList
    // Synchronize-обёртки (выполняются в UI-потоке) — только host-зависимые
    procedure SyncWebRun;
    // --- CAT settings (UI/persist; контекст+движок — в TCATAdapter) ----------
    procedure OnCATSettingsChange(
      const SerEnabled: array of Boolean;
      const SerPort: array of string;
      const SerBaud, SerDataBits, SerStopBits, SerParity: array of Integer;
      const SerAndromeda: array of Boolean;
      TcpEnabled: Boolean; TcpPort: Integer);
    procedure OnSliceCATSettingsChange(
      const Enabled: array of Boolean;
      const Port: array of Integer;
      const AutoTX: array of Boolean;
      const OutDev: array of string;
      const InDev: array of string);
    // Выбор устройства во флаге слайса = устройство его слота: зеркалим в
    // конфиг, чтобы вкладка Slices показывала то же самое.
    procedure StoreSliceSlotAudio(SliceId: Integer);
    // Аудио слотов из конфига → контроллер (старт и смена устройства): слайсы
    // восстанавливаются позже и должны встать на свои звуковухи.
    procedure ApplySliceSlotAudio;
    procedure OnAlexSettingsChange(const A: TAlexSettings);
    procedure OnOCSettingsChange(const O: TOCSettings);
    procedure OnXvtrSettingsChange(const X: TXvtrSettings);
    procedure PushXvtrToWeb;
    procedure PushRatePresetsToWeb;   // backend sample-rate пресеты → web
    procedure PushBandsToWeb;         // backend band-план → web
    // XVTR helpers
    function  FindXvtrIdxForFreq(FreqHz: Double): Integer;
    function  XvtrTranslate(VisibleHz: Double): Double;
    function  XvtrTranslateTX(VisibleHz: Double): Double;
    procedure ApplyXvtrToNetwork;
    procedure RebuildXvtrButtons;
    procedure RelayoutBelowBands;
    procedure UpdateLeftPanelScroll;
    function  CursorOverLeftPanel: Boolean;
    procedure LeftScrollChanged(Sender: TObject);
    procedure BtnXvtrBandClick(Sender: TObject);
    procedure ActivateXvtrBand(Idx: Integer);
    procedure DeactivateXvtr;
    // -------------------------------------------------------------------------
    procedure BtnSettingsClick(Sender: TObject);
    procedure UpdateSetupButton;         // SETUP заперт, пока не знаем устройство
    // Семантические обработчики событий флагов (роутинг по FPan.CurSliceId).
    // Коллекция/раскладка/диспетчер флагов — в TPanafallPanel.
    procedure OnModeFilterSelect(Mode: Integer; FilterBW: Integer);
    procedure OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
    procedure OnVfoOverlaySquelchChange(SQLOn: Boolean; SQLLevel: Integer);
    procedure OnVfoOverlayAGCChange(AGCMode: Integer);
    procedure OnVfoOverlayVolume(Vol: Integer);
    procedure OnVfoOverlayMute(Sender: TObject);
    procedure OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string);
    procedure OnVfoOverlayAudioInDevice(DevIndex: Integer; const DevName: string);
    procedure OnVfoOverlaySplit(Sender: TObject);
    procedure OnVfoOverlayTxSelect(Sender: TObject);
    procedure OnVfoOverlayRxMuteTx(Sender: TObject);      // RXM — приём слайса на TX
    procedure RefreshTxIndicators;                        // TX-бейджи всех флагов
    procedure OnVfoOverlaySliceSelect(Sender: TObject);   // выбрать слайс активным
    procedure OnVfoOverlayClose(Sender: TObject);         // × — удалить слайс
    procedure OnVfoOverlayInvalidate(Sender: TObject);
    procedure OnPanSliceClosed(SliceId: Integer);         // FPan закрыл слайс
    // --- Мультислайсы (UI) ---
    function  CurSliceId: Integer;              // SliceId обрабатываемого сейчас флага
    procedure WireOverlayEvents(O: TVfoOverlay);
    procedure AddSliceAtFreq(Hz: Double);
    // --- Стек панов (этап 3.3) ---
    function  PanCount: Integer;                          // активных панов (>=1)
    function  PanFromSender(Sender: TObject): TPanafallPanel;
    function  PanOfOverlay(Sender: TObject): TPanafallPanel;
    procedure PushFlagStateAllPans(SliceId: Integer);
    procedure LayoutFlagsAllPans;
    function  CreatePanCore(PanId: Integer; Freq: Double;
                RateKHz: Word; ADC: Byte): TPanafallPanel;  // DDC+панель (без слайса)
    procedure OnAddPanClick(Sender: TObject);             // «⊞» пана 0
    procedure OnPanHeaderClose(Sender: TObject);          // «×» шапки пана
    procedure ClosePanadapter(PanId: Integer);
    procedure DestroyAllExtraPans;                        // stop/выход
    function  PanContextKey: string;                      // 'hf' | 'xvtr_N'
    procedure TearDownWorkspace;                          // снос панов+слайсов контекста
    procedure SavePanWorkspace;                           // OnBeforeStop: текущий контекст
    procedure SavePanWorkspaceCtx(const Ctx: string);     // снимок панов+слайсов контекста
    procedure RestorePanWorkspace;                        // восстановление при Run (once)
    procedure RestorePanWorkspaceCtx(const Ctx: string);  // восстановление контекста Ctx
    procedure RestoreSliceInto(P: TPanafallPanel; PanId: Integer;
                const SC: TSliceCfg);
    procedure UpdatePanHeaders;                           // текст+видимость шапок
    procedure ApplyPanHeaderTheme(P: TPanafallPanel);     // цвета шапки пана
    procedure UpdateAddPanButton;
    procedure OnPanRateClick(Sender: TObject);            // «NNN kHz» шапки пана N
    procedure OnPanRateSelect(Tag: Integer);             // выбор rate из flat-меню
    procedure OnPanBandClick(Sender: TObject);            // «20m» шапки пана N (3.6)
    procedure OnPanBandSelect(Tag: Integer);             // выбор диапазона из flat-меню
    function  CurrentAppTheme: TAppTheme;                 // тек. тема (light/dark)
    procedure ApplyPanBand(P: TPanafallPanel; BandIdx: Integer);  // смена диапазона отдельного пана
    function  PanBandSelectorVisible: Boolean;            // КВ (не XVTR, не Pluto)
    procedure OnPanADCClick(Sender: TObject);             // бейдж «RX1/RX2» (3.4)
    // Per-pan дисплей (3.6): «◑» → поповер палитры/gamma/уровней.
    procedure OnPanDisplayClick(Sender: TObject);
    procedure OnPanDisplayApplied(P: TPanafallPanel; FullRefresh: Boolean);
    procedure OnPanDisplayReset(P: TPanafallPanel);       // сброс к глоб. дефолту
    procedure OnPanDisplayApplyAll(P: TPanafallPanel);    // раскатать на все паны
    procedure ApplyGlobalRenderToView(V: TSpectrumView);  // глоб. рендер → вьюха
    procedure MarkPanDisplayDirty(P: TPanafallPanel; ClearWf: Boolean);  // репейнт (+сброс истории водопада)
    // Pop-out (3.5): «⧉» выносит пан в отдельное окно, [×] окна возвращает в стек.
    procedure OnPanPopOutClick(Sender: TObject);
    procedure PopOutPan(PanId: Integer);
    procedure DockBackPan(PanId: Integer);
    procedure PanFloatClose(Sender: TObject; var CloseAction: TCloseAction);
    procedure PanFloatResize(Sender: TObject);
    function  PanFloating(PanId: Integer): Boolean;
    procedure ResetPanGLCaches(P: TPanafallPanel);        // после смены родителя
    function  AddSliceAtFreqPan(P: TPanafallPanel; Hz: Double): Integer;
    procedure PanClickTune(P: TPanafallPanel; X, W: Integer);  // клик по фону пана N
    procedure PanViewWindow(P: TPanafallPanel; out VC, VS: Double);
    procedure SyncPanZoomUI(P: TPanafallPanel);           // вьюха+зум-бар ← контроллер
    procedure RefreshPanZoom(P: TPanafallPanel);          // + флаги/инвалидции
    procedure SyncPanViews;                               // центр/span вьюх панов
    procedure MarkPanDirty(P: TPanafallPanel);            // репейнт пана N
    // Мышь панов N>0 (у пана 0 семантика ниже, PbSpectrum*).
    procedure PanNSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
                Shift: TShiftState; X, Y: Integer);
    procedure PanNSpectrumMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
    procedure PanNSpectrumMouseUp(Sender: TObject; Button: TMouseButton;
                Shift: TShiftState; X, Y: Integer);
    procedure PanNSpectrumMouseLeave(Sender: TObject);
    procedure PanNSpectrumDblClick(Sender: TObject);
    // Пиксели панов из display-потока движка.
    procedure OnPanSpectrumReady(PanId: Integer; const Pixels: array of Single;
                Count: Integer);
    procedure OnPanWaterfallReady(PanId: Integer; const Pixels: array of Single;
                Count: Integer);
    // Сплиттеры между панами.
    procedure PanSplitMouseDown(Sender: TObject; Button: TMouseButton;
                Shift: TShiftState; X, Y: Integer);
    procedure PanSplitMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
    procedure OnPanGridToggle(Sender: TObject);          // «▦/▤» в ряду зума пана 0
    function  PanGridActive: Boolean;                    // грид реально применён
    function  PanGridTwoCol: Boolean;                    // грид из 2 колонок (пан 0 + 1)
    function  ExtraPanCount: Integer;                    // доп. паны в раскладке
    procedure PanVSplitMouseDown(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure PanVSplitMouseMove(Sender: TObject; Shift: TShiftState; X, Y: Integer);
    procedure PanVSplitMouseUp(Sender: TObject; Button: TMouseButton;
      Shift: TShiftState; X, Y: Integer);
    procedure PanSplitMouseUp(Sender: TObject; Button: TMouseButton;
                Shift: TShiftState; X, Y: Integer);
    procedure LayoutPanStack(AWideH: Integer);   // регионы панов + Layout каждого
    function  PanMinHeight(PanId: Integer): Integer;  // мин. высота региона пана
    function  Pan0HeaderVisible: Boolean;        // шапка пана 0: стек ИЛИ 2 входа
    function  Pan0HeaderH: Integer;              // высота шапки пана 0 (0 без стека)
    procedure PbSpectrumDblClick(Sender: TObject);
    procedure TrkDriveChange(Sender: TObject);
    procedure UpdateDriveLabel;   // % (openHPSDR) / дБ (Pluto) рядом с ползунком drive
    procedure TrkVolumeChange(Sender: TObject);
    procedure UpdateVolumeLabel;
    function  CalcDriveByte: Byte;
    function  ActiveTXFreqHz: Double;
    function  DefaultMicSource: TTXMicSource;
    procedure ApplyMOX(Active: Boolean);
    procedure OnPASettingsChange(MaxPower: Double; const BandCal: array of Double);
    procedure OnCalibrationSettingsChange(const C: TCalibration);
    procedure OnPlutoTxMaxAttChange(MaxAttDb: Double);
    procedure OnAnt936xChange(const A: TAD936xAnt);   // разъёмы AD936x (Antenna)
    procedure OnVHFCalSettingsChange(const VHFCal: array of Double);
    procedure OnTXSettingsChange(const T: TTXSettings);
    procedure OnCWSettingsChange(const C: TCWSettings);
    procedure ShowCWMessages;
    procedure ShowCWTerminal;
    procedure ServiceCWTerminal;              // таймер: разбор + обновление окна
    procedure OnCWLogAppend(const S: string; IsTX: Boolean);
    procedure OnCWTermBackspace(Sender: TObject);
    procedure OnCWTermDecoder(Value: Boolean);
    procedure OnCWTermKbdKey(Dot, Dash: Boolean);
    procedure OnCWTermSpeed(WPM: Integer);
    procedure OnCWTermMacro(Sender: TObject);
    procedure OnCWMessageSend(const Text: string);
    procedure OnCWMessageAbort(Sender: TObject);
    procedure FormKeyDownCW(Sender: TObject; var Key: Word; Shift: TShiftState);
    procedure BtnAGCModeClick(Sender: TObject);
    procedure TrkAGCChange(Sender: TObject);
    procedure TrkRxGainChange(Sender: TObject);
    procedure TrkAttChange(Sender: TObject);
    procedure UpdateAttLabel;
    // (BtnPlutoGainClick объявлен выше)
    procedure MeterTimerTick(Sender: TObject);
    procedure SpectrumTimerTick(Sender: TObject);
    procedure RightPanelResize(Sender: TObject);
    procedure AfterShowTick(Sender: TObject);

  public
    // Методы для SettingsForm — немедленное применение настроек
    procedure ApplyDisplayParams(FFTSize, WinType, SpecDet, SpecAvgMode: Integer;
      SpecAvgTimeMS: Double);
    procedure ApplyWaterfallParams(WfDet, WfAvgMode: Integer;
      WfAvgTimeMS, WfHigh, WfLow, WfAGCOffset: Double);
    procedure ApplyWfRenderParams(Palette: Integer; Gamma: Double;
      ResLimit, SpeedDiv: Integer);
    procedure ApplySpecMSAA(Samples: Integer);
    function  EffectiveWfInterval: Integer;
    procedure ApplyWfAGCNF(WfAGC, WfNF: Boolean);
    procedure ApplyADCSettings(Dither, Random: Boolean);
    procedure ApplyWebSettings(Enabled: Boolean; Port: Integer;
      const BindAddr, User, Pass: string; SpecPixels: Integer);
    procedure ApplyTCISettings(Enabled: Boolean; Port: Integer;
      const BindAddr: string);
    // Фокус главного окна → APP_FOCUS клиентам TCI (логгеры так решают, кому
    // отдавать клавиатуру).
    procedure TCIFormActivate(Sender: TObject);
    procedure TCIFormDeactivate(Sender: TObject);
    procedure TCIFocusRequest;
    procedure ApplyGridParams(RefLevel, Range, GridStep: Double);
    // Пушит активные grid-параметры (RX или TX в зависимости от FTransmitting)
    // в FSpecView и сбрасывает кэш сетки. Вызывается при смене RX↔TX и при
    // правке любого из соответствующих полей (FSpec*, FTXSpec*).
    procedure ApplySpecViewGridFromState;
    procedure ApplyAudioDevice(DevIndex: Integer; const DevName: string);
    procedure ApplyAudioInputDevice(DevIndex: Integer; const DevName: string);
    procedure ApplyAudioBufferSize(BufferSize: Integer);
    procedure ApplySendAudioToRadio(Enabled: Boolean);
    // TX settings (ApplyTXSettingsToDSP/SendDUCSpecificFromSettings/
    // BuildMicLineSelectByte) переехали в TRadioController.
    procedure ApplyVisibility(ShowSpectrum, ShowWaterfall, ShowWideband,
      WidebandFill, SpectrumFill: Boolean);
    procedure ApplyFPS(FPS: Integer);
    procedure ApplyFreqMhzDigits(Digits: Integer);
    procedure UpdateFreqDisplayMax;   // верхняя граница FreqDisplay (XVTR/Pluto/HF)

  published
    // Обработчики событий формы — должны быть в published для RTTI/LFM
    procedure FormCreate(Sender: TObject);
    procedure FormDestroy(Sender: TObject);
    procedure FormClose(Sender: TObject; var CloseAction: TCloseAction);
    procedure FormMouseWheel(Sender: TObject; Shift: TShiftState;
                             WheelDelta: Integer; MousePos: TPoint;
                             var Handled: Boolean);
  end;

var
  MainForm: TMainForm;

implementation

uses SettingsForm;

{$R *.lfm}

const
  // Высота RX-блока левой панели: с рядом ATT (openHPSDR) и без него (Pluto —
  // там усиление тракта в блоке RF AGC). Переключает LayoutLeftPanel, он же
  // двигает панели ниже.
  RX_BLOCK_H     = 100;
  RX_BLOCK_H_ATT = 124;

function TMainForm.LeftPanelButtonWidth(PanelWidth, ColCount,
  ColIndex: Integer): Integer;
var
  InnerW, BaseW, Extra, Pad, Gap: Integer;
begin
  if ColCount <= 0 then Exit(0);
  Pad := MulDiv(LEFT_PANEL_BTN_PAD, PanelWidth, LEFT_PANEL_CONTENT_W);
  Gap := MulDiv(LEFT_PANEL_BTN_GAP, PanelWidth, LEFT_PANEL_CONTENT_W);
  InnerW := PanelWidth - 2 * Pad - (ColCount - 1) * Gap;
  BaseW := InnerW div ColCount;
  Extra := InnerW mod ColCount;
  Result := BaseW;
  if ColIndex < Extra then Inc(Result);
end;

function TMainForm.LeftPanelButtonLeft(PanelWidth, ColCount,
  ColIndex: Integer): Integer;
var
  i, Pad, Gap: Integer;
begin
  Pad := MulDiv(LEFT_PANEL_BTN_PAD, PanelWidth, LEFT_PANEL_CONTENT_W);
  Gap := MulDiv(LEFT_PANEL_BTN_GAP, PanelWidth, LEFT_PANEL_CONTENT_W);
  Result := Pad;
  for i := 0 to ColIndex - 1 do
    Inc(Result, LeftPanelButtonWidth(PanelWidth, ColCount, i) +
      Gap);
end;

// ===========================================================================
// Settings helpers
// ===========================================================================

procedure TMainForm.RestoreWindowBounds;
var
  L, T, Wd, Ht, SavedDPI, CurDPI: Integer;
  Maximized: Boolean;
  R, WA: TRect;
  Mon: TMonitor;
begin
  FController.FSettings.LoadWindowBounds(L, T, Wd, Ht, SavedDPI, Maximized);
  if (Wd > 400) and (Ht > 300) then
  begin
    CurDPI := CurrentScreenDPI;
    if SavedDPI <= 0 then SavedDPI := CurDPI;

    if SavedDPI <> CurDPI then
    begin
      // Позицию не масштабируем: на LCLWin32 координаты физические и не зависят
      // от DPI-фактора; на Qt6/GTK/Cocoa — логические (device-independent), тоже
      // инвариантны. Выход за рабочую область поймает EnsureRange ниже.
      //
      // Размер масштабируем, чтобы окно выглядело одинаково при смене DPI:
      //   LCLWin32: физические пиксели → Wd × CurDPI/SavedDPI
      //   Qt6/GTK/Cocoa: логические пиксели → Wd × SavedDPI/CurDPI (обратное)
      {$IFDEF LCLWin32}
      Wd := MulDiv(Wd, CurDPI, SavedDPI);
      Ht := MulDiv(Ht, CurDPI, SavedDPI);
      {$ELSE}
      Wd := MulDiv(Wd, SavedDPI, CurDPI);
      Ht := MulDiv(Ht, SavedDPI, CurDPI);
      {$ENDIF}
    end;

    // Минимальный размер:
    //   LCLWin32 — в физических пикселях, масштабируется с DPI.
    //   Qt6/GTK/Cocoa — в логических пикселях, device-independent, фиксированный.
    {$IFDEF LCLWin32}
    Wd := Max(MulDiv(900, CurDPI, 96), Wd);
    Ht := Max(MulDiv(560, CurDPI, 96), Ht);
    {$ELSE}
    Wd := Max(900, Wd);
    Ht := Max(560, Ht);
    {$ENDIF}
    R := Bounds(L, T, Wd, Ht);
    Mon := Screen.MonitorFromRect(R);
    if Mon <> nil then WA := Mon.WorkareaRect
    else              WA := Screen.PrimaryMonitor.WorkareaRect;

    if Wd > WA.Right - WA.Left then Wd := WA.Right - WA.Left;
    if Ht > WA.Bottom - WA.Top then Ht := WA.Bottom - WA.Top;
    L := EnsureRange(L, WA.Left,  Max(WA.Left, WA.Right - Wd));
    T := EnsureRange(T, WA.Top,   Max(WA.Top,  WA.Bottom - Ht));

    SetBounds(L, T, Wd, Ht);
    if Maximized then
      WindowState := wsMaximized
    else
    begin
      // На Qt6/X11 frame extents ещё не известны Qt во время FormCreate,
      // поэтому move() может поставить контент туда, куда мы хотели рамку.
      // Повторно применим позицию в AfterShowTick, когда WM уже декорировал окно.
      FRestoreL := L;
      FRestoreT := T;
      FHasPendingRestore := True;
    end;
  end;
end;

procedure TMainForm.SaveWindowBounds;
var
  L, T, Wd, Ht, DPI: Integer;
  Maximized: Boolean;
begin
  Maximized := WindowState = wsMaximized;
  if WindowState = wsNormal then
  begin
    L := Left; T := Top; Wd := Width; Ht := Height;
  end
  else
  begin
    L := RestoredLeft; T := RestoredTop;
    Wd := RestoredWidth; Ht := RestoredHeight;
  end;

  // RestoredLeft/Top могут вернуть мусор на Qt6 при максимизации;
  // ставим разумные запасные значения
  if (L < -32000) or (T < -32000) then begin L := 80; T := 80; end;
  if (Wd <= 400) or (Ht <= 300) then Exit;
  DPI := CurrentScreenDPI;
  FController.FSettings.SaveWindowBounds(L, T, Wd, Ht, DPI, Maximized);
end;

procedure TMainForm.SaveCurrentBand;
begin
  // Логика сохранения (band-cache/XVTR-слот + конфиг) — в контроллере.
  FController.SaveCurrentBand;
end;

procedure TMainForm.RestoreBand(BandIdx: Integer);
begin
  // Тонкий делегат: состояние+DSP+финальная перестройка+рендер — в команде
  // контроллера; сброс Wf-avg буфера — в OnControllerState(rfBandRestore).
  FController.RestoreBand(BandIdx);
end;

procedure TMainForm.SaveAllAndExit;
begin
  if FController.FDevConnected then
  begin
    SaveCurrentBand;
    FController.SaveGlobalSettings;
    if FController.FDevConnected then
      FController.FSettings.SaveTX(FController.FDevMAC, FController.FTXSettings);
    // Запомнить LastFreq A/B XVTR для следующего запуска
    if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) then
    begin
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFreq  := FController.FVfoA;
      FController.FXvtrSettings.Entries[FController.FCurrentXvtr].LastFreqB := FController.FVfoB;
    end;
    FController.FSettings.SaveXvtr(FController.FDevMAC, FController.FXvtrSettings);
    FController.FSettings.Save;
  end;
end;

procedure TMainForm.FormCreate(Sender: TObject);
var
  bi_, StartupRate, i: Integer;
  WebCfg: TWebSettings;
  StartupVfoA, StartupVfoB: Double;
begin
  SetExceptionMask(GetExceptionMask + [exZeroDivide, exInvalidOp]);
  // Headless ядро: владеет состоянием и движками. Создаём первым — все
  // присваивания FController.FXxx ниже зависят от него. Движки создаются/
  // освобождаются здесь же (в полях FController) — Фаза 1 миграции.
  FController := TRadioController.Create;
  FController.OnInvoke := DoInvoke;
  FController.OnStateChanged := OnControllerState;
  // Адаптер каналов поверх контроллера: оркеструет команды, держит активный
  // канал/pre-channel-drive. Рендер кнопки Channel — через OnActiveChanged;
  // XVTR-вход/выход — через UI-обёртки (wideband/web живут в них).
  FChannelController := TChannelController.Create(FController);
  FChannelController.OnActiveChanged  := OnChannelActiveChanged;
  // Канал-оркестрация фаером из контроллера в конце SetVfoA (любой путь тюнинга).
  FController.OnAfterTune := FChannelController.OnVfoTuned;
  // Снимок панов+слайсов перед гашением DDC в StopRunning (персист).
  FController.OnBeforeStop := SavePanWorkspace;
  FController.FVfoA      := 14200000;
  FController.FVfoB      := 7100000;
  FController.FActiveVfo := 0;
  FController.FMode      := MODE_USB;
  FController.FFilter    := 5;
  FController.FAGCMode   := 1;   // MEDIUM
  FController.FAGCTop    := 90;  // −90 dBm
   // default: зависит от режима, будет сброшен в UpdateFilterButtons
  FController.FCTun      := False;
  // Фильтр (индекс + кромки + производная ширина) — одной точкой, чтобы
  // состояние не расползалось: ширину отдельно больше не выставляем.
  FController.SelectFilterSlotState(FController.FFilter);
  FSpecDrag  := False;
  FController.FDriveLevel    := 0;
  FController.FPAMaxPower    := 100.0;
  for i := 0 to BAND_COUNT - 1 do FController.FPABandCal[i] := CFG_PA_BAND_CAL_DEFAULT[i];
  for i := 0 to CFG_XVTR_COUNT - 1 do FController.FVHFBandCal[i] := CFG_VHF_CAL_DEFAULT;
  FController.FRunning       := False;
  FController.FTransmitting  := False;
  FController.FSplitTxB      := False;
  FController.FDisplayDuplex := False;
  FController.FTuning        := False;
  FController.FMuted     := False;
  FController.FVolume    := 70;
  FController.FCenterFreq    := FController.FVfoA;
  FController.FSpanHz        := 192000;
  FController.FSampleRate    := 192000;
  FController.FCurrentBand   := 5;  // 20m по умолчанию
  // Waterfall normalization
  FController.FWfManualHigh := -80.0;
  FController.FWfManualLow  := -130.0;
  FController.FWfAGCOffset  := 0.0;
  FController.FWfAGCEnabled := True;
  FController.FWfNFEnabled  := False;
  FController.FDitherEnabled := True;
  FController.FRandomEnabled := True;
  FController.FFMDeviation    := FILT_FM_DEV[FILT_FM_DEF];
  FController.FFMCTCSSOn      := False;
  FController.FFMCTCSSToneIdx := 0;
  FController.FFMSQOn         := False;
  FController.FFMSQLevel      := 30;
  FController.FFMStepOn       := True;
  FController.FFMStepIdx      := FM_STEP_DEF;
  FController.FFMRptDir          := RPT_NONE;
  FController.FFMRptOffsetHz     := RPT_DEFAULT_2M;
  FController.FFMRptAutoActive   := False;
  FController.FFMCTCSSAutoActive := False;
  FController.FDevConnected  := False;
  // Spectrum grid settings defaults
  FController.FSpecRefLevel  := -20.0;
  FController.FSpecRange     := 110.0;
  FController.FSpecGridStep  := 10.0;
  // TX grid defaults (синхронно с TSettingsManager.DefaultTX)
  FController.FTXSpecRefLevel := 0.0;
  FController.FTXSpecRange    := 80.0;
  FController.FTXSpecGridStep := 10.0;
  FSettingsForm  := nil;
  FChannelsForm     := nil;
  FCWMsgForm        := nil;
  FBeaconScopeForm  := nil;
  // F1..F8 для памяти телеграфа. Обработчик молчит, пока окно памяти не
  // открывали и пока режим не CW, поэтому прочим клавишам ничего не мешает.
  KeyPreview := True;
  OnKeyDown  := FormKeyDownCW;
  FillChar(FController.FDevMAC, SizeOf(FController.FDevMAC), 0);

  // Инициализируем кэш диапазонов умолчаниями
  for bi_ := 0 to CFG_BAND_COUNT - 1 do
    TSettingsManager.DefaultBand(bi_, FController.FBandCache[bi_]);
  FController.FCurrentBand := 5;

  // Загружаем JSON настройки (FSettings создаётся в TRadioController.Create)
  FController.FSettings.Load;

  // Загружаем список каналов
  FController.FChannelStore := TChannelStore.Create(CHANNELS_FILE);
  FController.FChannelStore.Load;
  if FController.FSettings.LoadStartupPreview(StartupVfoA, StartupVfoB, StartupRate) then
  begin
    FController.FVfoA := StartupVfoA;
    FController.FVfoB := StartupVfoB;
    FController.FCenterFreq := FController.FVfoA;
    if StartupRate > 0 then
    begin
      FController.FSampleRate := StartupRate;
      FController.FSpanHz     := StartupRate;
    end;
  end;
  // Восстанавливаем размер/позицию окна при старте (до подключения устройства)
  RestoreWindowBounds;
  // S-метр позиционируем после рестора размера окна
  // (будет пересчитан в первом тике SpectrumTimerTick)
  FController.FLastSMeter    := -130;

  // Загружаем настройки веб-сервера из JSON и создаём сервер
  FController.FSettings.LoadWebSettings(WebCfg);
  FWebEnabled  := WebCfg.Enabled;
  FWebPort     := WebCfg.Port;
  FWebBindAddr := WebCfg.BindAddr;
  FWebUser     := WebCfg.User;
  FWebPass     := WebCfg.Pass;
  FWebSpecPixels := EnsureRange(WebCfg.SpecPixels, 256, SPECTRUM_PIXELS);
  FController.FWebSpecPixels := FWebSpecPixels;
  FWebServer := TWebServer.Create(FWebUser, FWebPass, Word(FWebPort), FWebBindAddr);
  // Вынесенные в TWebAdapter чистые-контроллерные команды (host=nil: их хендлеры
  // ещё не нужны хосту; host-зависимые WebOnXxx пока на MainForm — переедут в WA2).
  FWebAdapter := TWebAdapter.Create(FController, FWebServer, nil);
  FWebServer.OnMode   := FWebAdapter.OnMode;
  FWebServer.OnFilter := FWebAdapter.OnFilter;
  FWebServer.OnAGC    := FWebAdapter.OnAGC;
  FWebServer.OnAGCTop := FWebAdapter.OnAGCTop;
  FWebServer.OnVolume := FWebAdapter.OnVolume;
  FWebServer.OnWfAGC  := FWebAdapter.OnWfAGC;
  FWebServer.OnWfNF   := FWebAdapter.OnWfNF;
  FWebServer.OnMute   := FWebAdapter.OnMute;
  FWebServer.OnNR     := FWebAdapter.OnNR;
  FWebServer.OnNB     := FWebAdapter.OnNB;
  FWebServer.OnSNB    := FWebAdapter.OnSNB;
  FWebServer.OnANF    := FWebAdapter.OnANF;
  FWebServer.OnFreqB  := FWebAdapter.OnFreqB;
  FWebServer.OnCenter := FWebAdapter.OnCenter;
  FWebServer.OnAttn   := FWebAdapter.OnAttn;
  // Web-слой держит FController.FWebClientActive актуальным (выбор mic-source
  // при TX, в т.ч. для HWPTT из контроллера) — больше не зеркалим лениво.
  FWebServer.OnClientActiveChanged := FWebAdapter.OnClientActiveChanged;
  // Host-зависимые (UI-обёртки) и исходящий push — пока на MainForm:
  FWebServer.OnCtun      := FWebAdapter.OnCtun;
  FWebServer.OnActiveVfo := FWebAdapter.OnActiveVfo;
  FWebServer.OnMOX       := FWebAdapter.OnMOX;
  FWebServer.OnTun       := FWebAdapter.OnTun;
  FWebServer.OnFMStep    := FWebAdapter.OnFMStep;
  // Host-зависимые (UI-обёртки) — пока на MainForm:
  FWebServer.OnFreq   := FWebAdapter.OnFreq;
  FWebServer.OnBand   := FWebAdapter.OnBand;
  FWebServer.OnSpan   := FWebAdapter.OnSpan;
  FWebServer.OnRun    := WebOnRun;
  FWebServer.OnDrive     := FWebAdapter.OnDrive;
  FWebServer.OnFreqA     := FWebAdapter.OnFreqA;
  FWebServer.OnWebMic    := FWebAdapter.OnMic;
  FWebServer.OnXvtrBand  := FWebAdapter.OnXvtrBand;
  FWebServer.OnBeacon     := FWebAdapter.OnBeacon;
  FWebServer.OnBeaconSeed := FWebAdapter.OnBeaconSeed;
  FWebServer.OnRxGainMode := FWebAdapter.OnRxGainMode;
  FWebServer.OnRxGain     := FWebAdapter.OnRxGain;
  FWebServer.OnRxMute     := FWebAdapter.OnRxMute;
  FWebServer.OnConnect       := WebOnConnect;
  FWebServer.OnDiscoverDev   := WebOnDiscover;
  FWebServer.OnDisconnectDev := WebOnDisconnect;
  FWebServer.OnDevAdd        := WebOnDevAdd;
  FWebServer.OnDevRemove     := WebOnDevRemove;
  FWebServer.OnDevAutoStart  := WebOnDevAutoStart;
  if FWebEnabled then FWebServer.Start;
  PushXvtrToWeb;
  PushDeviceListToWeb;   // saved-устройства сразу видны web-overlay
  PushRatePresetsToWeb;  // дефолтный бэкенд до connect; перезапишется на смене
  PushBandsToWeb;
  FillChar(FCATLastGlobal, SizeOf(FCATLastGlobal), 0);
  FCATLastGlobal.CATTcpPort := 19090;
  for i := 0 to CFG_CAT_SLICE_COUNT - 1 do
    FCATLastGlobal.CATSlicePort[i] := CFG_CAT_SLICE_PORT0 + i;   // 19091, 19092, …
  for i := 0 to 3 do
  begin
    FCATLastGlobal.CATSerialBaud[i]     := 9600;
    FCATLastGlobal.CATSerialDataBits[i] := 8;
    FCATLastGlobal.CATSerialStopBits[i] := 1;
    FCATLastGlobal.CATSerialPort[i] := SerDefaultPortName(i);
  end;
  FController.FSettings.LoadCATSettings(FCATLastGlobal);
  FLightTheme := FController.FSettings.LoadTheme;
  FCATAdapter := TCATAdapter.Create(FController);
  FCATAdapter.ApplySettings(FCATLastGlobal);
  ApplySliceSlotAudio;
  FSMeterPeak    := -130;
  FSMeterMin     := -130;
  FSMeterAvg     := -130;
  FController.FRXPacketCount := 0;
  FController.FActiveDDC     := 0;
  FController.FLastDDCSeq    := 0;
  FController.FLastDDCIndex  := 0;
  FillChar(FController.FDDCLastSeq,  SizeOf(FController.FDDCLastSeq),  0);
  FillChar(FController.FDDCSeqValid, SizeOf(FController.FDDCSeqValid), 0);
  FController.FSeqErrorCount    := 0;
  FController.FLastSeqErrorDDC  := -1;
  FController.FLastSeqErrorDelta:= 0;
  FController.FSeqOkStreak      := 0;
  FController.FLastFwdW   := 0;
  FController.FLastSWR    := 1;
  FController.FLastSupplyV:= -1;
  FController.FLastSupplyA:= -1;
  FController.FLastPLLLock:= False;
  FDeviceDialog := TDeviceDialog.Create(Self);
  FDeviceDialog.Store := FController.FDeviceStore;
  FDeviceDialog.OnDiscover := BtnDiscoverFromDialog;
  FVfoOverlay := TVfoOverlay.Create(Self);
  WireOverlayEvents(FVfoOverlay);
  FVfoOverlay.SetSliceInfo(0, 'A', True);   // главный приёмник = слайс A
  FVfoOverlay.Visible := False;
  FActiveSliceId := 0;
  FSliceDrag := False;
  FSliceDragId := 0;
  FPanelHidden  := False;
  FController.FShowSpectrum  := True;
  FController.FShowWaterfall := True;
  FController.FShowWideband  := False;
  FController.FWidebandFill  := False;
  FController.FSpectrumFill  := True;
  FDisplayFPS    := 60;
  FFreqMhzDigits := 3;

  // OpenGLContextEx (QOpenGLWidget-бэкенд) работает и в нативном Wayland,
  // старая X11-заглушка больше не нужна
  FUseOpenGLSpectrum := HasOpenGLSpectrumSwitch;
  // Панадаптер: владеет view/зум-баром/контролами спектра; контролы создаст
  // FPan.Build в BuildUI. Owner=nil — освобождаем сами в FormDestroy.
  FPan := TPanafallPanel.Create(nil, FUseOpenGLSpectrum);
  FSpecView   := FPan.View;      // алиас (историческое имя)
  FPanZoomBar := FPan.ZoomBar;   // алиас
  // Флаги: контроллер, проводка событий новых флагов, обратные вызовы.
  FPan.Controller        := FController;
  FPan.OnWireFlag        := WireOverlayEvents;
  FPan.OnSliceClosed     := OnPanSliceClosed;
  FPan.OnFlagsInvalidate := OnVfoOverlayInvalidate;
  FPan.AttachMainFlag(FVfoOverlay);   // внутри: FSpecView.VfoOverlay := флаг A
  // Стек панов (этап 3.3): пан 0 — в общем массиве; «⊞» добавляет пан.
  FPan.PanId    := 0;
  FPan.OnAddPan := OnAddPanClick;
  FPan.OnADCClick := OnPanADCClick;   // бейдж RX1/RX2 (3.4, платы с 2 АЦП)
  FPan.OnDisplayClick := OnPanDisplayClick;   // «◑» дисплей-поповер (3.6)
  FPans[0]        := FPan;
  FPanShare[0]    := 1.0;
  FPanColShare[0] := 1.0;   // грид «две колонки»: пан 0 — тоже колонка
  FPanGridMode     := False;
  FPanGridRowShare := 0.5;   // грид: пан 0 — верхняя половина
  FPanCbLock    := SyncObjs.TCriticalSection.Create;
  FSpecView.VfoA           := FController.FVfoA;
  FSpecView.VfoB           := FController.FVfoB;
  FSpecView.ActiveVfo      := FController.FActiveVfo;
  FSpecView.CenterFreq     := FController.FCenterFreq;
  FSpecView.SpanHz         := FController.FSpanHz;
  FSpecView.Mode           := FController.FMode;
  FSpecView.FilterBW       := FController.FFilterBW;
  FSpecView.AGCTop         := FController.FAGCTop;
  FSpecView.WfAGCEnabled   := FController.FWfAGCEnabled;
  FSpecView.WfNFEnabled    := FController.FWfNFEnabled;
  FSpecView.WfManualHigh   := FController.FWfManualHigh;
  FSpecView.WfManualLow    := FController.FWfManualLow;
  FSpecView.WfAGCOffset    := FController.FWfAGCOffset;
  FSpecView.WfPalette      := FController.FWfPalette;
  FSpecView.WfGamma        := FController.FWfGamma;
  FSpecView.SpecRefLevel   := FController.FSpecRefLevel;
  FSpecView.SpecRange      := FController.FSpecRange;
  FSpecView.SpecGridStep   := FController.FSpecGridStep;
  // TX overlay: ширина окна = TX_SAMPLE_RATE из движка (фикс 192k)
  FSpecView.TXSpanHz        := WDSPEngine.TX_SAMPLE_RATE;
  FSpecView.WfFrameInterval := EffectiveWfInterval;
  FSpecView.PAMaxPower      := FController.FPAMaxPower;
  FWidebandView := TWidebandView.Create;

  // Движки создаёт контроллер (владелец lifecycle — нужно для демона); здесь
  // лишь навешиваем колбэки на методы MainForm. FSampleRate уже загружен из
  // startup-preview выше, поэтому FDSPEngine рождается с нужной частотой.
  FController.CreateEngines(FController.FSampleRate);
  FController.FNetwork.OnDeviceFound := FController.OnDeviceFound;
  FController.FNetwork.OnHPStatus    := FController.OnHPStatus;
  FController.FNetwork.OnDDCIQ       := FController.OnDDCIQ;
  FController.FNetwork.OnMicPacket   := FController.OnMicPacket;
  FController.FNetwork.OnWideband    := OnWidebandCB;
  // Дефолты TX, Alex и XVTR (на случай если устройство ещё не выбрано).
  TSettingsManager.DefaultTX(FController.FTXSettings);
  // Заводской набор TX-профилей до подключения (ConnectDevice перечитает свой).
  DefaultTXProfiles(FController.FTXSettings, FController.FDrivePercent,
                    FController.FTXProfiles);
  TSettingsManager.DefaultAlex(FController.FAlexSettings);
  TSettingsManager.DefaultOC(FController.FOCSettings);
  TSettingsManager.DefaultXvtr(FController.FXvtrSettings);
  FController.FCurrentXvtr := -1;

  BuildUI;
  ResizeSMeter;
  ApplyDarkTheme;
  UpdateVfoDisplay;
  UpdateFilterButtons;

  // DSP Engine.Open (загрузка libwdsp, ~1-2 сек) вызывается при нажатии START.
  FController.FWDSPReady := False;
  FController.FDSPEngine.OnAudio     := FController.OnAudioReady;
  FController.FDSPEngine.OnSpectrum  := FController.OnSpectrumReady;
  FController.FDSPEngine.OnWaterfall := FController.OnWaterfallReady;
  // Пиксели доп. панов (этап 3.3) — прямо в UI (display-поток; см. FPanCbLock).
  FController.FDSPEngine.OnPanSpectrum  := OnPanSpectrumReady;
  FController.FDSPEngine.OnPanWaterfall := OnPanWaterfallReady;
  FController.FDSPEngine.OnTXIQ      := FController.OnTXIQ;
  // Web-клиент перехватывает RX-аудио (моно-микс) у контроллера, если подключён.
  FController.OnAudioConsume := FWebAdapter.AudioConsume;
  // Сырьё спектра/водопада из контроллера → рендер FSpecView (UI-подписчик).
  FController.OnSpectrumData  := OnSpectrumReady;
  FController.OnWaterfallData := OnWaterfallReady;
  // Sound-card mic путь: WDSP TX-thread дёргает этот колбэк перед каждым
  // тиком, если выбран источник SoundCard. Для Radio колбэк не вызывается.
  FController.FDSPEngine.OnPullMicSamples := FController.PullSoundCardMic;

  FMeterTimer := TTimer.Create(Self);
  FMeterTimer.Interval := 100;
  FMeterTimer.OnTimer  := MeterTimerTick;
  FMeterTimer.Enabled  := True;


  FSpectrumTimer := TTimer.Create(Self);
  FSpectrumTimer.Interval := 50;
  FSpectrumTimer.OnTimer  := SpectrumTimerTick;
  FSpectrumTimer.Enabled  := True;

  // Однократный таймер — инициализация аудио после показа формы
  FAfterShowTimer := TTimer.Create(Self);
  FAfterShowTimer.Interval := 200;
  FAfterShowTimer.OnTimer  := AfterShowTick;
  FAfterShowTimer.Enabled  := True;

  OnMouseWheel := FormMouseWheel;

  // Проверяем и при необходимости добавляем исключение в Windows Firewall
  // (UDP входящий трафик для HPSDR Protocol 2)
  {$IFDEF WINDOWS}
  FirewallEnsureAllowed(ParamStr(0), 'OpenHPSDR Transceiver');
  {$ENDIF}
  FUIReady := True;   // с этого момента состояние контроллера можно рендерить
end;

procedure TMainForm.FormDestroy(Sender: TObject);
var i: Integer;
begin
  FMeterTimer.Enabled    := False;
  FSpectrumTimer.Enabled := False;
  // Поток кластера будим ПЕРВЫМ делом, не дожидаясь его: пока идёт (небыстрая)
  // разборка радио, он успевает выйти сам, и join в FreeAndNil(FDXClient) ниже
  // уже никого не ждёт. Иначе окно висело бы на выходе.
  if FDXClient <> nil then FDXClient.RequestStop;
  // Грациозный стоп радио (Run=0) до освобождения; сами движки закрывает и
  // освобождает TRadioController.Destroy (FreeEngines) ниже.
  if FController.FNetwork.Running then
    FController.FNetwork.SetRunAndFreq(False, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), 0);
  FController.FNetwork.Disconnect;
  // Панадаптер освобождает view, зум-бар и свои контролы; алиасы обнуляем.
  FSpecView   := nil;
  FPanZoomBar := nil;
  PbSpectrum  := nil;
  PbRuler     := nil;
  PanelSplitter := nil;
  PbWaterfall := nil;
  PbPanZoom   := nil;
  // DX-кластер: незакоммиченную правку SETUP дописываем в конфиг (соединение
  // при этом не трогаем — программа закрывается), гасим сетевой поток (его
  // Stop делает shutdown сокета и ждёт поток), потом базу спотов. Оверлеи
  // панов живут до гибели своих панов (ниже) и на общую базу ссылаются —
  // поэтому сначала отвязываем их: после Detach оверлей неактивен и к стору
  // не ходит, даже если между этими строками прилетит перерисовка.
  if FDXPending then
  begin
    FDXPending := False;
    FController.FSettings.SaveDXClusterSettings(FDXPendingCfg);
  end;
  // TCI гасим ПЕРЕД базой спотов: его клиенты кладут споты (SPOT/SPOT_DELETE/
  // SPOT_CLEAR) прямо в FDXStore из своих потоков, и команда, пришедшая между
  // гибелью стора и гибелью адаптера, обратилась бы к освобождённой памяти.
  // Destroy останавливает сервер и дожидается его потоков.
  FreeAndNil(FTCIAdapter);
  for i := 0 to MAX_PANS - 1 do
    if FPans[i] <> nil then FPans[i].DetachDXSpots;
  FDXSpotOverlay := nil;
  FreeAndNil(FDXClient);
  FreeAndNil(FDXStore);
  DestroyAllExtraPans;   // доп. паны (DDC/движок/панели) — до пана 0
  FreeAndNil(FPan);
  FPans[0] := nil;
  FreeAndNil(FPanCbLock);
  FreeAndNil(FWidebandView);

  // Сохраняем настройки при закрытии
  if FController.FDevConnected then
  begin
    SaveCurrentBand;
    FController.SaveGlobalSettings;
    if FController.FDevConnected then
      FController.FSettings.SaveTX(FController.FDevMAC, FController.FTXSettings);
  end;
  // Размер окна сохраняем всегда (не зависит от подключения)
  FController.FSettings.SaveStartupPreview(FController.FVfoA, FController.FVfoB, FController.FSampleRate);
  FController.FSettings.Save;
  FreeAndNil(FChannelController);   // адаптер каналов (не владеет store/контроллером)
  FreeAndNil(FController.FChannelStore);
  FWebServer.Stop;
  FreeAndNil(FWebAdapter);   // адаптер не владеет сервером — освобождаем после Stop
  FWebServer.Free;
  FreeAndNil(FCATAdapter);   // его Destroy останавливает+освобождает CAT движок/транспорты
                             // (TCI освобождён выше — до базы спотов)
  // Ядро освобождаем последним — его Destroy закрывает и освобождает движки
  // (FreeEngines) и FSettings.
  FreeAndNil(FController);
end;

// Маршалинг команд контроллера в UI-поток (фронтенды зовут FController.Invoke).
// В режиме демона хозяин подставит другой механизм (очередь/прямой вызов).
procedure TMainForm.DoInvoke(M: TThreadMethod);
begin
  TThread.Synchronize(nil, M);
end;

// Контроллер изменил состояние — обновляем соответствующие виджеты.
// Выполняется в UI-потоке (команды контроллера вызываются из UI-потока либо
// маршалятся адаптерами). FSyncingFromController защищает от рекурсии, когда
// программное изменение виджета триггерит его OnChange.
procedure TMainForm.OnControllerState(Sender: TObject; Field: TRadioField);
var
  i: Integer;
  SavedFilter, SavedFilterBW: Integer;
begin
  if not FUIReady then Exit;   // виджетов ещё нет — рисовать нечего
  FSyncingFromController := True;
  try
    case Field of
      rfVfoA:
        begin
          FreqDispA.Frequency := Round(FController.FVfoA);
          // Не рисуем синхронно из wheel/drag: частые события забивают UI-поток,
          // а мгновенный Invalidate давал GL-кадр на КАЖДОЕ событие мыши (при
          // драге рендер упирался в vsync и обходил FPS-гейт — на AMD APU это
          // трещало в HDMI-звуке того же GPU). Кадр отрисует ближайший тик
          // FSpectrumTimer по FSpectrumDirty. Линейка дешёвая — её сразу.
          SyncSpecViewFreq;
          FSpectrumDirty := True;
          if PbRuler <> nil then PbRuler.Invalidate;
          if FController.FLastVfoScrolled then
            FWaterfallDirty := True;
        end;
      rfActiveVfo:
        begin
          UpdateVfoDisplay;   // дисплеи A/B + подсветка RX/TX-кнопок
          FSpecView.InvalidateRulerCache;
          SyncSpecViewFreq;
          FSpecView.DrawSpectrum;
          PbSpectrum.Invalidate;
          FSpecView.DrawWaterfall;
          PbWaterfall.Invalidate;
          if PbRuler <> nil then PbRuler.Invalidate;
        end;
      rfCenterFreq:
        begin
          // CTUN-скролл центра (drag/web): ruler-метки сдвинулись → инвалидируем
          // кэш; спектр откладываем (FSpectrumDirty) — drag шлёт много событий,
          // кадр отрисует таймер (FPS-гейт, см. rfVfoA).
          FSpecView.InvalidateRulerCache;
          SyncSpecViewFreq;
          FSpectrumDirty := True;
          if PbRuler <> nil then PbRuler.Invalidate;
        end;
      rfVfoB:
        begin
          FreqDispB.Frequency := Round(FController.FVfoB);
          if FController.FActiveVfo = 1 then
          begin
            FSpecView.InvalidateRulerCache;
            SyncSpecViewFreq;
            FSpectrumDirty := True;   // кадр — по таймеру (FPS-гейт, см. rfVfoA)
            if PbRuler <> nil then PbRuler.Invalidate;
          end;
        end;
      rfTransmitting:
        begin
          if FController.FTransmitting then
          begin
            BtnMOX.ClrNorm    := TColor($00000044);
            BtnMOX.ClrText    := TColor($000000FF);
            BtnMOX.ClrTextAct := TColor($000000FF);
            BtnMOX.Active     := True;
          end else
            StyleButton(BtnMOX, False);
          // TX-overlay в SpectrumView: TX-частота/VFO-индекс (по селектору
          // FSplitTxB, независимо от активного RX-VFO) и режим.
          FSpecView.TXFreq := ActiveTXFreqHz;
          if FController.FSplitTxB then FSpecView.TXVfoIndex := 1
          else                          FSpecView.TXVfoIndex := 0;
          // DUP: данные от RX-анализатора (TXMode=False), TX-полоса рисуется
          // поверх через TXOverlay. Без DUP при TX оба флага True.
          FSpecView.TXMode    := FController.FTransmitting and not FController.FDisplayDuplex;
          // Красная полоса главного VFO — только когда передаём с него: при
          // TX со слайса краснеет полоса ТОГО слайса (DrawSliceFilterMarkers).
          // ★RadioKeyed, а не FTransmitting: в телеграфе ключ замыкает прошивка,
          // софт «не передаёт», и полоса оставалась бы приёмной всю связь.
          FSpecView.TXOverlay := FController.RadioKeyed and
                                 (FController.FTxSliceId = 0);
          PushFilterEdgesToView;        // на передаче полоса рисуется по TX-фильтру
          ApplySpecViewGridFromState;   // сетка спектра под активный тракт
          RefreshTxIndicators;          // TX-бейджи флагов (какой слайс передаёт)
          UpdateTXControlAvailability;
          UpdateIMDMarkers;             // 2TON: маркеры IMD на TX-спектре
        end;                            // (DUC-очередь сбрасывает SetMOX)
      rfTuning:
        StyleButton(BtnTUN, FController.FTuning);
      rfPureSignal, rfTwoTone:
        UpdatePSButtons;   // подсветка PS/2TON + обновление статусов поповера
      rfRunning:
        // Run-состояние: кнопка START/STOP, доступность MOX, запрет сна.
        // Текст offline-статуса контекстно-зависим (Disconnected / Connection
        // lost / …) — его ставит вызывающий, не общий рендер.
        if FController.FRunning then
        begin
          InhibitSleep;
          BtnStartStop.Caption := 'STOP';
          StyleButton(BtnStartStop, True);
          UpdateTXControlAvailability;
          UpdateAddPanButton;        // «⊞» доступна только в Run
          ResizeSpectrumPanels;
          RestorePanWorkspace;       // паны+слайсы прошлой сессии
        end
        else
        begin
          UninhibitSleep;
          BtnStartStop.Caption := 'START';
          StyleButton(BtnStartStop, False);
          UpdateTXControlAvailability;
          // Снимок панов уже сделан хуком OnBeforeStop=SavePanWorkspace (до
          // гашения DDC в StopRunning). Здесь только разрешаем повторный
          // restore на следующем старте.
          FPansRestored := False;
          // Доп. паны живут только в Run-сессии (их DDC уже погашены
          // контроллером в StopRunning) — убираем и UI.
          DestroyAllExtraPans;
          UpdateAddPanButton;
          ResizeSpectrumPanels;
        end;
      rfADCOverload:
        if FSpecView <> nil then
        begin
          FSpecView.ADCOverloadVisible := FController.FADCOverloadActive;
          if PbSpectrum <> nil then PbSpectrum.Invalidate;
        end;
      rfBand:
        // В XVTR-режиме HF-кнопки гасим (активна XVTR-кнопка, см. rfXvtr).
        for i := 0 to BAND_COUNT - 1 do
          StyleButton(BtnBand[i],
            (FController.FCurrentXvtr < 0) and (i = FController.FCurrentBand));
      rfXvtr:
        begin
          for i := 0 to CFG_XVTR_COUNT - 1 do
            if BtnXvtrBand[i] <> nil then
              StyleButton(BtnXvtrBand[i], i = FController.FCurrentXvtr);
          UpdateFreqDisplayMax;   // трансвертер может быть >6 ГГц (QO-100)
          // BEACON-кнопка — только Pluto в трансвертере (QO-100 lock); для
          // openHPSDR смысла не имеет (нет дрейфующего LNB → прячем).
          if BtnBeacon <> nil then
          begin
            // Только Pluto + full-duplex транспондер (QO-100) — beacon-lock завязан
            // на дрейф LNB, в обычных диапазонах/трансвертерах смысла нет.
            BtnBeacon.Visible := FController.InQO100;
            UpdateBeaconButton;
          end;
          UpdateRxMuteButton;   // RX MUTE — только Pluto + full-duplex (QO-100)
          UpdateBandPlanOverlay; // полоска бэндплана QO-100
          // Wideband-вид + web-зеркало xvtr (ранее в обёртках Activate/Deactivate).
          // rfXvtr эмитят ТОЛЬКО ActivateXvtr/DeactivateXvtr — состояние финальное.
          UpdateWidebandFrequencyView;
          if PbWideband <> nil then PbWideband.Invalidate;
          if PbWidebandRuler <> nil then PbWidebandRuler.Invalidate;
          PushXvtrToWeb;
        end;
      rfBandRestore:
        // Явная смена диапазона: сброс порогов водопада (не на каждом тюне).
        FSpecView.ResetWfAvgBuf;
      rfMute:
        begin
          StyleButton(BtnMute, FController.FMuted);
          if FController.FMuted then BtnMute.Caption := 'UNMUTE'
          else BtnMute.Caption := 'MUTE';
          BtnMute.Invalidate;
        end;
      rfNR:  UpdateNRButton;
      rfNB:  UpdateNBButton;
      rfSNB: UpdateSNBButton;
      rfANF: UpdateANFButton;
      rfMode:
        begin
          for i := 0 to MODE_COUNT - 1 do StyleButton(BtnMode[i], i = FController.FMode);
          // UpdateFilterButtons (для не-FM) мутирует FFilter → сбрасывает на дефолт
          // режима. Это поведение нужно при СМЕНЕ режима (web/channel), но не при
          // пассивном рендере: RestoreBand/ActivateXvtr уже задали фильтр из кэша.
          // На реальной смене режима контроллер выставляет дефолт через
          // ApplyModeDefaults ДО rfMode, так что сохраняется именно он — поведение
          // не меняется. Последующий rfFilter стилит кнопки под восстановленный фильтр.
          SavedFilter   := FController.FFilter;
          SavedFilterBW := FController.FFilterBW;
          UpdateFilterButtons;   // подписи + FM-панели под режим
          // Возвращаем СЛОТ целиком: кромки — истина, одной ширины мало.
          FController.SelectFilterSlotState(SavedFilter);
          for i := 0 to FILT_SLOTS - 1 do StyleButton(BtnFilter[i], i = FController.FFilter);
          PushFilterEdgesToView;
          // Редактор всегда показывает фильтры ТЕКУЩЕГО режима — иначе правка
          // ушла бы в чужую таблицу.
          if (FFilterPop <> nil) and FFilterPop.Visible then
          begin
            if FController.FMode = MODE_DMR then FFilterPop.Hide
            else FFilterPop.LoadFrom(FController, FController.FMode,
                                     FController.FFilter);
          end;
          SyncSpecViewFreq;      // иначе полоса фильтра рисуется под старый режим
          UpdateTXControlAvailability;
          UpdateDMRStatus;
          // Пилюля профиля во флаге A: привязка ключуется режимом, значит на
          // смене модуляции она показывает уже другой профиль. Флаг главного
          // приёмника живёт только на пане 0 — как в RefreshTxIndicators.
          FPan.PushMainFlagExtState;
        end;
      rfFilter:
        begin
          for i := 0 to FILT_SLOTS - 1 do StyleButton(BtnFilter[i], i = FController.FFilter);
          PushFilterEdgesToView;
          SyncSpecViewFreq;
          if (FFilterPop <> nil) and FFilterPop.Visible then
            FFilterPop.SyncSelection(FController.FMode, FController.FFilter);
        end;
      rfFMDeviation:
        if (TrkRawDev <> nil) and (LblRawDev <> nil) then
        begin
          TrkRawDev.Position := Round(FController.FRawFMDeviation) div 50;
          LblRawDev.Caption := Format('%.2f k', [FController.FRawFMDeviation / 1000.0]);
        end;
      rfAGCMode:
        for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = FController.FAGCMode);
      rfVolume:
        begin
          TrkVolume.Position := FController.ActiveVolume;   // RX или TX-монитор
          UpdateVolumeLabel;
        end;
      rfRxGain:
        UpdateRxGainUI;
      rfAtten:
        if TrkAtt <> nil then
        begin
          TrkAtt.Position := FController.FAtten;
          UpdateAttLabel;
        end;
      rfDrive:
        begin
          TrkDrive.Position := FController.FDrivePercent;  // слайдер ← drive%
          UpdateDriveLabel;                                // % / дБ рядом с ползунком
          FChannelController.OnDriveChanged;               // внешнее изменение снимает память канала
          // Мощность — часть TX-профиля: держим подпись в настройках свежей.
          if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then
            TSettingsForm(FSettingsForm).SetTXProfileDrive(FController.FDrivePercent);
        end;
      rfTXProfile:
        RenderTXProfile;
      rfAGCTop:
        begin
          TrkAGC.Position := FController.FAGCTop;
          LblAGCTop.Caption := Format('%ddB', [FController.FAGCTop]);
          FSpecView.AGCTop := FController.FAGCTop;
          FSpecView.DrawSpectrum;
          PbSpectrum.Invalidate;
        end;
      rfBeaconLock:
        begin
          // Наведение ставит не только клик по спектру: есть AUTO в окне маяка,
          // web и CAT. Как только декодер куда-то наведён — снимаем «вооружение»,
          // иначе следующий случайный ЛКМ по панораме утащил бы маяк на пустое
          // место. Перерисовку просим только в этот момент: маркеры и так едут с
          // кадрами, а на стопе спектр статичен.
          if FBeaconArming and (FController.BeaconDecodeFreqHz > 0) then
          begin
            FBeaconArming  := False;
            FSpectrumDirty := True;
          end;
          UpdateBeaconButton;   // статус/коррекция QO-100 beacon lock
        end;
      rfCTun:
        begin
          StyleButton(BtnCTun, FController.FCTun);
          FSpecView.InvalidateRulerCache;
          SyncSpecViewFreq;
          FSpecView.DrawSpectrum;
          FSpecView.DrawWaterfall;
          PbSpectrum.Invalidate;
          PbWaterfall.Invalidate;
          if PbRuler <> nil then PbRuler.Invalidate;
        end;
      rfDuplex:
        begin
          // Единая точка рендера DUP — для кнопки И для авто-вкл в QO-100 (оба идут
          // через SetDuplex → rfDuplex). Раньше QO-100 включал DUP, но кнопку не
          // подсвечивал → визуал расходился с состоянием.
          if BtnDUP <> nil then StyleButton(BtnDUP, FController.FDisplayDuplex);
          UpdateRxMuteButton;   // активность RX MUTE зависит от DUP
          UpdateIMDMarkers;     // IMD-маркеры 2TON живут только в DUP
          // На передаче переключаем источник пикселей и сбрасываем буферы, чтобы
          // старые TX/RX bin'ы не оставались на следующем кадре.
          if FController.FTransmitting then
          begin
            if FController.FWDSPReady then
              FController.FDSPEngine.SetDisplaySourceTX(not FController.FDisplayDuplex);
            FSpecView.TXMode := FController.FTransmitting and not FController.FDisplayDuplex;
            FSpecView.ResetSpectrumBuf;
            FSpecView.ResetWfAvgBuf;
            ApplySpecViewGridFromState;
            FSpecView.DrawSpectrum;
            FSpecView.DrawWaterfall;
            PbSpectrum.Invalidate;
            PbWaterfall.Invalidate;
          end;
        end;
      rfRxMuteOnTx:
        UpdateRxMuteButton;   // QO-100 self-monitor toggle: подсветка кнопки RX MUTE
      rfWfAGC:
        FSpecView.WfAGCEnabled := FController.FWfAGCEnabled;
      rfWfNF:
        FSpecView.WfNFEnabled := FController.FWfNFEnabled;
      rfFMStepIdx:
        begin
          if FStepDropDown <> nil then FStepDropDown.SetItemIndex(FController.FFMStepIdx);
          if FWebServer <> nil then FWebServer.FMStepIdx := FController.FFMStepIdx;
          if (FController.FMode in [MODE_FM, MODE_DMR, MODE_FMRAW]) and
             (FSpecView <> nil) then
            FSpecView.FMGridStepHz := FM_STEP_HZ[FController.FFMStepIdx];
        end;
      rfFMStep:
        if BtnFMStep <> nil then
        begin
          StyleButton(BtnFMStep, FController.FFMStepOn);
          BtnFMStep.Repaint;
        end;
      rfFMSQ:
        begin
          StyleButton(BtnFMSQ, FController.FFMSQOn);
          BtnFMSQ.Repaint;
        end;
      rfFMSQLevel:
        begin
          TrkFMSQ.Position := FController.FFMSQLevel;
          if LblFMSQ <> nil then LblFMSQ.Caption := IntToStr(FController.FFMSQLevel);
        end;
      rfFMCTCSS:
        begin
          StyleButton(BtnFMCTCSS, FController.FFMCTCSSOn);
          BtnFMCTCSS.Repaint;
        end;
      rfFMCTCSSTone:
        if FCTCSSDropDown <> nil then FCTCSSDropDown.SetItemIndex(FController.FFMCTCSSToneIdx);
      rfSampleRate:
        begin
          if Assigned(FSampleRateOverlay) then
            FSampleRateOverlay.SetCurrentRate(FController.FSampleRate);
          // ChangeSampleRate пересоздал WDSP-канал — пересчёт AGC-линий под
          // актуальную ширину спектра (UI-зависимая величина).
          if FController.FWDSPReady then
            FController.FDSPEngine.UpdateAGCLines(FPan.SpectrumWidth);
          UpdatePanZoomBar;
        end;
      rfZoom:
        begin
          // Зум/пан изменились: окно просмотра, шаг линейки и полоса фильтра
          // следуют за span → пересинк + полная перерисовка спектра и водопада.
          FSpecView.InvalidateRulerCache;
          SyncSpecViewFreq;
          UpdatePanZoomBar;
          FPan.LayoutFlags;
          FSpecView.DrawSpectrum;
          PbSpectrum.Invalidate;
          FSpecView.DrawWaterfall;
          if PbWaterfall <> nil then PbWaterfall.Invalidate;
          if PbRuler <> nil then PbRuler.Invalidate;
        end;
      rfFMRpt:
        begin
          if BtnFMRptMinus <> nil then
          begin
            StyleButton(BtnFMRptMinus, FController.FFMRptDir = RPT_MINUS);
            BtnFMRptMinus.Repaint;
          end;
          if BtnFMRptPlus <> nil then
          begin
            StyleButton(BtnFMRptPlus, FController.FFMRptDir = RPT_PLUS);
            BtnFMRptPlus.Repaint;
          end;
          // Текст оффсета не трогаем, пока пользователь печатает в поле.
          if (EdFMRptOffset <> nil) and not EdFMRptOffset.Focused then
            EdFMRptOffset.Text := RptFormatOffset(FController.FFMRptOffsetHz);
        end;
      rfDevice:
        begin
          // Полная синхронизация device-виджетов после ConnectDevice (load-by-MAC).
          // Состояние уже в контроллере; здесь только рендер + UI/CAT/web-побочки.
          FCATLastGlobal := FController.FLoadedGlobal;
          // Тема — app-global (своё хранилище LoadTheme/SaveTheme), не из device-блоба:
          // возвращаем живое значение в persist-буфер, чтобы blob нёс актуальную тему.
          FController.FLoadedGlobal.LightTheme := FLightTheme;
          FCATAdapter.ApplySettings(FController.FLoadedGlobal);
          ApplySliceSlotAudio;
          RelabelBands;            // HF ↔ VHF/UHF подписи под тип бэкенда
          UpdateSetupButton;       // MAC/серийник известны — SETUP открылся
          ApplyBackendCapsToUI;    // пресеты sample rate + гейтинг полей
          RebuildXvtrButtons;
          PushXvtrToWeb;
          TrkDrive.Position := FController.FDrivePercent;  // слайдер ← drive%
          TrkVolume.Position := FController.ActiveVolume;  // слайдер ← загруженная громкость (был баг: не синхронизировался)
          UpdateDriveLabel;                                // переключаем единицу %/дБ под бэкенд
          UpdateVolumeLabel;
          if Assigned(FSampleRateOverlay) then
            FSampleRateOverlay.SetCurrentRate(FController.FSampleRate);
          UpdateNRButton; UpdateNBButton; UpdateSNBButton; UpdateANFButton;
          FSpecView.WfAGCEnabled := FController.FWfAGCEnabled;
          FSpecView.WfNFEnabled  := FController.FWfNFEnabled;
          FSpecView.WfManualHigh := FController.FWfManualHigh;
          FSpecView.WfManualLow  := FController.FWfManualLow;
          FSpecView.WfAGCOffset  := FController.FWfAGCOffset;
          FSpecView.WfPalette    := FController.FWfPalette;
          FSpecView.WfGamma      := FController.FWfGamma;
          FSpecView.WfFrameInterval := EffectiveWfInterval;
          ApplySpecMSAA(FController.FSpecMSAA);
          FSpecView.ResetWfAvgBuf;
          if Assigned(FWidebandView) then
            FWidebandView.SetFillSpectrum(FController.FWidebandFill);
          FSpecView.FillSpectrum := FController.FSpectrumFill;
          if BtnDUP <> nil then StyleButton(BtnDUP, FController.FDisplayDuplex);
          RenderTXProfile;         // список профилей этого устройства (загружен по MAC)
          if FController.FLoadedGlobal.DisplayFPS > 0 then
            ApplyFPS(FController.FLoadedGlobal.DisplayFPS);
          ApplyFreqMhzDigits(EnsureRange(FController.FLoadedGlobal.FreqMhzDigits, 3, 5));
          if FController.FLoadedGlobal.FFTSize > 0 then
          begin
            FSpecView.SpecRefLevel := FController.FSpecRefLevel;
            FSpecView.SpecRange    := FController.FSpecRange;
            FSpecView.SpecGridStep := FController.FSpecGridStep;
            InvalidateGridCache;
          end;
        end;

      rfSliceFreq, rfSliceState:
        begin
          // Слайс перестроен по своему CAT-порту в пределах захваченной полосы:
          // окно DDC не двигалось (rfPanFreq не придёт), но цифры и полоса
          // фильтра во флаге — его собственные поля, их надо перезалить.
          // ★rfSliceState — то же самое для всего остального: моды, фильтра,
          // АРУ, DSP, шумодава, громкости. Правит их и UI, и CAT слайса, и TCI,
          // а без этой ветки флаг доп. панорамы оставался с прежними надписями
          // (частота при этом двигалась — она приходит отдельным rfSliceFreq).
          PushFlagStateAllPans(FController.FSliceFreqId);
          LayoutFlagsAllPans;
          for i := 0 to MAX_PANS - 1 do
            if FPans[i] <> nil then MarkPanDirty(FPans[i]);
        end;

      rfPanFreq:
        begin
          // Центр доп. пана переехал не из UI (CAT слайса перестроил его за
          // пределы захваченной полосы) — переложить флаги/шапку/зум-бар.
          for i := 0 to MAX_PANS - 1 do
            if FPans[i] <> nil then
            begin
              SyncPanZoomUI(FPans[i]);
              FPans[i].LayoutFlags;
              FPans[i].PushAllSliceFlagStates;
              MarkPanDirty(FPans[i]);
            end;
          UpdatePanHeaders;
        end;
      rfDeviceList:
        begin
          // Discovery обновил список устройств (или завершился без них).
          if Assigned(FDeviceDialog) then
          begin
            if FController.FDiscoverNoneFound then
            begin
              FDeviceDialog.NoDevicesFound;
              SetStatusText(2, 'No hardware found');
            end
            else
            begin
              FDeviceDialog.RefreshFound;
              if FController.FDeviceStore.DiscoveredCount > 0 then
                SetStatusText(2, Format('Found: %s',
                  [FController.FDeviceStore.Discovered(
                     FController.FDeviceStore.DiscoveredCount - 1).IPAddress]));
            end;
          end;
          PushDeviceListToWeb;   // web-overlay тоже получает обновлённый список
        end;
    end;
    // Если флаг A виден при ОТКРЫТОЙ панели (есть слайсы B+), держим его в
    // синхроне с DSP/режимом/полосой/AGC, пришедшими с ЛЕВОЙ панели. При скрытой
    // панели флаг правит себя сам (DoHit), синк не нужен. Только дискретные поля —
    // SetState/SetDSPState безусловно инвалидируют (не звать на потоковых rfVfoA).
    if Assigned(FVfoOverlay) and FVfoOverlay.Visible and (not FPanelHidden) and
       (Field in [rfMode, rfFilter, rfNR, rfNB, rfSNB, rfANF, rfAGCMode,
                  rfFMSQ, rfFMSQLevel]) then
    begin
      FVfoOverlay.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FController.FLastSMeter);
      FVfoOverlay.SetFilterEdges(FController.FilterLo, FController.FilterHi);
      FVfoOverlay.SetDSPState(FController.FNRMode, FController.FNBMode, FController.FSNB, FController.FANF, FController.FAGCMode);
      FVfoOverlay.SetSquelchState(FController.FFMSQOn, FController.FFMSQLevel);
    end;
  finally
    FSyncingFromController := False;
  end;
end;

procedure TMainForm.FormClose(Sender: TObject; var CloseAction: TCloseAction);
begin
  // Сохраняем позицию здесь — окно ещё полностью видимо и стабильно.
  // В FormDestroy Qt-виджет может быть уже в нестабильном состоянии.
  SaveWindowBounds;
  // Останавливаем трансивер как при нажатии STOP
  if FController.FNetwork.Connected then
  begin
    FController.StopRunning;
    FController.FNetwork.Disconnect;
  end;
  CloseAction := caFree;
end;

// ===========================================================================
// Build UI (без анонимных процедур и inline var)
// ===========================================================================

procedure TMainForm.BuildUI;
const
  BTN_H  = 26;
  BTN_SM = 24;
  LEFT_W = LEFT_PANEL_CONTENT_W;
  // Заголовок с отступом (20) + MODE_ROWS рядов с шагом 27 + нижний отступ.
  MODE_PANEL_H = 20 + (MODE_ROWS - 1) * 27 + BTN_SM + 3;
  PLUTO_AGC_H = 64;   // высота отдельного Pluto RF-AGC блока (под AGC)
  LEFT_SLIDER_LEFT = 56;
  LEFT_SLIDER_W = LEFT_W - 96;
  LEFT_SLIDER_VALUE_LEFT = LEFT_W - 38;
  TOP_VFO_W = 244;
  TOP_VFO_FREQ_H = 47;
var
  i, X, Y, W: Integer;
  B: TFlatButton;
  SepLine: TPanel;

  function MakeBtn(Parent: TWinControl; const Cap: string;
    ALeft, ATop, AW, AH: Integer; Handler: TNotifyEvent): TFlatButton;
  begin
    Result := TFlatButton.Create(Self);
    Result.Parent  := Parent;
    Result.Caption := Cap;
    Result.Left    := ALeft;
    Result.Top     := ATop;
    Result.Width   := AW;
    Result.Height  := AH;
    Result.OnClick := Handler;
    StyleButton(Result, False);
  end;

  procedure MakeLbl(Parent: TWinControl; const Cap: string; ALeft, ATop: Integer);
  var
    L: TLabel;
  begin
    L := TLabel.Create(Self);
    L.Parent   := Parent;
    L.Caption  := Cap;
    L.Left     := ALeft;
    L.Top      := ATop;
    L.Font.Color := CLR_TEXTDIM;
    L.Font.Size  := 8;
  end;

begin
  // ---- Toolbar ----
  PanelToolbar := TPanel.Create(Self);
  PanelToolbar.Parent     := Self;
  PanelToolbar.Align      := alTop;
  PanelToolbar.Height     := 72;
  PanelToolbar.BevelOuter := bvNone;
  PanelToolbar.Color      := CLR_PANEL;


  X := 4;
  BtnDiscover  := MakeBtn(PanelToolbar, 'DISCOVER',  X,      3, 80, BTN_H, BtnDiscoverClick);
  BtnStartStop := MakeBtn(PanelToolbar, 'START',    X+84,   3, 76, BTN_H, BtnStartStopClick);
  BtnSettings  := MakeBtn(PanelToolbar, 'SETUP',    X+164,  3, 66, BTN_H, BtnSettingsClick);
  // SETUP отпирается только после подключения — см. UpdateSetupButton.
  UpdateSetupButton;
  // DX живёт в блоке RX левой панели, рядом с CTUN (см. ниже): споты — часть
  // приёмного вида, а не глобальная команда уровня DISCOVER/START/SETUP.

  // DISCOVER — синеватый акцент
  BtnDiscover.ClrNorm    := TColor($00101828);
  BtnDiscover.ClrHot     := TColor($00182038);
  BtnDiscover.ClrActive  := TColor($00182038);
  BtnDiscover.ClrBorder  := TColor($00304860);
  BtnDiscover.ClrText    := TColor($0090B8D0);
  BtnDiscover.ClrTextAct := TColor($00B0D0F0);
  // START/STOP — зелёный тон в обоих состояниях
  BtnStartStop.ClrNorm   := TColor($00101810);
  BtnStartStop.ClrBorder := TColor($00304830);
  BtnStartStop.ClrText   := TColor($0090C090);

  // Линия-разделитель внутри PanelToolbar, прижата к его нижнему краю
  SepLine := TPanel.Create(Self);
  SepLine.Parent      := PanelToolbar;
  SepLine.BevelOuter  := bvNone;
  SepLine.Color       := TColor($00606060);
  SepLine.SetBounds(16, PanelToolbar.Height - 10, PanelToolbar.Width - 32, 2);
  SepLine.Anchors     := [akLeft, akRight, akBottom];

  // ---- Status bar ----
  StatusPanel := TMainStatusBar.Create(Self);
  StatusPanel.Parent     := Self;

  // ---- Left panel ----
  PanelLeft := TPanel.Create(Self);
  PanelLeft.Parent     := Self;
  PanelLeft.Align      := alLeft;
  // Узкий правый gutter зарезервирован постоянно: появление overlay-scrollbar
  // не меняет ширину панели и не дёргает область спектра.
  PanelLeft.Width      := LEFT_W + LEFT_SCROLL_GUTTER_W;
  PanelLeft.BevelOuter := bvNone;

  FLeftScrollOffset   := 0;
  FLeftScrollBar := TOverlayScrollBar.Create(Self);
  FLeftScrollBar.Parent := PanelLeft;
  FLeftScrollBar.Align := alRight;
  FLeftScrollBar.Width := LEFT_SCROLL_GUTTER_W;
  FLeftScrollBar.HoverTarget := PanelLeft;
  FLeftScrollBar.OnPositionChange := LeftScrollChanged;

  Y := 0;

  // Compact top VFO group
  PanelTopVfoGroup := TPanel.Create(Self);
  PanelTopVfoGroup.Parent := PanelToolbar;
  PanelTopVfoGroup.SetBounds(0, 0, TOP_VFO_W * 2 + 64, 60);
  PanelTopVfoGroup.BevelOuter := bvNone;
  PanelTopVfoGroup.Color := TColor($00181818);

  // VFO A
  PanelTopVfoA := TPanel.Create(Self);
  PanelTopVfoA.Parent := PanelTopVfoGroup;
  PanelTopVfoA.SetBounds(0, 0, TOP_VFO_W, 60);
  PanelTopVfoA.BevelOuter := bvNone;
  PanelTopVfoA.Color := CLR_PANEL;

  PanelVfoA := TPanel.Create(Self);
  PanelVfoA.Parent := PanelTopVfoA;
  PanelVfoA.SetBounds(0, 0, TOP_VFO_W, TOP_VFO_FREQ_H);
  PanelVfoA.BevelOuter := bvNone;

  BtnTopVfoASelect := MakeBtn(PanelVfoA, 'A', 0, 5, 20, 18, BtnTopVfoASelectClick);
  BtnTopVfoASelect.Font.Size := 8;
  BtnTopVfoASelect.Font.Bold := True;
  BtnTopVfoASelect.ClrNorm := TColor($00303030);
  BtnTopVfoASelect.ClrHot := TColor($00404040);
  BtnTopVfoASelect.ClrActive := TColor($00404040);
  BtnTopVfoASelect.ClrBorder := TColor($00505050);
  BtnTopVfoASelect.ClrText := CLR_TEXTDIM;
  BtnTopVfoASelect.ClrTextAct := CLR_TEXT;

  BtnTopVfoATX := MakeBtn(PanelVfoA, 'TX', 0, 25, 20, 18, BtnTopVfoATXClick);
  BtnTopVfoATX.Font.Size := 8;
  BtnTopVfoATX.Font.Bold := True;
  BtnTopVfoATX.ClrNorm := TColor($00303030);
  BtnTopVfoATX.ClrHot := TColor($00404040);
  BtnTopVfoATX.ClrActive := TColor($00404040);
  BtnTopVfoATX.ClrBorder := TColor($00505050);
  BtnTopVfoATX.ClrText := CLR_TEXTDIM;
  BtnTopVfoATX.ClrTextAct := CLR_TEXT;

  LblVfoALabel := TLabel.Create(Self);
  LblVfoALabel.Parent    := PanelVfoA;
  LblVfoALabel.Caption   := 'VFO-A';
  LblVfoALabel.Left      := 0; LblVfoALabel.Top := 0;
  LblVfoALabel.Font.Size := 8;
  LblVfoALabel.Font.Color := CLR_TEXTDIM;
  LblVfoALabel.Visible := False;

  FreqDispA := TFreqDisplay.Create(Self);
  FreqDispA.Parent      := PanelVfoA;
  FreqDispA.SetBounds(20, -2, TOP_VFO_W - 20, TOP_VFO_FREQ_H);
  FreqDispA.FontSize    := TOP_VFO_FONT;
  FreqDispA.FontName    := 'Courier New';
  FreqDispA.Frequency   := Round(FController.FVfoA);
  FreqDispA.ColorNormal := CLR_FREQ;
  FreqDispA.ColorHover  := TColor($0040DDFF);
  FreqDispA.ColorDim    := CLR_TEXTDIM;
  FreqDispA.MinMhzDigits := 3;
  FreqDispA.DimLeadingZeros := True;
  FreqDispA.CenterText := False;
  FreqDispA.OnChange    := FreqDispAChanged;
  FreqDispA.OnClick     := FreqDispAClick;

  // VFO B
  PanelTopVfoB := TPanel.Create(Self);
  PanelTopVfoB.Parent := PanelTopVfoGroup;
  PanelTopVfoB.SetBounds(0, 0, TOP_VFO_W, 60);
  PanelTopVfoB.BevelOuter := bvNone;
  PanelTopVfoB.Color := CLR_PANEL;

  PanelVfoB := TPanel.Create(Self);
  PanelVfoB.Parent := PanelTopVfoB;
  PanelVfoB.SetBounds(0, 0, TOP_VFO_W, TOP_VFO_FREQ_H);
  PanelVfoB.BevelOuter := bvNone;

  BtnTopVfoBSelect := MakeBtn(PanelVfoB, 'B', 0, 5, 20, 18, BtnTopVfoBSelectClick);
  BtnTopVfoBSelect.Font.Size := 8;
  BtnTopVfoBSelect.Font.Bold := True;
  BtnTopVfoBSelect.ClrNorm := TColor($00303030);
  BtnTopVfoBSelect.ClrHot := TColor($00404040);
  BtnTopVfoBSelect.ClrActive := TColor($00404040);
  BtnTopVfoBSelect.ClrBorder := TColor($00505050);
  BtnTopVfoBSelect.ClrText := CLR_TEXTDIM;
  BtnTopVfoBSelect.ClrTextAct := CLR_TEXT;

  BtnTopVfoBTX := MakeBtn(PanelVfoB, 'TX', 0, 25, 20, 18, BtnTopVfoBTXClick);
  BtnTopVfoBTX.Font.Size := 8;
  BtnTopVfoBTX.Font.Bold := True;
  BtnTopVfoBTX.ClrNorm := TColor($00303030);
  BtnTopVfoBTX.ClrHot := TColor($00404040);
  BtnTopVfoBTX.ClrActive := TColor($00404040);
  BtnTopVfoBTX.ClrBorder := TColor($00505050);
  BtnTopVfoBTX.ClrText := CLR_TEXTDIM;
  BtnTopVfoBTX.ClrTextAct := CLR_TEXT;

  LblVfoBLabel := TLabel.Create(Self);
  LblVfoBLabel.Parent    := PanelVfoB;
  LblVfoBLabel.Caption   := 'VFO-B';
  LblVfoBLabel.Left      := 0; LblVfoBLabel.Top := 0;
  LblVfoBLabel.Font.Size := 8;
  LblVfoBLabel.Font.Color := CLR_TEXTDIM;
  LblVfoBLabel.Visible := False;

  FreqDispB := TFreqDisplay.Create(Self);
  FreqDispB.Parent      := PanelVfoB;
  FreqDispB.SetBounds(20, -2, TOP_VFO_W - 20, TOP_VFO_FREQ_H);
  FreqDispB.FontSize    := TOP_VFO_FONT;
  FreqDispB.FontName    := 'Courier New';
  FreqDispB.Frequency   := Round(FController.FVfoB);
  FreqDispB.ColorNormal := CLR_FREQ_DIM;
  FreqDispB.ColorHover  := TColor($0040DDFF);
  FreqDispB.ColorDim    := CLR_TEXTDIM;
  FreqDispB.MinMhzDigits := 3;
  FreqDispB.DimLeadingZeros := True;
  FreqDispB.CenterText := False;
  FreqDispB.OnChange    := FreqDispBChanged;
  FreqDispB.OnClick     := FreqDispBClick;

  // VFO transfer buttons — горизонтальный ряд по центру
  PanelVfoButtons := TPanel.Create(Self);
  PanelVfoButtons.Parent := PanelTopVfoGroup;
  PanelVfoButtons.SetBounds(0, 0, 140, 20);
  PanelVfoButtons.BevelOuter := bvNone;
  PanelVfoButtons.Color := TColor($00181818);

  BtnVfoACopyB := MakeBtn(PanelVfoButtons, 'A>B',  0, 0, 44, 20, BtnVfoACopyBClick);
  BtnVfoSwap   := MakeBtn(PanelVfoButtons, 'A<>B', 0, 0, 44, 20, BtnVfoSwapClick);
  BtnVfoBCopyA := MakeBtn(PanelVfoButtons, 'B>A',  0, 0, 44, 20, BtnVfoBCopyAClick);
  // Янтарный стиль, шрифт под размер кнопок
  StyleSpanButton(BtnVfoACopyB, False);
  StyleSpanButton(BtnVfoSwap,   False);
  StyleSpanButton(BtnVfoBCopyA, False);
  BtnVfoACopyB.Font.Size := 8;
  BtnVfoSwap.Font.Size   := 8;
  BtnVfoBCopyA.Font.Size := 8;

  LayoutTopVfoBlock;

  // TX controls
  PanelTX := TPanel.Create(Self);
  PanelTX.Parent := PanelLeft;
  PanelTX.SetBounds(0, Y, LEFT_W, 96);
  PanelTX.BevelOuter := bvNone;
  MakeLbl(PanelTX, 'TX', 4, 2);

  BtnMOX := MakeBtn(PanelTX, 'MOX',
                    LeftPanelButtonLeft(LEFT_W, 4, 0), 20,
                    LeftPanelButtonWidth(LEFT_W, 4, 0), BTN_SM, BtnMOXClick);
  BtnTUN := MakeBtn(PanelTX, 'TUN',
                    LeftPanelButtonLeft(LEFT_W, 4, 1), 20,
                    LeftPanelButtonWidth(LEFT_W, 4, 1), BTN_SM, BtnTUNClick);
  // PureSignal: ЛКМ = вкл/выкл, ПКМ = поповер настроек/управления.
  BtnPS := MakeBtn(PanelTX, 'PS',
                   LeftPanelButtonLeft(LEFT_W, 4, 2), 20,
                   LeftPanelButtonWidth(LEFT_W, 4, 2), BTN_SM, BtnPSClick);
  BtnPS.OnMouseDown := BtnPSMouseDown;
  Btn2Ton := MakeBtn(PanelTX, '2TON',
                     LeftPanelButtonLeft(LEFT_W, 4, 3), 20,
                     LeftPanelButtonWidth(LEFT_W, 4, 3), BTN_SM, Btn2TonClick);
  UpdateTXControlAvailability;

  BtnDUP := MakeBtn(PanelTX, 'DUP',
                    LeftPanelButtonLeft(LEFT_W, 4, 0), 48,
                    LeftPanelButtonWidth(LEFT_W, 4, 0), BTN_SM, BtnDUPClick);
  BtnDUP.Tag := 0;
  StyleButton(BtnDUP, FController.FDisplayDuplex);

  // Self-monitor RX-аудио на TX: рядом с DUP, col1. Видна на любом бэкенде,
  // активна только в DUP (см. UpdateRxMuteButton). ON (подсвечена, умолч.) =
  // RX-аудио глушится на TX; OFF = слышим свой downlink во время передачи.
  BtnRxMute := MakeBtn(PanelTX, 'RX MUTE',
                       LeftPanelButtonLeft(LEFT_W, 4, 1), 48,
                       LeftPanelButtonWidth(LEFT_W, 4, 1), BTN_SM, BtnRxMuteClick);
  StyleButton(BtnRxMute, FController.FRxMuteOnTx);
  UpdateRxMuteButton;   // видимость/активность по DUP

  // TX-профиль — свободные колонки 2..3 того же ряда (панель не растёт по
  // высоте). Подпись = имя активного профиля, ЛКМ — список, ПКМ — настройки.
  BtnTXProfile := MakeBtn(PanelTX, 'Default',
                          LeftPanelButtonLeft(LEFT_W, 4, 2), 48,
                          LeftPanelButtonLeft(LEFT_W, 4, 3)
                            + LeftPanelButtonWidth(LEFT_W, 4, 3)
                            - LeftPanelButtonLeft(LEFT_W, 4, 2),
                          BTN_SM, BtnTXProfileClick);
  BtnTXProfile.OnMouseDown := BtnTXProfileMouseDown;
  StyleButton(BtnTXProfile, False);

  FTXProfileDropDown := TFlatDropDown.Create(BtnTXProfile, PanelLeft, 1, BTN_SM,
    LEFT_SCROLL_GUTTER_W);
  FTXProfileDropDown.OnSelect := OnTXProfileDropDownSelect;
  RenderTXProfile;

  LblDrv := TLabel.Create(Self);
  LblDrv.Parent := PanelTX; LblDrv.Left := 4; LblDrv.Top := 77;
  LblDrv.Caption := 'DRV'; LblDrv.Font.Color := CLR_TEXTDIM;
  LblDrv.Font.Size := 8;

  TrkDrive := TFlatSlider.Create(Self);
  TrkDrive.Parent := PanelTX; TrkDrive.Left := LEFT_SLIDER_LEFT; TrkDrive.Top := 78;
  TrkDrive.Width := LEFT_SLIDER_W; TrkDrive.Height := 16;
  TrkDrive.Min := 0; TrkDrive.Max := 100; TrkDrive.Position := 50;
  TrkDrive.OnChange := TrkDriveChange;

  LblDrvVal := TLabel.Create(Self);
  LblDrvVal.Parent := PanelTX;
  LblDrvVal.Left := LEFT_SLIDER_VALUE_LEFT; LblDrvVal.Top := 77;
  LblDrvVal.Font.Color := CLR_TEXT;
  LblDrvVal.Font.Size := 8;
  UpdateDriveLabel;

  Inc(Y, 100);

  // RX block: CTUN + DX + Channel + BEACON, VOL, NR/NB/SNB/ANF/MUTE
  PanelRXBlock := TPanel.Create(Self);
  PanelRXBlock.Parent := PanelLeft;
  PanelRXBlock.SetBounds(0, Y, LEFT_W, RX_BLOCK_H_ATT);
  PanelRXBlock.BevelOuter := bvNone;
  MakeLbl(PanelRXBlock, 'RX', 4, 2);

  BtnCTun := MakeBtn(PanelRXBlock, 'CTUN',
                     LeftPanelButtonLeft(LEFT_W, 4, 0), 20,
                     LeftPanelButtonWidth(LEFT_W, 4, 0),
                     BTN_H, BtnCTunClick);
  BtnCTun.Tag := 0;
  StyleButton(BtnCTun, FController.FCTun);

  // DX — споты кластера: ЛКМ включает/выключает подписи на спектре, ПКМ
  // открывает окно списка (та же семантика, что у BEACON → окно констелляции).
  BtnDXCluster := MakeBtn(PanelRXBlock, 'DX',
                          LeftPanelButtonLeft(LEFT_W, 4, 1), 20,
                          LeftPanelButtonWidth(LEFT_W, 4, 1),
                          BTN_H, BtnDXClusterClick);
  BtnDXCluster.OnMouseDown := BtnDXClusterMouseDown;

  BtnChannels := MakeBtn(PanelRXBlock, 'Channel',
                         LeftPanelButtonLeft(LEFT_W, 4, 2), 20,
                         LeftPanelButtonWidth(LEFT_W, 4, 2),
                         BTN_H, BtnChannelsClick);
  BtnChannels.OnMouseDown := BtnChannelsMouseDown;
  StyleButton(BtnChannels, False);

  FChannelsDropDown := TFlatDropDown.Create(BtnChannels, PanelLeft, 1, BTN_SM,
    LEFT_SCROLL_GUTTER_W);
  FChannelsDropDown.OnSelect := OnChannelsDropDownSelect;
  RefreshChannelsDropDown;

  // QO-100 beacon lock — 4-я колонка ряда. Видима только в XVTR-режиме
  // (см. рендер rfXvtr ниже). Подпись/подсветка обновляются по rfBeaconLock.
  BtnBeacon := MakeBtn(PanelRXBlock, 'BEACON',
                       LeftPanelButtonLeft(LEFT_W, 4, 3), 20,
                       LeftPanelButtonWidth(LEFT_W, 4, 3),
                       BTN_H, BtnBeaconClick);
  BtnBeacon.Visible := False;
  BtnBeacon.OnMouseDown := BtnBeaconMouseDown;   // ПКМ → декодер + окно констелляции
  StyleButton(BtnBeacon, False);

  MakeLbl(PanelRXBlock, 'VOL', 4, 50);
  TrkVolume := TFlatSlider.Create(Self);
  TrkVolume.Parent := PanelRXBlock; TrkVolume.Left := LEFT_SLIDER_LEFT; TrkVolume.Top := 50;
  TrkVolume.Width := LEFT_SLIDER_W; TrkVolume.Height := 16;
  TrkVolume.Min := 0; TrkVolume.Max := 100;
  TrkVolume.Position := FController.ActiveVolume;
  TrkVolume.OnChange := TrkVolumeChange;

  LblVolume := TLabel.Create(Self);
  LblVolume.Parent := PanelRXBlock;
  LblVolume.Left := LEFT_SLIDER_VALUE_LEFT; LblVolume.Top := 50;
  LblVolume.Font.Color := CLR_TEXT;
  LblVolume.Font.Size := 8;
  UpdateVolumeLabel;

  BtnNR := MakeBtn(PanelRXBlock, 'NR',
                   LeftPanelButtonLeft(LEFT_W, 5, 0), 74,
                   LeftPanelButtonWidth(LEFT_W, 5, 0), BTN_SM, BtnNRClick);
  BtnNB := MakeBtn(PanelRXBlock, 'NB',
                   LeftPanelButtonLeft(LEFT_W, 5, 1), 74,
                   LeftPanelButtonWidth(LEFT_W, 5, 1), BTN_SM, BtnNBClick);
  BtnSNB := MakeBtn(PanelRXBlock, 'SNB',
                    LeftPanelButtonLeft(LEFT_W, 5, 2), 74,
                    LeftPanelButtonWidth(LEFT_W, 5, 2), BTN_SM, BtnSNBClick);
  BtnANF := MakeBtn(PanelRXBlock, 'ANF',
                    LeftPanelButtonLeft(LEFT_W, 5, 3), 74,
                    LeftPanelButtonWidth(LEFT_W, 5, 3), BTN_SM, BtnANFClick);
  BtnMute := MakeBtn(PanelRXBlock, 'MUTE',
                     LeftPanelButtonLeft(LEFT_W, 5, 4), 74,
                     LeftPanelButtonWidth(LEFT_W, 5, 4), BTN_SM, BtnMuteClick);

  // Шаговый аттенюатор ADC — плавный 0..31 дБ (железо openHPSDR так и умеет,
  // ступени 0/10/20 были только в web-UI). Показания S-метра/спектра на эти
  // дБ компенсируются в контроллере, шкала dBm остаётся абсолютной.
  LblAtt := TLabel.Create(Self);
  LblAtt.Parent := PanelRXBlock; LblAtt.Left := 4; LblAtt.Top := 106;
  LblAtt.Caption := 'ATT'; LblAtt.Font.Color := CLR_TEXTDIM;
  LblAtt.Font.Size := 8;

  TrkAtt := TFlatSlider.Create(Self);
  TrkAtt.Parent := PanelRXBlock; TrkAtt.Left := LEFT_SLIDER_LEFT; TrkAtt.Top := 106;
  TrkAtt.Width := LEFT_SLIDER_W; TrkAtt.Height := 16;
  TrkAtt.Min := 0; TrkAtt.Max := 31;          // ADC step attenuator, дБ
  TrkAtt.Position := FController.FAtten;
  TrkAtt.OnChange := TrkAttChange;

  LblAttVal := TLabel.Create(Self);
  LblAttVal.Parent := PanelRXBlock;
  LblAttVal.Left := LEFT_SLIDER_VALUE_LEFT; LblAttVal.Top := 106;
  LblAttVal.Font.Color := CLR_TEXT;
  LblAttVal.Font.Size := 8;
  UpdateAttLabel;

  Inc(Y, RX_BLOCK_H_ATT + 4);

  // AGC controls
  PanelAGC := TPanel.Create(Self);
  PanelAGC.Parent := PanelLeft;
  PanelAGC.SetBounds(0, Y, LEFT_W, 70);
  PanelAGC.BevelOuter := bvNone;

  // AGC mode — 5 кнопок в ряд
  MakeLbl(PanelAGC, 'AGC', 4, 2);
  BtnAGCMode[0] := MakeBtn(PanelAGC, 'FAST',
                           LeftPanelButtonLeft(LEFT_W, 5, 0), 20,
                           LeftPanelButtonWidth(LEFT_W, 5, 0),
                           BTN_SM, BtnAGCModeClick);
  BtnAGCMode[0].Tag := 0;
  BtnAGCMode[1] := MakeBtn(PanelAGC, 'MED',
                           LeftPanelButtonLeft(LEFT_W, 5, 1), 20,
                           LeftPanelButtonWidth(LEFT_W, 5, 1),
                           BTN_SM, BtnAGCModeClick);
  BtnAGCMode[1].Tag := 1;
  BtnAGCMode[2] := MakeBtn(PanelAGC, 'SLOW',
                           LeftPanelButtonLeft(LEFT_W, 5, 2), 20,
                           LeftPanelButtonWidth(LEFT_W, 5, 2),
                           BTN_SM, BtnAGCModeClick);
  BtnAGCMode[2].Tag := 2;
  BtnAGCMode[3] := MakeBtn(PanelAGC, 'LONG',
                           LeftPanelButtonLeft(LEFT_W, 5, 3), 20,
                           LeftPanelButtonWidth(LEFT_W, 5, 3),
                           BTN_SM, BtnAGCModeClick);
  BtnAGCMode[3].Tag := 3;
  BtnAGCMode[4] := MakeBtn(PanelAGC, 'OFF',
                           LeftPanelButtonLeft(LEFT_W, 5, 4), 20,
                           LeftPanelButtonWidth(LEFT_W, 5, 4),
                           BTN_SM, BtnAGCModeClick);
  BtnAGCMode[4].Tag := 4;
  for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = FController.FAGCMode);

  // AGC level slider
  MakeLbl(PanelAGC, 'THRESH', 4, 44);
  TrkAGC := TFlatSlider.Create(Self);
  TrkAGC.Parent := PanelAGC; TrkAGC.Left := LEFT_SLIDER_LEFT; TrkAGC.Top := 44;
  TrkAGC.Width := LEFT_SLIDER_W; TrkAGC.Height := 16;
  TrkAGC.Min := 20; TrkAGC.Max := 120;   // 20..120 → −20..-120 dBm
  TrkAGC.Position := FController.FAGCTop;
  TrkAGC.Reversed := True;
  TrkAGC.OnChange := TrkAGCChange;

  LblAGCTop := TLabel.Create(Self);
  LblAGCTop.Parent := PanelAGC;
  LblAGCTop.Left := LEFT_SLIDER_VALUE_LEFT; LblAGCTop.Top := 44;
  LblAGCTop.Caption := Format('-%ddB', [FController.FAGCTop]);
  LblAGCTop.Font.Color := CLR_TEXT;
  LblAGCTop.Font.Size := 8;

  Inc(Y, 74);

  // --- Pluto RF AGC/gain — отдельный блок ПОД блоком AGC (WDSP). Виден только
  // когда подключён Pluto-бэкенд; раскладку панелей ниже сдвигает LayoutLeftPanel.
  PanelPlutoAGC := TPanel.Create(Self);
  PanelPlutoAGC.Parent := PanelLeft;
  PanelPlutoAGC.SetBounds(0, Y, LEFT_W, PLUTO_AGC_H);
  PanelPlutoAGC.BevelOuter := bvNone;
  PanelPlutoAGC.Visible := False;
  MakeLbl(PanelPlutoAGC, 'RF AGC', 4, 2);
  // Режимы AD936x: FAST/SLOW/HYB/MAN. Tag = индекс режима (1/2/3/0).
  BtnPlutoGain[0] := MakeBtn(PanelPlutoAGC, 'FAST',
    LeftPanelButtonLeft(LEFT_W, 4, 0), 20, LeftPanelButtonWidth(LEFT_W, 4, 0),
    BTN_SM, BtnPlutoGainClick);
  BtnPlutoGain[0].Tag := 1;
  BtnPlutoGain[1] := MakeBtn(PanelPlutoAGC, 'SLOW',
    LeftPanelButtonLeft(LEFT_W, 4, 1), 20, LeftPanelButtonWidth(LEFT_W, 4, 1),
    BTN_SM, BtnPlutoGainClick);
  BtnPlutoGain[1].Tag := 2;
  BtnPlutoGain[2] := MakeBtn(PanelPlutoAGC, 'HYB',
    LeftPanelButtonLeft(LEFT_W, 4, 2), 20, LeftPanelButtonWidth(LEFT_W, 4, 2),
    BTN_SM, BtnPlutoGainClick);
  BtnPlutoGain[2].Tag := 3;
  BtnPlutoGain[3] := MakeBtn(PanelPlutoAGC, 'MAN',
    LeftPanelButtonLeft(LEFT_W, 4, 3), 20, LeftPanelButtonWidth(LEFT_W, 4, 3),
    BTN_SM, BtnPlutoGainClick);
  BtnPlutoGain[3].Tag := 0;
  // Manual gain slider.
  MakeLbl(PanelPlutoAGC, 'GAIN', 4, 44);
  TrkRxGain := TFlatSlider.Create(Self);
  TrkRxGain.Parent := PanelPlutoAGC; TrkRxGain.Left := LEFT_SLIDER_LEFT; TrkRxGain.Top := 44;
  TrkRxGain.Width := LEFT_SLIDER_W; TrkRxGain.Height := 16;
  TrkRxGain.Min := 0; TrkRxGain.Max := 73;   // AD936x hardwaregain dB
  TrkRxGain.Position := FController.FRxGainDb;
  TrkRxGain.OnChange := TrkRxGainChange;
  LblRxGain := TLabel.Create(Self);
  LblRxGain.Parent := PanelPlutoAGC;
  LblRxGain.Left := LEFT_SLIDER_VALUE_LEFT; LblRxGain.Top := 44;
  LblRxGain.Caption := Format('%ddB', [FController.FRxGainDb]);
  LblRxGain.Font.Color := CLR_TEXT;
  LblRxGain.Font.Size := 8;
  // НЕ Inc(Y): панель скрыта и наложена на позицию Bands; LayoutLeftPanel сдвинет.

  // Band selector. Высота резервирует 2 дополнительных ряда под XVTR-кнопки
  // (создаются динамически в RebuildXvtrButtons). В каждом ряду до 6 кнопок,
  // итого до 12 XVTR-кнопок (наш CFG_XVTR_COUNT = 8 умещается с запасом).
  PanelBands := TPanel.Create(Self);
  PanelBands.Parent := PanelLeft;
  PanelBands.SetBounds(0, Y, LEFT_W, 74);
  PanelBands.BevelOuter := bvNone;
  MakeLbl(PanelBands, 'BAND', 4, 2);
  for i := 0 to BAND_COUNT - 1 do
  begin
    B := MakeBtn(PanelBands, BAND_NAMES[i],
                 LeftPanelButtonLeft(LEFT_W, 6, i mod 6),
                 20 + (i div 6) * 27,
                 LeftPanelButtonWidth(LEFT_W, 6, i mod 6),
                 BTN_SM, BtnBandClick);
    B.Tag := i;
    // Пустой стартовый слот (12-й в HF-плане) прячем до RelabelBands.
    if BAND_NAMES[i] = '' then B.Visible := False;
    BtnBand[i] := B;
  end;
  // XVTR кнопки создаются позже (в RebuildXvtrButtons), их Tag = -(idx+1)
  for i := 0 to CFG_XVTR_COUNT - 1 do
    BtnXvtrBand[i] := nil;

  Inc(Y, 76);

  // Mode selector — MODE_ROWS рядов по 4 кнопки (последний неполный).
  PanelMode := TPanel.Create(Self);
  PanelMode.Parent := PanelLeft;
  PanelMode.SetBounds(0, Y, LEFT_W, MODE_PANEL_H);
  PanelMode.BevelOuter := bvNone;
  MakeLbl(PanelMode, 'MODE', 4, 2);
  for i := 0 to MODE_COUNT - 1 do
  begin
    B := MakeBtn(PanelMode, MODE_NAMES[i],
                 LeftPanelButtonLeft(LEFT_W, 4, i mod 4),
                 20 + (i div 4) * 27,
                 LeftPanelButtonWidth(LEFT_W, 4, i mod 4),
                 BTN_SM, BtnModeClick);
    B.Tag := i;
    B.OnMouseDown := BtnModeMouseDown;   // ПКМ по CWL/CWU → терминал
    BtnMode[i] := B;
    StyleButton(B, i = FController.FMode);
  end;

  Inc(Y, MODE_PANEL_H + 2);

  // Filter selector (12 кнопок в 2 ряда по 6: 10 пресетов + VAR1/VAR2).
  // ПКМ по любой из них открывает редактор фильтров.
  PanelFilter := TPanel.Create(Self);
  PanelFilter.Parent := PanelLeft;
  PanelFilter.SetBounds(0, Y, LEFT_W, 74);
  PanelFilter.BevelOuter := bvNone;
  MakeLbl(PanelFilter, 'FILTER', 4, 2);
  for i := 0 to FILT_SLOTS - 1 do
  begin
    B := MakeBtn(PanelFilter, '---',
                 LeftPanelButtonLeft(LEFT_W, FILT_GRID_COLS, i mod FILT_GRID_COLS),
                 20 + (i div FILT_GRID_COLS) * 27,
                 LeftPanelButtonWidth(LEFT_W, FILT_GRID_COLS, i mod FILT_GRID_COLS),
                 BTN_SM, BtnFilterClick);
    B.Tag := i;
    B.OnMouseDown := BtnFilterMouseDown;
    BtnFilter[i] := B;
  end;

  Inc(Y, 76);

  // FM RAW discriminator deviation. Slider units are 50 Hz: fine enough for
  // decoder calibration, while the full 0.5..10 kHz range remains usable.
  PanelRawDev := TPanel.Create(Self);
  PanelRawDev.Parent := PanelLeft;
  PanelRawDev.SetBounds(0, Y, LEFT_W, 50);
  PanelRawDev.BevelOuter := bvNone;
  PanelRawDev.Visible := False;
  MakeLbl(PanelRawDev, 'DEVIATION', 4, 2);
  TrkRawDev := TFlatSlider.Create(Self);
  TrkRawDev.Parent := PanelRawDev;
  TrkRawDev.Left := 34;
  TrkRawDev.Top := 22;
  TrkRawDev.Width := LEFT_SLIDER_W;
  TrkRawDev.Height := 14;
  TrkRawDev.Min := RAW_FM_DEV_MIN div 50;
  TrkRawDev.Max := RAW_FM_DEV_MAX div 50;
  TrkRawDev.Position := Round(FController.FRawFMDeviation) div 50;
  TrkRawDev.OnChange := TrkRawDevChange;
  LblRawDev := TLabel.Create(Self);
  LblRawDev.Parent := PanelRawDev;
  LblRawDev.Left := LEFT_W - 61;
  LblRawDev.Top := 20;
  LblRawDev.Width := 57;
  LblRawDev.Alignment := taRightJustify;
  LblRawDev.Caption := Format('%.2f k', [FController.FRawFMDeviation / 1000.0]);
  LblRawDev.Font.Color := CLR_TEXT;
  LblRawDev.Font.Size := 8;

  // FM Squelch panel (visible only in FM mode, above CTCSS)
  PanelFMSQ := TPanel.Create(Self);
  PanelFMSQ.Parent := PanelLeft;
  PanelFMSQ.SetBounds(0, Y, LEFT_W, 50);
  PanelFMSQ.BevelOuter := bvNone;
  PanelFMSQ.Visible := False;
  MakeLbl(PanelFMSQ, 'SQL', 4, 2);
  W := LeftPanelButtonWidth(LEFT_W, 4, 0);
  BtnFMSQ := MakeBtn(PanelFMSQ, 'SQL',
                     LeftPanelButtonLeft(LEFT_W, 4, 0), 20,
                     W, BTN_SM, BtnFMSQClick);
  StyleButton(BtnFMSQ, False);
  // Slider vertically centred with the button row.
  // Reserve 28px on the right for the numeric label
  TrkFMSQ := TFlatSlider.Create(Self);
  TrkFMSQ.Parent := PanelFMSQ;
  TrkFMSQ.Left   := BtnFMSQ.Left + BtnFMSQ.Width + 3;
  TrkFMSQ.Top    := 25;
  TrkFMSQ.Width  := LEFT_SLIDER_W;
  TrkFMSQ.Height := 14;
  TrkFMSQ.Min := 0; TrkFMSQ.Max := 100;
  TrkFMSQ.Position := FController.FFMSQLevel;
  TrkFMSQ.OnChange := TrkFMSQChange;
  // Numeric value label to the right of the slider
  LblFMSQ := TLabel.Create(Self);
  LblFMSQ.Parent := PanelFMSQ;
  LblFMSQ.Left   := TrkFMSQ.Left + TrkFMSQ.Width + 3;
  LblFMSQ.Top    := 23;
  LblFMSQ.Width  := 26;
  LblFMSQ.Caption := IntToStr(FController.FFMSQLevel);
  LblFMSQ.Font.Color := CLR_TEXT;
  LblFMSQ.Font.Size  := 8;

  // FM CTCSS panel (visible only when FM mode is active)
  PanelFMCTCSS := TPanel.Create(Self);
  PanelFMCTCSS.Parent := PanelLeft;
  PanelFMCTCSS.SetBounds(0, Y, LEFT_W, 50);
  PanelFMCTCSS.BevelOuter := bvNone;
  PanelFMCTCSS.Visible := False;
  MakeLbl(PanelFMCTCSS, 'CTCSS', 4, 2);
  BtnFMCTCSS := MakeBtn(PanelFMCTCSS, 'CTCSS',
                        LeftPanelButtonLeft(LEFT_W, 3, 0), 20,
                        LeftPanelButtonWidth(LEFT_W, 3, 0),
                        BTN_H, BtnFMCTCSSClick);
  StyleButton(BtnFMCTCSS, False);
  BtnFMCTCSSTone := MakeBtn(PanelFMCTCSS, CTCSS_NAMES[0],
                            LeftPanelButtonLeft(LEFT_W, 3, 1), 20,
                            LEFT_W - LEFT_PANEL_BTN_PAD -
                            LeftPanelButtonLeft(LEFT_W, 3, 1),
                            BTN_H, BtnFMCTCSSToneClick);
  StyleButton(BtnFMCTCSSTone, False);

  FCTCSSDropDown := TFlatDropDown.Create(BtnFMCTCSSTone, PanelLeft, 4, BTN_SM,
    LEFT_SCROLL_GUTTER_W);
  FCTCSSDropDown.SetItems(CTCSS_NAMES);
  FCTCSSDropDown.SetItemIndex(FController.FFMCTCSSToneIdx);
  FCTCSSDropDown.OnSelect := OnCTCSSDropDownSelect;

  // FM Step panel (visible only in FM mode, between Filter and SQL)
  PanelFMStep := TPanel.Create(Self);
  PanelFMStep.Parent := PanelLeft;
  PanelFMStep.SetBounds(0, Y, LEFT_W, 50);
  PanelFMStep.BevelOuter := bvNone;
  PanelFMStep.Visible := False;
  MakeLbl(PanelFMStep, 'STEP', 4, 2);
  BtnFMStep := MakeBtn(PanelFMStep, 'STEP',
                       LeftPanelButtonLeft(LEFT_W, 3, 0), 20,
                       LeftPanelButtonWidth(LEFT_W, 3, 0),
                       BTN_H, BtnFMStepClick);
  StyleButton(BtnFMStep, FController.FFMStepOn);
  BtnFMStepSel := MakeBtn(PanelFMStep, FM_STEP_NAMES[FController.FFMStepIdx],
                           LeftPanelButtonLeft(LEFT_W, 3, 1), 20,
                           LEFT_W - LEFT_PANEL_BTN_PAD -
                           LeftPanelButtonLeft(LEFT_W, 3, 1),
                           BTN_H, BtnFMStepSelClick);
  StyleButton(BtnFMStepSel, False);

  FStepDropDown := TFlatDropDown.Create(BtnFMStepSel, PanelLeft, FM_STEP_COUNT,
    BTN_SM, LEFT_SCROLL_GUTTER_W);
  FStepDropDown.SetItems(FM_STEP_NAMES);
  FStepDropDown.SetItemIndex(FController.FFMStepIdx);
  FStepDropDown.OnSelect := OnStepDropDownSelect;

  // FM Repeater offset panel (visible only in FM mode, below CTCSS)
  PanelFMRpt := TPanel.Create(Self);
  PanelFMRpt.Parent := PanelLeft;
  PanelFMRpt.SetBounds(0, Y, LEFT_W, 50);
  PanelFMRpt.BevelOuter := bvNone;
  PanelFMRpt.Visible := False;
  MakeLbl(PanelFMRpt, 'RPT', 4, 2);
  BtnFMRptMinus := MakeBtn(PanelFMRpt, '-',
    LeftPanelButtonLeft(LEFT_W, 5, 0), 20,
    LeftPanelButtonWidth(LEFT_W, 5, 0), BTN_H, BtnFMRptMinusClick);
  StyleButton(BtnFMRptMinus, False);
  BtnFMRptPlus := MakeBtn(PanelFMRpt, '+',
    LeftPanelButtonLeft(LEFT_W, 5, 4), 20,
    LeftPanelButtonWidth(LEFT_W, 5, 4), BTN_H, BtnFMRptPlusClick);
  StyleButton(BtnFMRptPlus, False);
  W := LeftPanelButtonLeft(LEFT_W, 5, 1);
  EdFMRptOffset := TFlatEdit.Create(Self);
  EdFMRptOffset.Parent := PanelFMRpt;
  EdFMRptOffset.Left   := W;
  EdFMRptOffset.Top    := 22;
  EdFMRptOffset.Width  := LeftPanelButtonLeft(LEFT_W, 5, 4) - W - LEFT_PANEL_BTN_GAP;
  EdFMRptOffset.Height := BTN_H - 4;
  EdFMRptOffset.Text   := RptFormatOffset(FController.FFMRptOffsetHz);
  EdFMRptOffset.OnChange := EdFMRptOffsetChange;

  // ---- Right panel ----
  PanelRight := TPanel.Create(Self);
  PanelRight.Parent     := Self;
  PanelRight.Align      := alClient;
  PanelRight.BevelOuter     := bvNone;
  PanelRight.DoubleBuffered := True;
  PanelRight.OnResize   := RightPanelResize;

  FSampleRateOverlay := TSampleRateOverlay.Create(Self);
  FSampleRateOverlay.OnSpanSelect := OnSampleRateSelect;
  FSampleRateOverlay.OnHidePanel  := OnSampleRateHidePanel;
  FSampleRateOverlay.OnInvalidate := OnSampleRateOverlayInvalidate;
  FSampleRateOverlay.SetCurrentRate(FController.FSampleRate);
  FSpecView.SampleRateOverlay := FSampleRateOverlay;

  // Бэндплан QO-100 — полоска внизу спектра; видимость по InQO100.
  FBandPlanOverlay := TBandPlanOverlay.Create(Self);
  FBandPlanOverlay.SetView(FController.FCenterFreq, FController.FSpanHz);
  FSpecView.BandPlanOverlay := FBandPlanOverlay;

  // DX-кластер: база спотов + сетевой поток + оверлей подписей.
  InitDXCluster;

  // TCI-сервер — равноправный фронтенд рядом с CAT и web. Создаём после
  // InitDXCluster: споты от TCI-клиентов кладутся в тот же стор.
  FController.FSettings.LoadTCISettings(FTCICfg);
  FTCIAdapter := TTCIAdapter.Create(FController, FDXStore);
  FTCIAdapter.OnFocusRequest := TCIFocusRequest;
  // Порт может быть занят (второй экземпляр, чужая программа) — молчать об
  // этом нельзя: галка стоит, а сервера нет.
  if not FTCIAdapter.ApplySettings(FTCICfg) then
    ShowMessage('TCI server failed to start on ' + FTCICfg.BindAddr + ':' +
                IntToStr(FTCICfg.Port) + '.' + LineEnding +
                'Port busy or address invalid — check Settings → CAT.');
  OnActivate   := TCIFormActivate;
  OnDeactivate := TCIFormDeactivate;

  // S-метр — внутри PanelToolbar, справа
  PanelSMeterRight := TPanel.Create(Self);
  PanelSMeterRight.Parent     := PanelToolbar;
  PanelSMeterRight.BevelOuter := bvNone;
  PanelSMeterRight.Color      := CLR_PANEL;
  PbSMeterRight := TPaintBox.Create(Self);
  PbSMeterRight.Parent   := PanelSMeterRight;
  PbSMeterRight.Align    := alClient;
  PbSMeterRight.OnPaint  := FSpecView.PaintSMeterRight;
  PbSMeterRight.Color    := CLR_PANEL;

  if FUseOpenGLSpectrum then
  begin
    PbWideband := TOpenGLControl.Create(Self);
    TOpenGLControl(PbWideband).AutoResizeViewport := False;
    TOpenGLControl(PbWideband).DoubleBuffered := True;
    TOpenGLControl(PbWideband).OnPaint := FWidebandView.Paint;
    TOpenGLControl(PbWideband).OnMouseDown := PbWidebandMouseDown;
  end
  else
  begin
    PbWideband := TPaintBox.Create(Self);
    TPaintBox(PbWideband).OnPaint := FWidebandView.Paint;
    TPaintBox(PbWideband).OnMouseDown := PbWidebandMouseDown;
  end;
  PbWideband.Parent := PanelRight;
  PbWideband.Visible := False;

  PbWidebandRuler := TPaintBox.Create(Self);
  PbWidebandRuler.Parent := PanelRight;
  PbWidebandRuler.OnPaint := FWidebandView.PaintRuler;
  PbWidebandRuler.Cursor := crDefault;
  PbWidebandRuler.Visible := False;

  // ---- Панадаптер: контролы спектра/линейки/сплиттера/водопада/пан-зума ----
  // Создаёт и раскладывает TPanafallPanel; семантика мыши остаётся здесь —
  // обработчики подвешиваются ДО Build.
  FPan.SpectrumMouseDown  := PbSpectrumMouseDown;
  FPan.SpectrumMouseMove  := PbSpectrumMouseMove;
  FPan.SpectrumMouseUp    := PbSpectrumMouseUp;
  FPan.SpectrumMouseLeave := PbSpectrumMouseLeave;
  FPan.SpectrumDblClick   := PbSpectrumDblClick;
  FPan.WaterfallMouseDown := PbWaterfallMouseDown;
  FPan.WaterfallMouseMove := PbWaterfallMouseMove;
  FPan.WaterfallMouseUp   := PbWaterfallMouseUp;
  FPan.ZoomInClick        := BtnZoomInClick;
  FPan.ZoomDefClick       := BtnZoomDefClick;
  FPan.ZoomOutClick       := BtnZoomOutClick;
  FPan.GridClick          := OnPanGridToggle;   // «▦/▤» грид-раскладка панов
  FPan.OnSplitterMoved    := RightPanelResize;
  FPan.Build(PanelRight, Max(1, FController.FSpecMSAA));
  // Алиасы контролов (исторические имена; постепенная миграция на FPan.*)
  PbSpectrum    := FPan.PbSpectrum;
  PbRuler       := FPan.PbRuler;
  PanelSplitter := FPan.Splitter;
  PbWaterfall   := FPan.PbWaterfall;
  PbPanZoom     := FPan.PbPanZoom;
  BtnZoomIn     := FPan.BtnZoomIn;
  BtnZoomDef    := FPan.BtnZoomDef;
  BtnZoomOut    := FPan.BtnZoomOut;
  FPanZoomBar.OnZoomPan := OnPanZoomWindow;

  // Initial layout
  ResizeSpectrumPanels;
  UpdatePanZoomBar;
end;

// ===========================================================================
// Resize handler (normal method instead of anonymous procedure)
// Сплиттер спектр/водопад живёт в TPanafallPanel; по OnSplitterMoved панель
// зовёт RightPanelResize → полный пересчёт.
// ===========================================================================

procedure TMainForm.RightPanelResize(Sender: TObject);
begin
  UpdateLeftPanelScroll;
  ResizeSpectrumPanels;
end;

procedure TMainForm.LayoutTopVfoBlock;
const
  MARGIN = 4;
  TOP_OFFSET = 2;
  TOP_VFO_FREQ_H = 47;
  VFO_BTN_FREQ_GAP = 4;
  VFO_FREQ_LEFT_PAD = 2;
  VFO_FREQ_RIGHT_PAD = 8;
  XFER_BTN_W   = 44;
  XFER_BTN_H   = 20;
  XFER_BTN_GAP = 4;
  XFER_PANEL_W = XFER_BTN_W * 3 + XFER_BTN_GAP * 2;  // = 140
  XFER_SIDE_GAP = 14;   // минимальный зазор между VFO и кнопками трансфера
  SETTINGS_GAP  = 8;
  MAX_SPREAD    = 100;  // максимальный дополнительный отступ с каждой стороны при растяжении
  // VFO-B получает левый отступ = правому паддингу VFO-A для симметрии зазоров
  VFO_B_LEFT_PAD = VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD;  // = 10
var
  BlockH, FreqTop, CenterY, SafeLeft, SafeRight, GroupLeft, GroupTop: Integer;
  RightW, SMW, SMLeft, AvailW, MinGroupW, ExtraPerSide, DynGap: Integer;
  TopVfoAW, TopVfoBW, NeedFreqAW, NeedFreqBW, FreqTextW, GroupW: Integer;
  XferX, XferY: Integer;
begin
  if (PanelToolbar = nil) or (PanelTopVfoGroup = nil) or
     (PanelTopVfoA = nil) or (PanelTopVfoB = nil) then Exit;

  Canvas.Font.Name := FreqDispB.FontName;
  Canvas.Font.Size := FreqDispB.FontSize;
  Canvas.Font.Style := [fsBold];
  FreqDispA.Left := BtnTopVfoASelect.Width + VFO_BTN_FREQ_GAP - VFO_FREQ_LEFT_PAD;
  FreqDispB.Left := VFO_B_LEFT_PAD + BtnTopVfoBSelect.Width + VFO_BTN_FREQ_GAP - VFO_FREQ_LEFT_PAD;
  FreqTextW  := Canvas.TextWidth(StringOfChar('0', Max(3, FFreqMhzDigits)) + '.000.000');
  NeedFreqAW := FreqDispA.Left + FreqTextW + VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD;
  NeedFreqBW := FreqDispB.Left + FreqTextW + VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD;
  TopVfoAW := NeedFreqAW;
  TopVfoBW := NeedFreqBW;

  // Минимальная ширина компактной группы
  MinGroupW := TopVfoAW + XFER_SIDE_GAP + XFER_PANEL_W + XFER_SIDE_GAP + TopVfoBW;

  SafeLeft := MARGIN;
  if BtnSettings <> nil then
    SafeLeft := BtnSettings.Left + BtnSettings.Width + SETTINGS_GAP;
  SafeRight := ClientWidth - MARGIN;
  if PanelLeft <> nil then
  begin
    RightW := ClientWidth - PanelLeft.Width;
    if RightW > 200 then
    begin
      SMW   := Round(RightW * SMETER_WIDTH_RATIO);
      SMLeft := ClientWidth - SMW - SMETER_MARGIN;
      SafeRight := Min(SafeRight, SMLeft - TOP_SMETER_GAP);
    end;
  end;

  AvailW := SafeRight - SafeLeft;
  if AvailW < MinGroupW then
  begin
    PanelTopVfoGroup.Visible := False;
    Exit;
  end;

  // Динамический зазор: растёт с шириной окна, но не больше MAX_SPREAD
  ExtraPerSide := Min((AvailW - MinGroupW) div 2, MAX_SPREAD);
  DynGap := XFER_SIDE_GAP + ExtraPerSide;
  GroupW := TopVfoAW + DynGap + XFER_PANEL_W + DynGap + TopVfoBW;

  BlockH := PanelToolbar.Height - (MARGIN * 2) - 8;
  if BlockH < 56 then BlockH := 56;

  // Центрируем группу в доступном пространстве
  GroupLeft := SafeLeft + (AvailW - GroupW) div 2;
  GroupTop  := TOP_OFFSET;

  CenterY := PanelToolbar.Height div 2 - GroupTop;

  PanelTopVfoGroup.SetBounds(GroupLeft, GroupTop, GroupW, BlockH);
  PanelTopVfoGroup.Visible := True;

  // VFO-A: левый край группы
  PanelTopVfoA.SetBounds(0, 0, TopVfoAW, BlockH);
  PanelTopVfoA.Visible := True;
  PanelVfoA.SetBounds(0, 0, TopVfoAW, BlockH);
  // A выше оси, TX ниже; 1px зазор с каждой стороны
  BtnTopVfoASelect.SetBounds(0, CenterY - BtnTopVfoASelect.Height - 1,
                             BtnTopVfoASelect.Width, BtnTopVfoASelect.Height);
  BtnTopVfoATX.SetBounds(0, CenterY + 1,
                         BtnTopVfoATX.Width, BtnTopVfoATX.Height);
  FreqTop := CenterY - (TOP_VFO_FREQ_H div 2);
  FreqDispA.SetBounds(FreqDispA.Left, FreqTop,
    FreqTextW + VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD, TOP_VFO_FREQ_H);

  // Кнопки трансфера: горизонтальный ряд, вертикально по оси
  XferX := TopVfoAW + DynGap;
  XferY := CenterY - (XFER_BTN_H div 2);
  PanelVfoButtons.SetBounds(XferX, XferY, XFER_PANEL_W, XFER_BTN_H);
  BtnVfoACopyB.SetBounds(0, 0, XFER_BTN_W, XFER_BTN_H);
  BtnVfoSwap.SetBounds(XFER_BTN_W + XFER_BTN_GAP, 0, XFER_BTN_W, XFER_BTN_H);
  BtnVfoBCopyA.SetBounds((XFER_BTN_W + XFER_BTN_GAP) * 2, 0, XFER_BTN_W, XFER_BTN_H);

  // VFO-B: правый край группы
  PanelTopVfoB.SetBounds(TopVfoAW + DynGap + XFER_PANEL_W + DynGap, 0, TopVfoBW, BlockH);
  PanelTopVfoB.Visible := True;
  PanelVfoB.SetBounds(0, 0, TopVfoBW, BlockH);
  BtnTopVfoBSelect.SetBounds(VFO_B_LEFT_PAD, CenterY - BtnTopVfoBSelect.Height - 1,
                             BtnTopVfoBSelect.Width, BtnTopVfoBSelect.Height);
  BtnTopVfoBTX.SetBounds(VFO_B_LEFT_PAD, CenterY + 1,
                         BtnTopVfoBTX.Width, BtnTopVfoBTX.Height);
  FreqTop := CenterY - (TOP_VFO_FREQ_H div 2);
  FreqDispB.SetBounds(FreqDispB.Left, FreqTop,
    FreqTextW + VFO_FREQ_LEFT_PAD + VFO_FREQ_RIGHT_PAD, TOP_VFO_FREQ_H);

end;

procedure TMainForm.ResizeSMeter;
// PanelSMeterRight на главной форме (Parent=Self).
// По Y: от самого верха формы до низа тулбара.
var
  FW:           Integer;
  RightW:       Integer;   // ширина PanelRight
  SMW:          Integer;   // ширина S-метра
  SMLeft:       Integer;
  SMTop:        Integer;
  SMBot:        Integer;
begin
  if (PanelSMeterRight = nil) or (PanelToolbar = nil) or (PanelLeft = nil) then Exit;

  LayoutTopVfoBlock;

  FW := ClientWidth;
  // Ширина зоны спектра (PanelRight)
  RightW := FW - PanelLeft.Width;
  if RightW < 200 then Exit;

  // Ширина S-метра = 32% от PanelRight
  SMW := Round(RightW * SMETER_WIDTH_RATIO);

  // X: прижат к правому краю PanelToolbar
  SMLeft := FW - SMW - SMETER_MARGIN;

  SMTop := SMETER_MARGIN;
  SMBot := PanelToolbar.Height - SMETER_MARGIN - 9;

  PanelSMeterRight.SetBounds(SMLeft, SMTop, SMW, SMBot - SMTop);
end;

function TMainForm.UpdateWidebandFrequencyView: Boolean;
var
  E: TXvtrEntry;
  SrcStart, SrcEnd: Double;
  MarkerChanged, ViewChanged: Boolean;
begin
  Result := False;
  if FWidebandView = nil then Exit;
  if FController.FActiveVfo = 0 then
    MarkerChanged := FWidebandView.SetMarkerHz(FController.FVfoA)
  else
    MarkerChanged := FWidebandView.SetMarkerHz(FController.FVfoB);
  if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) and
     FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled then
  begin
    E := FController.FXvtrSettings.Entries[FController.FCurrentXvtr];
    SrcStart := E.FreqBegin - E.LOOffset + E.LOError;
    SrcEnd   := E.FreqEnd   - E.LOOffset + E.LOError;
    ViewChanged := FWidebandView.SetFrequencyView(E.FreqBegin, E.FreqEnd,
      SrcStart, SrcEnd);
  end
  else
    ViewChanged := FWidebandView.SetFrequencyView(0.0, 61440000.0, 0.0,
      61440000.0);
  Result := MarkerChanged or ViewChanged;
end;

procedure TMainForm.ResizeSpectrumPanels;
const
  WB_H = 118;
var
  RW, RH, WideH, WideSpecH, WideTop: Integer;
  RULER_H: Integer;
  PANZOOM_H: Integer;
begin
  RULER_H := DpiScale(18);
  if PanelRight = nil then Exit;
  if Assigned(FSpecView) then SyncSpecViewFreq;
  RW     := PanelRight.ClientWidth;
  RH     := PanelRight.ClientHeight;
  // Низ занят панелью пана/зума (позиционирует FPan.Layout той же формулой) —
  // wideband-блок делит оставшуюся высоту.
  PANZOOM_H := DpiScale(22);
  if FController.FShowSpectrum or FController.FShowWaterfall then
    RH := RH - PANZOOM_H;
  UpdateWidebandFrequencyView;
  WideH  := 0;
  WideSpecH := 0;
  if FController.FShowWideband then
    WideH := Min(DpiScale(WB_H), Max(40, RH div 2));
  if WideH > 0 then
    WideSpecH := Max(1, WideH - RULER_H);
  // Wideband — всегда во всю ширину PanelRight, самой верхней полосой.
  // В обычном стеке над ним стоит шапка пана 0; в гриде «две колонки» шапки
  // панов уходят в свои колонки ПОД wideband, поэтому он начинается с нуля.
  WideTop := IfThen(PanGridTwoCol, 0, Pan0HeaderH);
  if PbWideband <> nil then
  begin
    PbWideband.SetBounds(0, WideTop, RW, WideSpecH);
    PbWideband.Visible := FController.FShowWideband and (WideSpecH > 0);
    if FController.FShowWideband and (RW > 0) and (WideSpecH > 0) then
    begin
      FWidebandView.SetBitmapSize(RW, WideSpecH);
      PbWideband.Invalidate;
    end;
  end;
  if PbWidebandRuler <> nil then
  begin
    PbWidebandRuler.SetBounds(0, WideTop + WideSpecH, RW, IfThen(FController.FShowWideband and (WideH > 0), RULER_H, 0));
    PbWidebandRuler.Visible := FController.FShowWideband and (WideH > 0);
    if FController.FShowWideband and (RW > 0) and (WideH > 0) then
    begin
      FWidebandView.SetRulerSize(RW, RULER_H);
      PbWidebandRuler.Invalidate;
    end;
  end;
  // S-метр позиционируется отдельно в ResizeSMeter
  ResizeSMeter;

  // Стек панов: регионы по долям, каждый пан раскладывает свой стек
  // спектр/линейка/сплиттер/водопад/пан-зум внутри своего региона.
  LayoutPanStack(WideH);
  // Доп. панадаптер приходит со своим слайсом и сразу показывает его флаг —
  // главный приёмник должен быть представлен флагом A с того же момента
  // (раньше флаг A появлялся только от слайса, созданного на самом пане 0).
  if FPan <> nil then
  begin
    FPan.MainFlagForced := PanCount > 1;
    FPan.UpdateMainFlagVisibility;
  end;
  PositionSampleRateOverlay;
end;

function TMainForm.Pan0HeaderVisible: Boolean;
// Шапка пана 0 — только при стеке панов (у одиночного пана она лишняя). Единый
// источник правды: LayoutPanStack (обе ветки), Pan0HeaderH, UpdatePanHeaders.
// Выбор приёмного входа у одноканальных по панам бэкендов (Pluto/LibreSDR) живёт
// не здесь, а во вкладке Antenna настроек.
begin
  Result := PanCount > 1;
end;

function TMainForm.Pan0HeaderH: Integer;
begin
  if Pan0HeaderVisible then
    Result := DpiScale(20)   // = HEADER_H панели
  else
    Result := 0;
end;

function TMainForm.ExtraPanCount: Integer;
// Доп. паны (без пана 0), участвующие в раскладке (плавающие не в счёт).
var i: Integer;
begin
  Result := 0;
  for i := 1 to MAX_PANS - 1 do
    if (FPans[i] <> nil) and not PanFloating(i) then Inc(Result);
end;

function TMainForm.PanGridActive: Boolean;
// Грид реально применён: включён юзером и есть хотя бы один доп. пан в стеке.
begin
  Result := FPanGridMode and (ExtraPanCount >= 1);
end;

function TMainForm.PanGridTwoCol: Boolean;
// Частный случай грида: пан 0 + ОДИН доп. пан = две колонки бок о бок во всю
// высоту (верхнего ряда/горизонтального сплиттера нет).
begin
  Result := FPanGridMode and (ExtraPanCount = 1);
end;

function TMainForm.PanMinHeight(PanId: Integer): Integer;
// Мин. высота региона пана в стеке/гриде: ниже неё Layout панели клампит
// спектр/водопад к минимумам и виджеты вылезают за регион (паны наезжают).
begin
  if FPans[PanId] = nil then Exit(0);
  Result := FPans[PanId].MinLayoutHeight(
    IfThen(PanId = 0, FPanWideH, 0),
    FController.FShowSpectrum, FController.FShowWaterfall);
end;

procedure TMainForm.LayoutPanStack(AWideH: Integer);
const
  PAN_SPLIT_H = 5;
var
  ActiveIds: array[0..MAX_PANS-1] of Integer;
  MinH:      array[0..MAX_PANS-1] of Integer;
  N, i, k, TotalH, TotalW, UsableH, UsableW, Y, X, Hi, Wi, MinW: Integer;
  Row0H, RowTop, RowH, M, MinRest, RemainH, BotMin: Integer;
  ShareSum: Double;
  Grid: Boolean;
begin
  // Плавающие (pop-out) паны в стеке не участвуют — у них своё окно.
  N := 0;
  for i := 0 to MAX_PANS - 1 do
    if (FPans[i] <> nil) and not PanFloating(i) then
    begin ActiveIds[N] := i; Inc(N); end;
  if N = 0 then Exit;
  FPanWideH := AWideH;   // для PanMinHeight (в т.ч. из сплиттер-драга)
  TotalH := PanelRight.ClientHeight;
  TotalW := PanelRight.ClientWidth;
  Grid   := PanGridActive;   // 2 колонки (пан 0 + 1 доп.) либо ряд + колонки

  // Тумблер «▦/▤» в ряду зума пана 0: есть смысл уже при ОДНОМ доп. пане
  // (грид = пан 0 и доп. пан бок о бок вместо «друг под другом»).
  if FPans[0] <> nil then
  begin
    // Виден при >=1 доп. пане; при включённом гриде — всегда (выключить можно
    // даже когда паны закрылись и раскладка сама откатилась в стек).
    FPans[0].GridBtnEnabled := FPanGridMode or (N >= 2);
    if FPans[0].BtnGrid <> nil then
    begin
      if FPanGridMode then FPans[0].BtnGrid.Caption := '▤'
      else                 FPans[0].BtnGrid.Caption := '▦';
    end;
  end;

  if not Grid then
  begin
    // ---- Вертикальный стек (как раньше) ----
    for i := 1 to MAX_PANS - 1 do
      if FPanVSplitters[i] <> nil then FPanVSplitters[i].Visible := False;
    UsableH := TotalH - (N - 1) * PAN_SPLIT_H;
    ShareSum := 0;
    for k := 0 to N - 1 do ShareSum := ShareSum + FPanShare[ActiveIds[k]];
    if ShareSum <= 0 then ShareSum := 1;
    // Видимость шапки — по ОБЩЕМУ числу панов (включая плавающие), как
    // Pan0HeaderH/UpdatePanHeaders: иначе при pop-out пана N шапка пана 0
    // прячется, а wideband остаётся сдвинутым на Pan0HeaderH — спектр
    // накрывает линейку частот wideband. Ставим ДО расчёта минимумов
    // (MinLayoutHeight учитывает шапку).
    MinRest := 0;
    for k := 0 to N - 1 do
    begin
      i := ActiveIds[k];
      if i = 0 then FPans[0].HeaderVisible := Pan0HeaderVisible
      else          FPans[i].HeaderVisible := PanCount > 1;
      MinH[k] := PanMinHeight(i);
      Inc(MinRest, MinH[k]);
    end;
    Y := 0;
    RemainH := UsableH;
    for k := 0 to N - 1 do
    begin
      i := ActiveIds[k];
      Dec(MinRest, MinH[k]);   // теперь = сумма минимумов панов ПОСЛЕ текущего
      if k > 0 then
      begin
        FPanSplitters[i].Visible := True;   // грид мог спрятать
        FPanSplitters[i].SetBounds(0, Y, TotalW, PAN_SPLIT_H);
        Inc(Y, PAN_SPLIT_H);
      end;
      if k = N - 1 then
        Hi := RemainH   // последний пан добирает остаток (округления долей)
      else
      begin
        Hi := Round(UsableH * FPanShare[i] / ShareSum);
        // Доли не смеют ужать пан ниже минимума содержимого (виджеты вылезут
        // за регион и паны наедут друг на друга), а раздутый минимумом пан —
        // отобрать у оставшихся их минимумы.
        if Hi < MinH[k] then Hi := MinH[k];
        if Hi > RemainH - MinRest then Hi := RemainH - MinRest;
        if Hi < 1 then Hi := 1;   // окно меньше суммы минимумов — деградируем без наезда выше
      end;
      Dec(RemainH, Hi);
      FPans[i].StackTop      := Y;
      FPans[i].StackHeight   := Hi;
      FPans[i].StackLeft     := 0;
      FPans[i].StackWidth    := 0;   // вся ширина
      if i = 0 then
        FPans[i].Layout(AWideH, FController.FShowSpectrum, FController.FShowWaterfall)
      else
        FPans[i].Layout(0, FController.FShowSpectrum, FController.FShowWaterfall);
      Inc(Y, Hi);
    end;
    Exit;
  end;

  if PanGridTwoCol then
  begin
    // ---- Грид «две колонки»: пан 0 слева, единственный доп. пан справа ----
    // Wideband остаётся сверху во всю ширину (его полосу кладёт
    // ResizeSpectrumPanels), обе колонки начинаются ПОД ним и идут до низа;
    // между ними вертикальный сплиттер (доли FPanColShare[0]/FPanColShare[доп]).
    i := ActiveIds[1];   // единственный застекованный доп. пан
    for k := 1 to MAX_PANS - 1 do
    begin
      if FPanSplitters[k] <> nil then FPanSplitters[k].Visible := False;
      if (k <> i) and (FPanVSplitters[k] <> nil) then
        FPanVSplitters[k].Visible := False;
    end;
    FPans[0].HeaderVisible := Pan0HeaderVisible;
    FPans[i].HeaderVisible := True;
    UsableW  := TotalW - PAN_SPLIT_H;
    ShareSum := FPanColShare[0] + FPanColShare[i];
    if ShareSum <= 0 then ShareSum := 1;
    MinW := Round(0.15 * TotalW);   // = кламп драга вертикального сплиттера
    Wi   := Round(UsableW * FPanColShare[0] / ShareSum);
    if Wi < MinW then Wi := MinW;
    if Wi > UsableW - MinW then Wi := UsableW - MinW;
    if Wi < 1 then Wi := 1;
    RowTop := AWideH;             // низ полосы wideband = верх обеих колонок
    RowH   := TotalH - RowTop;
    FPans[0].StackTop    := RowTop;
    FPans[0].StackHeight := RowH;
    FPans[0].StackLeft   := 0;
    FPans[0].StackWidth  := Wi;
    // ATopOffset=0: wideband вне региона пана 0 (он уже учтён в StackTop).
    FPans[0].Layout(0, FController.FShowSpectrum, FController.FShowWaterfall);
    FPanVSplitters[i].Visible := True;
    FPanVSplitters[i].SetBounds(Wi, RowTop, PAN_SPLIT_H, RowH);
    FPans[i].StackTop    := RowTop;
    FPans[i].StackHeight := RowH;
    FPans[i].StackLeft   := Wi + PAN_SPLIT_H;
    FPans[i].StackWidth  := TotalW - Wi - PAN_SPLIT_H;
    FPans[i].Layout(0, FController.FShowSpectrum, FController.FShowWaterfall);
    Exit;
  end;

  // ---- Грид: пан 0 сверху во всю ширину, доп. паны колонками снизу ----
  FPanGridRowShare := EnsureRange(FPanGridRowShare, 0.15, 0.85);
  Row0H := Round((TotalH - PAN_SPLIT_H) * FPanGridRowShare);
  // Кламп ряда минимумами содержимого (шапки — до расчёта минимумов):
  // ни пан 0, ни колонки нижнего ряда не смеют стать ниже своих минимумов.
  FPans[0].HeaderVisible := Pan0HeaderVisible;
  BotMin := 0;
  for k := 1 to N - 1 do
  begin
    i := ActiveIds[k];
    FPans[i].HeaderVisible := True;
    BotMin := Max(BotMin, PanMinHeight(i));
  end;
  if Row0H > TotalH - PAN_SPLIT_H - BotMin then
    Row0H := TotalH - PAN_SPLIT_H - BotMin;
  if Row0H < PanMinHeight(0) then Row0H := PanMinHeight(0);
  FPans[0].StackTop      := 0;
  FPans[0].StackHeight   := Row0H;
  FPans[0].StackLeft     := 0;
  FPans[0].StackWidth    := 0;
  FPans[0].Layout(AWideH, FController.FShowSpectrum, FController.FShowWaterfall);

  // Горизонтальный сплиттер ряда — сплиттер ПЕРВОЙ колонки (во всю ширину);
  // сплиттеры остальных колонок прячем (между колонками — вертикальные).
  M := N - 1;   // колонок в нижнем ряду
  FPanSplitters[ActiveIds[1]].Visible := True;
  FPanSplitters[ActiveIds[1]].SetBounds(0, Row0H, TotalW, PAN_SPLIT_H);
  for k := 2 to N - 1 do
    FPanSplitters[ActiveIds[k]].Visible := False;

  RowTop  := Row0H + PAN_SPLIT_H;
  RowH    := TotalH - RowTop;
  UsableW := TotalW - (M - 1) * PAN_SPLIT_H;
  ShareSum := 0;
  for k := 1 to N - 1 do ShareSum := ShareSum + FPanColShare[ActiveIds[k]];
  if ShareSum <= 0 then ShareSum := 1;
  X := 0;
  for k := 1 to N - 1 do
  begin
    i := ActiveIds[k];
    if k > 1 then
    begin
      FPanVSplitters[i].Visible := True;
      FPanVSplitters[i].SetBounds(X, RowTop, PAN_SPLIT_H, RowH);
      Inc(X, PAN_SPLIT_H);
    end
    else if FPanVSplitters[i] <> nil then
      FPanVSplitters[i].Visible := False;   // у первой колонки левого сплиттера нет
    if k = N - 1 then
      Wi := TotalW - X
    else
      Wi := Round(UsableW * FPanColShare[i] / ShareSum);
    FPans[i].StackTop      := RowTop;
    FPans[i].StackHeight   := RowH;
    FPans[i].StackLeft     := X;
    FPans[i].StackWidth    := Wi;
    FPans[i].HeaderVisible := True;
    FPans[i].Layout(0, FController.FShowSpectrum, FController.FShowWaterfall);
    Inc(X, Wi);
  end;
  // Вертикальные сплиттеры панов вне нижнего ряда (плавающие) — спрятать.
  for i := 1 to MAX_PANS - 1 do
    if (FPanVSplitters[i] <> nil) and ((FPans[i] = nil) or PanFloating(i)) then
      FPanVSplitters[i].Visible := False;
end;

// ===========================================================================
// Dark Theme
// ===========================================================================

procedure TMainForm.ApplyDarkTheme;
var T: TAppTheme; i: Integer;
  procedure DP(P: TPanel);
  begin P.Color := T.Panel; P.Font.Color := T.Text; end;
  procedure DPL(P: TPanel);
  var
    J: Integer;
  begin
    if P = nil then Exit;
    for J := 0 to P.ControlCount - 1 do
      if P.Controls[J] is TLabel then
        TLabel(P.Controls[J]).Font.Color := T.TextDim;
  end;
  procedure DL(L: TLabel; Dim: Boolean = True);
  begin if Dim then L.Font.Color := T.TextDim else L.Font.Color := T.Text; end;
  procedure DS(S: TFlatSlider);
  begin
    S.SetThemeColors(T.SliderBG, T.SliderTrackEmpty, T.SliderTrackFill,
      T.SliderThumbNorm, T.SliderThumbHot, T.SliderThumbDrag, T.SliderThumbBdr);
  end;
begin
  T := CurrentAppTheme;

  Color := T.BG; Font.Color := T.Text;
  DP(PanelToolbar); DP(PanelLeft);
  if FLeftScrollBar <> nil then
    FLeftScrollBar.SetColors(T.Panel, T.Border,
      T.SliderThumbHot, T.SliderThumbDrag);
  if FSampleRateOverlay <> nil then FSampleRateOverlay.SetTheme(T);
  DP(PanelTopVfoGroup); DP(PanelTopVfoA); DP(PanelTopVfoB);
  DP(PanelVfoA); DP(PanelVfoB); DP(PanelVfoButtons);
  DP(PanelBands); DP(PanelMode); DP(PanelFilter);
  if PanelPlutoAGC <> nil then DP(PanelPlutoAGC);
  if PanelFMSQ <> nil then DP(PanelFMSQ);
  if PanelRawDev <> nil then DP(PanelRawDev);
  if PanelFMCTCSS <> nil then DP(PanelFMCTCSS);
  if PanelFMStep <> nil then DP(PanelFMStep);
  if PanelFMRpt <> nil then DP(PanelFMRpt);
  if EdFMRptOffset <> nil then EdFMRptOffset.SetAppTheme(T);
  DP(PanelRXBlock); DP(PanelAGC); DP(PanelTX);
  DP(PanelRight);

  if PanelSMeterRight <> nil then PanelSMeterRight.Color := T.Panel;
  if PbSMeterRight <> nil then PbSMeterRight.Color := T.Panel;
  ApplyStatusTheme(T);

  // Заголовки групп и подписи строк.
  DPL(PanelTX);
  DPL(PanelRXBlock);
  DPL(PanelAGC);
  DPL(PanelPlutoAGC);
  DPL(PanelBands);
  DPL(PanelMode);
  DPL(PanelFilter);
  DPL(PanelRawDev);
  DPL(PanelFMStep);
  DPL(PanelFMSQ);
  DPL(PanelFMCTCSS);
  DPL(PanelFMRpt);
  // Значения справа от ползунков ярче обычных подписей.
  DL(LblAGCTop, False);
  DL(LblDrv);
  DL(LblDrvVal, False);
  DL(LblVolume, False);
  DL(LblAttVal, False);
  DL(LblRxGain, False);
  DL(LblRawDev, False);
  if LblFMSQ <> nil then DL(LblFMSQ, False);

  // Кнопки тулбара
  BtnDiscover.ClrNorm    := T.TbDiscoverNorm;
  BtnDiscover.ClrHot     := T.TbDiscoverHot;
  BtnDiscover.ClrActive  := T.TbDiscoverHot;
  BtnDiscover.ClrBorder  := T.TbDiscoverBorder;
  BtnDiscover.ClrText    := T.TbDiscoverText;
  BtnDiscover.ClrTextAct := T.TbDiscoverText;
  BtnDiscover.Invalidate;
  BtnStartStop.ClrNorm   := T.TbStartNorm;
  BtnStartStop.ClrActive := T.TbStartNorm;
  BtnStartStop.ClrHot    := T.TbStartNorm;
  BtnStartStop.ClrBorder := T.TbStartBorder;
  BtnStartStop.ClrText   := T.TbStartText;
  BtnStartStop.ClrTextAct:= T.TbStartText;
  BtnStartStop.Invalidate;
  StyleButton(BtnSettings, False);

  // Обычные кнопки — все массивы
  for i := 0 to BAND_COUNT-1  do StyleButton(BtnBand[i],   BtnBand[i].Active);
  for i := 0 to CFG_XVTR_COUNT - 1 do
    if BtnXvtrBand[i] <> nil then
      StyleButton(BtnXvtrBand[i], BtnXvtrBand[i].Active);
  for i := 0 to MODE_COUNT-1  do StyleButton(BtnMode[i],   BtnMode[i].Active);
  for i := 0 to FILT_SLOTS-1  do StyleButton(BtnFilter[i], BtnFilter[i].Active);
  for i := 0 to 4             do StyleButton(BtnAGCMode[i],BtnAGCMode[i].Active);
  StyleButton(BtnCTun,  BtnCTun.Active);
  StyleButton(BtnDUP,   BtnDUP.Active);
  StyleButton(BtnPS,    BtnPS.Active);
  StyleButton(Btn2Ton,  Btn2Ton.Active);
  StyleButton(BtnRxMute, BtnRxMute.Active);
  StyleButton(BtnBeacon, BtnBeacon.Active);
  // DX — тумблер подписей спотов, состояние берём из самой кнопки (сосед CTUN).
  if BtnDXCluster <> nil then StyleButton(BtnDXCluster, BtnDXCluster.Active);
  if BtnFMSQ <> nil then StyleButton(BtnFMSQ, BtnFMSQ.Active);
  if TrkFMSQ <> nil then DS(TrkFMSQ);
  if BtnFMCTCSS <> nil then StyleButton(BtnFMCTCSS, BtnFMCTCSS.Active);
  if BtnFMCTCSSTone <> nil then StyleButton(BtnFMCTCSSTone, False);
  if BtnFMStep <> nil then StyleButton(BtnFMStep, BtnFMStep.Active);
  if BtnFMStepSel <> nil then StyleButton(BtnFMStepSel, False);
  if BtnFMRptMinus <> nil then StyleButton(BtnFMRptMinus, BtnFMRptMinus.Active);
  if BtnFMRptPlus  <> nil then StyleButton(BtnFMRptPlus,  BtnFMRptPlus.Active);
  for i := 0 to 3 do
    if BtnPlutoGain[i] <> nil then
      StyleButton(BtnPlutoGain[i], BtnPlutoGain[i].Active);
  if FCTCSSDropDown <> nil then FCTCSSDropDown.ApplyStyle(StyleButton, T.Panel);
  if FStepDropDown  <> nil then FStepDropDown.ApplyStyle(StyleButton, T.Panel);
  if BtnZoomIn  <> nil then StyleButton(BtnZoomIn,  False);
  if BtnZoomDef <> nil then StyleButton(BtnZoomDef, False);
  if BtnZoomOut <> nil then StyleButton(BtnZoomOut, False);
  // Стек панов: шапки/кнопки всех панелей (пан 0 — «⊞», паны N — «×»).
  for i := 0 to MAX_PANS - 1 do
    if FPans[i] <> nil then
    begin
      FPans[i].View.SetTheme(T);
      // Палитра подписей спотов парная под тему — у каждого пана свой оверлей.
      if FPans[i].DXOverlay <> nil then
        FPans[i].DXOverlay.SetLightTheme(FLightTheme);
      if FPans[i].PbSpectrum is TPaintBox then
        TPaintBox(FPans[i].PbSpectrum).Color := T.BG;
      if FPans[i].PbWaterfall is TPaintBox then
        TPaintBox(FPans[i].PbWaterfall).Color := T.BG;
      if FPans[i].Splitter <> nil then FPans[i].Splitter.Color := T.Border;
      if FPans[i].BtnZoomIn <> nil then StyleButton(FPans[i].BtnZoomIn, False);
      if FPans[i].BtnZoomDef <> nil then StyleButton(FPans[i].BtnZoomDef, False);
      if FPans[i].BtnZoomOut <> nil then StyleButton(FPans[i].BtnZoomOut, False);
      if FPans[i].BtnAddPan <> nil then StyleButton(FPans[i].BtnAddPan, False);
      if FPans[i].BtnGrid <> nil then StyleButton(FPans[i].BtnGrid, False);
      if FPans[i].BtnPopPan <> nil then StyleButton(FPans[i].BtnPopPan, False);
      if FPans[i].BtnClosePan <> nil then StyleButton(FPans[i].BtnClosePan, False);
      if FPans[i].BtnDisplay <> nil then StyleButton(FPans[i].BtnDisplay, False);
      ApplyPanHeaderTheme(FPans[i]);
      if i > 0 then
      begin
        FPans[i].SetFlagsTheme(T);
        if FPanSplitters[i] <> nil then FPanSplitters[i].Color := T.Border;
        if FPanVSplitters[i] <> nil then FPanVSplitters[i].Color := T.Border;
        if FPanFloatForm[i] <> nil then FPanFloatForm[i].Color := T.BG;
        // Зум-бар пана N (пан 0 — через алиас FPanZoomBar ниже).
        if FPans[i].ZoomBar <> nil then FPans[i].ZoomBar.SetTheme(T);
        if FPans[i].PbPanZoom <> nil then FPans[i].PbPanZoom.Color := T.SliderBG;
      end;
      // Тема инвалидирует кэши и GL-текстуры сама; историю водопада сохраняем.
      MarkPanDisplayDirty(FPans[i], False);
    end;
  if FPanZoomBar <> nil then
  begin
    FPanZoomBar.SetTheme(T);
    if PbPanZoom <> nil then PbPanZoom.Color := T.SliderBG;
  end;
  if BtnChannels      <> nil then StyleButton(BtnChannels, False);
  if FChannelsDropDown <> nil then FChannelsDropDown.ApplyStyle(StyleButton, T.Panel);
  if BtnTXProfile       <> nil then StyleButton(BtnTXProfile, False);
  if FTXProfileDropDown <> nil then FTXProfileDropDown.ApplyStyle(StyleButton, T.Panel);
  StyleButton(BtnNR,    BtnNR.Active);
  StyleButton(BtnNB,    BtnNB.Active);
  StyleButton(BtnSNB,   BtnSNB.Active);
  StyleButton(BtnANF,   BtnANF.Active);
  StyleButton(BtnMute,  BtnMute.Active);
  StyleButton(BtnMOX,   BtnMOX.Active);
  StyleButton(BtnTUN,   BtnTUN.Active);

  // VFO-copy
  StyleSpanButton(BtnVfoACopyB, False);
  StyleSpanButton(BtnVfoSwap,   False);
  StyleSpanButton(BtnVfoBCopyA, False);

  // Ползунки
  DS(TrkAGC); DS(TrkVolume); DS(TrkDrive);
  DS(TrkAtt); DS(TrkRxGain); DS(TrkRawDev);

  // Wideband не входит в стек обычных панов.
  if Assigned(FWidebandView) then FWidebandView.SetTheme(T);

  // Флаги слайсов (оверлеи) — цвета кнопок из темы, как левая панель.
  if FPan <> nil then FPan.SetFlagsTheme(T);

  // VFO display — вызывает UpdateVfoDisplay, который использует тему
  UpdateVfoDisplay;
end;

procedure TMainForm.SetStatusText(Index: Integer; const Text: string);
begin
  if StatusPanel = nil then Exit;
  StatusPanel.SetStatusText(Index, Text);
end;

procedure TMainForm.SetRadioOfflineStatus(const ConnText: string; ClearDevice: Boolean);
begin
  SetStatusText(2, ConnText);
  SetStatusText(3, 'Supply --');
  SetStatusText(4, 'RX idle');
  SetStatusText(5, 'TX idle');
  SetStatusText(6, 'SEQ --');
  SetStatusText(7, 'PLL --');
  UpdateDMRStatus;
  if FSpecView <> nil then
    FSpecView.ADCOverloadVisible := False;
  if PbSpectrum <> nil then
    PbSpectrum.Invalidate;
  FillChar(FController.FDDCSeqValid, SizeOf(FController.FDDCSeqValid), 0);
  FController.FSeqErrorCount     := 0;
  FController.FLastSeqErrorDDC   := -1;
  FController.FLastSeqErrorDelta := 0;
  FController.FSeqOkStreak       := 0;
  if ClearDevice then
  begin
    SetStatusText(0, 'Board --');
    SetStatusText(1, 'IP --');
  end;
end;

procedure TMainForm.ApplyStatusTheme(const T: TAppTheme);
begin
  if StatusPanel = nil then Exit;
  StatusPanel.ApplyTheme(T);
end;

procedure TMainForm.SetLightTheme(V: Boolean);
var
  T: TAppTheme;
begin
  if FLightTheme = V then Exit;
  FLightTheme := V;
  FController.FLoadedGlobal.LightTheme := V;  // зеркало в persist-буфер контроллера
  ApplyDarkTheme;
  T := CurrentAppTheme;
  if FDeviceDialog <> nil then FDeviceDialog.SetTheme(T);
  if FChannelsForm <> nil then TChannelsForm(FChannelsForm).ApplyTheme(T);
  if FCWMsgForm <> nil then TCWMessagesForm(FCWMsgForm).ApplyTheme(T);
  if FCWTermForm <> nil then TCWTerminalForm(FCWTermForm).ApplyTheme(T);
  if FBeaconScopeForm <> nil then FBeaconScopeForm.ApplyTheme(T);
  if FDXClusterForm <> nil then FDXClusterForm.ApplyTheme(T);
  // Оверлеи спотов перекрашены в ApplyDarkTheme — общим циклом по FPans.
  FController.FSettings.SaveTheme(V);
  FController.FSettings.Save;
  if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then
    TSettingsForm(FSettingsForm).LoadLightTheme(V);
end;

procedure TMainForm.StyleButton(B: TFlatButton; Active: Boolean);
var T: TAppTheme;
begin
  T := CurrentAppTheme;
  B.Active     := Active;
  B.ClrNorm    := T.BtnNorm;
  B.ClrActive  := T.BtnActive;
  B.ClrHot     := T.BtnHot;
  if Active then B.ClrBorder := T.BtnBorderActive
  else           B.ClrBorder := T.BtnBorderNorm;
  B.ClrText    := T.BtnText;
  B.ClrTextAct := T.BtnTextActive;
  B.Font.Size  := 8;
end;

procedure TMainForm.StyleSpanButton(B: TFlatButton; Active: Boolean);
var T: TAppTheme;
begin
  T := CurrentAppTheme;
  B.Active     := Active;
  B.ClrNorm    := T.SpanNorm;
  B.ClrActive  := T.SpanActive;
  B.ClrHot     := T.SpanActive;
  B.ClrBorder  := T.SpanBorder;
  B.ClrText    := T.SpanText;
  B.ClrTextAct := T.SpanText;
  B.Font.Size  := 8;
end;

procedure TMainForm.PositionSampleRateOverlay;
const
  MARGIN_X = 34;
  MARGIN_Y = 4;
begin
  if (FSampleRateOverlay = nil) or (PbSpectrum = nil) then Exit;
  // Ширина = доступная ширина спектра справа от MARGIN_X (контент сам клампится
  // к этому пределу). Раньше фиксировалось 320px → 8 Pluto-кнопок не влезали.
  FSampleRateOverlay.SetBounds(
    MARGIN_X,
    MARGIN_Y,
    Max(320, PbSpectrum.Width - MARGIN_X * 2),
    FSampleRateOverlay.Height);
end;

procedure TMainForm.OnSampleRateOverlayInvalidate(Sender: TObject);
begin
  FSpectrumDirty := True;
  if Assigned(FSpecView) then
  begin
    FSpecView.SpectrumDirty := True;
    FSpecView.InvalidateOverlayCache;
  end;
  if Assigned(PbSpectrum) then PbSpectrum.Invalidate;
end;

// ===========================================================================
// VFO
// ===========================================================================

function TMainForm.FormatFreq(Hz: Double): string;
var
  Mhz: Int64;
  KHz, Rest: Integer;
begin
  Mhz  := Trunc(Hz / 1000000);
  KHz  := Trunc((Hz - Mhz * 1000000) / 1000);
  Rest := Trunc(Hz) mod 1000;
  Result := Format('%d.%3.3d.%3.3d', [Mhz, KHz, Rest]);
end;

procedure TMainForm.UpdateVfoDisplay;
const
  TOP_VFO_FREQ_H = 47;
var T: TAppTheme;
begin
  T := CurrentAppTheme;

  FreqDispA.Frequency := Round(FController.FVfoA);
  FreqDispB.Frequency := Round(FController.FVfoB);
  FreqDispA.FontSize := TOP_VFO_FONT;
  FreqDispB.FontSize := TOP_VFO_FONT;
  FreqDispA.Height := TOP_VFO_FREQ_H;
  FreqDispB.Height := TOP_VFO_FREQ_H;

  FreqDispA.ColorHover   := T.FreqHover;
  FreqDispA.ColorHoverBG := T.FreqHoverBG;
  FreqDispA.ColorDim     := T.FreqDimDot;
  FreqDispB.ColorHover   := T.FreqHover;
  FreqDispB.ColorHoverBG := T.FreqHoverBG;
  FreqDispB.ColorDim     := T.FreqDimDot;

  if FController.FActiveVfo = 0 then
  begin
    FreqDispA.ColorNormal := T.FreqActive;
    FreqDispB.ColorNormal := T.FreqDim;
    LblVfoALabel.Font.Color := T.FreqActive;
    LblVfoBLabel.Font.Color := T.TextDim;
    if BtnTopVfoASelect <> nil then begin
      BtnTopVfoASelect.ClrNorm    := T.VfoSelActNorm;
      BtnTopVfoASelect.ClrActive  := T.VfoSelActNorm;
      BtnTopVfoASelect.ClrHot     := T.VfoSelActNorm;
      BtnTopVfoASelect.ClrBorder  := T.VfoSelActBorder;
      BtnTopVfoASelect.ClrText    := T.VfoSelActText;
      BtnTopVfoASelect.ClrTextAct := T.VfoSelActTextAct;
      BtnTopVfoASelect.Active := True;
      BtnTopVfoASelect.Invalidate;
    end;
    if BtnTopVfoBSelect <> nil then begin
      BtnTopVfoBSelect.ClrNorm    := T.VfoSelInaNorm;
      BtnTopVfoBSelect.ClrActive  := T.VfoSelInaNorm;
      BtnTopVfoBSelect.ClrHot     := T.VfoSelInaNorm;
      BtnTopVfoBSelect.ClrBorder  := T.VfoSelInaBorder;
      BtnTopVfoBSelect.ClrText    := T.VfoSelInaText;
      BtnTopVfoBSelect.ClrTextAct := T.VfoSelInaTextAct;
      BtnTopVfoBSelect.Active := False;
      BtnTopVfoBSelect.Invalidate;
    end;
  end else begin
    FreqDispA.ColorNormal := T.FreqDim;
    FreqDispB.ColorNormal := T.FreqActive;
    LblVfoALabel.Font.Color := T.TextDim;
    LblVfoBLabel.Font.Color := T.FreqActive;
    if BtnTopVfoASelect <> nil then begin
      BtnTopVfoASelect.ClrNorm    := T.VfoSelInaNorm;
      BtnTopVfoASelect.ClrActive  := T.VfoSelInaNorm;
      BtnTopVfoASelect.ClrHot     := T.VfoSelInaNorm;
      BtnTopVfoASelect.ClrBorder  := T.VfoSelInaBorder;
      BtnTopVfoASelect.ClrText    := T.VfoSelInaText;
      BtnTopVfoASelect.ClrTextAct := T.VfoSelInaTextAct;
      BtnTopVfoASelect.Active := False;
      BtnTopVfoASelect.Invalidate;
    end;
    if BtnTopVfoBSelect <> nil then begin
      BtnTopVfoBSelect.ClrNorm    := T.VfoSelActNorm;
      BtnTopVfoBSelect.ClrActive  := T.VfoSelActNorm;
      BtnTopVfoBSelect.ClrHot     := T.VfoSelActNorm;
      BtnTopVfoBSelect.ClrBorder  := T.VfoSelActBorder;
      BtnTopVfoBSelect.ClrText    := T.VfoSelActText;
      BtnTopVfoBSelect.ClrTextAct := T.VfoSelActTextAct;
      BtnTopVfoBSelect.Active := True;
      BtnTopVfoBSelect.Invalidate;
    end;
  end;
  if BtnTopVfoATX <> nil then
  begin
    if not FController.FSplitTxB then
    begin
      BtnTopVfoATX.ClrNorm    := T.VfoTxActNorm;
      BtnTopVfoATX.ClrHot     := T.VfoTxActNorm;
      BtnTopVfoATX.ClrActive  := T.VfoTxActNorm;
      BtnTopVfoATX.ClrBorder  := T.VfoTxActBorder;
      BtnTopVfoATX.ClrText    := T.VfoTxActText;
      BtnTopVfoATX.ClrTextAct := T.VfoTxActText;
      BtnTopVfoATX.Active := True;
    end
    else
    begin
      BtnTopVfoATX.ClrNorm    := T.VfoTxInaNorm;
      BtnTopVfoATX.ClrHot     := T.VfoTxInaNorm;
      BtnTopVfoATX.ClrActive  := T.VfoTxInaNorm;
      BtnTopVfoATX.ClrBorder  := T.VfoTxInaBorder;
      BtnTopVfoATX.ClrText    := T.VfoTxInaText;
      BtnTopVfoATX.ClrTextAct := T.VfoTxInaText;
      BtnTopVfoATX.Active := False;
    end;
    BtnTopVfoATX.Invalidate;
  end;
  if BtnTopVfoBTX <> nil then
  begin
    if FController.FSplitTxB then
    begin
      BtnTopVfoBTX.ClrNorm    := T.VfoTxActNorm;
      BtnTopVfoBTX.ClrHot     := T.VfoTxActNorm;
      BtnTopVfoBTX.ClrActive  := T.VfoTxActNorm;
      BtnTopVfoBTX.ClrBorder  := T.VfoTxActBorder;
      BtnTopVfoBTX.ClrText    := T.VfoTxActText;
      BtnTopVfoBTX.ClrTextAct := T.VfoTxActText;
      BtnTopVfoBTX.Active := True;
    end
    else
    begin
      BtnTopVfoBTX.ClrNorm    := T.VfoTxInaNorm;
      BtnTopVfoBTX.ClrHot     := T.VfoTxInaNorm;
      BtnTopVfoBTX.ClrActive  := T.VfoTxInaNorm;
      BtnTopVfoBTX.ClrBorder  := T.VfoTxInaBorder;
      BtnTopVfoBTX.ClrText    := T.VfoTxInaText;
      BtnTopVfoBTX.ClrTextAct := T.VfoTxInaText;
      BtnTopVfoBTX.Active := False;
    end;
    BtnTopVfoBTX.Invalidate;
  end;
  FreqDispA.Invalidate;
  FreqDispB.Invalidate;
  LblVfoALabel.Invalidate;
  LblVfoBLabel.Invalidate;
end;

// ===========================================================================
// Rendering helpers — delegated to TSpectrumView
// ===========================================================================

procedure TMainForm.InvalidateGridCache;
begin
  FSpecView.InvalidateGridCache;
end;

procedure TMainForm.SyncSpecViewFreq;
var ViewCenter, ViewSpan: Double;
begin
  // Видимое окно с учётом зума (центр+span). Без зума == FCenterFreq/FSpanHz.
  FController.GetViewWindow(ViewCenter, ViewSpan);
  FSpecView.VfoA      := FController.FVfoA;
  FSpecView.VfoB      := FController.FVfoB;
  FSpecView.ActiveVfo := FController.FActiveVfo;
  FSpecView.CenterFreq := ViewCenter;
  FSpecView.SpanHz    := ViewSpan;
  FSpecView.Mode      := FController.FMode;
  FSpecView.FilterBW  := FController.FFilterBW;
  if FController.FMode in [MODE_FM, MODE_DMR, MODE_FMRAW] then
    FSpecView.FMGridStepHz := FM_STEP_HZ[FController.FFMStepIdx]
  else
    FSpecView.FMGridStepHz := 0;
  // TX overlay следует за активной TX-частотой (учитывает split). При CTUN
  // спектр TX будет отрисован на VFO, а не в центре дисплея.
  FSpecView.TXFreq    := ActiveTXFreqHz;
  // Какой VFO привязан к TX (для split-полос фильтра): задаётся селектором
  // FSplitTxB (TX-A/TX-B), независимо от активного RX-VFO.
  if FController.FSplitTxB then FSpecView.TXVfoIndex := 1
  else                          FSpecView.TXVfoIndex := 0;
  // Бэндплан QO-100 следует за видом спектра (downlink-домен). SetView сам
  // отсекает неизменное → пересборки кэша на каждый вызов нет.
  if Assigned(FBandPlanOverlay) then
    FBandPlanOverlay.SetView(ViewCenter, ViewSpan);
  // Подписи спотов — тот же видимый домен, что у бэндплана.
  if Assigned(FDXSpotOverlay) then
    FDXSpotOverlay.SetView(ViewCenter, ViewSpan);
  if UpdateWidebandFrequencyView and FController.FShowWideband and (PbWideband <> nil) then
    PbWideband.Invalidate;
  UpdatePanZoomBar;   // линейка пана следует за частотой/зумом (no-op если без изменений)
end;


// ===========================================================================
// Timers
// ===========================================================================

procedure TMainForm.MeterTimerTick(Sender: TObject);
const
  AVG_ALPHA     = 0.25;   // EMA для сглаженного среднего уровня
  ZONE_DB       = 6.0;    // полуширина зоны вокруг среднего (дБ)
  // Адаптивный alpha: базовый + ускорение при большой разнице
  PEAK_BASE     = 0.06;   // базовая скорость пика
  MIN_BASE      = 0.06;   // базовая скорость минимума
  ACCEL_FACTOR  = 0.018;  // ускорение на каждый dB разницы
  MAX_ALPHA     = 0.85;   // максимальная скорость (не мгновенно)
var
  PeakTarget, MinTarget: Double;
  PeakDiff,  MinDiff:    Double;
  PeakAlpha, MinAlpha:   Double;
begin
  // Затухание спотов не зависит от того, крутится ли приём.
  ServiceDXCluster;

  if not FController.FRunning then
  begin
    if PbSMeterRight <> nil then PbSMeterRight.Invalidate;
    Exit;
  end;

  // QO-100 beacon lock: одна итерация контура (no-op если лок выключен).
  FController.ServiceBeaconLock;
  // Pluto: опрос температуры/RSSI (троттлинг внутри; no-op для HPSDR).
  FController.ServicePlutoTelemetry;
  // Телеграф: фронты «железо в эфире» (у Pluto HP-статуса нет — гнать индикацию
  // и отпускать реле T/R после выдержки больше некому).
  FController.ServiceCWKeyed;
  // Телеграф: разбор принятого в текст + окно-терминал + лента под спектром.
  ServiceCWTerminal;
  // PureSignal: state machine команд + auto-attenuate + опрос GetPSInfo
  // (no-op на бэкендах без PS; рендер кнопок/поповера через rfPureSignal).
  FController.PureSignalTick;

  // Сглаженное среднее (EMA)
  FSMeterAvg := FSMeterAvg * (1.0 - AVG_ALPHA) + FController.FLastSMeter * AVG_ALPHA;

  // Цели для Peak и Min
  PeakTarget := Max(FController.FLastSMeter, FSMeterAvg + ZONE_DB);
  MinTarget  := Min(FController.FLastSMeter, FSMeterAvg - ZONE_DB);

  // Адаптивный alpha: чем дальше от цели — тем быстрее догоняем
  PeakDiff  := Abs(PeakTarget - FSMeterPeak);
  MinDiff   := Abs(MinTarget  - FSMeterMin);
  PeakAlpha := Min(MAX_ALPHA, PEAK_BASE + PeakDiff * ACCEL_FACTOR);
  MinAlpha  := Min(MAX_ALPHA, MIN_BASE  + MinDiff  * ACCEL_FACTOR);

  FSMeterPeak := FSMeterPeak + PeakAlpha * (PeakTarget - FSMeterPeak);
  FSMeterMin  := FSMeterMin  + MinAlpha  * (MinTarget  - FSMeterMin);

  FSpecView.LastSMeter  := FController.FLastSMeter;
  FSpecView.SMeterPeak  := FSMeterPeak;
  FSpecView.SMeterMin   := FSMeterMin;
  FSpecView.LastFwdW    := FController.FLastFwdW;
  FSpecView.LastSWR     := FController.FLastSWR;
  // Метр — по RadioKeyed, а не по FTransmitting: в телеграфе софт «не передаёт»,
  // ключ замыкает прошивка, и стрелка осталась бы на S-метре всю связь.
  FSpecView.Transmitting := FController.RadioKeyed;

  if PbSMeterRight <> nil then PbSMeterRight.Invalidate;
  // PWR/SWR метки и бары обновляем здесь (10 Гц), а не в DoUpdateStatus —
  // HP Status пакеты идут ~150 раз/с, при таком rate цифры дёргаются.
  UpdateTXMeters;

  if FController.FWDSPReady then
  begin
    if FController.FTransmitting then
    begin
      case FController.FDSPEngine.TXMicSource of
        txmsSoundCard: SetStatusText(5, 'TX sound card');
        txmsWeb:       SetStatusText(5, 'TX web mic');
      else
        SetStatusText(5, 'TX radio mic');
      end;
    end
    else
      SetStatusText(5, 'TX idle');
  end;
end;

procedure TMainForm.SpectrumTimerTick(Sender: TObject);
var
  NowTick:     QWord;
  ElapsedTick: QWord;
  LastPktTick: QWord;
  I:           Integer;
begin
  // «×» шапки пана: закрытие отложено сюда (нельзя free панель из её кнопки).
  if FPendingPanClose <> 0 then
  begin
    I := FPendingPanClose;
    FPendingPanClose := 0;
    ClosePanadapter(I);
  end;
  // Синхронизируем размеры если размер панели изменился (после ресайза).
  // Важно: проверяем только видимые панели — когда спектр скрыт,
  // PbSpectrum.Height может быть ненулевым при FPan.SpectrumHeight=0,
  // что вызвало бы сброс буфера водопада на каждом тике.
  // Wideband сверяем с шириной PanelRight, а НЕ со спектром пана 0: в гриде
  // «две колонки» пан 0 — половина ширины, а wideband остаётся во всю (иначе
  // условие вечно истинно и тик дёргает ResizeSpectrumPanels каждый кадр —
  // раскладка пересчитывается под курсором и сплиттеры не тянутся).
  if (FPan.SpectrumWidth = 0) or
     (FController.FShowWideband and (PanelRight <> nil) and
      (PbWideband.Width <> PanelRight.ClientWidth)) or
     (FController.FShowSpectrum and ((PbSpectrum.Width <> FPan.SpectrumWidth) or
                         (PbSpectrum.Height <> FPan.SpectrumHeight))) or
     (FController.FShowWaterfall and not FController.FShowSpectrum and
                        ((PbWaterfall.Width <> FPan.SpectrumWidth) or
                         (PbWaterfall.Height <> FPan.WaterfallHeight))) then
    ResizeSpectrumPanels;
  if FPan.SpectrumWidth <= 0 then Exit;
  SyncSpecViewFreq;

  // Проверка таймаута — трансивер не отвечает
  if FController.FRunning and FController.FNetwork.Running then
  begin
    NowTick     := GetTickCount64;
    ElapsedTick := NowTick - FController.FRXStartTime;
    // Сетевой поток может обновить FRXLastPktTime на 1 ms ПОСЛЕ NowTick.
    // QWord-вычитание в таком случае underflow'ится в огромное значение и
    // давало ложный "Connection lost" при совершенно живом DDC-потоке.
    LastPktTick := FController.FRXLastPktTime;
    // Ждём первый пакет 5 секунд после старта
    if (LastPktTick = 0) and (ElapsedTick > 5000) then
    begin
      BtnStartStop.Caption := 'START';
      StyleButton(BtnStartStop, False);
      FController.FRunning := False;
      UninhibitSleep;
      FController.FNetwork.SetRunAndFreq(False, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), 0);
      SetRadioOfflineStatus('Hardware timeout');
      UpdateTXControlAvailability;
      Exit;
    end;
    // После первого пакета — следим чтобы поток не прерывался более 3 сек
    if (LastPktTick > 0) and (NowTick >= LastPktTick) and
       ((NowTick - LastPktTick) > 3000) then
    begin
      BtnStartStop.Caption := 'START';
      StyleButton(BtnStartStop, False);
      FController.FRunning := False;
      UninhibitSleep;
      FController.FNetwork.SetRunAndFreq(False, XvtrTranslate(FController.FCenterFreq), XvtrTranslate(FController.FCenterFreq), 0);
      SetRadioOfflineStatus('Connection lost');
      UpdateTXControlAvailability;
      Exit;
    end;
  end;

  if FController.FRunning then
  begin
    // Обновляем статус из таймера — без Synchronize в сетевом потоке.
    SetStatusText(4, 'RX running');
    if FController.FSeqErrorCount > 0 then
      SetStatusText(6, Format('SEQ ERR DDC%d', [FController.FLastSeqErrorDDC]))
    else
      SetStatusText(6, 'SEQ OK');

    if FController.FWDSPReady then
    begin
      FController.FLastSMeter := FController.ReadSMeterDBm;
      FController.FDSPEngine.SetSpectrumWidth(FPan.SpectrumWidth);
      Inc(FAgcLineCounter);
      if FAgcLineCounter >= 6 then
      begin
        FController.FDSPEngine.UpdateAGCLines(FPan.SpectrumWidth);
        FSpecView.AGCThresh    := FController.FDSPEngine.AGCThresh;
        FSpecView.AGCHangLevel := FController.FDSPEngine.AGCHangLevel;
        FAgcLineCounter := 0;
      end;
    end;
    FSpecView.WDSPReady := FController.FWDSPReady;
    // Флаги: метр/частота главного, позиции, троттленный S-метр слайсов.
    FPan.TickFlags;
    // Доп. паны: синк вьюх + тик флагов + рендер готовых кадров.
    SyncPanViews;
    for I := 1 to MAX_PANS - 1 do
      if FPans[I] <> nil then
      begin
        FPans[I].TickFlags;
        if FPanSpecDirty[I] then
        begin
          FPans[I].View.DrawSpectrum;
          FPans[I].PbSpectrum.Invalidate;
          if FPans[I].View.NeedsRulerRedraw then
            FPans[I].PbRuler.Invalidate;
          FPanSpecDirty[I] := False;
        end;
        if FPanWfDirty[I] then
        begin
          FPans[I].View.DrawWaterfall;
          FPans[I].PbWaterfall.Invalidate;
          FPanWfDirty[I] := False;
        end;
      end;
    // Панорама и водопад теперь рисуются независимо по факту новых кадров.
    // Маленький FFT даёт новые строки чаще.
    if FSpectrumDirty then
    begin
      FSpecView.DrawSpectrum;
      PbSpectrum.Invalidate;
      if FSpecView.NeedsRulerRedraw then
        PbRuler.Invalidate;
      FSpectrumDirty := False;
    end;
    if FWaterfallDirty then
    begin
      FSpecView.DrawWaterfall;
      PbWaterfall.Invalidate;
      FWaterfallDirty := False;
    end;
    if FWidebandDirty and FController.FShowWideband then
    begin
      PbWideband.Invalidate;
      FWidebandDirty := False;
    end;
  end
  else
  begin
    // Не подключены — показываем чистый экран (без анимации)
    if not FController.FNetwork.Connected then
    begin
      // При первом старте показываем демо-спектр
      if FSpecView.SpectrumBitmapWidth <> FPan.SpectrumWidth then
      begin
        FSpecView.SetSpectrumBitmapSize(FPan.SpectrumWidth, FPan.SpectrumHeight);
        FSpecView.ResetSpectrumBuf;
        FSpecView.DrawSpectrum;
        PbSpectrum.Invalidate;
      end;
    end
    else
    begin
      // Подключены, но не запущены — статичный спектр (шум)
      FSpecView.DrawSpectrum;
      PbSpectrum.Invalidate;
      FSpectrumDirty := False;
    end;
  end;

  // Если не в режиме RUN, но есть "грязный" кадр (drag/marker/UI), перерисуем.
  if (FSpectrumDirty or FWaterfallDirty) and (not FController.FRunning) then
  begin
    if FSpectrumDirty then
      FSpecView.DrawSpectrum;
    if FWaterfallDirty then
      FSpecView.DrawWaterfall;
    if FSpectrumDirty then
      PbSpectrum.Invalidate;
    if FSpecView.NeedsRulerRedraw then
      PbRuler.Invalidate;
    if FWaterfallDirty then
      PbWaterfall.Invalidate;
    FWaterfallDirty := False;
    FSpectrumDirty := False;
  end;

  // Исходящее зеркало состояния + спектр/водопад в web — в адаптере (всё из
  // FController, включая последний кадр пикселей). Сервер сам гейтит по FRunning.
  if Assigned(FWebAdapter) then
    FWebAdapter.PushState;
end;

// ===========================================================================
// Network callbacks → sync helpers
// ===========================================================================

// OnDeviceFound / discovery теперь в TRadioController (сетевой поток → store +
// rfDeviceList). UI слушает rfDeviceList в OnControllerState.

procedure TMainForm.UpdateTXMeters;
// Вызывается из MeterTimerTick (10 Гц). FController.FLastFwdW/FLastSWR/
// FLastSupplyV/FLastSupplyA обновляются на rate HP Status (~150/с)
// в TRadioController.ApplyHPStatus.
begin
  UpdateStatusField7;   // PLL (openHPSDR) или статус beacon lock (Pluto)
  UpdateDMRStatus;      // отдельное динамическое поле DMR
  UpdateStatusField3;   // Supply (openHPSDR) или Temp/RSSI (Pluto)
end;

procedure TMainForm.UpdateTXControlAvailability;
var MOXAllowed, TuneAllowed: Boolean;
begin
  if (BtnMOX = nil) or (BtnTUN = nil) or (FController = nil) then Exit;
  MOXAllowed := FController.FRunning and
                (FController.ActiveTXMode <> MODE_DMR);
  // FM RAW transmits external PCM only; the speech-path tune generator is not
  // useful there and remains guarded in the controller as well.
  TuneAllowed := MOXAllowed and (FController.ActiveTXMode <> MODE_FMRAW);
  BtnMOX.Enabled := MOXAllowed;
  BtnTUN.Enabled := TuneAllowed;
  // Выбор TX-профиля: в DMR передатчика нет вовсе, в FM RAW профиль ни на что
  // не влияет (ApplyTXModeSettings глушит там всю обработку). Гейт по режиму
  // ИСТОЧНИКА передачи, а не главного приёмника: с главным в DMR можно
  // передавать со слайса, и там профиль по-прежнему нужен. Run не требуем —
  // профиль выбирается и оффлайн, как и на вкладке настроек.
  if BtnTXProfile <> nil then
  begin
    BtnTXProfile.Enabled :=
      not (FController.ActiveTXMode in [MODE_DMR, MODE_FMRAW]);
    if (not BtnTXProfile.Enabled) and (FTXProfileDropDown <> nil) then
      FTXProfileDropDown.ClosePopup;   // открытый список закрываем сами
  end;
  UpdatePSButtons;
end;

procedure TMainForm.UpdatePSButtons;
// PS: доступен на openHPSDR-платах с feedback-DDC (Caps.HasPureSignal) при
// Run. 2TON — обычный TX-тест, доступен как MOX/TUN.
var
  PSAllowed, TXAllowed: Boolean;
begin
  if (BtnPS = nil) or (Btn2Ton = nil) or (FController = nil) then Exit;
  TXAllowed := FController.FRunning and
               not (FController.ActiveTXMode in [MODE_DMR, MODE_FMRAW]);
  PSAllowed := TXAllowed and FController.PSAvailable;
  BtnPS.Enabled   := PSAllowed;
  Btn2Ton.Enabled := TXAllowed;
  StyleButton(BtnPS,   FController.FPSEnabled or FController.PSSingleCalActive);
  StyleButton(Btn2Ton, FController.FTwoTone);
  if (FPSPopup <> nil) and TPureSignalPopup(FPSPopup).Visible then
    TPureSignalPopup(FPSPopup).RefreshStatus;
  UpdateIMDMarkers;
end;

procedure TMainForm.UpdateIMDMarkers;
// 2TON: маркеры пиков тонов и продуктов IMD3. Только в DUP — дисплей остаётся
// на RX и показывает СВОЙ сигнал после PA (настоящие плечи IMD); цифровой
// TX-спектр продуктов PA не содержит, там замер бессмыслен. Абсолютные
// частоты = TX ± аудио-оффсеты тонов (знак как у генератора: для нижней
// боковой тона лежат НИЖЕ несущей). Замер/отрисовка — в SpectrumView.
var
  Sgn: Double;
begin
  if (FSpecView = nil) or (FController = nil) then Exit;
  if (FController.ActiveTXMode = MODE_LSB) or
     (FController.ActiveTXMode = MODE_CWL) or
     (FController.ActiveTXMode = MODE_DIGL) then
    Sgn := -1.0
  else
    Sgn := 1.0;
  FSpecView.SetIMDMarkers(
    FController.FTwoTone and FController.FTransmitting
      and FController.FDisplayDuplex,
    ActiveTXFreqHz + Sgn * FController.FTXSettings.PSTTFreq1,
    ActiveTXFreqHz + Sgn * FController.FTXSettings.PSTTFreq2);
end;

procedure TMainForm.UpdateStatusField3;
// Поле 3 статус-бара. openHPSDR — supply V/A (HP status). Pluto — температура
// чипа и RSSI приёмника (нет PA → supply неприменим). Значения берём из
// контроллера (FLastPlutoTempC/RSSIdB / FLastSupplyV/A) — логика опроса в ядре.
var
  S: string;
begin
  // Pluto: нет PA/supply — показываем аппаратную телеметрию AD936x.
  if FController.IsPluto then
  begin
    if not FController.FRunning then begin SetStatusText(3, 'Pluto --'); Exit; end;
    S := '';
    // Temp X/YC: X — трансивер AD9361, Y — Zynq SoC/FPGA (xadc).
    if (FController.FLastPlutoTempC > TELEMETRY_NONE)
       and (FController.FLastPlutoSocTempC > TELEMETRY_NONE) then
      S := Format('Temp %.0f/%.0fC', [FController.FLastPlutoTempC, FController.FLastPlutoSocTempC])
    else if FController.FLastPlutoTempC > TELEMETRY_NONE then
      S := Format('Temp %.0fC', [FController.FLastPlutoTempC])
    else if FController.FLastPlutoSocTempC > TELEMETRY_NONE then
      S := Format('Temp %.0fC', [FController.FLastPlutoSocTempC]);
    if FController.FLastPlutoRSSIdB > TELEMETRY_NONE then
    begin
      if S <> '' then S := S + '  ';
      S := S + Format('RSSI %.0fdB', [FController.FLastPlutoRSSIdB]);
    end;
    if S = '' then S := 'Pluto --';
    SetStatusText(3, S);
    Exit;
  end;

  if not FController.FRunning then
  begin
    SetStatusText(3, 'Supply --');
    Exit;
  end;

  if not BoardSupportsSupplyVoltage(FController.FNetwork.Device.BoardType) then
  begin
    SetStatusText(3, 'Supply n/a');
    Exit;
  end;

  if FController.FLastSupplyV >= 0 then
  begin
    if FController.FLastSupplyA >= 0 then
      SetStatusText(3,
        Format('Supply %.1fV %.1fA', [FController.FLastSupplyV, FController.FLastSupplyA]))
    else
      SetStatusText(3,
        Format('Supply %.1fV', [FController.FLastSupplyV]));
  end else
    SetStatusText(3, 'Supply --');
end;

function TMainForm.BeaconStatusText: string;
// Компактный статус QO-100 beacon lock для поля 7 статус-бара (Pluto). Courier 8pt,
// поле ~200px → держим ≤ ~24 символов.
begin
  if not FController.BeaconLockEnabled then Exit('BCN: off');
  if FController.BeaconDecodeFreqHz <= 0 then Exit('BCN: click beacon');
  case FController.BeaconState of
    bsLock:   Result := Format('BCN LOCK %.0fHz /%.0fdB',
                               [FController.BeaconCorrectionHz, FController.BeaconSNRdB]);
    bsSearch: Result := Format('BCN sync /%.0fdB', [FController.BeaconSNRdB]);
  else        Result := 'BCN --';
  end;
end;

function TMainForm.DMRStatusText: string;
var
  S: TDMRStatus;
  Inv, Details, TargetText: string;
begin
  if not FController.GetDMRStatus(S) then Exit('DMR unavailable');
  if not S.Enabled then Exit('DMR off');
  if not S.Synced then Exit(Format('DMR sync... %.3f', [S.SignalRMS]));
  if S.Inverted then Inv := ' INV' else Inv := '';
  Details := '';
  if S.LinkControlValid then Details := Format(' TS%d', [S.LCSlot + 1])
  else if S.CACHValid then Details := Format(' TS%d', [S.Slot + 1]);
  if S.SlotTypeValid then
    Details := Details + Format(' CC%d/DT%d', [S.ColorCode, S.DataType]);
  TargetText := TDMRDecoder.LinkControlTargetText(S);
  if TargetText <> '' then Details := Details + ' ' + TargetText;
  Result := Format('DMR %s%s%s #%d',
                   [TDMRDecoder.SyncKindName(S.SyncKind), Details, Inv,
                    S.SyncCount]);
end;

procedure TMainForm.UpdateStatusField7;
// Поле 7 статус-бара. openHPSDR — PLL-lock (как прежде). Pluto в трансвертере —
// статус QO-100 beacon lock (у Pluto нет PLL-телеметрии); Pluto без XVTR — пусто.
begin
  if FController.IsPluto then
  begin
    if FController.FCurrentXvtr >= 0 then SetStatusText(7, BeaconStatusText)
    else                                  SetStatusText(7, '');
    Exit;
  end;
  if not FController.FRunning then SetStatusText(7, 'PLL --')
  else if FController.FLastPLLLock then SetStatusText(7, 'PLL OK')
  else SetStatusText(7, 'PLL?');
end;

procedure TMainForm.UpdateDMRStatus;
begin
  if StatusPanel = nil then Exit;
  StatusPanel.SetStatusVisible(8, FController.FMode = MODE_DMR);
  if FController.FMode = MODE_DMR then
    SetStatusText(8, DMRStatusText)
  else
    SetStatusText(8, '');
end;

procedure TMainForm.OnWidebandCB(ADCIndex: Integer;
  const Samples: array of SmallInt; Count: Integer);
begin
  if (not FController.FShowWideband) or (FWidebandView = nil) then Exit;
  FWidebandView.SetSamples(Samples, Count);
  FWidebandDirty := True;
end;

procedure TMainForm.DoUpdateDDCSeq(DDCIdx: Integer; Seq: LongWord);
begin
  SetStatusText(4, 'RX running');
end;

// ---------------------------------------------------------------------------
// TX settings — применение к WDSP, сборка DUC Specific, sound-card pull
// ---------------------------------------------------------------------------

// BuildMicLineSelectByte / SendDUCSpecificFromSettings переехали в
// TRadioController (чистые над FTXSettings/движком; нужны headless StartRunning).

function TMainForm.DefaultMicSource: TTXMicSource;
// Mic source для MOX-кнопки и Web-TX: если настроена звуковая карта — берём с неё,
// иначе используем HW mic-стрим от трансивера.
// HW PTT всегда переключает на txmsRadio непосредственно в ApplyMOX.
begin
  // Учитываем вход TX-слайса: у него может быть свой (виртуальный кабель),
  // даже когда общего входа нет — иначе взяли бы микрофон радио.
  if FController.ActiveMicInDevName <> '' then Result := txmsSoundCard
  else                                        Result := txmsRadio;
end;

// ApplyTXSettingsToDSP переехал в TRadioController (вся TX-цепь над FTXSettings).

// ===========================================================================
// Button handlers
// ===========================================================================

{ Поток открытия — отдельный класс, без анонимных процедур }
// Открываем WDSP в фоновом потоке чтобы не блокировать UI при нажатии START
type
  TWDSPOpenThread = class(TThread)
  private
    FForm:   TMainForm;
    FResult: Boolean;
    procedure SyncDone;
  protected
    procedure Execute; override;
  public
    constructor Create(AForm: TMainForm);
  end;

constructor TWDSPOpenThread.Create(AForm: TMainForm);
begin
  inherited Create(False);
  FForm            := AForm;
  FResult          := False;
  FreeOnTerminate  := True;
end;

procedure TWDSPOpenThread.Execute;
var
  M: TThreadMethod;
begin
  try
    FResult := FForm.FController.FDSPEngine.Open;
  except
    FResult := False;
  end;
  M := SyncDone;
  TThread.Synchronize(nil, M);
end;

procedure TWDSPOpenThread.SyncDone;
begin
  FForm.OnWDSPOpenDone(FResult);
end;

// TDiscoverThread / no-device-сигнал переехали в TRadioController.Discover.

procedure TMainForm.RecreateDSPEngine(ASampleRate: Integer);
begin
  if ASampleRate <= 0 then Exit;
  if Assigned(FController.FDSPEngine) then
  begin
    FController.FDSPEngine.Close;
    FreeAndNil(FController.FDSPEngine);
  end;
  FController.FWDSPReady := False;
  FController.FDSPEngine := TWDSPEngine.Create(ASampleRate, 48000, 512);
  // Внутренние колбэки контроллера (аудио слайсов, отвод демодулятора для
  // декодеров и RX_AUDIO по TCI, калибровка дисплея, тап IQ, декодер маяка) —
  // одним вызовом: их навешивает CreateEngines, а новый движок про них не знал.
  FController.AttachEngineCallbacks;
  FController.FDSPEngine.OnAudio     := FController.OnAudioReady;
  FController.FDSPEngine.OnSpectrum  := FController.OnSpectrumReady;
  FController.FDSPEngine.OnWaterfall := FController.OnWaterfallReady;
  // Пиксели доп. панов (этап 3.3) — прямо в UI (display-поток; см. FPanCbLock).
  FController.FDSPEngine.OnPanSpectrum  := OnPanSpectrumReady;
  FController.FDSPEngine.OnPanWaterfall := OnPanWaterfallReady;
  FController.FDSPEngine.OnTXIQ      := FController.OnTXIQ;
  // Иначе sound-card mic путь теряется после смены sample rate (колбэк
  // навешивается в FormCreate, но новый движок про него не знал).
  FController.FDSPEngine.OnPullMicSamples := FController.PullSoundCardMic;
end;

function TMainForm.EnsureWDSPWisdom: Boolean;
var
  WisdomDir:  string;
  WisdomPath: string;
  DirA:       AnsiString;
  BuildThread: TWisdomBuildThread;
  Dlg:         TWisdomProgressDialog;
begin
  Result := True;

  if not Assigned(@WDSPwisdom) then Exit;

  WisdomDir  := GetAppCfgDir;
  WisdomPath := WisdomDir + WDSP_WISDOM_FILE;

  if FileExists(WisdomPath) then
  begin
    // По рекомендации WDSP вызываем функцию на каждом старте:
    // если файл уже есть, библиотека просто импортирует wisdom.
    DirA := AnsiString(WisdomDir);
    WDSPwisdom(PAnsiChar(DirA));
    Exit;
  end;

  BtnStartStop.Enabled := False;
  SetStatusText(2, 'Creating WDSP wisdom...');

  BuildThread := TWisdomBuildThread.Create(WisdomDir);
  try
    if FLightTheme then
      Dlg := TWisdomProgressDialog.Create(Self, BuildThread, LightTheme)
    else
      Dlg := TWisdomProgressDialog.Create(Self, BuildThread, DarkTheme);
    try
      Dlg.ShowModal;
    finally
      Dlg.Free;
    end;

    BuildThread.WaitFor;

    if BuildThread.ErrorText <> '' then
    begin
      ShowMessage('Failed to create WDSP wisdom.' + LineEnding +
        BuildThread.ErrorText);
      Result := False;
    end
    else if not FileExists(WisdomPath) then
    begin
      ShowMessage('WDSP wisdom was not created.' + LineEnding +
        'Expected file: ' + WisdomPath);
      Result := False;
    end;
  finally
    BuildThread.Free;
    BtnStartStop.Enabled := True;
  end;

  if Result then
    SetStatusText(2, 'DSP ready')
  else
    SetStatusText(2, 'WDSP wisdom missing');
end;

procedure TMainForm.BtnDiscoverClick(Sender: TObject);
begin
  // Открываем диалог выбора устройства
  if not Assigned(FDeviceDialog) then
    FDeviceDialog := TDeviceDialog.Create(Self);
  FDeviceDialog.Store := FController.FDeviceStore;
  FDeviceDialog.OnDiscover := BtnDiscoverFromDialog;
  FDeviceDialog.SetTheme(CurrentAppTheme);
  FDeviceDialog.ClearResult;  // сбрасываем старый выбор перед открытием
  FDeviceDialog.ShowModal;
  // Если пользователь нажал CONNECT — результат хранится в DialogResult,
  // START его заберёт и использует с приоритетом над autostart
end;

procedure TMainForm.BtnDiscoverFromDialog(Sender: TObject);
// Запускается когда пользователь нажимает DISCOVER внутри диалога.
// Контроллер чистит список и стартует поток; UI слушает rfDeviceList.
// Сетевые Pluto (без mDNS) не находятся scan'ом — собираем список URI для
// прямого probe: сохранённые Pluto + ручной ввод IP в поле диалога.
var
  URIs: array of string;
  i: Integer;
  ip: string;

  procedure AddURI(const U: string);
  var k: Integer;
  begin
    if U = '' then Exit;
    for k := 0 to High(URIs) do if SameText(URIs[k], U) then Exit;
    SetLength(URIs, Length(URIs) + 1);
    URIs[High(URIs)] := U;
  end;

begin
  SetLength(URIs, 0);
  for i := 0 to FController.FDeviceStore.SavedCount - 1 do
    if FController.FDeviceStore.Saved(i).Kind = bkPluto then
      AddURI(FController.FDeviceStore.Saved(i).URI);
  if Assigned(FDeviceDialog) then
  begin
    ip := FDeviceDialog.ManualIP;
    if ip <> '' then
    begin
      if (Pos('ip:', ip) = 1) or (Pos('usb:', ip) = 1) or (Pos('local:', ip) = 1) then
        AddURI(ip)
      else
        AddURI('ip:' + ip);
    end;
  end;
  FController.SetPlutoProbeURIs(URIs);

  SetStatusText(2, 'Discovering...');
  FController.Discover;
end;

procedure TMainForm.BtnStartStopClick(Sender: TObject);
var
  Dev: THPSDRDevice;
  AutoIP: string;
begin
  if FController.FNetwork.Connected then
  begin
    // --- STOP ---
    DoStopAndDisconnect;
    Exit;
  end;

  // --- START ---
  AutoIP := '';
  FPendingBoardType := 0;

  // Явный выбор через CONNECT в диалоге имеет приоритет над autostart
  if Assigned(FDeviceDialog) and FDeviceDialog.DialogResult.Accepted then
  begin
    FPendingIP        := FDeviceDialog.DialogResult.IPAddress;
    FPendingBoardType := FDeviceDialog.GetSavedBoardType(
                           FDeviceDialog.DialogResult.SavedIdx);
    FDeviceDialog.ClearResult;  // consumed — следующий START снова спросит
  end
  else
  begin
    if Assigned(FDeviceDialog) then
      AutoIP := FDeviceDialog.GetAutoStartIP;

    if AutoIP <> '' then
    begin
      FPendingIP        := AutoIP;
      FPendingBoardType := FDeviceDialog.GetAutoStartBoardType;
    end
    else
    begin
      if not Assigned(FDeviceDialog) then
        FDeviceDialog := TDeviceDialog.Create(Self);
      FDeviceDialog.Store := FController.FDeviceStore;
      FDeviceDialog.OnDiscover := BtnDiscoverFromDialog;
      FDeviceDialog.SetTheme(CurrentAppTheme);
      if FDeviceDialog.ShowModal <> mrOk then Exit;
      if not FDeviceDialog.DialogResult.Accepted then Exit;
      FPendingIP := FDeviceDialog.DialogResult.IPAddress;
      FPendingBoardType := FDeviceDialog.GetSavedBoardType(
                             FDeviceDialog.DialogResult.SavedIdx);
      FDeviceDialog.ClearResult;
    end;
  end;

  // Создаём запись устройства. Если это известный (discovered/saved) Pluto —
  // восстанавливаем Kind/URI/Serial/MAC из store по IP; иначе HPSDR по IP.
  Dev := ResolveDevice(FPendingIP);
  if Dev.Kind = bkHPSDR then Dev.BoardType := FPendingBoardType;
  StartWithDevice(Dev);
end;

function TMainForm.ResolveDevice(const IP: string): THPSDRDevice;
// По IP/URI ищет устройство среди discovered, затем saved, чтобы восстановить
// Kind/URI/Serial/MAC (нужно для Pluto). Логика общая с демоном — в контроллере.
begin
  Result := FController.ResolveDevice(IP);
end;

procedure TMainForm.DoStopAndDisconnect;
// STOP-teardown, общий для десктоп-STOP и web-disconnect. Движок (graceful Run=0,
// сброс TX/телеметрии) + персист настроек + Disconnect — в контроллере
// (StopAndDisconnect); рендер run-кнопки/MOX/sleep — через rfRunning. Здесь —
// только очистка спектра и контекстный offline-статус.
begin
  FController.StopAndDisconnect;
  FSpecView.ResetSpectrumBuf;
  FSpecView.DrawSpectrum;
  PbSpectrum.Invalidate;
  SetRadioOfflineStatus('Disconnected', True);
end;

procedure TMainForm.StartWithDevice(const Dev: THPSDRDevice);
// Запуск с уже выбранным устройством — общий путь для десктоп-диалога и
// web-оверлея (без модального диалога). Подбор sample rate до WDSP, открытие
// WDSP (async) и подключение.
var
  PreloadRate: Integer;
  FoundByIP: Boolean;
  i: Integer;
  PreG: TGlobalSettings;
  PreBands: array[0..CFG_BAND_COUNT-1] of TBandSettings;
begin
  // Подбираем sample rate до открытия WDSP, чтобы не делать двойной Open:
  // 1) Open на 192k, 2) сразу ChangeSampleRate+Open ещё раз.
  // Если устройство уже было обнаружено (есть MAC) и есть сохранённый профиль,
  // создаём WDSPEngine сразу с нужной частотой.
  PreloadRate := FController.FSampleRate;
  FoundByIP := False;
  i := FController.FDeviceStore.FindDiscoveredByIP(Dev.IPAddress);
  if i >= 0 then
  begin
    FoundByIP := True;
    if FController.FSettings.LoadDevice(FController.FDeviceStore.Discovered(i).MAC, PreG, PreBands)
       and (PreG.SampleRate > 0) then
      PreloadRate := PreG.SampleRate;
  end;
  if FoundByIP and (PreloadRate > 0) then
  begin
    FController.FSampleRate := PreloadRate;
    FController.FSpanHz := PreloadRate;
    if (not FController.FWDSPReady) and (Assigned(FController.FDSPEngine)) and (FController.FDSPEngine.SampleRate <> PreloadRate) then
      RecreateDSPEngine(PreloadRate);
  end;

  // --- Открываем WDSP асинхронно чтобы не блокировать UI ---
  FPendingDev := Dev;
  BtnStartStop.Enabled := False;
  SetStatusText(2, 'Opening DSP...');
  if FController.FWDSPReady then
    // WDSP уже открыт — сразу подключаемся
    DoConnectDevice(Dev)
  else
    // Открываем в фоновом потоке
    TWDSPOpenThread.Create(Self);
end;

procedure TMainForm.OnWDSPOpenDone(Success: Boolean);
begin
  // Вызывается из TWDSPOpenThread.SyncDone — уже в главном потоке
  FController.FWDSPReady := Success;
  if not Success then
  begin
    SetStatusText(2, 'DSP failed');
    BtnStartStop.Enabled := True;
    BtnStartStop.Caption := 'START';
    StyleButton(BtnStartStop, False);
    ShowMessage('WDSP not loaded.' + LineEnding +
      IfThen(FController.FDSPEngine.LastError <> '', FController.FDSPEngine.LastError, 'Unknown WDSP init error.') + LineEnding +
      'Put wdsp.dll рядом с ewsdr.exe (той же разрядности, что и приложение).');
    Exit;
  end;
  DoConnectDevice(FPendingDev);
end;

procedure TMainForm.RelabelBands;
// Переписывает подписи band-кнопок под план активного бэкенда (HF ↔ VHF/UHF).
var i: Integer; pl: Boolean;
begin
  pl := FController.IsPluto;
  for i := 0 to BAND_COUNT - 1 do
    if BtnBand[i] <> nil then
    begin
      BtnBand[i].Caption := BandName(i, pl);
      // Пустой слот плана (HF: 12-й) — прячем кнопку.
      BtnBand[i].Visible := BtnBand[i].Caption <> '';
    end;
end;

procedure TMainForm.ApplyBackendCapsToUI;
// Пресеты sample-rate оверлея из Caps (Pluto >560k) + гейтинг того, чего у
// бэкенда физически нет. Настройки гейтятся при открытии окна (SF.Set*Backend
// в BtnSettingsClick), здесь — то, что видно в главном окне.
var
  Caps: TBackendCaps;
  i:    Integer;
begin
  Caps := FController.BackendCaps;
  if Assigned(FSampleRateOverlay) then
  begin
    // Единый источник — контроллер (caps бэкенда). UI больше не владеет списком.
    FSampleRateOverlay.SetRatePresets(FController.SampleRatePresets);
    FSampleRateOverlay.SetCurrentRate(FController.FSampleRate);
  end;
  PushRatePresetsToWeb;   // тот же источник зеркалим в web-морду
  PushBandsToWeb;         // band-план бэкенда → web (HF ↔ VHF/UHF)

  // Отдельный Pluto RF-AGC блок под AGC: показываем и сдвигаем панели ниже.
  // Широкополосный АЦП есть только у openHPSDR. Галка на такой машине из
  // настроек убрана, поэтому включённой её оставлять нельзя: панель осталась бы
  // пустой, а выключить её стало бы нечем.
  if (not Caps.HasWideband) and FController.FShowWideband then
    ApplyVisibility(FController.FShowSpectrum, FController.FShowWaterfall,
                    False, FController.FWidebandFill, FController.FSpectrumFill);

  // Измеритель PWR/SWR: без моста детектора (AD936x) FLastFwdW/FLastSWR стоят
  // на инициализации, и «0W / SWR 1.0» на передаче были бы выдумкой — шкала
  // рисуется пустой.
  if FSpecView <> nil then FSpecView.HasPATelemetry := Caps.HasPA;
  for i := 0 to High(FPans) do
    if (FPans[i] <> nil) and (FPans[i].View <> nil) then
      FPans[i].View.HasPATelemetry := Caps.HasPA;

  LayoutLeftPanel(Caps.HasHWGain);
  if Caps.HasHWGain then UpdateRxGainUI;
  UpdateRxMuteButton;   // RX MUTE — видимость по Pluto + full-duplex (если QO-100 уже активен)
  UpdateBandPlanOverlay; // полоска бэндплана QO-100
end;

procedure TMainForm.DoConnectDevice(const Dev: THPSDRDevice);
begin
  BtnStartStop.Enabled := True;

  // Connect + load-by-MAC + перестройка диапазона/XVTR/активного VFO + go-live —
  // вся оркестрация в контроллере (ConnectAndRun); рендер device/band/run-
  // виджетов идёт через события (rfDevice/rfBandRestore/rfXvtr/rfActiveVfo/
  // rfRunning). Здесь — только UI-extras (статус-строки/AGCTop/resize).
  if not FController.ConnectAndRun(Dev, FPan.SpectrumWidth) then
  begin
    ShowMessage('Failed to connect to ' + Dev.IPAddress +
      IfThen(FController.FNetwork.LastError <> '', ': ' + FController.FNetwork.LastError, ''));
    Exit;
  end;

  // Активный VFO=A: подсветку (рисовалась в FormCreate при FActiveVfo=0) освежаем
  // здесь — для B это сделал SetActiveVfo(1) внутри ConnectAndRun.
  if FController.FActiveVfo <> 1 then
    UpdateVfoDisplay;

  SetStatusText(1, 'IP: ' + FController.FNetwork.Device.IPAddress);
  SetStatusText(0, 'Board: ' + FController.BoardDisplayName);
  FSpecView.AGCTop := FController.FAGCTop;
  SetStatusText(6, 'SEQ OK');
  SetStatusText(2, 'Running');
  ResizeSpectrumPanels;
end;


// ---------------------------------------------------------------------------
// ActivateVfo — переключаем активный VFO и перестраиваем приёмник.
// Idx=0 → VFO-A активен, Idx=1 → VFO-B активен.
// Перестройка всегда происходит на частоту активного VFO (не на оффсет).
// CTUN при этом сбрасывается чтобы не было путаницы с оффсетом.

// ---------------------------------------------------------------------------
// ActiveVfoFreq — частота активного VFO
// ---------------------------------------------------------------------------
function TMainForm.ActiveVfoFreq: Int64;
begin
  if FController.FActiveVfo = 0 then Result := Round(FController.FVfoA)
  else                    Result := Round(FController.FVfoB);
end;

// ---------------------------------------------------------------------------
// ActivateVfo — переключаем активный VFO и перестраиваем приёмник.
// Idx=0 → VFO-A активен, Idx=1 → VFO-B активен.
// Перестройка на частоту выбранного VFO. CTUN не трогаем.
// ---------------------------------------------------------------------------
procedure TMainForm.ActivateVfo(Idx: Integer);
begin
  // Движок (переключение RX-VFO, CTUN-логика, центр/сеть, диапазон) — в
  // контроллере; рендер — OnControllerState(rfActiveVfo/rfBand/rfCTun).
  FController.SetActiveVfo(Idx);
end;

procedure TMainForm.FreqDispAClick(Sender: TObject);
begin
  // Клик на VFO-A → активируем A (перестройка на FController.FVfoA)
  if FController.FActiveVfo <> 0 then
    ActivateVfo(0);
end;

procedure TMainForm.FreqDispBClick(Sender: TObject);
begin
  // Клик на VFO-B → активируем B (перестройка на FController.FVfoB)
  if FController.FActiveVfo <> 1 then
    ActivateVfo(1);
end;

procedure TMainForm.BtnTopVfoASelectClick(Sender: TObject);
begin
  if FController.FActiveVfo <> 0 then
    ActivateVfo(0);
end;

procedure TMainForm.BtnTopVfoATXClick(Sender: TObject);
begin
  FController.FSplitTxB := False;
  UpdateVfoDisplay;

  if FController.FNetwork.Connected and FController.FNetwork.Running then
  begin
    FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), FController.TXTuneFreqHz, FController.FDriveLevel,
                         FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone);
    FController.FNetwork.SendFullHP;
  end;
end;

procedure TMainForm.BtnTopVfoBSelectClick(Sender: TObject);
begin
  if FController.FActiveVfo <> 1 then
    ActivateVfo(1);
end;

procedure TMainForm.BtnTopVfoBTXClick(Sender: TObject);
begin
  FController.FSplitTxB := True;
  if FController.FSplitTxB and (FController.FActiveVfo <> 0) then
    ActivateVfo(0)
  else
    UpdateVfoDisplay;

  if FController.FNetwork.Connected and FController.FNetwork.Running then
  begin
    FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), FController.TXTuneFreqHz, FController.FDriveLevel,
                         FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone);
    FController.FNetwork.SendFullHP;
  end;
end;

procedure TMainForm.FreqDispAChanged(Sender: TObject; NewFreq: Int64);
begin
  if FController.FActiveVfo = 0 then
    ApplyVfoA(NewFreq)
  else
    FController.FVfoA := NewFreq;  // просто сохраняем, приёмник не перестраиваем
end;

procedure TMainForm.FreqDispBChanged(Sender: TObject; NewFreq: Int64);
begin
  // Движок (сохранение B, перестройка при активном B, диапазон, сеть) — в
  // контроллере; рендер — OnControllerState(rfVfoB).
  FController.SetVfoB(NewFreq);
end;

// ---------------------------------------------------------------------------
// UpdateFilterButtons — обновляет подписи кнопок фильтра под текущий режим
// ---------------------------------------------------------------------------
// Раскладывает N кнопок фильтра: пресеты режима + VAR1/VAR2 следом. Ряды по
// FILT_GRID_COLS, но узкие режимы (FM=2+2, WFM=3+2) занимают один ряд целиком,
// растягивая кнопки — как было до появления VAR.
procedure TMainForm.LayoutFilterButtons(PresetCount: Integer);
var
  i, Total, Cols, Rows, RowOf, ColOf, InRow: Integer;
  HeaderH, RowStep, BottomPad: Integer;
begin
  Total := PresetCount + 2;                     // + VAR1/VAR2
  if Total <= FILT_GRID_COLS then Cols := Total // FM/WFM: всё в один ряд
  else                            Cols := FILT_GRID_COLS;
  Rows := (Total + Cols - 1) div Cols;
  HeaderH := PanelFilter.Scale96ToForm(20);
  RowStep := PanelFilter.Scale96ToForm(27);
  BottomPad := PanelFilter.Scale96ToForm(4);
  for i := 0 to FILT_SLOTS - 1 do
  begin
    if i < PresetCount then      InRow := i             // пресет на своём месте
    else if i = FILT_VAR1 then   InRow := PresetCount   // VAR1 сразу за пресетами
    else if i = FILT_VAR2 then   InRow := PresetCount + 1
    else begin BtnFilter[i].Visible := False; Continue; end;
    RowOf := InRow div Cols;
    ColOf := InRow mod Cols;
    BtnFilter[i].Left    := LeftPanelButtonLeft(PanelFilter.Width, Cols, ColOf);
    BtnFilter[i].Width   := LeftPanelButtonWidth(PanelFilter.Width, Cols, ColOf);
    BtnFilter[i].Top     := HeaderH + RowOf * RowStep;
    BtnFilter[i].Caption := FController.FilterSlot(FController.FMode, i).Name;
    BtnFilter[i].Visible := True;
    StyleButton(BtnFilter[i], i = FController.FFilter);
  end;
  PanelFilter.Height := HeaderH + Rows * RowStep + BottomPad;
end;

procedure TMainForm.UpdateFilterButtons;
var
  i:    Integer;
  DefIdx: Integer;
begin
  // DMR has a mandatory 12.5 kHz channel. There is nothing for the operator
  // to choose, so hide the whole filter selector and restore it on exit.
  PanelFilter.Visible := FController.FMode <> MODE_DMR;
  if FController.FMode = MODE_FM then
  begin
    // FM: NFM/FM + VAR1/VAR2 в один ряд (панель остаётся низкой, SQL/CTCSS
    // садятся сразу под ней).
    CloseCTCSSPopup;
    FController.SanitizeFilterState;
    LayoutFilterButtons(FILT_FM_COUNT);
    if PanelFMSQ <> nil then
    begin
      PanelFMSQ.Visible := True;
      StyleButton(BtnFMSQ, FController.FFMSQOn);
      TrkFMSQ.Position := FController.FFMSQLevel;
      LblFMSQ.Caption  := IntToStr(FController.FFMSQLevel);
    end;
    if PanelFMCTCSS <> nil then PanelFMCTCSS.Visible := True;
    if PanelFMStep <> nil then
    begin
      StyleButton(BtnFMStep, FController.FFMStepOn);
      if FStepDropDown <> nil then FStepDropDown.SetItemIndex(FController.FFMStepIdx);
      PanelFMStep.Visible := True;
    end;
    if PanelFMRpt <> nil then
    begin
      StyleButton(BtnFMRptMinus, FController.FFMRptDir = RPT_MINUS);
      StyleButton(BtnFMRptPlus,  FController.FFMRptDir = RPT_PLUS);
      EdFMRptOffset.Text := RptFormatOffset(FController.FFMRptOffsetHz);
      PanelFMRpt.Visible := True;
    end;
    if PanelRawDev <> nil then PanelRawDev.Visible := False;
    RelayoutBelowBands;
    Exit;
  end;

  // Non-FM: restore full 2-row height and 5-column layout, hide FM panels.
  // DMR reuses only the tuning STEP panel; SQL/CTCSS/repeater stay FM-only.
  PanelFilter.Height := PanelFilter.Scale96ToForm(74);
  if PanelFMSQ <> nil then PanelFMSQ.Visible := False;
  if PanelFMCTCSS <> nil then PanelFMCTCSS.Visible := False;
  if PanelFMStep <> nil then
  begin
    if FController.FMode in [MODE_DMR, MODE_FMRAW] then
    begin
      StyleButton(BtnFMStep, FController.FFMStepOn);
      if FStepDropDown <> nil then
        FStepDropDown.SetItemIndex(FController.FFMStepIdx);
      PanelFMStep.Visible := True;
    end
    else
    begin
      CloseFMStepPopup;
      PanelFMStep.Visible := False;
    end;
  end;
  if PanelFMRpt <> nil then PanelFMRpt.Visible := False;
  if PanelRawDev <> nil then PanelRawDev.Visible := False;

  if FController.FMode = MODE_WFM then
  begin
    // WFM: полосы вещательного канала — три кнопки + VAR1/VAR2 в один ряд.
    CloseCTCSSPopup;
    FController.SanitizeFilterState;
    LayoutFilterButtons(FILT_WFM_COUNT);
    RelayoutBelowBands;
    Exit;
  end;

  if FController.FMode = MODE_FMRAW then
  begin
    CloseCTCSSPopup;
    FController.SanitizeFilterState;
    LayoutFilterButtons(FILT_RAW_COUNT);
    if PanelRawDev <> nil then
    begin
      TrkRawDev.Position := Round(FController.FRawFMDeviation) div 50;
      LblRawDev.Caption := Format('%.2f k', [FController.FRawFMDeviation / 1000.0]);
      PanelRawDev.Visible := True;
    end;
    RelayoutBelowBands;
    Exit;
  end;

  if FController.FMode = MODE_DMR then
  begin
    CloseCTCSSPopup;
    FController.SanitizeFilterState;
    for i := 0 to FILT_SLOTS - 1 do BtnFilter[i].Visible := False;
    RelayoutBelowBands;
    Exit;
  end;

  // Прочие режимы: смена режима возвращает заводской индекс фильтра (как было
  // до таблицы), кромки и подписи приезжают из таблицы контроллера.
  PanelFilter.Height := PanelFilter.Scale96ToForm(74);
  DefIdx := FilterDefIdx(FController.FMode);
  FController.SelectFilterSlotState(DefIdx);
  LayoutFilterButtons(FILT_COUNT);
  RelayoutBelowBands;
end;

// ---------------------------------------------------------------------------
// ApplyVfoA — единая точка смены частоты VFO A
//
// CTUN ВЫКЛ (классический режим):
//   FController.FCenterFreq = FController.FVfoA — спектр центрирован на VFO, маркер всегда в центре
//
// CTUN ВКЛ:
//   FController.FCenterFreq статичен — спектр/водопад не двигаются
//   FController.FVfoA движется по дисплею (маркер гуляет)
//   Перецентрирование — только когда край полосы фильтра выходит за границу
//   дисплея.
// ---------------------------------------------------------------------------
procedure TMainForm.ApplyVfoA(NewFreq: Int64);
begin
  // Тонкий делегат: движок (XVTR-клэмп, CTUN, band-detect, FM авто-RPT, сеть) —
  // в SetVfoA; канальная оркестрация — в FController.OnAfterTune (хук в конце
  // SetVfoA → FChannelController.OnVfoTuned); рендер — OnControllerState.
  FController.SetVfoA(NewFreq);
end;

// ---------------------------------------------------------------------------
// CTUN toggle
// ---------------------------------------------------------------------------
procedure TMainForm.BtnCTunClick(Sender: TObject);
begin
  // Логика (band cache, center/shift, сеть) — в контроллере; рендер — в
  // OnControllerState(rfCTun).
  FController.SetCTun(not FController.FCTun);
end;

procedure TMainForm.BtnBeaconClick(Sender: TObject);
// ЛКМ по BEACON: вкл/выкл лок маяка (= декодер + трим LOError). При включении
// «вооружаем» клик (следующий ЛКМ по спектру наведёт декодер на маяк) и открываем
// окно констелляции/бюллетеня. Декодер ведёт несущую, контур стекает дрейф в LOError.
begin
  FController.SetBeaconLock(not FController.BeaconLockEnabled);
  FBeaconArming := FController.BeaconLockEnabled;
  if FController.BeaconLockEnabled then
  begin
    if FBeaconScopeForm = nil then
      FBeaconScopeForm := TBeaconScopeForm.CreateWith(Self, FController);
    FBeaconScopeForm.ApplyTheme(CurrentAppTheme);
    FBeaconScopeForm.Show;
  end;
  UpdateBeaconButton;
end;

procedure TMainForm.BtnBeaconMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
// ПКМ по BEACON: показать/скрыть окно констелляции+бюллетеня (диагностика декодера).
// Лок не трогает — он на ЛКМ.
begin
  if Button <> mbRight then Exit;
  if not FController.BeaconLockEnabled then Exit;
  if FBeaconScopeForm = nil then
    FBeaconScopeForm := TBeaconScopeForm.CreateWith(Self, FController);
  FBeaconScopeForm.ApplyTheme(CurrentAppTheme);
  if FBeaconScopeForm.Visible then FBeaconScopeForm.Hide
  else FBeaconScopeForm.Show;
end;

procedure TMainForm.UpdateBeaconButton;
// Beacon-маркеры + кнопка BEACON. Кнопка видима только на Pluto в трансвертере
// (см. rfXvtr) — это ПРОСТО кнопка: подпись 'BEACON', подсветка = лок включён.
// Вся инфа (lock/коррекция/поиск/«click») — в статус-баре, поле 7 (вместо PLL),
// см. UpdateStatusField7. На openHPSDR кнопка скрыта, beacon-маркеры неактивны.
begin
  // Маркеры на спектре: опорная частота (зелёная) + отслеживаемый центроид (оранж).
  if FSpecView <> nil then
  begin
    FSpecView.SetBeaconMarkers(FController.BeaconLockEnabled,
                               FController.BeaconRefHz,
                               FController.BeaconTrackedFreqHz);
    // Узкий фильтр-маркер декодера (±450 Гц ≈ полоса BPSK-маяка).
    FSpecView.SetBeaconDecMarker(
      FController.BeaconLockEnabled and (FController.BeaconDecodeFreqHz > 0),
      FController.BeaconDecodeFreqHz, 450.0);
  end;
  UpdateStatusField7;     // моментальный апдейт статус-бара (не ждём MeterTimer)
  if BtnBeacon = nil then Exit;
  BtnBeacon.Caption := 'BEACON';
  StyleButton(BtnBeacon, FController.BeaconLockEnabled);
  BtnBeacon.Repaint;
end;

// ---------------------------------------------------------------------------
// DUP toggle — локальный режим дисплея без аппаратной команды.
// ---------------------------------------------------------------------------
procedure TMainForm.ApplyDUP(Active: Boolean);
begin
  // Единый путь: контроллер ставит FDisplayDuplex + KeepRXDuringTX (живьём) и шлёт
  // rfDuplex → там рендер кнопки/дисплея. Так тумблер и QO-100-авто согласованы
  // (раньше кнопка писала FDisplayDuplex напрямую, минуя KeepRXDuringTX и rfDuplex).
  FController.SetDuplex(Active);
end;

procedure TMainForm.BtnDUPClick(Sender: TObject);
begin
  ApplyDUP(not FController.FDisplayDuplex);
end;

procedure TMainForm.BtnRxMuteClick(Sender: TObject);
begin
  FController.SetRxMuteOnTx(not FController.FRxMuteOnTx);   // рендер через rfRxMuteOnTx
end;

procedure TMainForm.UpdateRxMuteButton;
// Кнопка RX MUTE видна на ЛЮБОМ бэкенде, но АКТИВНА только в DUP (self-monitor
// осмыслен лишь в full-duplex; без DUP звук на TX и так не проходит — движок
// гейтит по FKeepRXDuringTX=FDisplayDuplex). ON (подсвечена) = RX-аудио глушится
// на TX; OFF = слышим свой downlink во время передачи.
begin
  if BtnRxMute = nil then Exit;
  BtnRxMute.Visible := True;
  BtnRxMute.Enabled := FController.DisplayDuplex;
  StyleButton(BtnRxMute, FController.FRxMuteOnTx);
end;

// ═══════════════════════════════════════════════════════════════════════════
// DX-кластер: база спотов + telnet-клиент + оверлей подписей на спектре
// ═══════════════════════════════════════════════════════════════════════════

function DXModeSetFromMask(Mask: LongWord): TDXModeSet;
// Маска настроек → множество мод. 0 = без фильтра (показываем всё).
var M: TDXMode;
begin
  Result := [];
  if Mask = 0 then Exit;
  for M := Low(TDXMode) to High(TDXMode) do
    if (Mask and (LongWord(1) shl Ord(M))) <> 0 then Include(Result, M);
end;

procedure TMainForm.InitDXCluster;
begin
  FDXStore  := TDXSpotStore.Create;
  FDXClient := TDXClusterClient.Create(FDXStore);

  // Оверлей пана 0 живёт в самом пане, как и у панов N; FDXSpotOverlay — только
  // алиас на него (как FSpecView для FPan.View), чтобы главный пан не был
  // особым случаем в настройках/теме/тике.
  FPan.AttachDXSpots(FDXStore);
  FDXSpotOverlay := FPan.DXOverlay;
  FDXSpotOverlay.SetLightTheme(FLightTheme);
  FDXSpotOverlay.SetView(FController.FCenterFreq, FController.FSpanHz);

  FDXAgeTickAt := Now;
  FController.FSettings.LoadDXClusterSettings(FDXCfg);
  FDXStore.TTLMinutes := FDXCfg.TTLMinutes;
  ApplyDXClusterSettings(FDXCfg);
  // FDXConnCfg пуст → ApplyDXConnection увидит смену параметров и поднимет
  // соединение, если оно включено и позывной задан.
  ApplyDXConnection(FDXCfg);
end;

procedure TMainForm.ApplyDXClusterSettings(const D: TDXClusterSettings);
// Вид: оверлей + кнопка. Применяется сразу на каждую правку в SETUP — это
// дёшево, обратимо и даёт живой отклик. Всё, что МЕНЯЕТ ДАННЫЕ (TTL стора) или
// трогает соединение, откладывается до CommitDXClusterSettings: набирая «30» в
// поле TTL, пользователь на миг проходит через «3», а это выбросило бы из базы
// все споты старше трёх минут — необратимо.
var i: Integer;
begin
  FDXCfg := D;
  // Настройки общие для всех панов: споты рисуются на каждом, где частота
  // попадает в его окно.
  for i := 0 to MAX_PANS - 1 do
    if FPans[i] <> nil then
    begin
      ApplyDXOverlayCfg(FPans[i].DXOverlay);
      if i > 0 then MarkPanDirty(FPans[i]);
    end;
  UpdateDXClusterButton;
  FSpectrumDirty := True;
end;

procedure TMainForm.ApplyDXOverlayCfg(O: TDXSpotOverlay);
// Вид одного оверлея по текущему FDXCfg (сеттеры сами гасят «то же самое»).
begin
  if O = nil then Exit;
  O.MaxRows := FDXCfg.MaxRows;
  O.SetOwnCall(FDXCfg.Login);
  O.SetFilters(DXModeSetFromMask(FDXCfg.ModeMask), FDXCfg.TTLMinutes);
  O.SetEnabled(FDXCfg.ShowSpots);
  O.Invalidate;
end;

procedure TMainForm.AttachPanDXSpots(P: TPanafallPanel);
// Доп. пан получает СВОЙ оверлей на общей базе спотов: кэш полосы подписей
// ключуется центром/спаном/шириной пана, поэтому один экземпляр на всех
// пересобирался бы по кругу на каждый пан каждый кадр — ровно то, ради чего
// кэш и заводился.
var VC, VS: Double;
begin
  if (P = nil) or (FDXStore = nil) then Exit;
  P.AttachDXSpots(FDXStore);
  ApplyDXOverlayCfg(P.DXOverlay);
  P.DXOverlay.SetLightTheme(FLightTheme);
  PanViewWindow(P, VC, VS);
  P.DXOverlay.SetView(VC, VS);
end;

procedure TMainForm.ApplyDXConnection(const D: TDXClusterSettings);
// Соединение: конфиг в клиент + поднять/положить/переподнять. Поток снимает
// конфиг один раз на сессию, поэтому смена адреса/учётки требует рестарта —
// как у веб-сервера при смене порта.
var Reconnect: Boolean;
begin
  if FDXClient = nil then Exit;
  Reconnect := (D.Host <> FDXConnCfg.Host) or (D.Port <> FDXConnCfg.Port) or
               (D.Login <> FDXConnCfg.Login) or
               (D.Password <> FDXConnCfg.Password) or
               (D.PostLogin <> FDXConnCfg.PostLogin);
  FDXConnCfg := D;
  FDXClient.Configure(D.Host, D.Port, D.Login, D.Password, D.PostLogin);

  // Без позывного логиниться нечем — не поднимаем соединение вообще.
  if (not D.Enabled) or (Trim(D.Login) = '') then
  begin
    if FDXClient.Running then FDXClient.Stop;
    Exit;
  end;
  if FDXClient.Running then
  begin
    if not Reconnect then Exit;
    FDXClient.Stop;
  end;
  FDXClient.Start;
end;

procedure TMainForm.OnDXClusterSettingsChange(const D: TDXClusterSettings);
// Каждая правка в SETUP прилетает сюда ПОСИМВОЛЬНО (OnChange поля). Вид
// применяем сразу, а запись в конфиг и переподключение откладываем: иначе
// набор позывного «UA3XYZ» дёргал бы шесть реконнектов и шесть записей JSON.
begin
  ApplyDXClusterSettings(D);
  FDXPendingCfg := D;
  FDXPendingAt  := Now;
  FDXPending    := True;
end;

procedure TMainForm.CommitDXClusterSettings;
// Отложенный коммит правок SETUP: пользователь перестал печатать.
begin
  FDXPending := False;
  FController.FSettings.SaveDXClusterSettings(FDXPendingCfg);
  if FDXStore <> nil then FDXStore.TTLMinutes := FDXPendingCfg.TTLMinutes;
  ApplyDXConnection(FDXPendingCfg);
end;

procedure TMainForm.ServiceDXCluster;
// Раз в DX_AGE_TICK_SEC подталкиваем оверлей пересобраться: споты тускнеют с
// возрастом и уходят по TTL, а без нового спота версия стора не меняется и
// повода для пересборки бы не было. Реже — незаметно, чаще — впустую.
const
  DX_AGE_TICK_SEC       = 30;
  DX_SETTINGS_QUIET_MS  = 1200;   // тишина в SETUP, после которой коммитим
var i: Integer;
begin
  // Отложенный коммит настроек: пользователь перестал печатать в SETUP.
  if FDXPending and (MilliSecondsBetween(Now, FDXPendingAt) >= DX_SETTINGS_QUIET_MS) then
    CommitDXClusterSettings;

  if SecondsBetween(Now, FDXAgeTickAt) < DX_AGE_TICK_SEC then Exit;
  FDXAgeTickAt := Now;

  // TTL — свойство базы, а не картинки. Стор чистится только внутри Add и
  // снимков, поэтому с выключенными подписями и молчащим (или отключённым)
  // кластером просроченные споты не выбрасывал бы никто: новых Add нет, версия
  // стора не меняется, окно списка снимок не берёт, тик оверлея выключен.
  if FDXStore <> nil then FDXStore.Purge;

  if FDXSpotOverlay = nil then Exit;
  if not FDXSpotOverlay.Active then Exit;
  // Тик получают оверлеи всех панов: возраст спота от пана не зависит.
  for i := 0 to MAX_PANS - 1 do
    if (FPans[i] <> nil) and (FPans[i].DXOverlay <> nil) then
    begin
      FPans[i].DXOverlay.TickAge;
      if i > 0 then MarkPanDirty(FPans[i]);
    end;
  FSpectrumDirty := True;
end;

procedure TMainForm.UpdateDXClusterButton;
begin
  if BtnDXCluster = nil then Exit;
  StyleButton(BtnDXCluster,
    (FDXSpotOverlay <> nil) and FDXSpotOverlay.Active);
end;

procedure TMainForm.BtnDXClusterClick(Sender: TObject);
// ЛКМ — показывать/не показывать подписи спотов на спектре (настройка живёт
// в конфиге, чтобы состояние пережило перезапуск). Кнопка одна на все паны:
// применяем через общий Apply, иначе тумблер гасил бы только главный.
begin
  if FDXSpotOverlay = nil then Exit;
  FDXCfg.ShowSpots := not FDXCfg.ShowSpots;
  ApplyDXClusterSettings(FDXCfg);
  FController.FSettings.SaveDXClusterSettings(FDXCfg);
  // Правки SETUP ждут тишины в СВОЕЙ копии конфига. Не поправив её, отложенный
  // коммит записал бы старый FDXPendingCfg целиком и молча вернул прежнее
  // состояние тумблера — на экране новое, в файле старое.
  if FDXPending then FDXPendingCfg.ShowSpots := FDXCfg.ShowSpots;
  // Открытый SETUP держит СВОЮ копию конфига: без синхронизации галка «Show
  // spots» осталась бы старой, и первая же правка любого поля на этой странице
  // вернула бы подписи обратно.
  if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then
    TSettingsForm(FSettingsForm).LoadDXClusterSettings(FDXCfg);
end;

procedure TMainForm.BtnDXClusterMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
begin
  if Button = mbRight then ShowDXClusterForm;
end;

procedure TMainForm.ShowDXClusterForm;
begin
  if FDXClusterForm = nil then
  begin
    FDXClusterForm := TDXClusterForm.CreateWith(Self, FDXStore, FDXClient);
    FDXClusterForm.OnTuneSpot := OnDXTuneSpot;
  end;
  FDXClusterForm.ApplyTheme(CurrentAppTheme);
  if FDXClusterForm.Visible then FDXClusterForm.Hide
  else FDXClusterForm.Show;
end;

function DXSpotRadioMode(FreqHz: Double; Mode: TDXMode): Integer;
// Мода спота → режим приёмника. −1 = однозначно не определяется (dxmUnknown):
// текущую в этом случае не трогаем — гадать хуже, чем не менять.
begin
  Result := -1;
  case Mode of
    dxmCW:  Result := MODE_CWU;
    // Боковая по общепринятому правилу: ниже 10 МГц — LSB, выше — USB.
    dxmSSB: if FreqHz < 10000000.0 then Result := MODE_LSB else Result := MODE_USB;
    dxmFT8, dxmFT4, dxmDigi, dxmRTTY, dxmPSK: Result := MODE_DIGU;
    dxmSSTV: Result := MODE_USB;
    dxmFM:   Result := MODE_FM;
  end;
end;

procedure TMainForm.OnDXTuneSpot(FreqHz: Double; Mode: TDXMode);
// QSY по споту: клик по подписи на главном пане или двойной клик в окне списка.
// Крутим ТОТ VFO, который сейчас активен, — ровно как обычный клик по спектру
// (DoSpectrumClick): иначе при активном B спот уводил бы неактивный A.
var NewMode: Integer;
begin
  if FreqHz <= 0 then Exit;
  if FController.FActiveVfo = 0 then
    ApplyVfoA(Round(FreqHz))
  else
    FController.SetVfoB(Round(FreqHz));   // рендер через OnControllerState(rfVfoB)
  NewMode := DXSpotRadioMode(FreqHz, Mode);
  if (NewMode >= 0) and (NewMode <> FController.FMode) then
    FController.SetMode(NewMode);
end;

function TMainForm.DXTuneSpotOnPan(P: TPanafallPanel; FreqHz: Double;
  Mode: TDXMode): Integer;
// QSY по споту на доп. пане. Цель — слайс ЭТОГО пана (активный, если он здесь,
// иначе первый; нет ни одного — создаём в точке), ровно как у клика по фону
// пана: своего VFO у панов N нет, а ретюнить их DDC под спот нельзя — это
// увезло бы весь пан. Возвращает слайс, который увели на спот (0 — не увели):
// его номер и адресуют уведомления TCI.
var
  Id, NewMode: Integer;
  Hz: Int64;
  S: TCtrlSlice;
begin
  Result := 0;
  if (P = nil) or (FreqHz <= 0) then Exit;
  Hz := Round(FreqHz);
  // Спот видим в окне пана, но окно может быть шире полосы захвата (зум) —
  // слайс вне capture не поставить.
  if not FController.SliceFitsCapture(Hz, P.PanId) then Exit;

  if (FActiveSliceId <> 0) and FController.GetSlice(FActiveSliceId, S) and
     (S.PanId = P.PanId) then
    Id := FActiveSliceId
  else
    Id := P.FirstSliceId;

  NewMode := DXSpotRadioMode(FreqHz, Mode);
  if Id = 0 then
  begin
    // ★Берём то, что вернул сам вызов, а не FActiveSliceId: слоты могли
    // кончиться, и тогда в нём лежит прежний активный слайс — возможно, вообще
    // с другого пана. Раньше его и уводили на спот, а теперь ещё и объявляли
    // клиентам TCI приёмником, который «встал на спот».
    Id := AddSliceAtFreqPan(P, Hz);
    if Id = 0 then Exit;
  end
  else
    FController.SetSliceTarget(Id, Hz);

  // Режим слайса — с полосой пресета ЭТОГО режима: тащить полосу с главного
  // приёмника (как делает AddSliceAtFreqPan для нового слайса) значило бы
  // слушать телеграф в 2.7 кГц.
  if NewMode >= 0 then
    FController.SetSliceModeBW(Id, NewMode,
      FController.FilterBWFor(NewMode, FilterDefIdx(NewMode)));

  FActiveSliceId := Id;
  Result := Id;
  P.PushSliceFlagState(Id);
  P.LayoutFlags;
  MarkPanDirty(P);
end;

procedure TMainForm.UpdateBandPlanOverlay;
// Полоска бэндплана видна только в QO-100 (Pluto + full-duplex транспондер).
begin
  if FBandPlanOverlay = nil then Exit;
  FBandPlanOverlay.SetEnabled(FController.InQO100);
  FSpectrumDirty := True;   // моментальная перерисовка (не ждём таймер)
end;

// ---------------------------------------------------------------------------
// TUN — тон WDSP TXA PostGen + MOX с отдельным уровнем Drive.
// ---------------------------------------------------------------------------
procedure TMainForm.ApplyTUN(Active: Boolean);
begin
  // FWebClientActive держит web-слой актуальным (OnClientActiveChanged).
  FController.SetTune(Active);   // ядро TUN (safety/тон/drive/MOX) + рендер через rfTuning
end;

procedure TMainForm.BtnTUNClick(Sender: TObject);
begin
  ApplyTUN(not FController.FTuning);
end;

// ---------------------------------------------------------------------------
// PureSignal: кнопка PS (ЛКМ = вкл/выкл, ПКМ = поповер настроек) + 2TON
// ---------------------------------------------------------------------------
procedure TMainForm.BtnPSClick(Sender: TObject);
begin
  if not FController.PSAvailable then Exit;
  FController.SetPureSignal(not FController.FPSEnabled);   // рендер через rfPureSignal
end;

procedure TMainForm.BtnPSMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  Pop: TPureSignalPopup;
begin
  if Button <> mbRight then Exit;
  if not FController.PSAvailable then Exit;
  if FPSPopup = nil then
  begin
    Pop := TPureSignalPopup.Create(Self);
    Pop.OnChanged := OnPSPopupChanged;
    FPSPopup := Pop;
  end
  else
    Pop := TPureSignalPopup(FPSPopup);
  if Pop.Visible then
    Pop.Hide
  else
    Pop.PopupBelow(FController, BtnPS);
end;

procedure TMainForm.Btn2TonClick(Sender: TObject);
begin
  FController.SetTwoTone(not FController.FTwoTone);        // рендер через rfTwoTone
end;

procedure TMainForm.OnPSPopupChanged(Sender: TObject);
// Правка PS-параметров в поповере: применение к WDSP делает сам поповер
// (ApplyPSSettingsToDSP), здесь — персист per-device + live-обновление
// частот IMD-маркеров (если правили тона при активном 2TON).
begin
  if FController.FDevConnected then
  begin
    FController.FSettings.SaveTX(FController.FDevMAC, FController.FTXSettings);
    FController.FSettings.Save;
  end;
  UpdateIMDMarkers;
end;

// ---------------------------------------------------------------------------
// Клик/драг по спектру/водопаду
// ---------------------------------------------------------------------------

// Перевод X пикселя в частоту
function PixelToFreq(PixelX, PanelWidth: Integer;
                     CenterFreq, SpanHz: Double): Double;
begin
  Result := CenterFreq + (PixelX / PanelWidth - 0.5) * SpanHz;
end;

procedure TMainForm.DoSpectrumClick(PixelX: Integer; PanelWidth: Integer);
var
  ClickFreq: Int64;
  StepHz:    Int64;
  ViewCenter, ViewSpan: Double;
begin
  if PanelWidth <= 0 then Exit;
  if (FController.FMode in [MODE_FM, MODE_DMR, MODE_FMRAW]) and
     FController.FFMStepOn then
    StepHz := FM_STEP_HZ[FController.FFMStepIdx]
  else
    StepHz := 100;
  FController.GetViewWindow(ViewCenter, ViewSpan);
  ClickFreq := Round(ViewCenter + (PixelX / PanelWidth - 0.5) * ViewSpan);
  ClickFreq := ((ClickFreq + StepHz div 2) div StepHz) * StepHz;
  if FController.FActiveVfo = 0 then
    ApplyVfoA(ClickFreq)
  else
    FController.SetVfoB(ClickFreq);  // движок + рендер через OnControllerState(rfVfoB)
end;

procedure TMainForm.DoWidebandClick(PixelX: Integer; PanelWidth: Integer);
var
  FreqHz: Double;
  ClickFreq: Int64;
  StepHz: Int64;
  BandIdx: Integer;
  WasCTun: Boolean;
begin
  if (FWidebandView = nil) or
     (not FWidebandView.TryPixelToFrequency(PixelX, PanelWidth, FreqHz)) then Exit;

  if (FController.FMode in [MODE_FM, MODE_DMR, MODE_FMRAW]) and
     FController.FFMStepOn then
    StepHz := FM_STEP_HZ[FController.FFMStepIdx]
  else
    StepHz := 100;
  ClickFreq := Round(FreqHz);
  ClickFreq := ((ClickFreq + StepHz div 2) div StepHz) * StepHz;

  if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) and
     FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled then
  begin
    if ClickFreq < FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqBegin then
      ClickFreq := Round(FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqBegin);
    if ClickFreq > FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqEnd then
      ClickFreq := Round(FController.FXvtrSettings.Entries[FController.FCurrentXvtr].FreqEnd);
  end;

  if FController.FCurrentXvtr < 0 then
  begin
    BandIdx := FreqToBandIdx(ClickFreq, FController.IsPluto);
    if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then
    begin
      if FController.FDevConnected then
      begin
        SaveCurrentBand;
        FController.FSettings.Save;
      end;
      FController.FCurrentBand := BandIdx;
      RestoreBand(FController.FCurrentBand);
    end;
  end;

  if FController.FActiveVfo = 0 then
  begin
    WasCTun := FController.FCTun;
    FController.FCTun := False;
    ApplyVfoA(ClickFreq);
    FController.FCTun := WasCTun;
    if WasCTun and FController.FWDSPReady then
      FController.FDSPEngine.SetShift(0.0);
  end
  else
  begin
    FController.FVfoB := ClickFreq;
    FreqDispB.Frequency := Round(FController.FVfoB);
    FController.FCenterFreq := ClickFreq;
    if FController.FWDSPReady then FController.FDSPEngine.SetShift(0.0);
    if FController.FNetwork.Connected and FController.FNetwork.Running then
    begin
      FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq),
        FController.TXTuneFreqHz, FController.FDriveLevel, FController.FTransmitting, True, True,
        FController.FTuning, FController.FTwoTone);
      FController.FNetwork.SendFullHP;
    end;
    if FController.FCurrentXvtr < 0 then
    begin
      BandIdx := FreqToBandIdx(FController.FVfoB, FController.IsPluto);
      if (BandIdx >= 0) and (BandIdx <> FController.FCurrentBand) then
      begin
        if FController.FCurrentBand >= 0 then
          StyleButton(BtnBand[FController.FCurrentBand], False);
        FController.FCurrentBand := BandIdx;
        StyleButton(BtnBand[FController.FCurrentBand], True);
      end;
    end;
    UpdateVfoDisplay;
    SyncSpecViewFreq;
    FSpectrumDirty := True;
    PbSpectrum.Invalidate;
  end;

  FSpecView.InvalidateRulerCache;
  if PbRuler <> nil then PbRuler.Invalidate;
  FWaterfallDirty := True;
  if PbWaterfall <> nil then PbWaterfall.Invalidate;
  if UpdateWidebandFrequencyView and (PbWideband <> nil) then
    PbWideband.Invalidate;
end;

procedure TMainForm.DoSpectrumDrag(PixelX: Integer; PanelWidth: Integer);
var
  dPix:  Integer;
  dFreq: Double;
  ViewCenter, ViewSpan: Double;
begin
  if PanelWidth <= 0 then Exit;
  FController.GetViewWindow(ViewCenter, ViewSpan);
  dPix  := PixelX - FSpecDragX0;
  dFreq := dPix / PanelWidth * ViewSpan;
  if FController.FCTun then
    // CTUN ON: drag двигает окно просмотра (DDC-центр) без смены VFO; shift,
    // сеть и рендер — в контроллере SetCenter / OnControllerState(rfCenterFreq).
    FController.SetCenter(FSpecDragFreq - dFreq)
  else
  begin
    // CTUN OFF: drag двигает VFO, FController.FCenterFreq следует через Apply
    if FController.FActiveVfo = 0 then
      ApplyVfoA(Round(FSpecDragFreq - dFreq))
    else
      FController.SetVfoB(FSpecDragFreq - dFreq);  // движок + рендер (rfVfoB)
  end;
end;

// ---------------------------------------------------------------------------
// Панель пана / зума
// ---------------------------------------------------------------------------

procedure TMainForm.UpdatePanZoomBar;
begin
  if FPanZoomBar <> nil then
  begin
    // Полоса — линейка ПОЛНОГО диапазона (базовые центр/span), стекло-ползунок
    // показывает видимое окно (zoom/pan).
    FPanZoomBar.SetFreq(FController.FCenterFreq, FController.FSpanHz);
    FPanZoomBar.SetState(FController.ZoomFactor, FController.PanSlider);
  end;
end;

procedure TMainForm.OnPanZoomWindow(Sender: TObject; AZoom, APan: Double);
var i: Integer;
begin
  // Чей бар? Паны N — свой зум (пан-локальный, без band-персиста);
  // иначе пан 0 — контроллерный зум (rfZoom обновит UI).
  for i := 1 to MAX_PANS - 1 do
    if (FPans[i] <> nil) and (TObject(FPans[i].ZoomBar) = Sender) then
    begin
      FController.SetPanZoomPan(i, AZoom, APan);
      RefreshPanZoom(FPans[i]);
      Exit;
    end;
  // Окно-селектор задаёт и зум, и пан напрямую (без якоря на VFO).
  FController.ApplyZoom(AZoom, APan);
end;

function TMainForm.PanOfZoomButton(Sender: TObject): TPanafallPanel;
// Зум-кнопки создаются MakeFlatBtn с Owner = контейнер (PanelRight/окно), а не
// панель — PanFromSender их НЕ находит (клик уходил в зум главного пана).
// Матчим по идентичности контролов.
var i: Integer;
begin
  Result := nil;
  for i := 0 to MAX_PANS - 1 do
    if FPans[i] <> nil then
      if (Sender = TObject(FPans[i].BtnZoomIn)) or
         (Sender = TObject(FPans[i].BtnZoomDef)) or
         (Sender = TObject(FPans[i].BtnZoomOut)) then
        Exit(FPans[i]);
end;

procedure TMainForm.BtnZoomInClick(Sender: TObject);
var P: TPanafallPanel;
begin
  P := PanOfZoomButton(Sender);
  if (P <> nil) and (P.PanId > 0) then
  begin
    FController.PanZoomIn(P.PanId);
    RefreshPanZoom(P);
  end
  else
    FController.ZoomIn;
end;

procedure TMainForm.BtnZoomDefClick(Sender: TObject);
var P: TPanafallPanel;
begin
  P := PanOfZoomButton(Sender);
  if (P <> nil) and (P.PanId > 0) then
  begin
    FController.PanZoomDefault(P.PanId);
    RefreshPanZoom(P);
  end
  else
    FController.ZoomDefault;
end;

procedure TMainForm.BtnZoomOutClick(Sender: TObject);
var P: TPanafallPanel;
begin
  P := PanOfZoomButton(Sender);
  if (P <> nil) and (P.PanId > 0) then
  begin
    FController.PanZoomOut(P.PanId);
    RefreshPanZoom(P);
  end
  else
    FController.ZoomOut;
end;

// Спектр
procedure TMainForm.PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  VC, VS: Double;
  DXSpot: TDXSpot;
begin
  if FPan.DispatchFlagsMouseDown(Button, X, Y) then
  begin
    FPan.ProcessPendingSliceClose;   // × мог пометить слайс к удалению
    Exit;
  end;
  if Assigned(FSampleRateOverlay) and
     FSampleRateOverlay.HandleMouseDown(Button, X, Y) then Exit;

  // ЛКМ по подписи DX-спота — QSY на него. Проверяется до тюнинга/драга, но
  // только в полосе подписей вверху: ниже неё поведение спектра прежнее.
  if (Button = mbLeft) and Assigned(FDXSpotOverlay) and
     FDXSpotOverlay.SpotAtPixel(X, Y, DXSpot) then
  begin
    OnDXTuneSpot(DXSpot.FreqHz, DXSpot.Mode);
    // Клик по споту — уведомление TCI-клиентам (логгер открывает QSO-карточку).
    if FTCIAdapter <> nil then
      FTCIAdapter.NotifySpotClicked(DXSpot.Call, DXSpot.FreqHz, 0, FController.FActiveVfo);
    Exit;
  end;

  // Ctrl+ЛКМ по спектру — добавить софт-слайс на этой частоте (в пределах захвата).
  if (Button = mbLeft) and (ssCtrl in Shift) and (PbSpectrum.Width > 0) then
  begin
    FController.GetViewWindow(VC, VS);
    AddSliceAtFreq(PixelToFreq(X, PbSpectrum.Width, VC, VS));
    Exit;
  end;

  if Button = mbLeft then
  begin
    // Наведение маяка: ЛКМ указывает на маяк (после включения BEACON «вооружено»,
    // либо Shift+ЛКМ в любой момент при включённом локе для перенаведения). Декодер
    // наводится на эту частоту, дальше сам ведёт несущую. Не трогает VFO.
    if FController.BeaconLockEnabled and (PbSpectrum.Width > 0) and
       (FBeaconArming or (ssShift in Shift)) then
    begin
      FController.GetViewWindow(VC, VS);
      FController.BeaconSeedAtHz(
        PixelToFreq(X, PbSpectrum.Width, VC, VS));
      FBeaconArming := False;
      UpdateBeaconButton;
      Exit;
    end;
    // ЛКМ по краю полосы фильтра — тянем именно этот край (левый/правый
    // независимо). Проверяется РАНЬШЕ слайс-маркера и панорамирования, иначе
    // край у самой несущей (узкий CW) было бы не поймать.
    if (PbSpectrum.Width > 0) and (FilterEdgeAtPixel(X) <> 0) then
    begin
      FEdgeDrag  := FilterEdgeAtPixel(X);
      FEdgeDragLo := FController.FilterLo;
      FEdgeDragHi := FController.FilterHi;
      PbSpectrum.Cursor := crSizeWE;
      Exit;
    end;
    // ЛКМ по маркеру несущей слайса — начинаем его перетаскивание (перетюн).
    if (PbSpectrum.Width > 0) then
    begin
      FSliceDragId := FPan.SliceAtPixel(X);
      if FSliceDragId <> 0 then
      begin
        FSliceDrag := True;
        FActiveSliceId := FSliceDragId;
        PbSpectrum.Cursor := crSizeWE;
        Exit;
      end;
    end;
    // Левая кнопка: сбрасываем маркер если активен
    if FSpecView.MarkerActive then
    begin
      FSpecView.MarkerActive := False;
      PbSpectrum.Invalidate;
      PbWaterfall.Invalidate;
    end;
    FSpecDrag   := True;
    FSpecDragX0 := X;
    if FController.FCTun then FSpecDragFreq := FController.FCenterFreq
    else begin
      if FController.FActiveVfo = 0 then FSpecDragFreq := FController.FVfoA
      else                   FSpecDragFreq := FController.FVfoB;
    end;
    PbSpectrum.Cursor := crSizeWE;
  end
  else if Button = mbRight then
  begin
    // Правая кнопка: toggle маркера
    if FSpecView.MarkerActive and (Abs(Round(X / PbSpectrum.Width * 1000) - FSpecView.MarkerX) < 8) then
    begin
      // Клик рядом с текущим маркером — убираем
      FSpecView.MarkerActive := False;
    end
    else
    begin
      // Ставим маркер в новое место
      FSpecView.MarkerActive := True;
      FSpecView.MarkerX      := Round(X / PbSpectrum.Width * 1000);
    end;
    PbSpectrum.Invalidate;
    PbWaterfall.Invalidate;
  end;
end;

procedure TMainForm.PbWidebandMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
begin
  if Button = mbLeft then
    DoWidebandClick(X, TControl(Sender).Width);
end;

procedure TMainForm.PbSpectrumMouseMove(Sender: TObject; Shift: TShiftState;
  X, Y: Integer);
var
  VC, VS, NewHz, LoHz, HiHz: Double;
begin
  // Перетаскивание края полосы фильтра.
  if FEdgeDrag <> 0 then
  begin
    if ssLeft in Shift then DoFilterEdgeDrag(X, Shift);
    Exit;
  end;

  // Перетаскивание маркера несущей слайса — перетюн в пределах захваченной полосы.
  if FSliceDrag then
  begin
    if (ssLeft in Shift) and (PbSpectrum.Width > 0) then
    begin
      FController.GetViewWindow(VC, VS);
      NewHz := PixelToFreq(X, PbSpectrum.Width, VC, VS);
      FController.CapturedSpan(LoHz, HiHz);
      if NewHz < LoHz then NewHz := LoHz;
      if NewHz > HiHz then NewHz := HiHz;
      // Перезалив флага и раскладку несёт rfSliceFreq из SetSliceTarget —
      // одним путём для мыши, CAT и TCI (иначе внешние клиенты о перетаскивании
      // не узнавали).
      FController.SetSliceTarget(FSliceDragId, NewHz);
      FSpectrumDirty := True;   // кадр — по таймеру (FPS-гейт, как при drag VFO)
    end;
    Exit;
  end;

  if Assigned(FSampleRateOverlay) then
  begin
    FSampleRateOverlay.HandleMouseMove(X, Y);
  end;
  FPan.DispatchFlagsMouseMove(X, Y);
  if not FSpecDrag then
  begin
    if (Assigned(FVfoOverlay) and (FVfoOverlay.HotIdx >= 0)) or
       (Assigned(FSampleRateOverlay) and (FSampleRateOverlay.HotIdx >= 0)) then
      PbSpectrum.Cursor := crHandPoint
    else if FilterEdgeAtPixel(X) <> 0 then
      PbSpectrum.Cursor := crSizeWE   // над краем фильтра — край можно тянуть
    else if FPan.SliceAtPixel(X) <> 0 then
      PbSpectrum.Cursor := crSizeWE   // над маркером слайса — его можно тащить
    else
      PbSpectrum.Cursor := crDefault;
  end;

  if FSpecDrag and (ssLeft in Shift) then
    DoSpectrumDrag(X, PbSpectrum.Width);
  // Маркер следует за курсором — обновление произойдёт в ближайшем тике таймера (50ms)
  if FSpecView.MarkerActive then
  begin
    FSpecView.MarkerX := Round(X / PbSpectrum.Width * 1000);
    FSpectrumDirty := True;
  end;
end;

procedure TMainForm.PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
begin
  if FEdgeDrag <> 0 then
  begin
    FEdgeDrag := 0;
    PbSpectrum.Cursor := crDefault;
    Exit;
  end;
  if FSliceDrag then
  begin
    FSliceDrag := False;
    FSliceDragId := 0;
    PbSpectrum.Cursor := crDefault;
    Exit;
  end;
  if FPan.DispatchFlagsMouseUp(X, Y) then
  begin
    FSpecDrag := False;
    PbSpectrum.Cursor := crDefault;
    Exit;   // клик/drag по накладке — не пускаем в спектр
  end;
  if Button = mbLeft then
  begin
    if (Abs(X - FSpecDragX0) < 4) then
      DoSpectrumClick(X, PbSpectrum.Width);  // это был клик, не драг
    FSpecDrag := False;
    PbSpectrum.Cursor := crDefault;
  end;
end;

procedure TMainForm.PbSpectrumMouseLeave(Sender: TObject);
begin
  if Assigned(FSampleRateOverlay) then
    FSampleRateOverlay.HandleMouseLeave;
  FPan.DispatchFlagsMouseLeave;
  if not FSpecDrag then PbSpectrum.Cursor := crDefault;
end;

// Водопад (аналогично)
procedure TMainForm.PbWaterfallMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
begin
  if Button = mbLeft then
  begin
    if FSpecView.MarkerActive then
    begin
      FSpecView.MarkerActive := False;
      PbSpectrum.Invalidate;
      PbWaterfall.Invalidate;
    end;
    FSpecDrag   := True;
    FSpecDragX0 := X;
    if FController.FCTun then FSpecDragFreq := FController.FCenterFreq
    else begin
      if FController.FActiveVfo = 0 then FSpecDragFreq := FController.FVfoA
      else                   FSpecDragFreq := FController.FVfoB;
    end;
    PbWaterfall.Cursor := crSizeWE;
  end
  else if Button = mbRight then
  begin
    if FSpecView.MarkerActive and (Abs(Round(X / PbWaterfall.Width * 1000) - FSpecView.MarkerX) < 8) then
      FSpecView.MarkerActive := False
    else
    begin
      FSpecView.MarkerActive := True;
      FSpecView.MarkerX      := Round(X / PbWaterfall.Width * 1000);
    end;
    PbSpectrum.Invalidate;
    PbWaterfall.Invalidate;
  end;
end;

procedure TMainForm.PbWaterfallMouseMove(Sender: TObject; Shift: TShiftState;
  X, Y: Integer);
begin
  if FSpecDrag and (ssLeft in Shift) then
    DoSpectrumDrag(X, PbWaterfall.Width);
  if FSpecView.MarkerActive then
  begin
    FSpecView.MarkerX := Round(X / PbWaterfall.Width * 1000);
    FSpectrumDirty := True;
  end;
end;

procedure TMainForm.PbWaterfallMouseUp(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
begin
  if Button = mbLeft then
  begin
    if (Abs(X - FSpecDragX0) < 4) then
      DoSpectrumClick(X, PbWaterfall.Width);
    FSpecDrag := False;
    PbWaterfall.Cursor := crDefault;
  end;
end;

procedure TMainForm.BtnBandClick(Sender: TObject);
var
  NewBand: Integer;
begin
  NewBand := (Sender as TFlatButton).Tag;
  if (NewBand < 0) or (NewBand >= BAND_COUNT) then Exit;
  // Если в XVTR-режиме — DeactivateXvtr вернёт HF-состояние (RestoreBand
  // на сохранённом FController.FCurrentBand). Если NewBand совпадает с восстановленным
  // FController.FCurrentBand, дальше делать ничего не нужно.
  if FController.FCurrentXvtr >= 0 then
  begin
    DeactivateXvtr;
    if NewBand = FController.FCurrentBand then Exit;
  end
  else if NewBand = FController.FCurrentBand then
    Exit;

  if FController.FDevConnected then
  begin
    SaveCurrentBand;
    FController.FSettings.Save;
  end;
  FController.FCurrentBand := NewBand;
  RestoreBand(FController.FCurrentBand);
end;

procedure TMainForm.BtnModeClick(Sender: TObject);
begin
  // Логика (FMode, дефолтный фильтр, DSP SetMode/TUN/ApplyModeFilter, band cache)
  // — в контроллере; рендер кнопок/фильтра/спектра — в OnControllerState(rfMode).
  FController.SetMode((Sender as TFlatButton).Tag);
end;

procedure TMainForm.BtnFilterClick(Sender: TObject);
begin
  // Логика (слот → кромки, ApplyModeFilter, band cache) — в контроллере;
  // рендер — в OnControllerState(rfFilter).
  FController.SetFilterIdx((Sender as TFlatButton).Tag);
end;

function TMainForm.FilterEdgeAtPixel(X: Integer): Integer;
// -1 = курсор у левого края полосы фильтра, +1 = у правого, 0 = мимо.
// Работает по тем же кромкам, что нарисованы (главный VFO, не слайсы).
var
  VC, VS, Lo, Hi: Double;
  W, X1, X2: Integer;
begin
  Result := 0;
  W := PbSpectrum.Width;
  if (W <= 0) or (FController.FMode = MODE_DMR) then Exit;   // DMR: полоса фиксирована
  if FController.FTransmitting then Exit;   // на передаче фильтр мышью не крутим
  FController.GetViewWindow(VC, VS);
  if VS <= 0 then Exit;
  Lo := ActiveVfoFreq + FController.FilterLo;
  Hi := ActiveVfoFreq + FController.FilterHi;
  X1 := Round((Lo - VC + VS / 2) / VS * W);
  X2 := Round((Hi - VC + VS / 2) / VS * W);
  // Слишком узкая на экране полоса (CW на полном спане) — края слиплись с
  // несущей, и захват съел бы обычный клик-тюн. Тогда фильтр правится только
  // в редакторе; чтобы потянуть мышью, достаточно зазумиться.
  if X2 - X1 < 2 * EDGE_GRAB_PX + 2 then Exit;
  // Приоритет правому краю: при узкой полосе левый иначе «съедает» оба.
  if Abs(X - X2) <= EDGE_GRAB_PX then Result := 1
  else if Abs(X - X1) <= EDGE_GRAB_PX then Result := -1;
end;

procedure TMainForm.DoFilterEdgeDrag(X: Integer; Shift: TShiftState);
// Тянем ОДИН край; второй стоит. Alt — симметрично (второй край зеркалит).
// Именно Alt, а не Ctrl: Ctrl+ЛКМ по спектру уже создаёт слайс. Модификатор
// читаем из Shift обработчика, а не GetKeyState — надёжнее на Wayland.
var
  VC, VS, Hz: Double;
  W, Lo, Hi, D: Integer;
begin
  W := PbSpectrum.Width;
  if W <= 0 then Exit;
  FController.GetViewWindow(VC, VS);
  if VS <= 0 then Exit;
  Hz := PixelToFreq(X, W, VC, VS) - ActiveVfoFreq;
  Lo := FEdgeDragLo;
  Hi := FEdgeDragHi;
  if FEdgeDrag > 0 then Hi := Round(Hz / EDGE_SNAP_HZ) * EDGE_SNAP_HZ
  else                  Lo := Round(Hz / EDGE_SNAP_HZ) * EDGE_SNAP_HZ;
  if ssAlt in Shift then
  begin
    // Симметрия относительно центра исходной полосы.
    D := (FEdgeDragLo + FEdgeDragHi) div 2;
    if FEdgeDrag > 0 then Lo := 2 * D - Hi
    else                  Hi := 2 * D - Lo;
  end;
  if Hi - Lo < FILT_MIN_WIDTH then Exit;   // схлопывать полосу не даём
  FController.SetFilterEdges(Lo, Hi);      // уходит в VAR — пресеты целы
  FSpectrumDirty := True;
end;

procedure TMainForm.PushFilterEdgesToView;
// Кромки в спектр: RX — то, что реально стоит в приёмнике (фильтр может быть
// произвольным), TX — из TXSignedEdges движка. Полоса на экране обязана
// совпадать с тем, что слышно и что уходит в эфир.
var Lo, Hi, i: Integer;
begin
  if FSpecView = nil then Exit;
  FSpecView.SetRXEdges(FController.FilterLo, FController.FilterHi);
  FController.TXFilterEdges(Lo, Hi);
  FSpecView.SetTXEdges(Lo, Hi);
  // Доп. паны рисуют полосу передающего слайса теми же кромками
  // (CalcSliceBandX). Без этого их FTXEdge* оставались нулями, ветка TX не
  // срабатывала, и слайс на передаче показывал RX-ширину. RX-кромки главного
  // сюда не отдаём: на панах N главный VFO не рисуется.
  for i := 1 to MAX_PANS - 1 do
    if FPans[i] <> nil then
    begin
      FPans[i].View.SetTXEdges(Lo, Hi);
      FPanSpecDirty[i] := True;
    end;
end;

procedure TMainForm.BtnModeMouseDown(Sender: TObject; Button: TMouseButton;
                                     Shift: TShiftState; X, Y: Integer);
// ПКМ по CWL/CWU открывает телеграфный терминал — по той же идиоме, что ПКМ по
// кнопке фильтра открывает редактор фильтров. ЛКМ не трогаем: переключение
// режима не должно тащить за собой окно.
begin
  if Button <> mbRight then Exit;
  if not ((Sender as TFlatButton).Tag in [MODE_CWL, MODE_CWU]) then Exit;
  ShowCWTerminal;
end;

procedure TMainForm.BtnFilterMouseDown(Sender: TObject; Button: TMouseButton;
                                       Shift: TShiftState; X, Y: Integer);
// ПКМ по кнопке фильтра открывает редактор на этом слоте. ЛКМ не трогаем —
// иначе окно вылезало бы при каждом обычном переключении фильтра.
begin
  if Button = mbRight then OpenFilterEditor((Sender as TFlatButton).Tag);
end;

procedure TMainForm.OpenFilterEditor(SlotIdx: Integer);
var Anchor: TFlatButton;
begin
  if FController.FMode = MODE_DMR then Exit;   // у DMR полоса жёстко задана
  if FFilterPop = nil then
  begin
    FFilterPop := TFilterPopup.Create(Self);
    FFilterPop.OnSlotEdited := OnFilterSlotEdited;
    FFilterPop.OnSlotReset  := OnFilterSlotReset;
  end;
  if FFilterPop.Visible then begin FFilterPop.Hide; Exit; end;   // повторный ПКМ закрывает
  Anchor := BtnFilter[EnsureRange(SlotIdx, 0, FILT_SLOTS - 1)];
  if (Anchor = nil) or not Anchor.Visible then Anchor := BtnFilter[0];
  FFilterPop.PopupBelow(FController, Anchor, FController.FMode, SlotIdx);
end;

// Обновляет ТОЛЬКО подписи/раскладку кнопок фильтра. Полный
// UpdateFilterButtons для не-FM режимов сбрасывает выбранный фильтр на
// заводской — из редактора это отобрало бы у оператора текущий фильтр на
// каждом нажатии клавиши.
procedure TMainForm.RefreshFilterCaptions;
begin
  if FController.FMode = MODE_DMR then Exit;
  LayoutFilterButtons(FilterPresetCount(FController.FMode));
end;

procedure TMainForm.OnFilterSlotEdited(Mode, Idx: Integer; const S: TFilterSlot);
begin
  FController.SetFilterSlot(Mode, Idx, S);
  RefreshFilterCaptions;    // подпись слота могла измениться
  OnControllerState(FController, rfFilter);
end;

procedure TMainForm.OnFilterSlotReset(Mode, Idx: Integer);
begin
  if Idx < 0 then FController.ResetFilterSet(Mode)
  else            FController.ResetFilterSlot(Mode, Idx);
  if FFilterPop <> nil then
    FFilterPop.LoadFrom(FController, Mode, FFilterPop.SlotIdx);
  RefreshFilterCaptions;
  OnControllerState(FController, rfFilter);
end;

// ---------------------------------------------------------------------------
// FM CTCSS helpers
// ---------------------------------------------------------------------------

procedure TMainForm.ApplyFMSquelch;
begin
  if FController.FWDSPReady then
    FController.FDSPEngine.SetFMSquelch(FController.FFMSQOn and (FController.FMode = MODE_FM), FController.FFMSQLevel);
end;

procedure TMainForm.BtnFMSQClick(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.SetFMSquelch(not FController.FFMSQOn);   // DSP + OnControllerState
  SaveCurrentBand;
end;

procedure TMainForm.TrkFMSQChange(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.SetFMSquelchLevel(TrkFMSQ.Position);
  SaveCurrentBand;
end;

procedure TMainForm.TrkRawDevChange(Sender: TObject);
var Hz: Integer;
begin
  if FSyncingFromController then Exit;
  Hz := TrkRawDev.Position * 50;
  FController.SetRawFMDeviation(Hz);
end;

procedure TMainForm.CloseCTCSSPopup;
begin
  if FCTCSSDropDown <> nil then FCTCSSDropDown.ClosePopup;
  CloseFMStepPopup;
  if FChannelsDropDown  <> nil then FChannelsDropDown.ClosePopup;
  if FTXProfileDropDown <> nil then FTXProfileDropDown.ClosePopup;
end;

procedure TMainForm.BtnFMRptMinusClick(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  // Toggle решает UI; команда несёт сетевой сдвиг + рендер кнопок (OnControllerState).
  if FController.FFMRptDir = RPT_MINUS then
    FController.SetFMRpt(RPT_NONE)
  else
    FController.SetFMRpt(RPT_MINUS);
  SaveCurrentBand;
end;

procedure TMainForm.BtnFMRptPlusClick(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  if FController.FFMRptDir = RPT_PLUS then
    FController.SetFMRpt(RPT_NONE)
  else
    FController.SetFMRpt(RPT_PLUS);
  SaveCurrentBand;
end;

procedure TMainForm.EdFMRptOffsetChange(Sender: TObject);
var Hz: Double;
begin
  if FSyncingFromController then Exit;
  Hz := RptParseOffset(EdFMRptOffset.Text);
  if Hz > 0.0 then
  begin
    FController.SetFMRptOffset(Hz);   // применяет, если RPT вкл; OnControllerState
    if FController.FFMRptDir <> RPT_NONE then SaveCurrentBand;
  end;
end;

procedure TMainForm.UpdateRptAutoState;
begin
  // Логика авто-MINUS — в контроллере; рендер кнопок — OnControllerState(rfFMRpt).
  FController.UpdateRptAutoState;
end;

procedure TMainForm.CloseFMStepPopup;
begin
  if FStepDropDown     <> nil then FStepDropDown.ClosePopup;
  if FChannelsDropDown <> nil then FChannelsDropDown.ClosePopup;
end;

procedure TMainForm.SetFMStep(Idx: Integer);
begin
  // Тонкая обёртка: команда контроллера обновит UI/web/спецвью через
  // OnControllerState(rfFMStepIdx). Сохранена для существующих вызовов
  // (RestoreBand, dropdown-select, web-sync, XVTR-restore).
  FController.SetFMStep(Idx);
end;

procedure TMainForm.BtnFMStepClick(Sender: TObject);
begin
  FController.FFMStepOn := not FController.FFMStepOn;
  StyleButton(BtnFMStep, FController.FFMStepOn);
  BtnFMStep.Repaint;
  SaveCurrentBand;
end;

procedure TMainForm.BtnFMStepSelClick(Sender: TObject);
begin
  FCTCSSDropDown.ClosePopup;
  FStepDropDown.Toggle;
end;

procedure TMainForm.OnStepDropDownSelect(Sender: TObject; Idx: Integer);
begin
  SetFMStep(Idx);
  SaveCurrentBand;
end;

// ---------------------------------------------------------------------------
// Channels dropdown / store
// ---------------------------------------------------------------------------

procedure TMainForm.RefreshChannelsDropDown;
var
  names: array of string;
  i:     Integer;
begin
  if (FController.FChannelStore = nil) or (FChannelsDropDown = nil) then Exit;
  SetLength(names, FController.FChannelStore.Count);
  for i := 0 to FController.FChannelStore.Count - 1 do
    names[i] := FController.FChannelStore.GetChannel(i).Name;
  FChannelsDropDown.SetItems(names);
  if (FChannelController.ActiveIdx >= 0) and (FChannelController.ActiveIdx < FController.FChannelStore.Count) then
    FChannelsDropDown.SetItemIndex(FChannelController.ActiveIdx)
  else if BtnChannels <> nil then
    BtnChannels.Caption := 'Channel';
end;

procedure TMainForm.OnChannelStoreChanged(Sender: TObject);
begin
  RefreshChannelsDropDown;
end;

procedure TMainForm.BtnChannelsClick(Sender: TObject);
begin
  if FController.FChannelStore.Count = 0 then Exit;
  FCTCSSDropDown.ClosePopup;
  FStepDropDown.ClosePopup;
  if FTXProfileDropDown <> nil then FTXProfileDropDown.ClosePopup;
  FChannelsDropDown.Toggle;
end;

procedure TMainForm.BtnChannelsMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  Def: TChannel;
begin
  if Button <> mbRight then Exit;
  FChannelsDropDown.ClosePopup;
  if FChannelsForm = nil then
    FChannelsForm := TChannelsForm.Create(Self, FController.FChannelStore, OnChannelStoreChanged);
  // Передаём текущее состояние радио как значения по умолчанию для нового канала
  TChannelStore.DefaultChannel(Def);
  Def.RXFreq       := FController.FVfoA;
  Def.TXFreq       := ActiveTXFreqHz;
  Def.Mode         := FController.FMode;
  Def.RptDir       := FController.FFMRptDir;
  Def.RptOffsetHz  := FController.FFMRptOffsetHz;
  Def.CTCSSOn      := FController.FFMCTCSSOn;
  Def.CTCSSToneIdx := FController.FFMCTCSSToneIdx;
  Def.Power        := 100;
  Def.Name         := '';
  TChannelsForm(FChannelsForm).SetCurrentDefaults(Def);
  TChannelsForm(FChannelsForm).ApplyTheme(CurrentAppTheme);
  TChannelsForm(FChannelsForm).Show;
end;

procedure TMainForm.OnChannelsDropDownSelect(Sender: TObject; Idx: Integer);
begin
  // Логика применения канала (band/XVTR/mode/FM/drive/freq) — в адаптере каналов;
  // рендер кнопки Channel — в OnChannelActiveChanged.
  FChannelController.Select(Idx);
end;

// ---------------------------------------------------------------------------
// TX-профили (кнопка блока TX + список)
// ---------------------------------------------------------------------------

procedure TMainForm.RenderTXProfile;
// Единая точка рендера: подпись кнопки, состав списка и — если открыт диалог
// настроек — его вкладка Transmit (профиль переписал живые TX-настройки).
var
  Names: array of string;
  i, Act, Cnt: Integer;
begin
  if BtnTXProfile = nil then Exit;
  Cnt := FController.TXProfileCount;
  Act := FController.ActiveTXProfile;
  if (Act >= 0) and (Act < Cnt) then
    BtnTXProfile.Caption := FController.TXProfileName(Act)
  else
    BtnTXProfile.Caption := 'TX Profile';
  BtnTXProfile.Invalidate;
  // Профиль несёт кромки TX-фильтра: на передаче красная полоска обязана
  // переехать сразу, а не ждать следующего rfMOX/rfFilter (их на TX не будет).
  PushFilterEdgesToView;
  FSpectrumDirty := True;
  if FTXProfileDropDown <> nil then
  begin
    SetLength(Names, Cnt);
    for i := 0 to Cnt - 1 do Names[i] := FController.TXProfileName(i);
    FTXProfileDropDown.SetItems(Names);
    if (Act >= 0) and (Act < Cnt) then FTXProfileDropDown.SetItemIndex(Act);
  end;
  if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then
  begin
    TSettingsForm(FSettingsForm).LoadTXSettings(FController.FTXSettings,
      IfThen(FController.FNetwork.Connected,
             FController.FNetwork.Device.BoardType, FPendingBoardType));
    TSettingsForm(FSettingsForm).LoadCWSettings(FController.CWSettings);
    PushTXProfilesToSettings;
  end;
  // Пилюли профиля на флагах: список общий, привязки могли съехать после
  // удаления/сброса — обновляем на всех панах.
  for i := 0 to MAX_PANS - 1 do
    if FPans[i] <> nil then FPans[i].PushAllFlagTXProfiles;
end;

procedure TMainForm.PushTXProfilesToSettings;
var
  Names: array of string;
  i: Integer;
begin
  if FSettingsForm = nil then Exit;
  SetLength(Names, FController.TXProfileCount);
  for i := 0 to FController.TXProfileCount - 1 do
    Names[i] := FController.TXProfileName(i);
  TSettingsForm(FSettingsForm).LoadTXProfiles(Names, FController.ActiveTXProfile,
                                              FController.FDrivePercent);
end;

procedure TMainForm.BtnTXProfileClick(Sender: TObject);
begin
  if FController.TXProfileCount = 0 then Exit;
  // Чужие списки гасим поимённо: общий CloseCTCSSPopup закрыл бы и наш, и
  // повторный клик по кнопке никогда не сворачивал бы список.
  if FCTCSSDropDown <> nil then FCTCSSDropDown.ClosePopup;
  CloseFMStepPopup;
  FTXProfileDropDown.Toggle;
end;

procedure TMainForm.BtnTXProfileMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
begin
  if Button <> mbRight then Exit;
  // ПКМ — прямо в редактор профиля (вкладка Transmit), как ПКМ по кнопке фильтра.
  if FTXProfileDropDown <> nil then FTXProfileDropDown.ClosePopup;
  BtnSettingsClick(nil);
  if FSettingsForm <> nil then TSettingsForm(FSettingsForm).ShowTransmitPage;
end;

procedure TMainForm.OnTXProfileDropDownSelect(Sender: TObject; Idx: Integer);
begin
  // Применение (WDSP + mic-байт + drive) и персист — в контроллере;
  // рендер придёт назад событием rfTXProfile.
  FController.SelectTXProfile(Idx);
end;

procedure TMainForm.OnTXProfileSelectFromSettings(Idx: Integer);
begin
  FController.SelectTXProfile(Idx);
end;

procedure TMainForm.OnTXProfileAddFromSettings(const AName: string);
begin
  FController.AddTXProfile(AName);
end;

procedure TMainForm.OnTXProfileRenameFromSettings(Idx: Integer; const AName: string);
begin
  FController.RenameTXProfile(Idx, AName);
end;

procedure TMainForm.OnTXProfileDeleteFromSettings(Idx: Integer);
begin
  FController.DeleteTXProfile(Idx);
end;

procedure TMainForm.OnTXProfileFactoryFromSettings(Sender: TObject);
begin
  FController.ResetTXProfilesToFactory;
end;

procedure TMainForm.OnChannelActiveChanged(Idx: Integer; const Name: string; Active: Boolean);
begin
  // Рендер кнопки Channel по событию адаптера каналов (Active=True → подсветка+имя).
  if BtnChannels = nil then Exit;
  StyleButton(BtnChannels, Active);
  if Active then BtnChannels.Caption := Name
  else           BtnChannels.Caption := 'Channel';
  BtnChannels.Repaint;
end;

procedure TMainForm.SetFMCTCSSTone(Idx: Integer);
begin
  // Тонкая обёртка: команда применяет к DSP, OnControllerState(rfFMCTCSSTone)
  // обновит выпадающий список. Сохранена для вызовов из restore/channel/XVTR.
  FController.SetFMCTCSSTone(Idx);
end;

procedure TMainForm.BtnFMCTCSSClick(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.FFMCTCSSAutoActive := False;  // ручное действие снимает авто-флаг
  FController.SetFMCTCSS(not FController.FFMCTCSSOn);
end;

procedure TMainForm.BtnFMCTCSSToneClick(Sender: TObject);
begin
  FStepDropDown.ClosePopup;
  FCTCSSDropDown.Toggle;
end;

procedure TMainForm.OnCTCSSDropDownSelect(Sender: TObject; Idx: Integer);
begin
  SetFMCTCSSTone(Idx);
end;

procedure TMainForm.BtnVfoSwapClick(Sender: TObject);
begin
  // Своп + перестройка — в контроллере; рендер дисплеев/спектра/band — через
  // OnControllerState(rfVfoA/rfVfoB/rfBand). Кэш линейки сбрасываем здесь
  // (большой скачок частоты — позиции тиков меняются).
  FController.VfoSwap;
  FSpecView.InvalidateRulerCache;
  if PbRuler <> nil then PbRuler.Invalidate;
end;

procedure TMainForm.BtnVfoACopyBClick(Sender: TObject);
begin
  // A>B: B получает частоту A. Команда сохраняет B, перестраивает если B
  // активен, рендерит через OnControllerState(rfVfoB).
  FController.VfoACopyB;
end;

procedure TMainForm.BtnVfoBCopyAClick(Sender: TObject);
begin
  // B>A: A получает частоту B. При активном A идём через ApplyVfoA (сохраняем
  // канальную оркестрацию); иначе только сохраняем + рендер дисплея.
  FController.FVfoA := FController.FVfoB;
  if FController.FActiveVfo = 0 then
    ApplyVfoA(Round(FController.FVfoA))
  else
    FreqDispA.Frequency := Round(FController.FVfoA);
end;

function TMainForm.ActiveTXFreqHz: Double;
begin
  // Логика перенесена в контроллер; обёртка для существующих вызовов в UI.
  Result := FController.ActiveTXFreqHz;
end;

procedure TMainForm.ApplyMOX(Active: Boolean);
begin
  // FWebClientActive держит web-слой актуальным (OnClientActiveChanged) —
  // определяет mic-source при TX-on.
  FController.SetMOX(Active);   // ядро TX (safety/PTT/DSP) + рендер через rfTransmitting
end;

procedure TMainForm.BtnMOXClick(Sender: TObject);
begin
  // Если идёт tune — клик MOX просто выходит из tune (без перехода на голос).
  if FController.FTuning then
  begin
    ApplyTUN(False);
    Exit;
  end;
  ApplyMOX(not FController.FTransmitting);
end;

procedure TMainForm.BtnMuteClick(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.ToggleMute;   // логика (движок) + OnControllerState обновит кнопку
end;

procedure TMainForm.UpdateNRButton;
begin
  case FController.FNRMode of
    1: BtnNR.Caption := 'NR';
    2: BtnNR.Caption := 'NR2';
    3: BtnNR.Caption := 'NR3';
    4: BtnNR.Caption := 'NR4';
  else
    BtnNR.Caption := 'NR';
  end;
  StyleButton(BtnNR, FController.FNRMode <> 0);
  BtnNR.Invalidate;
end;

procedure TMainForm.UpdateNBButton;
begin
  case FController.FNBMode of
    1: BtnNB.Caption := 'NB';
    2: BtnNB.Caption := 'NB2';
  else
    BtnNB.Caption := 'NB';
  end;
  StyleButton(BtnNB, FController.FNBMode <> 0);
  BtnNB.Invalidate;
end;

procedure TMainForm.UpdateSNBButton;
begin
  BtnSNB.Caption := 'SNB';
  StyleButton(BtnSNB, FController.FSNB);
  BtnSNB.Invalidate;
end;

procedure TMainForm.UpdateANFButton;
begin
  BtnANF.Caption := 'ANF';
  StyleButton(BtnANF, FController.FANF);
  BtnANF.Invalidate;
end;

// ApplyNoiseFilterButtonsToDSP переехал в TRadioController (NR/NB/SNB/ANF → WDSP).

procedure TMainForm.BtnNRClick(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.SetNR((FController.FNRMode + 1) mod 5);
end;

procedure TMainForm.BtnNBClick(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.SetNB((FController.FNBMode + 1) mod 3);
end;

procedure TMainForm.BtnSNBClick(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.SetSNB(not FController.FSNB);
end;

procedure TMainForm.BtnANFClick(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.SetANF(not FController.FANF);
end;


procedure TMainForm.ApplyWfAGCNF(WfAGC, WfNF: Boolean);
begin
  FController.SetWfAGC(WfAGC);
  FController.SetWfNF(WfNF);
end;

procedure TMainForm.ApplyADCSettings(Dither, Random: Boolean);
begin
  FController.FDitherEnabled := Dither;
  FController.FRandomEnabled := Random;
  if FController.FNetwork.Connected and FController.FRunning then
    FController.FNetwork.ConfigureDDCs(1, FController.FSampleRate div 1000, 0, FController.FDitherEnabled, FController.FRandomEnabled);
  if FController.FDevConnected then
    FController.SaveGlobalSettings;
end;

procedure TMainForm.ApplyWebSettings(Enabled: Boolean; Port: Integer;
  const BindAddr, User, Pass: string; SpecPixels: Integer);
var W: TWebSettings;
begin
  FWebEnabled  := Enabled;
  FWebPort     := Port;
  FWebBindAddr := BindAddr;
  FWebUser     := User;
  FWebPass     := Pass;
  // Потолок точек кадра для web — применяется сразу, следующий же кадр DSP
  // снимается с новым разрешением (переарм анализатора не нужен).
  FWebSpecPixels := EnsureRange(SpecPixels, 256, SPECTRUM_PIXELS);
  FController.FWebSpecPixels := FWebSpecPixels;
  FWebServer.Reconfigure(User, Pass, BindAddr, Word(Port));
  if Enabled then FWebServer.Start;
  W.Enabled  := Enabled;
  W.Port     := Port;
  W.BindAddr := BindAddr;
  W.User     := User;
  W.Pass     := Pass;
  W.SpecPixels := FWebSpecPixels;
  FController.FSettings.SaveWebSettings(W);
end;

procedure TMainForm.ApplyTCISettings(Enabled: Boolean; Port: Integer;
  const BindAddr: string);
// Сначала применяем, и только потом сохраняем. Обратный порядок означал бы:
// занятый порт или опечатка в адресе — и в файле навсегда осталась нерабочая
// конфигурация, с которой программа стартует и в следующий раз.
var Prev, Cfg: TTCISettings;
begin
  Prev := FTCICfg;
  // ★От текущей конфигурации, а не с чистого листа: вкладка правит три поля,
  // а в записи есть и другие (RecordDir), и сборка по полям их обнуляла бы.
  Cfg          := FTCICfg;
  Cfg.Enabled  := Enabled;
  Cfg.Port     := Port;
  Cfg.BindAddr := BindAddr;
  if FTCIAdapter = nil then
  begin
    FTCICfg := Cfg;
    FController.FSettings.SaveTCISettings(FTCICfg);
    Exit;
  end;
  if FTCIAdapter.ApplySettings(Cfg) then
  begin
    FTCICfg := Cfg;
    FController.FSettings.SaveTCISettings(FTCICfg);
    Exit;
  end;
  // Отказ: адаптер откатился на прежний слушатель — вернём туда же и настройки
  // с их отражением в окне, иначе оператор остаётся с галкой «включено» при
  // мёртвом сервере.
  FTCICfg := Prev;
  if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then
    TSettingsForm(FSettingsForm).LoadTCISettings(FTCICfg.Enabled, FTCICfg.Port,
                                                 FTCICfg.BindAddr);
  ShowMessage('TCI server failed to start on ' + BindAddr + ':' +
              IntToStr(Port) + '.' + LineEnding +
              'Port busy or address invalid — settings not saved.');
end;

// SET_IN_FOCUS от TCI-клиента: логгер просит поднять окно программы.
// Вызывается в UI-потоке (адаптер маршалит через FController.Invoke).
procedure TMainForm.TCIFocusRequest;
begin
  if WindowState = wsMinimized then WindowState := wsNormal;
  BringToFront;
end;

procedure TMainForm.TCIFormActivate(Sender: TObject);
begin
  if FTCIAdapter <> nil then FTCIAdapter.NotifyAppFocus(True);
end;

procedure TMainForm.TCIFormDeactivate(Sender: TObject);
begin
  if FTCIAdapter <> nil then FTCIAdapter.NotifyAppFocus(False);
end;

procedure TMainForm.OnSampleRateHidePanel;
begin
  FPanelHidden := not FPanelHidden;
  if FPanelHidden then
  begin
    FLeftPanelW := PanelLeft.Width;  // сохраняем реальную ширину (учитывает DPI на Win32)
    PanelLeft.Width := 0;
  end
  else
    PanelLeft.Width := FLeftPanelW;  // восстанавливаем сохранённую ширину
  // Видимость флага A решает общий инвариант (панель скрыта ИЛИ есть слайсы).
  FPan.MainFlagPinned := FPanelHidden;
  FPan.UpdateMainFlagVisibility;
  if Assigned(FSampleRateOverlay) then
    FSampleRateOverlay.SetPanelHidden(FPanelHidden);
  ResizeSpectrumPanels;
  ResizeSMeter;
end;

procedure TMainForm.PbSpectrumDblClick(Sender: TObject);
begin
  // Двойной клик — ничего не делаем (оверлей всегда виден при скрытой панели)
end;

procedure TMainForm.OnModeFilterSelect(Mode: Integer; FilterBW: Integer);
begin
  // Событие пришло от флага слайса (B+) — маршрутизируем в slice-API, главный не трогаем.
  if CurSliceId <> 0 then
  begin
    FController.SetSliceModeBW(CurSliceId, Mode, FilterBW);
    PushFlagStateAllPans(CurSliceId);
    LayoutFlagsAllPans;
    // Режим ИСТОЧНИКА передачи мог смениться — MOX/TUN/PS/профиль гейтятся им.
    UpdateTXControlAvailability;
    Exit;
  end;
  // Main flag A and the left panel edit the same receiver. Use the public
  // controller commands so rfMode/rfFilter repaint every duplicate control.
  if Mode <> FController.FMode then FController.SetMode(Mode);
  FController.SetFilterBW(FilterBW);
  FPan.LayoutFlags;    // перепозиционируем при смене LSB↔USB
end;

procedure TMainForm.OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean);
begin
  if CurSliceId <> 0 then
  begin
    FController.SetSliceDSP(CurSliceId, NRMode, NBMode, SNBOn, ANFOn);
    Exit;
  end;
  // Команды контроллера применяют к DSP и через OnControllerState обновляют кнопки.
  FController.SetNR(NRMode);
  FController.SetNB(NBMode);
  FController.SetSNB(SNBOn);
  FController.SetANF(ANFOn);
end;

procedure TMainForm.OnVfoOverlaySquelchChange(SQLOn: Boolean; SQLLevel: Integer);
begin
  if CurSliceId <> 0 then
  begin
    FController.SetSliceFMSquelch(CurSliceId, SQLOn, SQLLevel);
    Exit;
  end;
  FController.SetFMSquelchLevel(SQLLevel);
  FController.SetFMSquelch(SQLOn);
end;

procedure TMainForm.OnVfoOverlayAGCChange(AGCMode: Integer);
begin
  if CurSliceId <> 0 then
  begin
    FController.SetSliceAGCMode(CurSliceId, TRadioController.AGCModeFromUI(AGCMode));
    Exit;
  end;
  FController.SetAGCMode(AGCMode);
end;

procedure TMainForm.OnVfoOverlayVolume(Vol: Integer);
begin
  if CurSliceId <> 0 then
  begin
    FController.SetSliceVolume(CurSliceId, Vol / 100.0);
    Exit;
  end;
  FController.SetVolume(Vol);
end;

procedure TMainForm.OnVfoOverlayMute(Sender: TObject);
var S: TCtrlSlice;
begin
  if CurSliceId <> 0 then
  begin
    if FController.GetSlice(CurSliceId, S) then
      FController.SetSliceMute(CurSliceId, not S.Muted);
    Exit;
  end;
  FController.ToggleMute;
end;

procedure TMainForm.OnVfoOverlaySplit(Sender: TObject);
begin
  if CurSliceId <> 0 then Exit;   // SPLIT-бейджа у слайсов нет (страховка)
  FController.SetSplit(not FController.FSplitTxB);
  FPan.PushMainFlagExtState;
end;

procedure TMainForm.OnVfoOverlayTxSelect(Sender: TObject);
begin
  // Мультислайс-TX: клик по TX-бейджу делает слайс источником передачи
  // (эксклюзивно). CurSliceId=0 — главный VFO.
  FController.SetTxSlice(CurSliceId);   // → Changed(rfTransmitting) → RefreshTxIndicators
end;

procedure TMainForm.OnVfoOverlayRxMuteTx(Sender: TObject);
// Бейдж RXM на флаге слайса: слышим ли этот слайс, пока сами передаём.
// У главного приёмника то же делает кнопка RX MUTE на левой панели.
var S: TCtrlSlice;
begin
  if CurSliceId = 0 then Exit;   // у флага A бейджа RXM нет (страховка)
  if not FController.GetSlice(CurSliceId, S) then Exit;
  FController.SetSliceRxMuteOnTx(CurSliceId, not S.RxMuteOnTx);
  PushFlagStateAllPans(CurSliceId);
end;

procedure TMainForm.OnVfoOverlayTXProfile(Idx: Integer);
// Пилюля TX-профиля во флаге: «этот источник передачи звучит вот так».
// Idx = -1 — привязки нет, передаёт активный профиль.
begin
  // Привязка главного живёт в секции профилей (контроллер её уже сохранил),
  // привязка слайса уедет в конфиг с общим персистом панов — как RxMuteOnTx.
  FController.SetSliceTXProfile(CurSliceId, Idx);
  PushFlagStateAllPans(CurSliceId);
end;

procedure TMainForm.RefreshTxIndicators;
// Обновить TX-бейджи (TxSel/Tx) на всех флагах всех панов после смены
// TX-слайса / состояния передачи.
var i: Integer;
begin
  FPan.PushMainFlagExtState;
  for i := 0 to MAX_PANS - 1 do
    if FPans[i] <> nil then FPans[i].PushAllSliceFlagStates;
end;

procedure TMainForm.OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string);
begin
  if CurSliceId <> 0 then
  begin
    FController.SetSliceDevice(CurSliceId, DevIndex, DevName);
    StoreSliceSlotAudio(CurSliceId);    // вкладка Slices показывает то же устройство
    PushFlagStateAllPans(CurSliceId);   // обновить подсветку текущего устройства слайса
    Exit;
  end;
  ApplyAudioDevice(DevIndex, DevName);
  FPan.PushMainFlagAudioDevices;   // обновить подсветку текущего устройства
end;

procedure TMainForm.OnVfoOverlayAudioInDevice(DevIndex: Integer; const DevName: string);
// Вкладка IN во флаге: откуда слайс берёт модуляцию на передаче. У флага
// главного VFO это общий вход приложения (то же поле, что в настройках).
begin
  if CurSliceId <> 0 then
  begin
    FController.SetSliceInDevice(CurSliceId, DevIndex, DevName);
    StoreSliceSlotAudio(CurSliceId);
    PushFlagStateAllPans(CurSliceId);
    Exit;
  end;
  ApplyAudioInputDevice(DevIndex, DevName);
  FPan.PushMainFlagAudioDevices;
end;

procedure TMainForm.OnVfoOverlayInvalidate(Sender: TObject);
var P: TPanafallPanel;
begin
  // Флаг/панель доп. пана → грязним и репейнтим ЕГО канву, пан 0 не трогаем.
  P := PanOfOverlay(Sender);
  if (P <> nil) and (P <> FPan) then
  begin
    MarkPanDirty(P);
    Exit;
  end;
  FSpectrumDirty := True;
  if Assigned(FSpecView) then
  begin
    FSpecView.SpectrumDirty := True;
    // Полная инвалидация кэша текстур оверлеев (band/samplerate/главный VFO) нужна
    // ТОЛЬКО когда у главного оверлея изменился хром (CacheDirty). Если сдвинулся
    // лишь S-метр (MeterDirty) — перезаливаем только текстуру главного флага, не
    // дёргая band/samplerate/слайсы. Слайсы перезаливают свою текстуру сами.
    if Sender = TObject(FVfoOverlay) then
    begin
      if FVfoOverlay.CacheDirty then
        FSpecView.InvalidateOverlayCache
      else
        FSpecView.InvalidateVfoOverlay;
    end;
  end;
  if Assigned(PbSpectrum) then
  begin
    PbSpectrum.Invalidate;
  end;
end;

// ===========================================================================
// Мультислайсы (UI): коллекция флагов, роутинг событий по SliceId
// ===========================================================================

// SliceId флага, чьё событие сейчас обрабатывается (0 = главный приёмник / слайс A).
function TMainForm.CurSliceId: Integer;
// Диспетчер флагов синхронный — ненулевой CurSliceId сейчас максимум у одной
// панели (той, чей флаг обрабатывает событие).
var i: Integer;
begin
  Result := 0;
  for i := 0 to MAX_PANS - 1 do
    if (FPans[i] <> nil) and (FPans[i].CurSliceId <> 0) then
      Exit(FPans[i].CurSliceId);
end;

// Единая проводка событий флага (главного и слайсов) на общие обработчики.
procedure TMainForm.WireOverlayEvents(O: TVfoOverlay);
begin
  O.OnSelect      := OnModeFilterSelect;
  O.OnDSPChange   := OnVfoOverlayDSPChange;
  O.OnSquelchChange := OnVfoOverlaySquelchChange;
  O.OnAGCChange   := OnVfoOverlayAGCChange;
  O.OnVolume      := OnVfoOverlayVolume;
  O.OnMute        := OnVfoOverlayMute;
  O.OnAudioDevice := OnVfoOverlayAudioDevice;
  O.OnAudioInDevice := OnVfoOverlayAudioInDevice;
  O.OnSplit       := OnVfoOverlaySplit;
  O.OnTxSelect    := OnVfoOverlayTxSelect;
  O.OnRxMuteTx    := OnVfoOverlayRxMuteTx;
  O.OnTXProfile   := OnVfoOverlayTXProfile;
  O.OnSliceSelect := OnVfoOverlaySliceSelect;
  O.OnClose       := OnVfoOverlayClose;
  O.OnInvalidate  := OnVfoOverlayInvalidate;
  // Актуальная тема (цвета кнопок флага = как на левой панели).
  O.SetTheme(CurrentAppTheme);
end;

// Добавить софт-слайс на частоте Hz (режим/полоса копируются с главного).
procedure TMainForm.AddSliceAtFreq(Hz: Double);
var
  Lo, Hi, Id: Integer;
begin
  if not FController.SliceFitsCapture(Hz) then Exit;   // вне захваченной полосы (см. Этап 3)
  TRadioController.FilterEdgesFor(FController.FMode, FController.FFilterBW, Lo, Hi);
  Id := FController.AddSlice(Hz, FController.FMode, Lo, Hi,
          TRadioController.AGCModeFromUI(FController.FAGCMode), 0.7, -1, '');
  if Id < 0 then Exit;
  FPan.CreateSliceFlag(Id);
  FActiveSliceId := Id;
end;

// ===========================================================================
// Стек панадаптеров (этап 3.3): доп. паны на своих DDC
// ===========================================================================

function TMainForm.PanCount: Integer;
var i: Integer;
begin
  Result := 0;
  for i := 0 to MAX_PANS - 1 do
    if FPans[i] <> nil then Inc(Result);
end;

function TMainForm.PanFromSender(Sender: TObject): TPanafallPanel;
// Контролы панели создаются с Owner=панель (Build) — восстановление пана
// из Sender мышиного события.
begin
  Result := nil;
  if (Sender is TComponent) and (TComponent(Sender).Owner is TPanafallPanel) then
    Result := TPanafallPanel(TComponent(Sender).Owner);
end;

function TMainForm.PanOfOverlay(Sender: TObject): TPanafallPanel;
// Панель, которой принадлежит источник события: сама панель (Sender=панель),
// её флаг (Owner=панель) или главный флаг/nil → пан 0.
begin
  Result := FPan;
  if Sender is TPanafallPanel then Exit(TPanafallPanel(Sender));
  if (Sender is TComponent) and (TComponent(Sender).Owner is TPanafallPanel) then
    Result := TPanafallPanel(TComponent(Sender).Owner);
end;

procedure TMainForm.PushFlagStateAllPans(SliceId: Integer);
// Флаг слайса живёт ровно на одной панели; у остальных PushSliceFlagState
// не найдёт флаг и выйдет.
var i: Integer;
begin
  for i := 0 to MAX_PANS - 1 do
    if FPans[i] <> nil then FPans[i].PushSliceFlagState(SliceId);
end;

procedure TMainForm.LayoutFlagsAllPans;
var i: Integer;
begin
  for i := 0 to MAX_PANS - 1 do
    if FPans[i] <> nil then FPans[i].LayoutFlags;
end;

procedure TMainForm.MarkPanDirty(P: TPanafallPanel);
begin
  if P = nil then Exit;
  if P = FPan then
  begin
    FSpectrumDirty := True;
    if Assigned(FSpecView) then FSpecView.SpectrumDirty := True;
    if Assigned(PbSpectrum) then PbSpectrum.Invalidate;
    Exit;
  end;
  FPanSpecDirty[P.PanId] := True;
  P.View.SpectrumDirty := True;
  if P.PbSpectrum <> nil then P.PbSpectrum.Invalidate;
end;

procedure TMainForm.PanViewWindow(P: TPanafallPanel; out VC, VS: Double);
// Видимое окно доп. пана (с учётом его пан-зума).
begin
  FController.GetPanViewWindow(P.PanId, VC, VS);
  if VS <= 0 then VS := 384000;
end;

procedure TMainForm.SyncPanZoomUI(P: TPanafallPanel);
// Вьюха и зум-бар пана ← контроллер (после смены зума/центра/rate пана).
var VC, VS, StepHz: Double;
begin
  if (P = nil) or (P.PanId = 0) then Exit;
  FController.GetPanViewWindow(P.PanId, VC, VS);
  P.View.CenterFreq := VC;
  P.View.SpanHz     := VS;
  // Подписи спотов пана следуют за его окном (SetView сам отсекает неизменное
  // → пересборки кэша на каждый кадр нет).
  if P.DXOverlay <> nil then P.DXOverlay.SetView(VC, VS);
  // Сетка каналов по шагу FM — как на главном пане, но по режиму слайсов
  // ЭТОГО пана (у доп. панов нет своего FMode).
  if FController.PanHasFMSlice(P.PanId) then
    StepHz := FM_STEP_HZ[FController.FFMStepIdx]
  else
    StepHz := 0;
  if Abs(P.View.FMGridStepHz - StepHz) > 0.5 then
  begin
    P.View.FMGridStepHz := StepHz;   // сеттер сам сбросит кэши спектра/линейки
    MarkPanDirty(P);
    if P.PbRuler <> nil then P.PbRuler.Invalidate;
  end;
  P.ZoomBar.SetFreq(FController.PanDDCFreq(P.PanId),
                    FController.PanDDCRateKHz(P.PanId) * 1000.0);
  P.ZoomBar.SetState(FController.PanZoomFactor(P.PanId),
                     FController.PanPanSlider(P.PanId));
  // Инвалидация бара — внутри Set{Freq,State} и ТОЛЬКО при реальной смене:
  // нас зовут из тика на каждый пан каждый кадр, безусловный Invalidate =
  // полный софт-рендер бара + Qt-рекомпозит на частоте кадров (жор CPU).
end;

procedure TMainForm.RefreshPanZoom(P: TPanafallPanel);
// Полное обновление пана после смены зума: окно вьюхи, бар, флаги, спектр.
begin
  if P = nil then Exit;
  SyncPanZoomUI(P);
  P.LayoutFlags;
  MarkPanDirty(P);
  if P.PbWaterfall <> nil then P.PbWaterfall.Invalidate;
  if P.PbPanZoom <> nil then P.PbPanZoom.Invalidate;   // событийный путь — можно
end;

function TMainForm.AddSliceAtFreqPan(P: TPanafallPanel; Hz: Double): Integer;
// Слайс в доп. пане (режим/полоса копируются с главного, звуковуха default).
// Возвращает созданный слайс, 0 — не создали (вне полосы захвата, слоты
// кончились). Спрашивать об этом FActiveSliceId нельзя: при неудаче там
// остаётся ПРЕЖНИЙ активный слайс, вообще говоря с другого пана.
var Lo, Hi, Id: Integer;
begin
  Result := 0;
  if not FController.SliceFitsCapture(Hz, P.PanId) then Exit;
  TRadioController.FilterEdgesFor(FController.FMode, FController.FFilterBW, Lo, Hi);
  Id := FController.AddSlice(Hz, FController.FMode, Lo, Hi,
          TRadioController.AGCModeFromUI(FController.FAGCMode), 0.7, -1, '',
          P.PanId);
  if Id <= 0 then Exit;
  P.CreateSliceFlag(Id);
  FActiveSliceId := Id;
  Result := Id;
end;

procedure TMainForm.OnPanRateClick(Sender: TObject);
// Клик по «NNN kHz» в шапке пана N: меню с полной сеткой rate бэкенда
// (фильтр PanRateAllowed — кратные 48 кГц). Текущий rate отмечен галкой.
var
  P: TPanafallPanel;
  i, kHz, Cur, CurDDC: Integer;
begin
  if not (Sender is TPanafallPanel) then Exit;
  P := TPanafallPanel(Sender);
  if (P.PanId < 1) or not FController.PanDDCActive(P.PanId) then Exit;
  if FPanRateMenu = nil then
  begin
    FPanRateMenu := TFlatPopupMenu.Create(Self);
    FPanRateMenu.OnSelect := OnPanRateSelect;
  end;
  FPanRateMenu.SetTheme(CurrentAppTheme);
  FPanRateMenu.Clear;
  Cur := FController.PanDDCRateKHz(P.PanId);
  for i := 0 to High(FController.BackendCaps.RatePresets) do
  begin
    kHz := FController.BackendCaps.RatePresets[i] div 1000;
    if not FController.PanRateAllowed(Word(kHz)) then Continue;
    FPanRateMenu.AddItem(Format('%d kHz', [kHz]), kHz, kHz = Cur);
  end;
  // Аппаратный DDC пана: свободные индексы платы + текущий (отмечен галкой).
  CurDDC := FController.PanDDCIndex(P.PanId);
  for i := FController.PanDDCIndex(0) + 1 to FController.MaxDDCIndex do
    if (i = CurDDC) or FController.DDCFree(i) then
      FPanRateMenu.AddItem(Format('→ DDC %d', [i]), DDC_MENU_TAG + i, i = CurDDC);
  FPanRateMenuPanId := P.PanId;
  FPanRateMenu.PopupBelow(P.HeaderRateLabel);   // дочерний контрол формы
end;

procedure TMainForm.OnPanRateSelect(Tag: Integer);
var
  P: TPanafallPanel;
  PanId: Integer;
begin
  PanId := FPanRateMenuPanId;
  FPanRateMenuPanId := 0;
  if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
  P := FPans[PanId];
  if P = nil then Exit;
  // Теги DDC_MENU_TAG+ — переезд пана на другой hw DDC (движок не трогаем).
  if Tag >= DDC_MENU_TAG then
  begin
    if FController.MovePanDDC(PanId, Tag - DDC_MENU_TAG) then UpdatePanHeaders;
    Exit;
  end;
  if not FController.SetPanDDCRate(PanId, Word(Tag)) then Exit;
  // Вьюха/шапка/флаги под новый span (слайсы могли клампнуться к краям).
  SyncPanViews;
  P.PushAllSliceFlagStates;
  P.LayoutFlags;
  MarkPanDirty(P);
end;

function TMainForm.CurrentAppTheme: TAppTheme;
begin
  if FLightTheme then Result := LightTheme else Result := DarkTheme;
end;

function TMainForm.PanBandSelectorVisible: Boolean;
// Селектор диапазона пана — только на «настоящих КВ»: не в XVTR и не Pluto
// (у Pluto/трансвертера band-стек другой, сценарий не актуален).
begin
  Result := (FController.FCurrentXvtr < 0) and not FController.IsPluto;
end;

procedure TMainForm.OnPanBandClick(Sender: TObject);
// Клик по бейджу диапазона в шапке пана N: меню КВ-диапазонов, текущий (по
// частоте DDC пана) отмечен галкой.
var
  P: TPanafallPanel;
  i, CurBand: Integer;
begin
  if not (Sender is TPanafallPanel) then Exit;
  P := TPanafallPanel(Sender);
  if (P.PanId < 1) or not FController.PanDDCActive(P.PanId) then Exit;
  if not PanBandSelectorVisible then Exit;
  CurBand := FreqToBandIdx(FController.PanDDCFreq(P.PanId), False);
  if FPanBandMenu = nil then
  begin
    FPanBandMenu := TFlatPopupMenu.Create(Self);
    FPanBandMenu.OnSelect := OnPanBandSelect;
  end;
  FPanBandMenu.SetTheme(CurrentAppTheme);
  FPanBandMenu.Clear;
  for i := 0 to CFG_BAND_COUNT - 1 do
  begin
    if BandName(i, False) = '' then Continue;   // 6m-дубль/пустые слоты
    FPanBandMenu.AddItem(BandName(i, False), i, i = CurBand);
  end;
  FPanBandMenuPanId := P.PanId;
  FPanBandMenu.PopupBelow(P.HeaderBandLabel);
end;

procedure TMainForm.OnPanBandSelect(Tag: Integer);
var PanId: Integer;
begin
  PanId := FPanBandMenuPanId;
  FPanBandMenuPanId := 0;
  if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
  if FPans[PanId] = nil then Exit;
  ApplyPanBand(FPans[PanId], Tag);
end;

procedure TMainForm.ApplyPanBand(P: TPanafallPanel; BandIdx: Integer);
// Смена диапазона пана N: ретюн DDC на VfoA диапазона (из общего
// band-кэша, как пан 0), активный приёмник садится на VfoA с mode/filter/AGC
// (+zoom/pan) диапазона, прочие слайсы пана убираются (они вне новой полосы).
var
  B: TBandSettings;
  Freq: Double;
  Lo, Hi, Id: Integer;
begin
  if (P = nil) or (P.PanId < 1) then Exit;
  if not PanBandSelectorVisible then Exit;
  if (BandIdx < 0) or (BandIdx >= CFG_BAND_COUNT) then Exit;
  B := FController.FBandCache[BandIdx];
  Freq := B.VfoA;
  // Кэш диапазона мог нести чужую частоту (дефолты/битый конфиг) — тогда центр
  // диапазона по плану.
  if FreqToBandIdx(Freq, False) <> BandIdx then
    Freq := BandDefaultFreq(BandIdx, False);

  // Слайсы вне новой полосы — снести до ретюна DDC.
  P.DestroyAllSliceFlags;
  FActiveSliceId := 0;
  // Ретюн DDC пана + зум/пан диапазона.
  FController.SetPanDDCFreq(P.PanId, Freq);
  FController.SetPanZoomPan(P.PanId,
    EnsureRange(B.ZoomFactor, 0.0, 1.0), EnsureRange(B.PanSlider, 0.0, 1.0));
  // Один приёмник на VfoA с настройками диапазона.
  TRadioController.FilterEdgesFor(B.Mode, B.FilterBW, Lo, Hi);
  Id := FController.AddSlice(Freq, B.Mode, Lo, Hi,
          TRadioController.AGCModeFromUI(B.AGCMode), 0.7, -1, '', P.PanId);
  if Id >= 0 then
  begin
    P.CreateSliceFlag(Id);
    FActiveSliceId := Id;
  end;
  // Синк вьюхи/зум-бара/флагов/шапки под новый центр.
  P.PushAllSliceFlagStates;
  RefreshPanZoom(P);
  UpdatePanHeaders;
end;

procedure TMainForm.OnPanADCClick(Sender: TObject);
// Бейдж «RX1/RX2»: тумблер ADC-источника пана (двух-АЦПшные платы, 3.4).
var
  P: TPanafallPanel;
  N: Integer;
begin
  if not (Sender is TPanafallPanel) then Exit;
  P := TPanafallPanel(Sender);
  N := FController.BackendCaps.NumADCs;
  if N < 2 then Exit;
  if not FController.SetPanADC(P.PanId, (FController.PanADC(P.PanId) + 1) mod N) then
    Exit;
  UpdatePanHeaders;
end;

// ---- Per-pan дисплей (3.6): «◑» палитра/gamma/уровни отдельно на пан ----

procedure TMainForm.ApplyGlobalRenderToView(V: TSpectrumView);
// Глобальный дефолт рендера (контроллер) → вьюха пана. Используется при
// создании пана (дефолт) и «Сбросе к дефолту».
begin
  if V = nil then Exit;
  V.WfPalette    := FController.FWfPalette;
  V.WfGamma      := FController.FWfGamma;
  V.SpecRefLevel := FController.FSpecRefLevel;
  V.SpecRange    := FController.FSpecRange;
  V.SpecGridStep := FController.FSpecGridStep;
  V.WfAGCEnabled := FController.FWfAGCEnabled;
  V.WfAGCOffset  := FController.FWfAGCOffset;
  V.WfManualHigh := FController.FWfManualHigh;
  V.WfManualLow  := FController.FWfManualLow;
end;

procedure TMainForm.MarkPanDisplayDirty(P: TPanafallPanel; ClearWf: Boolean);
// Полный репейнт пана после смены рендера: сетка спектра + водопад
// (LUT/палитра пересоберётся в SpectrumView по своим no-op-гардам).
// ClearWf=True (смена палитры/gamma/авто) чистит историю водопада — GL хранит
// уже раскрашенные пиксели, иначе старые строки остаются в прежней палитре.
begin
  if P = nil then Exit;
  P.View.InvalidateGridCache;
  if ClearWf then P.View.ClearWaterfall;
  if P = FPan then
  begin
    FSpectrumDirty  := True;
    FWaterfallDirty := True;
    if Assigned(PbSpectrum)  then PbSpectrum.Invalidate;
    if Assigned(PbWaterfall) then PbWaterfall.Invalidate;
    Exit;
  end;
  P.View.SpectrumDirty := True;
  FPanSpecDirty[P.PanId] := True;
  FPanWfDirty[P.PanId]   := True;
  if P.PbSpectrum  <> nil then P.PbSpectrum.Invalidate;
  if P.PbWaterfall <> nil then P.PbWaterfall.Invalidate;
end;

procedure TMainForm.OnPanDisplayClick(Sender: TObject);
// «◑» в шапке пана: тоггл поповера под кнопкой (дочерний контрол формы).
var
  P: TPanafallPanel;
  Pop: TPanDisplayPopup;
begin
  if not (Sender is TPanafallPanel) then Exit;
  P := TPanafallPanel(Sender);
  if P.BtnDisplay = nil then Exit;
  if FPanDisplayPopup = nil then
  begin
    Pop := TPanDisplayPopup.Create(Self);
    Pop.OnApplied      := OnPanDisplayApplied;
    Pop.OnResetDefault := OnPanDisplayReset;
    Pop.OnApplyAll     := OnPanDisplayApplyAll;
    FPanDisplayPopup   := Pop;
  end
  else
    Pop := TPanDisplayPopup(FPanDisplayPopup);
  // Повторный клик по «◑» — закрыть.
  if Pop.Visible then begin Pop.Hide; Exit; end;
  Pop.PopupBelow(P, P.BtnDisplay);
end;

procedure TMainForm.OnPanDisplayApplied(P: TPanafallPanel; FullRefresh: Boolean);
// Поповер применил правку к P.View. Пан 0 = глобальный дефолт (зеркалим в
// контроллер, чтобы персистилось и наследовалось новыми панами); паны N —
// ставим override-флаг (персист в воркспейс).
begin
  if P = nil then Exit;
  if P.PanId = 0 then
  begin
    FController.FWfPalette    := P.View.WfPalette;
    FController.FWfGamma      := P.View.WfGamma;
    FController.FSpecRefLevel := P.View.SpecRefLevel;
    FController.FSpecRange    := P.View.SpecRange;
    FController.FWfAGCEnabled := P.View.WfAGCEnabled;
    FController.FWfAGCOffset  := P.View.WfAGCOffset;
    FController.FWfManualHigh := P.View.WfManualHigh;
    FController.FWfManualLow  := P.View.WfManualLow;
    // На диск не пишем на каждый тик — глобальные сохранятся при закрытии
    // (FormDestroy → SaveGlobalSettings), как и для главного диалога настроек.
  end
  else
    P.DisplayOverride := True;
  MarkPanDisplayDirty(P, FullRefresh);
end;

procedure TMainForm.OnPanDisplayReset(P: TPanafallPanel);
// «Сброс к дефолту»: пан снова наследует глобальный рендер.
begin
  if (P = nil) or (P.PanId = 0) then Exit;
  ApplyGlobalRenderToView(P.View);
  P.DisplayOverride := False;
  MarkPanDisplayDirty(P, True);   // палитра могла смениться → чистим водопад
end;

procedure TMainForm.OnPanDisplayApplyAll(P: TPanafallPanel);
// «Применить ко всем»: рендер пана P раскатывается на все паны + становится
// глобальным дефолтом (для пана 0 и новых панов).
var i: Integer; Q: TPanafallPanel;
begin
  if P = nil then Exit;
  // Глобальный дефолт ← P.
  FController.FWfPalette    := P.View.WfPalette;
  FController.FWfGamma      := P.View.WfGamma;
  FController.FSpecRefLevel := P.View.SpecRefLevel;
  FController.FSpecRange    := P.View.SpecRange;
  FController.FWfAGCEnabled := P.View.WfAGCEnabled;
  FController.FWfAGCOffset  := P.View.WfAGCOffset;
  FController.FWfManualHigh := P.View.WfManualHigh;
  FController.FWfManualLow  := P.View.WfManualLow;
  FController.SaveGlobalSettings;
  for i := 0 to MAX_PANS - 1 do
  begin
    Q := FPans[i];
    if Q = nil then Continue;
    if Q <> P then ApplyGlobalRenderToView(Q.View);
    Q.DisplayOverride := False;   // все синхронны с дефолтом ⇒ не override
    MarkPanDisplayDirty(Q, True);   // палитра раскатана → чистим водопад
  end;
end;

// ---- Pop-out (3.5): пан N в отдельном OS-окне ----

function TMainForm.PanFloating(PanId: Integer): Boolean;
begin
  Result := (PanId >= 1) and (PanId < MAX_PANS) and (FPanFloatForm[PanId] <> nil);
end;

procedure TMainForm.ResetPanGLCaches(P: TPanafallPanel);
// Смена родителя пересоздаёт хэндлы GL-канв → старые контексты (и все
// текстуры) мертвы; кэши вьюхи обнуляем БЕЗ glDelete (как при смене MSAA).
begin
  if FUseOpenGLSpectrum and (P.View is TSpectrumViewOpenGL) then
    TSpectrumViewOpenGL(P.View).ResetGLCache;
end;

procedure TMainForm.OnPanPopOutClick(Sender: TObject);
begin
  if Sender is TPanafallPanel then
    PopOutPan(TPanafallPanel(Sender).PanId);
end;

procedure TMainForm.PopOutPan(PanId: Integer);
var
  P: TPanafallPanel;
  F: TForm;
  T: TAppTheme;
begin
  if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
  P := FPans[PanId];
  if (P = nil) or PanFloating(PanId) then Exit;
  T := CurrentAppTheme;
  F := TForm.CreateNew(Self);
  F.Caption      := Format('EWSDR · PAN %d', [PanId]);
  F.Color        := T.BG;
  F.Font.Assign(Font);   // ParentFont-наследники (лейблы шапки) берут шрифт
                         // формы — без этого текст дефолтно-тёмный на тёмном
  F.Position     := poDefault;
  F.Width        := PanelRight.ClientWidth;
  F.Height       := Max(300, P.StackHeight);
  F.Tag          := PanId;
  F.OnClose      := PanFloatClose;
  F.OnResize     := PanFloatResize;
  F.OnMouseWheel := FormMouseWheel;   // колесо над флагами/фоном как в стеке
  FPanFloatForm[PanId] := F;
  P.ReparentTo(F);
  ResetPanGLCaches(P);
  P.StackTop    := 0;
  P.StackHeight := 0;   // 0/0 = весь родитель (окно)
  P.StackLeft   := 0;
  P.StackWidth  := 0;
  if FPanSplitters[PanId] <> nil then FPanSplitters[PanId].Visible := False;
  if FPanVSplitters[PanId] <> nil then FPanVSplitters[PanId].Visible := False;
  F.Show;
  UpdatePanHeaders;
  ResizeSpectrumPanels;          // пере-стек оставшихся в главном окне
  P.Layout(0, FController.FShowSpectrum, FController.FShowWaterfall);
  P.LayoutFlags;
  MarkPanDirty(P);
end;

procedure TMainForm.DockBackPan(PanId: Integer);
var
  P: TPanafallPanel;
  F: TForm;
begin
  P := FPans[PanId];
  F := FPanFloatForm[PanId];
  if F = nil then Exit;
  FPanFloatForm[PanId] := nil;
  if P <> nil then
  begin
    P.ReparentTo(PanelRight);
    ResetPanGLCaches(P);
  end;
  if FPanSplitters[PanId] <> nil then FPanSplitters[PanId].Visible := True;
  F.Release;   // безопасно из событий самого окна
  UpdatePanHeaders;
  ResizeSpectrumPanels;
  if P <> nil then
  begin
    P.LayoutFlags;
    MarkPanDirty(P);
  end;
end;

procedure TMainForm.PanFloatClose(Sender: TObject; var CloseAction: TCloseAction);
// [×] окна = вернуть пан в стек (не закрыть пан). Release — в DockBackPan.
begin
  CloseAction := caNone;
  DockBackPan(TForm(Sender).Tag);
end;

procedure TMainForm.PanFloatResize(Sender: TObject);
var P: TPanafallPanel; PanId: Integer;
begin
  PanId := TForm(Sender).Tag;
  if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
  P := FPans[PanId];
  if (P = nil) or (FPanFloatForm[PanId] <> Sender) then Exit;
  P.Layout(0, FController.FShowSpectrum, FController.FShowWaterfall);
  P.LayoutFlags;
  MarkPanDirty(P);
end;

procedure TMainForm.PanClickTune(P: TPanafallPanel; X, W: Integer);
// Клик по фону доп. пана = перестроить его слайс на частоту клика (аналог
// DoSpectrumClick главного). Цель — активный слайс, если он живёт в этом
// пане, иначе первый слайс пана; слайсов нет — создать в точке.
var
  VC, VS, LoHz, HiHz: Double;
  Hz, StepHz: Int64;
  Id, ModeHere: Integer;
  S: TCtrlSlice;
begin
  PanViewWindow(P, VC, VS);
  if (W <= 0) or (VS <= 0) then Exit;
  if (FActiveSliceId <> 0) and FController.GetSlice(FActiveSliceId, S) and
     (S.PanId = P.PanId) then
    Id := FActiveSliceId
  else
    Id := P.FirstSliceId;
  // Канальные режимы: клик садится на ту же сетку, что нарисована на пане.
  if Id <> 0 then
  begin
    if FController.GetSlice(Id, S) then ModeHere := S.Mode
    else ModeHere := FController.FMode;
  end
  else
    ModeHere := FController.FMode;
  if (ModeHere in [MODE_FM, MODE_DMR, MODE_FMRAW]) and FController.FFMStepOn then
    StepHz := FM_STEP_HZ[FController.FFMStepIdx]
  else
    StepHz := 100;
  Hz := Round(VC + (X / W - 0.5) * VS);
  Hz := ((Hz + StepHz div 2) div StepHz) * StepHz;
  FController.CapturedSpan(LoHz, HiHz, P.PanId);
  if Hz < LoHz then Hz := Round(LoHz);
  if Hz > HiHz then Hz := Round(HiHz);
  if Id = 0 then
  begin
    AddSliceAtFreqPan(P, Hz);
    Exit;
  end;
  FController.SetSliceTarget(Id, Hz);   // флаг и раскладку несёт rfSliceFreq
  FActiveSliceId := Id;
end;

procedure TMainForm.ApplyPanHeaderTheme(P: TPanafallPanel);
// Явные цвета шапки пана. Зовётся и из ApplyDarkTheme, и из UpdatePanHeaders:
// лейблы без явного Font.Color наследуют шрифт формы-родителя (ParentFont),
// и в pop-out окне текст становился дефолтно-тёмным на тёмном фоне.
var T: TAppTheme;
begin
  if (P = nil) or (P.HeaderPanel = nil) then Exit;
  T := CurrentAppTheme;
  P.HeaderPanel.Color := T.Panel;
  P.HeaderLabel.Font.Color := T.Text;
  // Rate-лейбл: кликабельный (паны N) подсвечиваем акцентом селектора.
  if P.RateClickable then
    P.HeaderRateLabel.Font.Color := T.BtnTextActive
  else
    P.HeaderRateLabel.Font.Color := T.Text;
  // Band-лейбл (3.6): кликабельный — акцент селектора.
  if P.HeaderBandLabel <> nil then
  begin
    if P.BandClickable then
      P.HeaderBandLabel.Font.Color := T.BtnTextActive
    else
      P.HeaderBandLabel.Font.Color := T.Text;
  end;
  // ADC-бейдж кликабелен на 2-АЦПшных платах — тоже акцент.
  if P.ADCClickable then
    P.HeaderADCLabel.Font.Color := T.BtnTextActive
  else
    P.HeaderADCLabel.Font.Color := T.Text;
end;

procedure TMainForm.UpdatePanHeaders;
// Текст/видимость шапок: у одного пана шапки нет, у стека — у всех;
// у плавающего (pop-out) пана шапка всегда (там его rate/ADC/×).
var
  i, N, NADC, bi: Integer;
  P: TPanafallPanel;
begin
  N := PanCount;
  NADC := FController.BackendCaps.NumADCs;
  for i := 0 to MAX_PANS - 1 do
  begin
    P := FPans[i];
    if P = nil then Continue;
    if i = 0 then P.HeaderVisible := Pan0HeaderVisible
    else          P.HeaderVisible := (N > 1) or PanFloating(i);
    if P.BtnClosePan <> nil then P.BtnClosePan.Visible := (N > 1) and (i > 0);
    if P.BtnPopPan <> nil then
      P.BtnPopPan.Visible := (N > 1) and (i > 0) and not PanFloating(i);
    // «◑» дисплей-поповер — на всех панах с видимой шапкой (вкл. пан 0).
    if P.BtnDisplay <> nil then P.BtnDisplay.Visible := P.HeaderVisible;
    if i = 0 then
    begin
      P.SetHeaderText(Format('PAN 0 · %.6f MHz ·',
        [FController.FCenterFreq / 1e6]));
      P.SetHeaderRate(Format('%d kHz', [FController.FSampleRate div 1000]));
    end
    else
    begin
      P.SetHeaderText(Format('PAN %d · %.6f MHz ·',
        [i, FController.PanDDCFreq(i) / 1e6]));
      // hw-индекс DDC в шапке: по клику там же выбирается rate и DDC пана.
      P.SetHeaderRate(Format('%d kHz · DDC%d',
        [FController.PanDDCRateKHz(i), FController.PanDDCIndex(i)]));
    end;
    // Бейдж диапазона (3.6): только паны N на «настоящих КВ»; клик = меню.
    if (i > 0) and PanBandSelectorVisible then
    begin
      bi := FreqToBandIdx(FController.PanDDCFreq(i), False);
      if (bi >= 0) and (BandName(bi, False) <> '') then
        P.SetHeaderBand(BandName(bi, False))
      else
        P.SetHeaderBand('—');
      P.BandClickable := True;
    end
    else
    begin
      P.SetHeaderBand('');
      P.BandClickable := False;
    end;
    // Бейдж АЦП: только на двух-АЦПшных платах; клик = тумблер.
    if NADC > 1 then
    begin
      P.SetHeaderADC(Format('RX%d', [FController.PanADC(i) + 1]));
      P.ADCClickable := True;
    end
    else
    begin
      P.SetHeaderADC('');
      P.ADCClickable := False;
    end;
    ApplyPanHeaderTheme(P);   // клик-статусы могли смениться + свежесозданные паны
  end;
end;

procedure TMainForm.UpdateAddPanButton;
var HasSlot: Boolean; i: Integer;
begin
  HasSlot := False;
  for i := 1 to MAX_PANS - 1 do
    if FPans[i] = nil then begin HasSlot := True; Break; end;
  FPan.AddPanEnabled := (FController.BackendCaps.MaxPans > 1) and
                        FController.FRunning and HasSlot;
end;

function TMainForm.CreatePanCore(PanId: Integer; Freq: Double;
  RateKHz: Word; ADC: Byte): TPanafallPanel;
// Ядро создания доп. пана: DDC + панель + проводка + сплиттеры + регистрация
// в FPans. НЕ создаёт слайс, НЕ трогает раскладку/шапки — это делают
// вызывающие (OnAddPanClick — новый пан; RestorePanWorkspace — восстановление).
// Возвращает nil, если DDC не создался.
var
  P: TPanafallPanel;
  Split: TPanel;
begin
  Result := nil;
  if not FController.AddPanDDC(PanId, Freq, RateKHz, ADC) then Exit;

  P := TPanafallPanel.Create(nil, FUseOpenGLSpectrum);
  P.PanId       := PanId;
  P.Controller  := FController;
  P.ShowZoomRow := True;    // пан-зум пана: свой бар/кнопки (диспетчер по Sender)
  P.ZoomInClick  := BtnZoomInClick;   // до Build — кнопки создаются в Build
  P.ZoomDefClick := BtnZoomDefClick;
  P.ZoomOutClick := BtnZoomOutClick;
  P.OnWireFlag        := WireOverlayEvents;
  P.OnSliceClosed     := OnPanSliceClosed;
  P.OnFlagsInvalidate := OnVfoOverlayInvalidate;
  P.OnPanClose        := OnPanHeaderClose;
  P.OnSplitterMoved   := RightPanelResize;   // ratio обновлён → полный re-layout
  P.OnRateClick       := OnPanRateClick;     // «NNN kHz» в шапке → меню rate
  P.RateClickable     := True;
  P.OnADCClick        := OnPanADCClick;      // «RX1/RX2» тумблер АЦП (3.4)
  P.OnPanPopOut       := OnPanPopOutClick;   // «⧉» в отдельное окно (3.5)
  P.OnDisplayClick    := OnPanDisplayClick;  // «◑» дисплей-поповер (3.6)
  P.OnBandClick       := OnPanBandClick;     // «20m» селектор диапазона (3.6, КВ)
  P.SpectrumMouseDown  := PanNSpectrumMouseDown;
  P.SpectrumMouseMove  := PanNSpectrumMouseMove;
  P.SpectrumMouseUp    := PanNSpectrumMouseUp;
  P.SpectrumMouseLeave := PanNSpectrumMouseLeave;
  P.SpectrumDblClick   := PanNSpectrumDblClick;
  P.WaterfallMouseDown := PanNSpectrumMouseDown;   // X→частота одинаково
  P.WaterfallMouseMove := PanNSpectrumMouseMove;
  P.WaterfallMouseUp   := PanNSpectrumMouseUp;
  P.Build(PanelRight, Max(1, FController.FSpecMSAA));
  P.ZoomBar.OnZoomPan := OnPanZoomWindow;   // drag окна-селектора
  P.ZoomBar.SetFreq(Freq, FController.PanDDCRateKHz(PanId) * 1000.0);
  P.ZoomBar.SetState(0.0, 0.5);

  // Параметры отображения — с главного view (пан-персональные — позже).
  P.View.WfAGCEnabled   := FController.FWfAGCEnabled;
  P.View.WfNFEnabled    := FController.FWfNFEnabled;
  P.View.WfManualHigh   := FController.FWfManualHigh;
  P.View.WfManualLow    := FController.FWfManualLow;
  P.View.WfAGCOffset    := FController.FWfAGCOffset;
  P.View.WfPalette      := FController.FWfPalette;
  P.View.WfGamma        := FController.FWfGamma;
  P.View.SpecRefLevel   := FController.FSpecRefLevel;
  P.View.SpecRange      := FController.FSpecRange;
  P.View.SpecGridStep   := FController.FSpecGridStep;
  P.View.WfFrameInterval := EffectiveWfInterval;
  P.View.PAMaxPower     := FController.FPAMaxPower;
  P.View.HasPATelemetry := FController.BackendCaps.HasPA;
  P.View.CenterFreq     := Freq;
  P.View.SpanHz         := FController.PanDDCRateKHz(PanId) * 1000.0;
  // VFO/TX-маркеры главного на доп. пане не рисуем — уводим за пределы окна.
  P.View.VfoA   := -1e15;
  P.View.VfoB   := -1e15;
  P.View.TXFreq := -1e15;
  // Подписи DX-спотов: свой оверлей на общей базе, настройки/тема — общие.
  AttachPanDXSpots(P);

  // Сплиттер над паном.
  Split := TPanel.Create(Self);
  Split.Parent      := PanelRight;
  Split.BevelOuter  := bvNone;
  Split.Color       := TColor($00303030);
  Split.Cursor      := crVSplit;
  Split.Tag         := PanId;
  Split.OnMouseDown := PanSplitMouseDown;
  Split.OnMouseMove := PanSplitMouseMove;
  Split.OnMouseUp   := PanSplitMouseUp;
  FPanSplitters[PanId] := Split;

  // Вертикальный сплиттер слева от колонки пана (грид-раскладка).
  Split := TPanel.Create(Self);
  Split.Parent      := PanelRight;
  Split.BevelOuter  := bvNone;
  Split.Color       := TColor($00303030);
  Split.Cursor      := crHSplit;
  Split.Tag         := PanId;
  Split.Visible     := False;   // включает LayoutPanStack в грид-режиме
  Split.OnMouseDown := PanVSplitMouseDown;
  Split.OnMouseMove := PanVSplitMouseMove;
  Split.OnMouseUp   := PanVSplitMouseUp;
  FPanVSplitters[PanId] := Split;
  FPanColShare[PanId]   := 1.0;

  // Публикуем пан для пиксельных колбэков (display-поток).
  FPanCbLock.Enter;
  try
    FPans[PanId] := P;
  finally
    FPanCbLock.Leave;
  end;
  Result := P;
end;

procedure TMainForm.OnAddPanClick(Sender: TObject);
// «⊞»: новый пан на своём DDC. Центр = частота активного VFO, rate наследуется
// от главного (кламп 768к); ADC 0; в центре автоматически создаётся слайс.
var
  PanId, i: Integer;
  RateKHz: Word;
  Freq: Double;
  P: TPanafallPanel;
begin
  if not FController.FRunning then Exit;
  PanId := -1;
  for i := 1 to MAX_PANS - 1 do
    if FPans[i] = nil then begin PanId := i; Break; end;
  if PanId < 0 then Exit;

  // Rate наследуется от главного пана с клампом сверху (768к — потолок доп.
  // пана по Ethernet/CPU; 48/96/192к наследуются как есть). Не из сетки
  // (напр. Pluto-подобные) — контроллер откатит на 384к. Дальше rate пана
  // независим: селектор «NNN kHz» в шапке, за сменой rate главного НЕ следует.
  RateKHz := FController.FSampleRate div 1000;
  if RateKHz > 768 then RateKHz := 768;

  Freq := ActiveVfoFreq;
  P := CreatePanCore(PanId, Freq, RateKHz, 0);
  if P = nil then Exit;

  for i := 0 to MAX_PANS - 1 do
    if FPans[i] <> nil then FPanShare[i] := 1.0 / PanCount;

  AddSliceAtFreqPan(P, Freq);
  ApplyDarkTheme;          // темизация шапки/кнопок нового пана
  UpdatePanHeaders;
  UpdateAddPanButton;
  ResizeSpectrumPanels;
end;

procedure TMainForm.OnPanHeaderClose(Sender: TObject);
// «×» шапки: нельзя free панель из обработчика её же кнопки — откладываем
// в тик (FPendingPanClose).
begin
  if Sender is TPanafallPanel then
    FPendingPanClose := TPanafallPanel(Sender).PanId;
end;

procedure TMainForm.ClosePanadapter(PanId: Integer);
var
  P: TPanafallPanel;
  S: TCtrlSlice;
  i: Integer;
begin
  if (PanId < 1) or (PanId >= MAX_PANS) then Exit;
  P := FPans[PanId];
  if P = nil then Exit;
  // Дисплей-поповер мог быть привязан к этому пану — спрятать (FPan станет
  // висячим; поповер перепривяжется при следующем показе через LoadFrom).
  if FPanDisplayPopup <> nil then TPanDisplayPopup(FPanDisplayPopup).Hide;
  // Отписываем от пиксельных колбэков ДО удаления DDC/панели.
  FPanCbLock.Enter;
  try
    FPans[PanId] := nil;
  finally
    FPanCbLock.Leave;
  end;
  // Слайсы пана (со звуковухами) + движок + DDC.
  FController.RemovePanDDC(PanId);
  if (FActiveSliceId <> 0) and not FController.GetSlice(FActiveSliceId, S) then
    FActiveSliceId := 0;
  FreeAndNil(FPanSplitters[PanId]);
  FreeAndNil(FPanVSplitters[PanId]);
  P.Free;
  // Пан был в отдельном окне — окно больше не нужно. Release (не Free):
  // сюда можно попасть из событий контролов этого же окна («×» шапки → тик).
  if FPanFloatForm[PanId] <> nil then
  begin
    FPanFloatForm[PanId].Release;
    FPanFloatForm[PanId] := nil;
  end;
  for i := 0 to MAX_PANS - 1 do
    if FPans[i] <> nil then FPanShare[i] := 1.0 / PanCount;
  UpdatePanHeaders;
  UpdateAddPanButton;
  ResizeSpectrumPanels;
end;

procedure TMainForm.DestroyAllExtraPans;
var i: Integer;
begin
  for i := 1 to MAX_PANS - 1 do
    if FPans[i] <> nil then ClosePanadapter(i);
end;

function TMainForm.PanContextKey: string;
// Ключ контекста персиста панов: КВ и каждый трансвертер — раздельно
// (паны/слайсы одного контекста не имеют смысла в другом).
begin
  if FController.FCurrentXvtr < 0 then
    Result := 'hf'
  else
    Result := 'xvtr_' + IntToStr(FController.FCurrentXvtr);
end;

procedure TMainForm.TearDownWorkspace;
// Полный снос панов+слайсов текущего контекста (доп. паны + слайсы пана 0).
begin
  DestroyAllExtraPans;
  FPan.DestroyAllSliceFlags;
  FActiveSliceId := 0;
end;

procedure TMainForm.SavePanWorkspace;
// OnBeforeStop-хук: снимок текущего контекста перед гашением панов.
begin
  SavePanWorkspaceCtx(PanContextKey);
end;

procedure TMainForm.SavePanWorkspaceCtx(const Ctx: string);
// Снимок панов+слайсов+раскладки в секцию "pans"/Ctx под MAC. Зовётся при STOP/
// закрытии/смене контекста (пока железо ещё подключено), до разрушения панов.
var
  Cfg: TPansConfig;
  i, s, n: Integer;
begin
  if not FController.FNetwork.Connected then Exit;   // MAC валиден только при девайсе
  TSettingsManager.DefaultPans(Cfg);
  Cfg.GridMode     := FPanGridMode;
  Cfg.GridRowShare := FPanGridRowShare;
  for i := 0 to MAX_PANS - 1 do
  begin
    Cfg.Pans[i].StackShare := FPanShare[i];
    Cfg.Pans[i].ColShare   := FPanColShare[i];   // пан 0 — колонка в гриде «2 колонки»
    if FPans[i] <> nil then Cfg.Pans[i].SplitRatio := FPans[i].SplitterRatio;
    if i > 0 then
    begin
      if not FController.PanDDCActive(i) then Continue;   // слайсы мёртвого пана не пишем
      Cfg.Pans[i].Active    := True;
      Cfg.Pans[i].FreqHz    := FController.PanDDCFreq(i);
      Cfg.Pans[i].RateKHz   := FController.PanDDCRateKHz(i);
      Cfg.Pans[i].ADC       := FController.PanADC(i);
      Cfg.Pans[i].Zoom      := FController.PanZoomFactor(i);
      Cfg.Pans[i].PanSlider := FController.PanPanSlider(i);
      // Per-pan дисплей (3.6): пишем override только если пан его задал.
      if (FPans[i] <> nil) and FPans[i].DisplayOverride then
      begin
        Cfg.Pans[i].HasDisplay := True;
        Cfg.Pans[i].DPalette   := FPans[i].View.WfPalette;
        Cfg.Pans[i].DGamma     := FPans[i].View.WfGamma;
        Cfg.Pans[i].DSpecRef   := FPans[i].View.SpecRefLevel;
        Cfg.Pans[i].DSpecRange := FPans[i].View.SpecRange;
        Cfg.Pans[i].DWfAGC     := FPans[i].View.WfAGCEnabled;
        Cfg.Pans[i].DWfOffset  := FPans[i].View.WfAGCOffset;
        Cfg.Pans[i].DWfHigh    := FPans[i].View.WfManualHigh;
        Cfg.Pans[i].DWfLow     := FPans[i].View.WfManualLow;
      end;
    end;
    // Слайсы этого пана (главный VFO пана 0 — не слайс, его тут нет).
    n := 0;
    for s := 0 to High(FController.FSlices) do
      if FController.FSlices[s].Active and (FController.FSlices[s].PanId = i) then
        Inc(n);
    SetLength(Cfg.Pans[i].Slices, n);
    n := 0;
    for s := 0 to High(FController.FSlices) do
      if FController.FSlices[s].Active and (FController.FSlices[s].PanId = i) then
      begin
        Cfg.Pans[i].Slices[n].TargetHz  := FController.FSlices[s].TargetHz;
        Cfg.Pans[i].Slices[n].Mode      := FController.FSlices[s].Mode;
        Cfg.Pans[i].Slices[n].FilterLo  := FController.FSlices[s].FilterLo;
        Cfg.Pans[i].Slices[n].FilterHi  := FController.FSlices[s].FilterHi;
        Cfg.Pans[i].Slices[n].AGC       := Ord(FController.FSlices[s].AGC);
        Cfg.Pans[i].Slices[n].Volume    := FController.FSlices[s].Volume;
        Cfg.Pans[i].Slices[n].Muted     := FController.FSlices[s].Muted;
        Cfg.Pans[i].Slices[n].RxMuteOnTx := FController.FSlices[s].RxMuteOnTx;
        Cfg.Pans[i].Slices[n].FMSQOn    := FController.FSlices[s].FMSQOn;
        Cfg.Pans[i].Slices[n].FMSQLevel := FController.FSlices[s].FMSQLevel;
        Cfg.Pans[i].Slices[n].DevName   := FController.FSlices[s].DevName;
        Cfg.Pans[i].Slices[n].InDevName := FController.FSlices[s].InDevName;
        Cfg.Pans[i].Slices[n].TXProfByMode := FController.FSlices[s].TXProfByMode;
        Inc(n);
      end;
  end;
  FController.FSettings.SavePans(FController.FDevMAC, Ctx, Cfg);
  FController.FSettings.Save;
end;

procedure TMainForm.RestoreSliceInto(P: TPanafallPanel; PanId: Integer;
  const SC: TSliceCfg);
// Один слайс в пан P (PanId): создаёт WDSP-канал + флаг, применяет mute/squelch.
var Id, DevIdx: Integer;
begin
  if not FController.SliceFitsCapture(SC.TargetHz, PanId) then Exit;  // вне полосы захвата
  DevIdx := -1;
  if SC.DevName <> '' then
    DevIdx := FController.FAudioOut.FindDeviceByName(SC.DevName);
  Id := FController.AddSlice(SC.TargetHz, SC.Mode, SC.FilterLo, SC.FilterHi,
          TWDSPAGCMode(EnsureRange(SC.AGC, 0, Ord(High(TWDSPAGCMode)))),
          EnsureRange(SC.Volume, 0.0, 1.0), DevIdx, SC.DevName, PanId);
  if Id < 0 then Exit;
  // Свой вход слайса (имя устройства → PA-индекс текущей сессии).
  if SC.InDevName <> '' then
    FController.SetSliceInDevice(Id,
      FController.FAudioIn.FindDeviceByName(SC.InDevName), SC.InDevName);
  if SC.Muted then FController.SetSliceMute(Id, True);
  FController.SetSliceRxMuteOnTx(Id, SC.RxMuteOnTx);
  // Таблица «режим → профиль» этого слайса целиком (не через SetSliceTXProfile:
  // тот пишет только ячейку текущего режима).
  FController.SetSliceTXProfileTable(Id, SC.TXProfByMode);
  if SC.FMSQOn then FController.SetSliceFMSquelch(Id, True, SC.FMSQLevel);
  P.CreateSliceFlag(Id);
  FActiveSliceId := Id;
end;

procedure TMainForm.RestorePanWorkspace;
// Восстановление контекста при выходе в Run (один раз за сессию). Контекст
// (КВ|трансвертер) уже финальный — ConnectAndRun зовёт StartRunning последним.
begin
  if not FController.FRunning then Exit;
  if FPansRestored then Exit;
  FPansRestored := True;
  // Слайсы пана 0 переживают STOP (софт-fan-out) — снести выживших, иначе дубли.
  TearDownWorkspace;
  RestorePanWorkspaceCtx(PanContextKey);
end;

procedure TMainForm.RestorePanWorkspaceCtx(const Ctx: string);
// Восстановление панов+слайсов+раскладки контекста Ctx. Вызывающий обязан
// СНАЧАЛА снести текущий воркспейс (TearDownWorkspace). При отсутствии
// сохранёнки применяются дефолты (чистый стек, без панов).
var
  Cfg: TPansConfig;
  i, k: Integer;
  P: TPanafallPanel;
begin
  if not FController.FRunning then Exit;
  // LoadPans заполняет Cfg дефолтами и при отсутствии секции — применяем как есть
  // (грид off, панов нет) ради чистого целевого контекста.
  FController.FSettings.LoadPans(FController.FDevMAC, Ctx, Cfg);

  FPanGridMode     := Cfg.GridMode;
  FPanGridRowShare := Cfg.GridRowShare;

  // Пан 0: восстановить его доп. слайсы (главный VFO — не слайс) + доли.
  FPanShare[0]    := Cfg.Pans[0].StackShare;
  FPanColShare[0] := Cfg.Pans[0].ColShare;
  FPan.SplitterRatio := Cfg.Pans[0].SplitRatio;
  for k := 0 to High(Cfg.Pans[0].Slices) do
    RestoreSliceInto(FPan, 0, Cfg.Pans[0].Slices[k]);

  // Доп. паны на своих DDC.
  for i := 1 to MAX_PANS - 1 do
  begin
    if not Cfg.Pans[i].Active then Continue;
    if FPans[i] <> nil then Continue;
    P := CreatePanCore(i, Cfg.Pans[i].FreqHz,
           Word(EnsureRange(Cfg.Pans[i].RateKHz, 48, 1536)),
           Byte(EnsureRange(Cfg.Pans[i].ADC, 0, 3)));
    if P = nil then Continue;
    FPanShare[i]    := Cfg.Pans[i].StackShare;
    FPanColShare[i] := Cfg.Pans[i].ColShare;
    P.SplitterRatio := Cfg.Pans[i].SplitRatio;
    // Per-pan дисплей (3.6): CreatePanCore уже залил глоб. дефолт — при наличии
    // override перекрываем сохранёнкой.
    if Cfg.Pans[i].HasDisplay then
    begin
      P.View.WfPalette    := EnsureRange(Cfg.Pans[i].DPalette, 0, 2);
      P.View.WfGamma      := EnsureRange(Cfg.Pans[i].DGamma, 0.30, 3.00);
      P.View.SpecRefLevel := Cfg.Pans[i].DSpecRef;
      P.View.SpecRange    := Cfg.Pans[i].DSpecRange;
      P.View.WfAGCEnabled := Cfg.Pans[i].DWfAGC;
      P.View.WfAGCOffset  := Cfg.Pans[i].DWfOffset;
      P.View.WfManualHigh := Cfg.Pans[i].DWfHigh;
      P.View.WfManualLow  := Cfg.Pans[i].DWfLow;
      P.DisplayOverride   := True;
    end;
    FController.SetPanZoomPan(i, Cfg.Pans[i].Zoom, Cfg.Pans[i].PanSlider);
    for k := 0 to High(Cfg.Pans[i].Slices) do
      RestoreSliceInto(P, i, Cfg.Pans[i].Slices[k]);
  end;

  ApplyDarkTheme;
  UpdatePanHeaders;
  UpdateAddPanButton;
  ResizeSpectrumPanels;
  // Зум-бары/флаги — после раскладки (размеры вьюх уже верны).
  for i := 1 to MAX_PANS - 1 do
    if FPans[i] <> nil then RefreshPanZoom(FPans[i]);
end;

procedure TMainForm.SyncPanViews;
// Центр/span вьюх доп. панов (аналог SyncSpecViewFreq главного, но без
// VFO/TX-маркеров — они уведены за окно при создании).
var i: Integer; P: TPanafallPanel;
begin
  for i := 1 to MAX_PANS - 1 do
  begin
    P := FPans[i];
    if P = nil then Continue;
    SyncPanZoomUI(P);   // центр/span вьюхи (с пан-зумом) + зум-бар
    P.View.WDSPReady := FController.FWDSPReady;
  end;
  // Шапки следуют за частотами (Caption-сеттер — no-op при том же тексте).
  if PanCount > 1 then UpdatePanHeaders;
end;

procedure TMainForm.OnPanSpectrumReady(PanId: Integer;
  const Pixels: array of Single; Count: Integer);
// Display-поток движка. Лок закрывает гонку с Close/AddPanadapter (UI).
begin
  FPanCbLock.Enter;
  try
    if (PanId >= 1) and (PanId < MAX_PANS) and (FPans[PanId] <> nil) then
    begin
      FPans[PanId].View.SetSpectrumData(Pixels, Count);
      FPanSpecDirty[PanId] := True;
    end;
  finally
    FPanCbLock.Leave;
  end;
end;

procedure TMainForm.OnPanWaterfallReady(PanId: Integer;
  const Pixels: array of Single; Count: Integer);
begin
  FPanCbLock.Enter;
  try
    if (PanId >= 1) and (PanId < MAX_PANS) and (FPans[PanId] <> nil) then
    begin
      FPans[PanId].View.SetWaterfallData(Pixels, Count);
      if FPans[PanId].View.WaterfallDirty then FPanWfDirty[PanId] := True;
    end;
  finally
    FPanCbLock.Leave;
  end;
end;

// ---- Мышь доп. панов: тюн слайсов + drag центра (ретюн DDC). Семантика
// главного пана (VFO/CTUN/маяк/оверлеи) сюда не переносится. ----

procedure TMainForm.PanNSpectrumMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  P: TPanafallPanel;
  VC, VS: Double;
  Id, W: Integer;
  IsSpec: Boolean;
  DXSpot: TDXSpot;
begin
  P := PanFromSender(Sender);
  if P = nil then Exit;
  // Флаги и маркеры несущих живут ТОЛЬКО на спектре: координаты водопада в
  // диспетчер флагов отдавать нельзя (его прямоугольники — в системе спектра).
  IsSpec := TControl(Sender) = P.PbSpectrum;
  if IsSpec and P.DispatchFlagsMouseDown(Button, X, Y) then
  begin
    P.ProcessPendingSliceClose;
    Exit;
  end;
  // ЛКМ по подписи DX-спота на пане — QSY слайса ЭТОГО пана (у панов N нет
  // VFO A). Как на главном: только в полосе подписей, ниже поведение прежнее.
  if IsSpec and (Button = mbLeft) and (P.DXOverlay <> nil) and
     P.DXOverlay.SpotAtPixel(X, Y, DXSpot) then
  begin
    Id := DXTuneSpotOnPan(P, DXSpot.FreqHz, DXSpot.Mode);
    // ★Приёмник TCI — это слот СЛАЙСА (панадаптером приёмник быть перестал),
    // поэтому спрашиваем номер того слайса, который на спот и увели. Номер
    // пана здесь адресовал чужой приёмник, а то и несуществующий — клиенты с
    // фильтром по rx уведомление просто теряли.
    // ★Ничего не увели (спот вне полосы захвата зумнутого пана, слоты
    // кончились) — молчим. Иначе уведомление уезжало бы под номером 0, то есть
    // от имени ГЛАВНОГО приёмника, который никуда не двигался: логгер открывал
    // бы карточку QSO и тянул за собой VFO на частоту, где радио не стоит.
    if (Id <> 0) and (FTCIAdapter <> nil) then
      FTCIAdapter.NotifySpotClicked(DXSpot.Call, DXSpot.FreqHz,
                                    FTCIAdapter.RxOfSlice(Id), 0);
    Exit;
  end;
  W := TControl(Sender).Width;
  if W <= 0 then Exit;
  if (Button = mbLeft) and (ssCtrl in Shift) then
  begin
    PanViewWindow(P, VC, VS);
    AddSliceAtFreqPan(P, PixelToFreq(X, W, VC, VS));
    Exit;
  end;
  if Button = mbLeft then
  begin
    if IsSpec then
    begin
      Id := P.SliceAtPixel(X);
      if Id <> 0 then
      begin
        FPanSliceDrag   := True;
        FPanSliceDragId := Id;
        FPanMousePan    := P;
        FActiveSliceId  := Id;
        TControl(Sender).Cursor := crSizeWE;
        Exit;
      end;
    end;
    // Пустое место: армируем drag ЦЕНТРА пана (ретюн его DDC; слайсы стоят на
    // своих абсолютных частотах). Клик без движения = тюн слайса пана (MouseUp).
    // Курсор не трогаем до реального сдвига.
    FPanCenterDrag    := True;
    FPanCenterMoved   := False;
    FPanMousePan      := P;
    FPanCenterDragX0  := X;
    FPanCenterDragF0  := FController.PanDDCFreq(P.PanId);
  end;
end;

procedure TMainForm.PanNSpectrumMouseMove(Sender: TObject; Shift: TShiftState;
  X, Y: Integer);
var
  P: TPanafallPanel;
  VC, VS, NewHz, LoHz, HiHz: Double;
  W: Integer;
begin
  P := PanFromSender(Sender);
  if P = nil then Exit;
  W := TControl(Sender).Width;

  if FPanSliceDrag and (FPanMousePan = P) then
  begin
    if (ssLeft in Shift) and (W > 0) then
    begin
      PanViewWindow(P, VC, VS);
      NewHz := PixelToFreq(X, W, VC, VS);
      FController.CapturedSpan(LoHz, HiHz, P.PanId);
      if NewHz < LoHz then NewHz := LoHz;
      if NewHz > HiHz then NewHz := HiHz;
      FController.SetSliceTarget(FPanSliceDragId, NewHz);   // флаг — rfSliceFreq
    end;
    Exit;
  end;

  if FPanCenterDrag and (FPanMousePan = P) then
  begin
    if (ssLeft in Shift) and (W > 0) then
    begin
      // Гистерезис 4px: до него это ещё «клик» (тюн по MouseUp), не drag.
      if (not FPanCenterMoved) and (Abs(X - FPanCenterDragX0) < 4) then Exit;
      if not FPanCenterMoved then
      begin
        FPanCenterMoved := True;
        TControl(Sender).Cursor := crSizeWE;
      end;
      PanViewWindow(P, VC, VS);
      NewHz := FPanCenterDragF0 - (X - FPanCenterDragX0) * VS / W;
      // Кламп в ВИДИМЫХ частотах: при активном XVTR — его диапазон (иначе
      // 1296 МГц «падала» на MaxFreqHz железа ~61 МГц), без XVTR — железо.
      FController.VisibleFreqBounds(LoHz, HiHz);
      NewHz := Max(LoHz, Min(HiHz, NewHz));
      FController.SetPanDDCFreq(P.PanId, NewHz);
      SyncPanZoomUI(P);   // окно вьюхи с учётом зума + freq зум-бара
      P.LayoutFlags;
      UpdatePanHeaders;
      MarkPanDirty(P);
    end;
    Exit;
  end;

  // Hover-логика (флаги, маркеры несущих) — только на спектре; на водопаде
  // курсор остаётся обычным, как у главного пана.
  if TControl(Sender) = P.PbSpectrum then
  begin
    P.DispatchFlagsMouseMove(X, Y);
    if P.SliceAtPixel(X) <> 0 then
      TControl(Sender).Cursor := crSizeWE
    else
      TControl(Sender).Cursor := crArrow;   // crDefault «снимает» курсор — см. Build
  end;
end;

procedure TMainForm.PanNSpectrumMouseUp(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var P: TPanafallPanel; W: Integer;
begin
  P := PanFromSender(Sender);
  if P = nil then Exit;
  if FPanSliceDrag and (FPanMousePan = P) then
  begin
    FPanSliceDrag := False;
    FPanMousePan  := nil;
    TControl(Sender).Cursor := crArrow;
    Exit;
  end;
  if FPanCenterDrag and (FPanMousePan = P) then
  begin
    FPanCenterDrag := False;
    FPanMousePan   := nil;
    TControl(Sender).Cursor := crArrow;
    // Пресс без движения = клик: перестроить слайс пана на частоту клика.
    W := TControl(Sender).Width;
    if (Button = mbLeft) and (not FPanCenterMoved) and (W > 0) then
      PanClickTune(P, X, W);
    Exit;
  end;
  if (TControl(Sender) = P.PbSpectrum) and P.DispatchFlagsMouseUp(X, Y) then
    P.ProcessPendingSliceClose;
end;

procedure TMainForm.PanNSpectrumMouseLeave(Sender: TObject);
var P: TPanafallPanel;
begin
  P := PanFromSender(Sender);
  if P <> nil then P.DispatchFlagsMouseLeave;
end;

procedure TMainForm.PanNSpectrumDblClick(Sender: TObject);
begin
  // Дабл-клик на доп. пане пока без действия.
end;

// ---- Сплиттеры между панами: пересчёт долей по отпусканию мыши. ----

procedure TMainForm.PanSplitMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
const
  PAN_SPLIT_H = 5;
var
  BelowId, AboveId, i, TotalH, MinPx, BotMin: Integer;
  Split: TPanel;
begin
  if Button <> mbLeft then Exit;
  if PanGridTwoCol then Exit;   // две колонки: горизонтальных сплиттеров нет
  Split   := TPanel(Sender);
  BelowId := Split.Tag;
  TotalH := PanelRight.ClientHeight;
  if TotalH <= 0 then Exit;
  MinPx := Round(0.15 * TotalH);
  if PanGridActive then
  begin
    // Грид: этот сплиттер — граница ряда пан 0 / нижний ряд колонок.
    // Минимум — 15% ИЛИ минимум содержимого (что больше): ниже минимума
    // виджеты пана вылезают за регион и паны наезжают друг на друга.
    FPanSplitMinTop := Max(MinPx, PanMinHeight(0));
    BotMin := MinPx;   // минимум нижнего ряда — макс. по его колонкам
    for i := 1 to MAX_PANS - 1 do
      if (FPans[i] <> nil) and not PanFloating(i) then
        BotMin := Max(BotMin, PanMinHeight(i));
    FPanSplitMaxTop := TotalH - BotMin - PAN_SPLIT_H;
  end
  else
  begin
    // Пан над сплиттером — ближайший активный с меньшим PanId.
    AboveId := -1;
    for i := BelowId - 1 downto 0 do
      if FPans[i] <> nil then begin AboveId := i; Break; end;
    if (AboveId < 0) or (FPans[BelowId] = nil) then Exit;
    // Границы призрака: каждому пану — минимум 15% стека ИЛИ минимум его
    // содержимого (что больше; = кламп раздачи в LayoutPanStack).
    FPanSplitMinTop := FPans[AboveId].StackTop
                       + Max(MinPx, PanMinHeight(AboveId));
    FPanSplitMaxTop := FPans[BelowId].StackTop + FPans[BelowId].StackHeight
                       - Max(MinPx, PanMinHeight(BelowId)) - PAN_SPLIT_H;
  end;
  if FPanSplitMaxTop < FPanSplitMinTop then FPanSplitMaxTop := FPanSplitMinTop;
  FPanSplitDrag := BelowId;
  FPanSplitY0   := Mouse.CursorPos.Y;
  FPanSplitTop0 := Split.Top;
  Split.Color   := TColor($005050A0);   // подсветка при захвате (как у внутрипанного)
end;

procedure TMainForm.PanSplitMouseMove(Sender: TObject; Shift: TShiftState;
  X, Y: Integer);
var NewTop: Integer;
begin
  // Живой призрак: двигаем сам сплиттер, пересчёт регионов — по MouseUp.
  if (FPanSplitDrag = 0) or (TPanel(Sender).Tag <> FPanSplitDrag) then Exit;
  NewTop := FPanSplitTop0 + (Mouse.CursorPos.Y - FPanSplitY0);
  if NewTop < FPanSplitMinTop then NewTop := FPanSplitMinTop;
  if NewTop > FPanSplitMaxTop then NewTop := FPanSplitMaxTop;
  TPanel(Sender).Top := NewTop;
end;

procedure TMainForm.PanSplitMouseUp(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  BelowId, AboveId, i, DeltaY, TotalH: Integer;
  DShare, MinShare: Double;
begin
  if FPanSplitDrag = 0 then Exit;
  BelowId := FPanSplitDrag;
  FPanSplitDrag := 0;
  TPanel(Sender).Color := TColor($00303030);
  TotalH := PanelRight.ClientHeight;
  if TotalH <= 0 then Exit;
  if PanGridActive and not PanGridTwoCol then
  begin
    // Грид: сплиттер задаёт долю высоты верхнего ряда (пан 0) напрямую.
    FPanGridRowShare := EnsureRange(TPanel(Sender).Top / TotalH, 0.15, 0.85);
    ResizeSpectrumPanels;
    Exit;
  end;
  // Пан над сплиттером — ближайший активный с меньшим PanId.
  AboveId := -1;
  for i := BelowId - 1 downto 0 do
    if FPans[i] <> nil then begin AboveId := i; Break; end;
  if (AboveId < 0) or (FPans[BelowId] = nil) then Exit;
  // Дельта — по финальному положению призрака (он уже клампился в Move).
  DeltaY := TPanel(Sender).Top - FPanSplitTop0;
  DShare := DeltaY / TotalH;
  MinShare := 0.15;
  if FPanShare[AboveId] + DShare < MinShare then
    DShare := MinShare - FPanShare[AboveId];
  if FPanShare[BelowId] - DShare < MinShare then
    DShare := FPanShare[BelowId] - MinShare;
  FPanShare[AboveId] := FPanShare[AboveId] + DShare;
  FPanShare[BelowId] := FPanShare[BelowId] - DShare;
  ResizeSpectrumPanels;
end;

procedure TMainForm.OnPanGridToggle(Sender: TObject);
begin
  FPanGridMode := not FPanGridMode;
  ResizeSpectrumPanels;
end;

procedure TMainForm.PanVSplitMouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
const
  PAN_SPLIT_W = 5;
var
  RightId, LeftId, i, TotalW, MinPx: Integer;
  Split: TPanel;
begin
  if (Button <> mbLeft) or not PanGridActive then Exit;
  Split   := TPanel(Sender);
  RightId := Split.Tag;
  // Колонка слева — ближайший застекованный пан с меньшим PanId (в гриде из
  // двух колонок левая колонка — сам пан 0, в ряду колонок — доп. пан).
  LeftId := -1;
  for i := RightId - 1 downto IfThen(PanGridTwoCol, 0, 1) do
    if (FPans[i] <> nil) and not PanFloating(i) then begin LeftId := i; Break; end;
  if (LeftId < 0) or (FPans[RightId] = nil) then Exit;
  TotalW := PanelRight.ClientWidth;
  if TotalW <= 0 then Exit;
  // Каждой колонке — минимум 15% ширины (= кламп долей в MouseUp).
  MinPx := Round(0.15 * TotalW);
  FPanVSplitMinLeft := FPans[LeftId].StackLeft + MinPx;
  FPanVSplitMaxLeft := FPans[RightId].StackLeft + FPans[RightId].StackWidth
                       - MinPx - PAN_SPLIT_W;
  FPanVSplitDrag  := RightId;
  FPanVSplitX0    := Mouse.CursorPos.X;
  FPanVSplitLeft0 := Split.Left;
  Split.Color     := TColor($005050A0);
end;

procedure TMainForm.PanVSplitMouseMove(Sender: TObject; Shift: TShiftState;
  X, Y: Integer);
var NewLeft: Integer;
begin
  // Живой призрак: двигаем сам сплиттер, пересчёт долей — по MouseUp.
  if (FPanVSplitDrag = 0) or (TPanel(Sender).Tag <> FPanVSplitDrag) then Exit;
  NewLeft := FPanVSplitLeft0 + (Mouse.CursorPos.X - FPanVSplitX0);
  if NewLeft < FPanVSplitMinLeft then NewLeft := FPanVSplitMinLeft;
  if NewLeft > FPanVSplitMaxLeft then NewLeft := FPanVSplitMaxLeft;
  TPanel(Sender).Left := NewLeft;
end;

procedure TMainForm.PanVSplitMouseUp(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  RightId, LeftId, i, DeltaX, TotalW: Integer;
  DShare, MinShare, ShareSum: Double;
begin
  if FPanVSplitDrag = 0 then Exit;
  RightId := FPanVSplitDrag;
  FPanVSplitDrag := 0;
  TPanel(Sender).Color := TColor($00303030);
  LeftId := -1;
  for i := RightId - 1 downto IfThen(PanGridTwoCol, 0, 1) do
    if (FPans[i] <> nil) and not PanFloating(i) then begin LeftId := i; Break; end;
  if (LeftId < 0) or (FPans[RightId] = nil) then Exit;
  TotalW := PanelRight.ClientWidth;
  if TotalW <= 0 then Exit;
  // Доли колонок нормируются суммой (init 1.0 на пан) — пиксели → доли через неё.
  ShareSum := 0;
  for i := IfThen(PanGridTwoCol, 0, 1) to MAX_PANS - 1 do
    if (FPans[i] <> nil) and not PanFloating(i) then
      ShareSum := ShareSum + FPanColShare[i];
  if ShareSum <= 0 then ShareSum := 1;
  DeltaX := TPanel(Sender).Left - FPanVSplitLeft0;
  DShare := DeltaX / TotalW * ShareSum;
  MinShare := 0.15 * ShareSum;
  if FPanColShare[LeftId] + DShare < MinShare then
    DShare := MinShare - FPanColShare[LeftId];
  if FPanColShare[RightId] - DShare < MinShare then
    DShare := FPanColShare[RightId] - MinShare;
  FPanColShare[LeftId]  := FPanColShare[LeftId] + DShare;
  FPanColShare[RightId] := FPanColShare[RightId] - DShare;
  ResizeSpectrumPanels;
end;

procedure TMainForm.OnVfoOverlaySliceSelect(Sender: TObject);
begin
  if Sender is TVfoOverlay then
    FActiveSliceId := TVfoOverlay(Sender).SliceId;
end;

procedure TMainForm.OnVfoOverlayClose(Sender: TObject);
var P: TPanafallPanel;
begin
  if not (Sender is TVfoOverlay) then Exit;
  if TVfoOverlay(Sender).IsMain then Exit;
  // Нельзя освобождать флаг внутри его же обработчика мыши — панель пометит
  // и снимет его после возврата из диспетчера (ProcessPendingSliceClose).
  P := PanOfOverlay(Sender);
  if P <> nil then P.RequestSliceClose(TVfoOverlay(Sender).SliceId);
end;

// FPan закрыл слайс («×») — сбрасываем активный, если это был он.
procedure TMainForm.OnPanSliceClosed(SliceId: Integer);
begin
  if FActiveSliceId = SliceId then FActiveSliceId := 0;
end;

procedure TMainForm.OnSampleRateSelect(SampleRate: Integer);
begin
  // Тонкая обёртка: движок (пересоздание WDSP-канала, DDC-rate, DSP-restore) +
  // персист — в команде SetSampleRate; рендер overlay/AGC — OnControllerState.
  FController.SetSampleRate(SampleRate);
end;

// ===========================================================================
// Веб-интерфейс: callbacks от TWebServer (вызываются из WS-потока)
// Все изменения состояния выполняем через TThread.Synchronize чтобы
// не трогать UI и WDSP из чужого потока.
// ===========================================================================

// Mode/Filter/AGC/AGCTop/Volume/Drive/Span/Band/XvtrBand/Freq/FreqA/FreqB/Center/
// WfAGC/WfNF/Mute/NR/NB/SNB/ANF/Attn/ClientActiveChanged перенесены в TWebAdapter
// (чистые команды контроллера).

// ── Run ────────────────────────────────────────────────────────────────────

procedure TMainForm.WebOnRun(On_: Boolean);
begin
  FWebSyncBool := On_;
  FWebSyncM := SyncWebRun;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebRun;
begin
  // set_run отвязан от десктопного модального диалога: web стартует через connect
  // (overlay), а set_run:true без выбранного устройства бессмысленно. Здесь только
  // STOP-путь (set_run:false), симметрично web-disconnect.
  if (not FWebSyncBool) and FController.FNetwork.Connected then
    DoStopAndDisconnect;
end;

// ── Web discovery / connect / disconnect / saved-CRUD ───────────────────────

procedure TMainForm.WebOnConnect(const IP: string);
begin
  FWebSyncStr1 := IP;
  FWebSyncM := SyncWebConnect;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebConnect;
var
  Dev: THPSDRDevice;
begin
  if FController.FNetwork.Connected then Exit;   // уже подключены
  // Восстанавливаем Kind/URI/Serial/BoardType/MAC из стора (discovered/saved) по
  // IP или URI — как десктоп-путь и демон (ConnectTo(ResolveDevice(...))). Без этого
  // saved Pluto коннектился с Kind=0 (=bkHPSDR) → UDP discovery → Hardware Timeout,
  // хотя web оптимистично показывал "connected / Unknown Board".
  Dev := FController.ResolveDevice(FWebSyncStr1);
  StartWithDevice(Dev);
end;

procedure TMainForm.WebOnDiscover;
begin
  FWebSyncM := SyncWebDiscover;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebDiscover;
begin
  SetStatusText(2, 'Discovering...');
  FController.Discover;   // rfDeviceList-рендер запушит обновлённый список в web
end;

procedure TMainForm.WebOnDisconnect;
begin
  FWebSyncM := SyncWebDisconnect;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebDisconnect;
begin
  if FController.FNetwork.Connected then
    DoStopAndDisconnect;
end;

procedure TMainForm.WebOnDevAdd(const AName, IP: string);
begin
  FWebSyncStr1 := AName;
  FWebSyncStr2 := IP;
  FWebSyncM := SyncWebDevAdd;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebDevAdd;
begin
  FController.AddSavedDevice(FWebSyncStr1, FWebSyncStr2);   // Pluto по схеме URI
  PushDeviceListToWeb;
end;

procedure TMainForm.WebOnDevRemove(Idx: Integer);
begin
  FWebSyncInt := Idx;
  FWebSyncM := SyncWebDevRemove;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebDevRemove;
begin
  FController.FDeviceStore.RemoveSaved(FWebSyncInt);
  PushDeviceListToWeb;
end;

procedure TMainForm.WebOnDevAutoStart(Idx: Integer);
begin
  FWebSyncInt := Idx;
  FWebSyncM := SyncWebDevAutoStart;
  TThread.Synchronize(nil, FWebSyncM);
end;

procedure TMainForm.SyncWebDevAutoStart;
begin
  FController.FDeviceStore.SetAutoStart(FWebSyncInt);
  PushDeviceListToWeb;
end;

procedure TMainForm.PushDeviceListToWeb;
// store (saved+discovered) → FWebServer.SetDeviceList. Saved идут первыми, поэтому
// их позиция в массиве == saved-индекс (web шлёт его в dev_remove/dev_autostart).
var
  Arr: TWebDeviceArray;
  i, k: Integer;
  D: TDiscoveredDevice;
  Sv: TSavedDevice;
begin
  if FWebServer = nil then Exit;
  SetLength(Arr, FController.FDeviceStore.SavedCount +
                 FController.FDeviceStore.DiscoveredCount);
  k := 0;
  for i := 0 to FController.FDeviceStore.SavedCount - 1 do
  begin
    Sv := FController.FDeviceStore.Saved(i);
    Arr[k].Name      := Sv.Name;
    Arr[k].IP        := Sv.IPAddress;
    Arr[k].BoardType := Sv.BoardType;
    Arr[k].IsSaved   := True;
    Arr[k].AutoStart := Sv.AutoStart;
    Inc(k);
  end;
  for i := 0 to FController.FDeviceStore.DiscoveredCount - 1 do
  begin
    D := FController.FDeviceStore.Discovered(i);
    Arr[k].Name      := D.DisplayName;
    Arr[k].IP        := D.IPAddress;
    Arr[k].BoardType := D.BoardType;
    Arr[k].IsSaved   := False;
    Arr[k].AutoStart := False;
    Inc(k);
  end;
  FWebServer.SetDeviceList(Arr);
end;

// ── CTUN ──────────────────────────────────────────────────────────────────

procedure TMainForm.TrkDriveChange(Sender: TObject);
begin
  if FSyncingFromController then Exit;   // программная установка из OnControllerState — не команда
  FController.SetDrive(TrkDrive.Position);   // rfDrive-рендер снимет память канала (OnDriveChanged)
end;

procedure TMainForm.UpdateDriveLabel;
// Значение рядом с ползунком drive: openHPSDR — проценты, Pluto — TX-аттенюация
// в дБ (берём из контроллера; маппинг drive%→дБ — там же).
begin
  if LblDrvVal = nil then Exit;
  if FController.IsPluto then
    LblDrvVal.Caption := Format('%.0fdB', [FController.PlutoTxAttForDrive])
  else
    LblDrvVal.Caption := Format('%d%%', [FController.FDrivePercent]);
end;

function TMainForm.CalcDriveByte: Byte;
begin
  // Формула — в контроллере (чистая над состоянием). Делегат для существующих
  // вызовов MainForm.
  Result := FController.CalcDriveByte;
end;

procedure TMainForm.OnPASettingsChange(MaxPower: Double;
  const BandCal: array of Double);
var i: Integer;
begin
  FController.FPAMaxPower := MaxPower;
  if FSpecView <> nil then FSpecView.PAMaxPower := MaxPower;
  for i := 0 to BAND_COUNT - 1 do
  begin
    if i <= High(BandCal) then
      FController.FPABandCal[i] := BandCal[i]
    else
      FController.FPABandCal[i] := 100.0;
  end;
  FController.FDriveLevel := CalcDriveByte;
  if FController.FNetwork.Connected and FController.FNetwork.Running then
    FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), FController.TXTuneFreqHz, FController.FDriveLevel, FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone);
end;

procedure TMainForm.OnPlutoTxMaxAttChange(MaxAttDb: Double);
begin
  // Логика (clamp/apply/persist) — в контроллере; UI лишь передаёт значение.
  FController.SetPlutoTxMaxAtt(MaxAttDb);
  UpdateDriveLabel;   // изменился потолок → пересчитать дБ-подпись drive
end;

procedure TMainForm.OnCalibrationSettingsChange(const C: TCalibration);
begin
  // Логика (санитизация/офсет S-метра в DSP/persist) — в контроллере.
  FController.SetCalibration(C);
end;

procedure TMainForm.OnAnt936xChange(const A: TAD936xAnt);
// Вкладка Antenna (AD936x): таблица «диапазон → разъём». Контроллер сохраняет её
// и сразу применяет разъёмы текущего диапазона/трансвертера.
begin
  FController.SetAnt936x(A);
end;

procedure TMainForm.OnVHFCalSettingsChange(const VHFCal: array of Double);
var i: Integer;
begin
  for i := 0 to CFG_XVTR_COUNT - 1 do
  begin
    if i <= High(VHFCal) then
      FController.FVHFBandCal[i] := VHFCal[i]
    else
      FController.FVHFBandCal[i] := CFG_VHF_CAL_DEFAULT;
  end;
  FController.FDriveLevel := CalcDriveByte;
  if FController.FNetwork.Connected and FController.FNetwork.Running then
    FController.FNetwork.UpdateState(XvtrTranslate(FController.FCenterFreq), FController.TXTuneFreqHz, FController.FDriveLevel, FController.FTransmitting, True, True, FController.FTuning, FController.FTwoTone);
end;

procedure TMainForm.OnTXSettingsChange(const T: TTXSettings);
// Любая правка в Settings→Transmit летит сюда. Применение записи (WDSP, DUC
// Specific, живой TUN, профиль, персист) — в контроллере: SetTXSettings, та же
// дверь, что у CAT и web. Форме остаётся рендер.
// Notify=False — иначе rfTXProfile перезагрузил бы вкладку Transmit прямо под
// руками у того, кто её сейчас правит.
var GridChanged: Boolean;
begin
  GridChanged := (FController.FTXSpecRefLevel <> T.TXSpecRefLevel)
              or (FController.FTXSpecRange    <> T.TXSpecRange)
              or (FController.FTXSpecGridStep <> T.TXSpecGridStep);
  FController.SetTXSettings(T, False);
  if GridChanged and FController.FTransmitting then
    ApplySpecViewGridFromState;
  // Low/High из вкладки Transmit — та же полоска фильтра на спектре: кромки
  // берём уже пересчитанными движком (знак боковой) и на передаче перерисовываем.
  PushFilterEdgesToView;
  if FController.FTransmitting then FSpectrumDirty := True;
end;

procedure TMainForm.OnCWSettingsChange(const C: TCWSettings);
// Телеграф целиком живёт в контроллере: pitch → движок, кейер/сайдтон → в
// прошивку, персист — там же. Форме остаётся только передать запись.
begin
  FController.SetCWSettings(C);
  // Скорость кейера = ширина полосы телеграфа на экране. Движок вьюху сам не
  // уведомляет — правило то же, что для профилей и кромок TX.
  PushFilterEdgesToView;
  if FController.FTransmitting then FSpectrumDirty := True;
  // Окно памяти показывает те же ячейки — держим его в курсе правок из настроек.
  if FCWMsgForm <> nil then TCWMessagesForm(FCWMsgForm).LoadFrom(FController.CWSettings);
end;

procedure TMainForm.ShowCWMessages;
begin
  if FCWMsgForm = nil then
  begin
    FCWMsgForm := TCWMessagesForm.Create(Self);
    TCWMessagesForm(FCWMsgForm).OnSend    := OnCWMessageSend;
    TCWMessagesForm(FCWMsgForm).OnAbort   := OnCWMessageAbort;
    TCWMessagesForm(FCWMsgForm).OnChanged := OnCWSettingsChange;
  end;
  TCWMessagesForm(FCWMsgForm).LoadFrom(FController.CWSettings);
  TCWMessagesForm(FCWMsgForm).ApplyTheme(CurrentAppTheme);
  TCWMessagesForm(FCWMsgForm).Show;
end;

procedure TMainForm.ShowCWTerminal;
begin
  if FCWTermForm = nil then
  begin
    FCWTermForm := TCWTerminalForm.Create(Self);
    TCWTerminalForm(FCWTermForm).OnSend      := OnCWMessageSend;
    TCWTerminalForm(FCWTermForm).OnAbort     := OnCWMessageAbort;
    TCWTerminalForm(FCWTermForm).OnBackspace := OnCWTermBackspace;
    TCWTerminalForm(FCWTermForm).OnDecoder   := OnCWTermDecoder;
    TCWTerminalForm(FCWTermForm).OnKbdKey    := OnCWTermKbdKey;
    TCWTerminalForm(FCWTermForm).OnSpeed     := OnCWTermSpeed;
    TCWTerminalForm(FCWTermForm).OnMacro     := OnCWTermMacro;
    // Лента собирается в контроллере (там же эхо своей передачи) и приходит
    // сюда прибавками — окно только рисует.
    FController.OnCWLog := OnCWLogAppend;
  end;
  TCWTerminalForm(FCWTermForm).LoadFrom(FController.CWSettings);
  TCWTerminalForm(FCWTermForm).ApplyTheme(CurrentAppTheme);
  TCWTerminalForm(FCWTermForm).Show;
end;

procedure TMainForm.OnCWLogAppend(const S: string; IsTX: Boolean);
begin
  if FCWTermForm <> nil then TCWTerminalForm(FCWTermForm).AppendLog(S, IsTX);
end;

procedure TMainForm.OnCWTermBackspace(Sender: TObject);
begin
  FController.CWXBackspace;
end;

procedure TMainForm.OnCWTermDecoder(Value: Boolean);
begin
  FController.SetCWDecoder(Value);
end;

procedure TMainForm.OnCWTermKbdKey(Dot, Dash: Boolean);
begin
  FController.CWKeyboardKey(Dot, Dash);
end;

procedure TMainForm.OnCWTermSpeed(WPM: Integer);
var C: TCWSettings;
begin
  C := FController.CWSettings;
  if C.Speed = WPM then Exit;
  C.Speed := WPM;
  FController.SetCWSettings(C);
end;

procedure TMainForm.OnCWTermMacro(Sender: TObject);
var Slot: Integer;
begin
  Slot := TComponent(Sender).Tag;
  if (Slot < 0) or (Slot >= CW_MSG_COUNT) then Exit;
  if FController.CWSettings.Messages[Slot] = '' then Exit;
  FController.CWXSend(FController.CWSettings.Messages[Slot]);
end;

procedure TMainForm.ServiceCWTerminal;
// Таймер: разбор принятого аудио + обновление окна и ленты под спектром.
// Разбор идёт ВСЕГДА, когда декодер включён, даже если окно закрыто: лента под
// спектром живёт сама по себе.
var
  St: string;
begin
  FController.ServiceCWDecoder;
  if FCWTermForm <> nil then
  begin
    TCWTerminalForm(FCWTermForm).SetPending(FController.CWXPendingText);
    if FController.CWDecoderOn then
    begin
      St := Format('RX %d WPM', [FController.CWDecSpeedWPM]);
      if FController.CWDecToneOffsetHz <> 0 then
        // Знак руками: флага '+' в Format Object Pascal нет, '%+d' съедается
        // молча и в строку попадает голая 'd'.
        St := St + Format(', tone %s%d Hz',
          [IfThen(FController.CWDecToneOffsetHz > 0, '+', ''),
           FController.CWDecToneOffsetHz]);
      if not FController.CWDecSignal then St := St + ', no signal';
    end
    else
      St := 'decoder off';
    // Набор молча уходит в никуда, если телеграф не вооружён (не CW, запрет
    // передачи на бэнде или в слоте трансвертера) — говорим об этом прямо.
    if not FController.CWTXActive then St := St + '   |   TX not armed';
    TCWTerminalForm(FCWTermForm).SetStatus(St);
  end;
  if FPans[0] <> nil then
    if FPans[0].SetCWStrip(FController.CWSettings.DecoderStrip and FController.CWDecoderOn,
                           FController.CWLogTail(300)) then
      ResizeSpectrumPanels;   // строка появилась/пропала — переложить пан
end;

procedure TMainForm.OnCWMessageSend(const Text: string);
begin
  FController.CWXSend(Text);
end;

procedure TMainForm.OnCWMessageAbort(Sender: TObject);
begin
  FController.CWXAbort;
end;

procedure TMainForm.FormKeyDownCW(Sender: TObject; var Key: Word;
  Shift: TShiftState);
// F1..F8 — ячейки памяти телеграфа. Единственное условие — телеграф на
// передающем источнике; в прочих режимах клавиши свободны для всего другого.
// ★Текст берём ИЗ НАСТРОЕК, а не из окна памяти: окно создаётся лениво, и
// раньше клавиши молчали, пока его хоть раз не открыли.
var Slot: Integer;
begin
  if Shift <> [] then Exit;
  // F9 — окно телеграфного терминала (лента приёма + набор с клавиатуры).
  if Key = VK_F9 then
  begin
    Key := 0;
    ShowCWTerminal;
    Exit;
  end;
  if (Key < VK_F1) or (Key > VK_F8) then Exit;
  if not (FController.ActiveTXMode in [MODE_CWL, MODE_CWU]) then Exit;
  Slot := Key - VK_F1;
  if (Slot < 0) or (Slot >= CW_MSG_COUNT) then Exit;
  Key := 0;
  if FController.CWSettings.Messages[Slot] = '' then Exit;
  FController.CWXSend(FController.CWSettings.Messages[Slot]);
end;

procedure TMainForm.OnAlexSettingsChange(const A: TAlexSettings);
begin
  FController.FAlexSettings := A;
  if FController.FNetwork.Connected then
    FController.FNetwork.SetAlexConfig(A);
  if FController.FDevConnected then
  begin
    FController.FSettings.SaveAlex(FController.FDevMAC, A);
    FController.FSettings.Save;
  end;
end;

procedure TMainForm.OnOCSettingsChange(const O: TOCSettings);
begin
  FController.FOCSettings := O;
  if FController.FNetwork.Connected then
    FController.FNetwork.SetOCConfig(O);
  if FController.FDevConnected then
  begin
    FController.FSettings.SaveOC(FController.FDevMAC, O);
    FController.FSettings.Save;
  end;
end;

// ===========================================================================
// XVTR (Transverter) integration
// ===========================================================================

// Ищет XVTR слот, в чьё [FreqBegin..FreqEnd] попадает данная видимая частота.
// Возвращает индекс 0..CFG_XVTR_COUNT-1 или -1.
function TMainForm.FindXvtrIdxForFreq(FreqHz: Double): Integer;
var i: Integer;
begin
  Result := -1;
  for i := 0 to CFG_XVTR_COUNT - 1 do
    with FController.FXvtrSettings.Entries[i] do
      if Enabled and (FreqHz >= FreqBegin) and (FreqHz <= FreqEnd) then
        Exit(i);
end;

// Транслирует видимую частоту в IF (то, что слышит/передаёт трансивер).
// Если активный XVTR не задан — возвращает входную частоту без изменений.
function TMainForm.XvtrTranslate(VisibleHz: Double): Double;
begin
  // Логика перенесена в контроллер; обёртка для существующих вызовов в UI.
  Result := FController.XvtrTranslate(VisibleHz);
end;

function TMainForm.XvtrTranslateTX(VisibleHz: Double): Double;
begin
  // TX-трансляция (QO-100 split-LO); обёртка над контроллером.
  Result := FController.XvtrTranslateTX(VisibleHz);
end;

// Передаёт XVTR-режим в сетевой слой. Вызывается при смене XVTR или его
// настроек. Управляет XVTR Enable bit, T/R relay suppression.
// XVTR DDC In (Alex bits 8+11) управляется через галочку XVTR в Antenna/Alex
// tab для IF-диапазона (Alex.RxOnly[IF_band]=3).
procedure TMainForm.ApplyXvtrToNetwork;
begin
  // Логика перенесена в контроллер; обёртка для существующих вызовов в UI.
  FController.ApplyXvtrToNetwork;
end;

// Перестраивает динамические XVTR-кнопки в PanelBands. Удаляет старые,
// создаёт новые для enabled слотов в 3-м и 4-м ряду PanelBands.
procedure TMainForm.RelayoutBelowBands;
var
  Y, Gap: Integer;
begin
  if PanelBands = nil then Exit;
  Gap := Scale96ToForm(2);
  Y := PanelBands.Top + PanelBands.Height + Gap;
  PanelMode.Top   := Y; Y := Y + PanelMode.Height + Gap;
  if PanelFilter.Visible then
  begin
    PanelFilter.Top := Y;
    Y := Y + PanelFilter.Height + Gap;
  end;
  if (PanelRawDev <> nil) and PanelRawDev.Visible then
  begin
    PanelRawDev.Top := Y; Y := Y + PanelRawDev.Height + Gap;
  end;
  if (PanelFMStep <> nil) and PanelFMStep.Visible then
  begin
    PanelFMStep.Top := Y; Y := Y + PanelFMStep.Height + Gap;
  end;
  if (PanelFMSQ <> nil) and PanelFMSQ.Visible then
  begin
    PanelFMSQ.Top := Y; Y := Y + PanelFMSQ.Height + Gap;
  end;
  if (PanelFMCTCSS <> nil) and PanelFMCTCSS.Visible then
  begin
    PanelFMCTCSS.Top := Y; Y := Y + PanelFMCTCSS.Height + Gap;
  end;
  if (PanelFMRpt <> nil) and PanelFMRpt.Visible then
  begin
    PanelFMRpt.Top := Y; Y := Y + PanelFMRpt.Height + Gap;
  end;
  UpdateLeftPanelScroll;
end;

function TMainForm.CursorOverLeftPanel: Boolean;
var
  P: TPoint;
begin
  Result := False;
  if (PanelLeft = nil) or (not PanelLeft.Visible) then Exit;
  P := PanelLeft.ScreenToClient(Mouse.CursorPos);
  Result := (P.X >= 0) and (P.Y >= 0) and
            (P.X < PanelLeft.ClientWidth) and (P.Y < PanelLeft.ClientHeight);
end;

procedure TMainForm.LeftScrollChanged(Sender: TObject);
var
  NewValue, Delta: Integer;

  procedure MovePanel(P: TPanel);
  begin
    if P <> nil then P.Top := P.Top + Delta;
  end;

begin
  if FLeftScrollBar = nil then Exit;
  NewValue := FLeftScrollBar.Position;
  if NewValue = FLeftScrollOffset then Exit;
  // Top хранится уже с учётом текущего scroll offset. При движении вниз
  // содержимое уходит вверх, поэтому знак у дельты обратный.
  Delta := FLeftScrollOffset - NewValue;
  FLeftScrollOffset := NewValue;

  MovePanel(PanelTX);
  MovePanel(PanelRXBlock);
  MovePanel(PanelAGC);
  MovePanel(PanelPlutoAGC);
  MovePanel(PanelBands);
  MovePanel(PanelMode);
  MovePanel(PanelFilter);
  MovePanel(PanelRawDev);
  MovePanel(PanelFMStep);
  MovePanel(PanelFMSQ);
  MovePanel(PanelFMCTCSS);
  MovePanel(PanelFMRpt);

  if FTXProfileDropDown <> nil then FTXProfileDropDown.ClosePopup;
  if FChannelsDropDown <> nil then FChannelsDropDown.ClosePopup;
  if FCTCSSDropDown <> nil then FCTCSSDropDown.ClosePopup;
  if FStepDropDown <> nil then FStepDropDown.ClosePopup;
end;

procedure TMainForm.UpdateLeftPanelScroll;
var
  ContentBottom, ViewH, NewMax: Integer;

  procedure MeasurePanel(P: TPanel);
  var
    B: Integer;
  begin
    if (P = nil) or (not P.Visible) then Exit;
    // Возвращаем логическую позицию до сдвига viewport.
    B := P.Top + FLeftScrollOffset + P.Height;
    if B > ContentBottom then ContentBottom := B;
  end;

begin
  if (PanelLeft = nil) or (FLeftScrollBar = nil) then Exit;
  ViewH := PanelLeft.ClientHeight;
  if ViewH <= 0 then Exit;

  ContentBottom := 0;
  MeasurePanel(PanelTX);
  MeasurePanel(PanelRXBlock);
  MeasurePanel(PanelAGC);
  MeasurePanel(PanelPlutoAGC);
  MeasurePanel(PanelBands);
  MeasurePanel(PanelMode);
  MeasurePanel(PanelFilter);
  MeasurePanel(PanelRawDev);
  MeasurePanel(PanelFMStep);
  MeasurePanel(PanelFMSQ);
  MeasurePanel(PanelFMCTCSS);
  MeasurePanel(PanelFMRpt);

  NewMax := Max(0, ContentBottom + Scale96ToForm(4) - ViewH);
  FLeftScrollBar.SetRange(NewMax, ViewH);
end;

procedure TMainForm.RebuildXvtrButtons;
var
  i, Pos, XvtrRows: Integer;
  BtnH, RowH, MaxBottom, RowGap, TopGap, BottomPad: Integer;
  B:      TFlatButton;
  Cap:    string;
begin
  // Уничтожаем все старые
  for i := 0 to CFG_XVTR_COUNT - 1 do
    if BtnXvtrBand[i] <> nil then
    begin
      BtnXvtrBand[i].Free;
      BtnXvtrBand[i] := nil;
    end;
  if PanelBands = nil then Exit;

  // Измеряем фактический bottom последнего ряда обычных кнопок BAND
  // (уже отмасштабированы LCL при показе формы)
  MaxBottom := 0;
  BtnH := 0;
  for i := 0 to BAND_COUNT - 1 do
    if BtnBand[i] <> nil then
    begin
      if BtnBand[i].Top + BtnBand[i].Height > MaxBottom then
        MaxBottom := BtnBand[i].Top + BtnBand[i].Height;
      BtnH := BtnBand[i].Height;
    end;
  if BtnH = 0 then BtnH := DpiScale(24);
  RowGap := PanelBands.Scale96ToForm(3);
  TopGap := PanelBands.Scale96ToForm(4);
  BottomPad := PanelBands.Scale96ToForm(6);
  RowH := BtnH + RowGap;

  Pos := 0;   // позиция среди enabled слотов
  for i := 0 to CFG_XVTR_COUNT - 1 do
    if FController.FXvtrSettings.Entries[i].Enabled then
    begin
      if Pos >= 12 then Break;  // 2 ряда по 6 кнопок
      Cap := FController.FXvtrSettings.Entries[i].ButtonText;
      if Cap = '' then Cap := 'X' + IntToStr(i + 1);
      B := TFlatButton.Create(Self);
      B.Parent  := PanelBands;
      B.Caption := Cap;
      B.Left    := LeftPanelButtonLeft(PanelBands.Width, 6, Pos mod 6);
      B.Top     := MaxBottom + TopGap + (Pos div 6) * RowH;
      B.Width   := LeftPanelButtonWidth(PanelBands.Width, 6, Pos mod 6);
      B.Height  := BtnH;
      B.OnClick := BtnXvtrBandClick;
      B.Tag     := -(i + 1);   // отрицательное значение = XVTR индекс
      BtnXvtrBand[i] := B;
      StyleButton(B, i = FController.FCurrentXvtr);
      Inc(Pos);
    end;
  // Динамически подгоняем высоту PanelBands под реальное содержимое
  if Pos = 0 then XvtrRows := 0
  else if Pos <= 6 then XvtrRows := 1
  else XvtrRows := 2;
  if XvtrRows = 0 then
    PanelBands.Height := MaxBottom + BottomPad
  else
    PanelBands.Height := MaxBottom + TopGap + XvtrRows * RowH + BottomPad;
  RelayoutBelowBands;
end;

// Обработчик клика по XVTR-кнопке. Tag = -(idx+1).
procedure TMainForm.BtnXvtrBandClick(Sender: TObject);
var
  TagVal, Idx: Integer;
begin
  TagVal := (Sender as TFlatButton).Tag;
  Idx := -TagVal - 1;
  if (Idx < 0) or (Idx >= CFG_XVTR_COUNT) then Exit;
  if not FController.FXvtrSettings.Entries[Idx].Enabled then Exit;
  if FController.FCurrentXvtr = Idx then Exit;  // уже на этом XVTR
  // Сохраняем текущий band перед сменой (как BtnBandClick)
  if FController.FDevConnected then
  begin
    SaveCurrentBand;
    FController.FSettings.Save;
  end;
  ActivateXvtrBand(Idx);
end;

// Активирует XVTR-band: устанавливает видимую частоту по LastFreq или
// середине [FreqBegin..FreqEnd], применяет network XVTR-режим.
procedure TMainForm.ActivateXvtrBand(Idx: Integer);
var Running: Boolean;
begin
  // Смена контекста персиста панов: сохранить старый (КВ|прежний XVTR), снести,
  // войти в слот, восстановить целевой. Паны живут только в Run.
  Running := FController.FRunning;
  if Running then
  begin
    SavePanWorkspaceCtx(PanContextKey);   // текущий контекст (до смены FCurrentXvtr)
    TearDownWorkspace;
  end;
  // Тонкий делегат: вход в XVTR-слот + рендер кнопок/частот/спектра/wideband/web —
  // всё в команде/событиях (rfXvtr/rfBand/rfActiveVfo).
  FController.ActivateXvtr(Idx);
  if Running then
    RestorePanWorkspaceCtx(PanContextKey);   // новый контекст (xvtr_Idx)
end;

// Деактивирует XVTR — возвращает к HF-режиму. FController.FCurrentBand указывает
// на HF-диапазон, с которого пользователь зашёл в трансвертер. RestoreBand вернёт
// VFO/Mode/Filter/AGC/CTUN/Span этого диапазона и обновит подсветку HF band-кнопки.
procedure TMainForm.DeactivateXvtr;
var Running: Boolean;
begin
  // Выход из XVTR в КВ = смена контекста персиста панов: сохранить XVTR-контекст,
  // снести, вернуться в HF, восстановить 'hf'.
  Running := FController.FRunning;
  if Running then
  begin
    SavePanWorkspaceCtx(PanContextKey);   // текущий XVTR-контекст (до сброса FCurrentXvtr)
    TearDownWorkspace;
  end;
  // Тонкий делегат: выход в HF (SaveCurrentBand + HF-возврат RestoreBand + рендер
  // кнопок/wideband/web) — всё в команде/событиях.
  FController.DeactivateXvtr;
  if Running then
    RestorePanWorkspaceCtx(PanContextKey);   // контекст 'hf'
end;

procedure TMainForm.OnXvtrSettingsChange(const X: TXvtrSettings);
begin
  FController.FXvtrSettings := X;
  RebuildXvtrButtons;
  // Если активный XVTR стал disabled — выйти из XVTR режима
  if (FController.FCurrentXvtr >= 0) and
     ((FController.FCurrentXvtr >= CFG_XVTR_COUNT) or
      (not FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled)) then
    DeactivateXvtr;
  // Перепрошить XVTR в сеть (если параметры текущего изменились)
  ApplyXvtrToNetwork;
  // Правка LO/LOError при живых панах: freq-слова их DDC — через свежую трансляцию.
  FController.RefreshPanDDCs;
  UpdateWidebandFrequencyView;
  if PbWideband <> nil then PbWideband.Invalidate;
  if PbWidebandRuler <> nil then PbWidebandRuler.Invalidate;
  PushXvtrToWeb;
  if FController.FDevConnected then
  begin
    FController.FSettings.SaveXvtr(FController.FDevMAC, FController.FXvtrSettings);
    FController.FSettings.Save;
  end;
end;

procedure TMainForm.PushRatePresetsToWeb;
// Пресеты sample-rate бэкенда → web (HPSDR/Pluto разные). Единый источник —
// FController.SampleRatePresets, как и для desktop-оверлея.
begin
  if FWebServer = nil then Exit;
  FWebServer.SetRatePresets(FController.SampleRatePresets);
end;

procedure TMainForm.PushBandsToWeb;
// Band-план бэкенда → web (HF / VHF-UHF). Единый источник — FController.BandPlan,
// тот же BoardUtils-план, что и для desktop-кнопок (RelabelBands).
var
  Plan: TBandPlanArray;
  Names: array of string;
  Freqs: array of Double;
  i: Integer;
begin
  if FWebServer = nil then Exit;
  Plan := FController.BandPlan;
  SetLength(Names, Length(Plan));
  SetLength(Freqs, Length(Plan));
  for i := 0 to High(Plan) do
  begin
    Names[i] := Plan[i].Name;
    Freqs[i] := Plan[i].FreqHz;
  end;
  FWebServer.SetBands(Names, Freqs);
  with FController.BackendCaps do FWebServer.SetFreqRange(MinFreqHz, MaxFreqHz);
end;

procedure TMainForm.PushXvtrToWeb;
var
  Arr: TWebXvtrArray;
  i, Cnt: Integer;
begin
  if FWebServer = nil then Exit;
  Cnt := 0;
  for i := 0 to CFG_XVTR_COUNT - 1 do
    if FController.FXvtrSettings.Entries[i].Enabled then Inc(Cnt);
  SetLength(Arr, Cnt);
  Cnt := 0;
  for i := 0 to CFG_XVTR_COUNT - 1 do
    if FController.FXvtrSettings.Entries[i].Enabled then
    begin
      Arr[Cnt].Idx  := i;
      Arr[Cnt].Name := FController.FXvtrSettings.Entries[i].ButtonText;
      Inc(Cnt);
    end;
  FWebServer.SetXvtrBands(Arr, FController.FCurrentXvtr);
end;

procedure TMainForm.TrkVolumeChange(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.SetVolume(TrkVolume.Position);
  UpdateVolumeLabel;
end;

procedure TMainForm.UpdateVolumeLabel;
begin
  if LblVolume <> nil then
    LblVolume.Caption := Format('%d%%', [FController.ActiveVolume]);
end;

// ===========================================================================
// Mouse wheel — перестройка VFO
// ===========================================================================

procedure TMainForm.FormMouseWheel(Sender: TObject; Shift: TShiftState;
  WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean);
// Колёсико на форме/спектре/водопаде — меняет активный VFO с квантованием
// Шаг зависит от модификаторов: Ctrl+Shift=1МГц, Ctrl=1кГц, Shift=10Гц, иначе 100Гц
// Квантование: результат выровнен до кратного шагу (как в FreqDisplay)
// CTUN логика: через ApplyVfoA
  function OverFreqDisp(C: TControl): Boolean;
  var P: TPoint;
  begin
    Result := False;
    if (C = nil) or (not C.Visible) then Exit;
    // Mouse.CursorPos — надёжные экранные координаты (переданный MousePos на
    // Cocoa может быть не в экранной системе).
    P := C.ScreenToClient(Mouse.CursorPos);
    Result := (P.X >= 0) and (P.Y >= 0) and (P.X < C.Width) and (P.Y < C.Height);
  end;
var
  Step:    Int64;
  Delta:   Int64;
  Base:    Int64;
  NewFreq: Int64;
  SliceId, i: Integer;
  SLo, SHi, SNew: Double;
  Sl:      TCtrlSlice;
  SlicePan: TPanafallPanel;
  P: TPoint;
begin
  // Над дисплеем VFO (A или B) колесо обрабатывает сам TFreqDisplay поразрядно
  // (включая неактивный VFO). Не дублируем здесь, иначе менялись бы обе частоты:
  // виджет крутил бы B, а этот хендлер — активный A.
  if OverFreqDisp(FreqDispA) or OverFreqDisp(FreqDispB) then Exit;

  // Если левая колонка не помещается, колесо над любой её частью прокручивает
  // колонку, а не частоту. Scrollbar остаётся overlay и появляется по hover.
  if CursorOverLeftPanel then
  begin
    UpdateLeftPanelScroll;
    if (FLeftScrollBar <> nil) and (FLeftScrollBar.Maximum > 0) then
    begin
      if WheelDelta > 0 then
        FLeftScrollBar.ScrollBy(-Scale96ToForm(64))
      else if WheelDelta < 0 then
        FLeftScrollBar.ScrollBy(Scale96ToForm(64));
      Handled := True;
      Exit;
    end;
  end;

  // Колесо над раскрытым списком устройств во флаге — прокрутка этого списка.
  for i := 0 to MAX_PANS - 1 do
    if (FPans[i] <> nil) and Assigned(FPans[i].PbSpectrum) and
       FPans[i].PbSpectrum.Visible then
    begin
      P := FPans[i].PbSpectrum.ScreenToClient(Mouse.CursorPos);
      if FPans[i].DispatchFlagsMouseWheel(P.X, P.Y, WheelDelta) then
      begin
        MarkPanDirty(FPans[i]);
        Handled := True;
        Exit;
      end;
    end;

  // Колесо над маркером слайса (любого пана) — двигаем частоту ЭТОГО слайса.
  SliceId := 0;
  SlicePan := nil;
  for i := 0 to MAX_PANS - 1 do
    if FPans[i] <> nil then
    begin
      SliceId := FPans[i].SliceUnderCursor;
      if SliceId <> 0 then begin SlicePan := FPans[i]; Break; end;
    end;
  // Иначе колесо над спектром/водопадом ДОП. пана крутит его главный слайс
  // (первый созданный на этом приёмнике), а не общий VFO. Пан 0 — общий VFO.
  if SliceId = 0 then
    for i := 1 to MAX_PANS - 1 do
      if (FPans[i] <> nil) and FPans[i].CursorOverDisplay then
      begin
        SliceId := FController.PanPrimarySliceId(i);
        if SliceId <> 0 then SlicePan := FPans[i];
        Break;
      end;
  if (SliceId <> 0) and FController.GetSlice(SliceId, Sl) then
  begin
    // Канальные режимы: шаг = выбранный шаг сетки (тот же, что рисуется на
    // пане), как на главном панадаптере. Ctrl/Shift — обычные мелкие шаги.
    if (Sl.Mode in [MODE_FM, MODE_DMR, MODE_FMRAW]) and
       FController.FFMStepOn and
       not ((ssCtrl in Shift) or (ssShift in Shift)) then
      Step := FM_STEP_HZ[FController.FFMStepIdx]
    else if (ssCtrl in Shift) and (ssShift in Shift) then Step := 1000000
    else if ssCtrl in Shift then Step := 1000
    else if ssShift in Shift then Step := 10
    else Step := 100;
    Delta := 1;
    if WheelDelta < 0 then Delta := -1;
    // Квантование к сетке шага + шаг в сторону колеса.
    Base := (Round(Sl.TargetHz) div Step) * Step;
    if Delta > 0 then SNew := Base + Step
    else if Round(Sl.TargetHz) = Base then SNew := Base - Step
    else SNew := Base;
    FController.CapturedSpan(SLo, SHi, Sl.PanId);   // полоса ЕГО пана
    if SNew < SLo then SNew := SLo;
    if SNew > SHi then SNew := SHi;
    FController.SetSliceTarget(SliceId, SNew);   // флаг — rfSliceFreq
    FActiveSliceId := SliceId;
    Handled := True;
    Exit;
  end;
  if (FController.FMode in [MODE_FM, MODE_DMR, MODE_FMRAW]) and
     FController.FFMStepOn and
     not ((ssCtrl in Shift) or (ssShift in Shift)) then
    Step := FM_STEP_HZ[FController.FFMStepIdx]
  else if (ssCtrl in Shift) and (ssShift in Shift) then Step := 1000000
  else if ssCtrl in Shift then Step := 1000
  else if ssShift in Shift then Step := 10
  else Step := 100;

  Delta := 1;
  if WheelDelta < 0 then Delta := -1;

  // Квантование (та же логика что в FreqDisplay.ChangeByDigit) — от частоты
  // АКТИВНОГО VFO, а не всегда VFO A.
  Base := (ActiveVfoFreq div Step) * Step;
  if Delta > 0 then
    NewFreq := Base + Step
  else
  begin
    if ActiveVfoFreq = Base then
      NewFreq := Base - Step
    else
      NewFreq := Base;   // снэп к нижней границе
  end;

  // Клэмп диапазона перестройки. XVTR — ApplyVfoA сам ограничит по FreqBegin/End.
  // Pluto — по диапазону бэкенда (Caps). Иначе HF (30 кГц..60 МГц).
  if (FController.FCurrentXvtr >= 0) and (FController.FCurrentXvtr < CFG_XVTR_COUNT) and
     FController.FXvtrSettings.Entries[FController.FCurrentXvtr].Enabled then
    NewFreq := Max(0, NewFreq)
  else if FController.IsPluto then
    NewFreq := Max(Round(FController.BackendCaps.MinFreqHz),
                   Min(Round(FController.BackendCaps.MaxFreqHz), NewFreq))
  else
    NewFreq := Max(30000, Min(60000000, NewFreq));
  if FController.FActiveVfo = 0 then
    ApplyVfoA(NewFreq)
  else
    FController.SetVfoB(NewFreq);   // крутим активный VFO B

  Handled := True;
end;


procedure TMainForm.BtnAGCModeClick(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.SetAGCMode((Sender as TFlatButton).Tag);
end;

procedure TMainForm.TrkAGCChange(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.SetAGCTop(TrkAGC.Position);
end;

procedure TMainForm.TrkRxGainChange(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.SetRxGain(TrkRxGain.Position);   // переводит в manual
end;

procedure TMainForm.TrkAttChange(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.SetAtten(TrkAtt.Position);
  UpdateAttLabel;
end;

procedure TMainForm.UpdateAttLabel;
begin
  if LblAttVal <> nil then
    LblAttVal.Caption := Format('%ddB', [FController.FAtten]);
end;

procedure TMainForm.BtnPlutoGainClick(Sender: TObject);
begin
  if FSyncingFromController then Exit;
  FController.SetRxGainMode((Sender as TFlatButton).Tag);   // Tag = индекс режима
end;

procedure TMainForm.UpdateRxGainUI;
// Рендер Pluto RF-AGC блока: подсветка режима + слайдер/значение manual gain.
var i: Integer;
begin
  if PanelPlutoAGC = nil then Exit;
  FSyncingFromController := True;
  try
    for i := 0 to 3 do
      if BtnPlutoGain[i] <> nil then
        StyleButton(BtnPlutoGain[i], BtnPlutoGain[i].Tag = FController.FRxGainMode);
    TrkRxGain.Enabled  := (FController.FRxGainMode = 0);   // активен только в manual
    if FController.FRxGainMode = 0 then
    begin
      TrkRxGain.Position := FController.FRxGainDb;
      LblRxGain.Caption  := Format('%ddB', [FController.FRxGainDb]);
    end
    else if FController.FLastPlutoGainDb > TELEMETRY_NONE then
    begin
      // AGC: показываем ФАКТИЧЕСКОЕ усиление железа (опрос ~2 Гц) — ползунок
      // ездит сам, тильда отличает измеренное от заданного. На это же значение
      // компенсируются уровни (FrontEndCalDB), поэтому шкала dBm стоит.
      TrkRxGain.Position := EnsureRange(Round(FController.FLastPlutoGainDb), 0, 73);
      LblRxGain.Caption  := Format('~%ddB', [Round(FController.FLastPlutoGainDb)]);
    end
    else
      LblRxGain.Caption := 'AGC';
  finally
    FSyncingFromController := False;
  end;
end;

procedure TMainForm.LayoutLeftPanel(Pluto: Boolean);
// Показывает/скрывает Pluto RF-AGC блок под AGC и сдвигает панели ниже
// (Bands и далее через RelayoutBelowBands).
var
  baseTop, Gap2, Gap4: Integer;
begin
  if (PanelPlutoAGC = nil) or (PanelAGC = nil) or (PanelBands = nil) then Exit;
  // BuildUI создаёт геометрию в 96-DPI координатах, после чего LCL
  // масштабирует её вместе с формой. Эта перекладка выполняется позднее,
  // уже после подключения устройства, поэтому её литеральные размеры тоже
  // нужно перевести в текущий DPI формы. Иначе при 125% RX-панель снова
  // получала Height=96, а её уже масштабированный ряд NR/NB/... обрезался.
  Gap2 := Scale96ToForm(2);
  Gap4 := Scale96ToForm(4);
  // Шаговый аттенюатор — только у openHPSDR; у AD936x усиление тракта в блоке
  // RF AGC ниже, поэтому ряд ATT убираем и RX-блок ужимаем.
  if (TrkAtt <> nil) and (LblAtt <> nil) and (LblAttVal <> nil) then
  begin
    TrkAtt.Visible    := not Pluto;
    LblAtt.Visible    := not Pluto;
    LblAttVal.Visible := not Pluto;
    if Pluto then PanelRXBlock.Height := Scale96ToForm(RX_BLOCK_H)
    else          PanelRXBlock.Height := Scale96ToForm(RX_BLOCK_H_ATT);
    PanelAGC.Top := PanelRXBlock.Top + PanelRXBlock.Height + Gap4;
  end;
  baseTop := PanelAGC.Top + PanelAGC.Height + Gap4;
  PanelPlutoAGC.Top := baseTop;
  PanelPlutoAGC.Visible := Pluto;
  if Pluto then
    PanelBands.Top := baseTop + PanelPlutoAGC.Height + Gap2
  else
    PanelBands.Top := baseTop;
  RelayoutBelowBands;
end;

// ===========================================================================
// DSP/Audio callbacks — вызываются из рабочих потоков
// ===========================================================================

procedure TMainForm.OnSpectrumReady(const Pixels: array of Single;
  Count: Integer);
// Подписчик на FController.OnSpectrumData (сырьё из DSP-потока) — только рендер;
// web-снимок держит контроллер (FSpectrumBuf).
begin
  FSpecView.SetSpectrumData(Pixels, Count);
  FSpectrumDirty := True;
end;

procedure TMainForm.OnWaterfallReady(const Pixels: array of Single;
  Count: Integer);
// Подписчик на FController.OnWaterfallData. Каждый кадр уходит во view —
// замедление/накопление (max-hold) делает сам TWaterfallView по
// WfFrameInterval; рендер дёргаем только когда view собрал строку.
begin
  FSpecView.SetWaterfallData(Pixels, Count);
  if FSpecView.WaterfallDirty then FWaterfallDirty := True;
end;

// ===========================================================================
// Settings apply methods (вызываются из TSettingsForm)
// ===========================================================================

procedure TMainForm.ApplyDisplayParams(FFTSize, WinType, SpecDet, SpecAvgMode: Integer;
  SpecAvgTimeMS: Double);
begin
  if FController.FWDSPReady then
    FController.FDSPEngine.SetSpectrumDisplay(SpecDet, SpecAvgMode, SpecAvgTimeMS);
  if FController.FWDSPReady then
    FController.FDSPEngine.SetFFTParams(FFTSize, WinType);
  FSpectrumDirty := True;
end;

procedure TMainForm.ApplyWaterfallParams(WfDet, WfAvgMode: Integer;
  WfAvgTimeMS, WfHigh, WfLow, WfAGCOffset: Double);
begin
  if FController.FWDSPReady then
    FController.FDSPEngine.SetWaterfallDisplay(WfDet, WfAvgMode, WfAvgTimeMS);
  FController.FWfManualHigh := WfHigh;
  FController.FWfManualLow  := WfLow;
  FController.FWfAGCOffset  := WfAGCOffset;
  FSpecView.WfManualHigh := FController.FWfManualHigh;
  FSpecView.WfManualLow  := FController.FWfManualLow;
  FSpecView.WfAGCOffset  := FController.FWfAGCOffset;
  FSpecView.ResetWfAvgBuf;
  FSpectrumDirty := True;
end;

procedure TMainForm.ApplyWfRenderParams(Palette: Integer; Gamma: Double;
  ResLimit, SpeedDiv: Integer);
begin
  FController.FWfPalette     := Palette;
  FController.FWfGamma       := Gamma;
  FController.FDisplayPixels := ResLimit;
  FController.FWfSpeedDiv    := SpeedDiv;
  FSpecView.WfPalette := Palette;
  FSpecView.WfGamma   := Gamma;
  FSpecView.WfFrameInterval := EffectiveWfInterval;
  if FController.FWDSPReady then
    FController.FDSPEngine.SetDisplayResLimit(ResLimit);
  FWaterfallDirty := True;
  FSpectrumDirty  := True;
end;

procedure TMainForm.ApplySpecMSAA(Samples: Integer);
begin
  Samples := EnsureRange(Samples, 1, 8);
  FController.FSpecMSAA := Samples;
  if FUseOpenGLSpectrum and (PbSpectrum is TOpenGLControl) and
     (TOpenGLControl(PbSpectrum).MultiSampling <> Cardinal(Samples)) then
  begin
    // Смена MSAA пересоздаёт хэндл → старый GL-контекст (и все текстуры)
    // умирает; кэши view нужно обнулить БЕЗ glDelete.
    TOpenGLControl(PbSpectrum).MultiSampling := Samples;
    if FSpecView is TSpectrumViewOpenGL then
      TSpectrumViewOpenGL(FSpecView).ResetGLCache;
    FSpectrumDirty := True;
  end;
end;

procedure TMainForm.ApplyGridParams(RefLevel, Range, GridStep: Double);
begin
  FController.FSpecRefLevel := RefLevel;
  FController.FSpecRange    := Range;
  if GridStep > 0 then FController.FSpecGridStep := GridStep;
  // На передаче FSpecView показывает TX-сетку — RX-правка не должна её перетирать.
  if not FController.FTransmitting then ApplySpecViewGridFromState;
end;

procedure TMainForm.ApplySpecViewGridFromState;
begin
  // При DUP во время TX данные приходят от RX-анализатора → используем RX-сетку.
  if FController.FTransmitting and not FController.FDisplayDuplex then
  begin
    FSpecView.SpecRefLevel := FController.FTXSpecRefLevel;
    FSpecView.SpecRange    := FController.FTXSpecRange;
    FSpecView.SpecGridStep := FController.FTXSpecGridStep;
  end else
  begin
    FSpecView.SpecRefLevel := FController.FSpecRefLevel;
    FSpecView.SpecRange    := FController.FSpecRange;
    FSpecView.SpecGridStep := FController.FSpecGridStep;
  end;
  InvalidateGridCache;
  FSpectrumDirty := True;
end;

procedure TMainForm.ApplyAudioDevice(DevIndex: Integer; const DevName: string);
begin
  if FController.FAudioOut.IsOpen then
    FController.FAudioOut.Close;
  FController.FAudioOut.DeviceIndex := DevIndex;
  FController.FAudioOutDevName := DevName;
  try
    FController.FAudioOut.Open;
  except
  end;
end;

procedure TMainForm.ApplyAudioInputDevice(DevIndex: Integer; const DevName: string);
// Общий вход: контроллер сам переоткрывает поток и перепривязывает к нему
// слайсы, которые ссылались на общее устройство (mic-пул).
begin
  FController.SetMainMicDevice(DevIndex, DevName);
end;

procedure TMainForm.ApplyAudioBufferSize(BufferSize: Integer);
var
  WasOpen: Boolean;
begin
  if BufferSize <= 128 then BufferSize := 128
  else if BufferSize <= 256 then BufferSize := 256
  else BufferSize := 512;
  if FController.FAudioOut.OutputBufferSize = BufferSize then Exit;

  WasOpen := FController.FAudioOut.IsOpen;
  if WasOpen then FController.FAudioOut.Close;
  FController.FAudioOut.OutputBufferSize := BufferSize;
  if WasOpen then
  begin
    try
      FController.FAudioOut.Open;
    except
    end;
  end;
  FController.FSettings.SaveAudioBufferSize(BufferSize);
  FController.FSettings.Save;
end;

procedure TMainForm.ApplySendAudioToRadio(Enabled: Boolean);
begin
  FController.FSendAudioToRadio := Enabled;
  // Один переключатель: вкл = слать поток + снять мьют динамика,
  // выкл = замьютить динамик (byte 1400 bit1) и не слать поток.
  FController.FNetwork.SetSpeakerAudio(Enabled);
  if FController.FDevConnected then
    FController.SaveGlobalSettings;
end;

procedure TMainForm.ApplyVisibility(ShowSpectrum, ShowWaterfall,
  ShowWideband, WidebandFill, SpectrumFill: Boolean);
begin
  FController.FShowSpectrum  := ShowSpectrum;
  FController.FShowWaterfall := ShowWaterfall;
  FController.FShowWideband  := ShowWideband;
  FController.FWidebandFill  := WidebandFill;
  FController.FSpectrumFill  := SpectrumFill;
  if Assigned(FWidebandView) then
    FWidebandView.SetFillSpectrum(FController.FWidebandFill);
  FSpecView.FillSpectrum := FController.FSpectrumFill;
  if Assigned(FController.FNetwork) and FController.FNetwork.Connected then
    FController.FNetwork.ConfigureWideband(0, FController.FShowWideband, 512, 16, 70, 32);
  ResizeSpectrumPanels;
end;

procedure TMainForm.ApplyFPS(FPS: Integer);
begin
  if FPS < 1  then FPS := 1;
  if FPS > 100 then FPS := 100;
  FDisplayFPS := FPS;
  FController.FLoadedGlobal.DisplayFPS := FPS;  // зеркало в persist-буфер контроллера
  FSpectrumTimer.Interval := 1000 div FPS;
  FWaterfallDirty := True;
  FSpecView.WfFrameInterval := EffectiveWfInterval;
  if FController.FWDSPReady then
    FController.FDSPEngine.SetDisplayFPS(FPS);
end;

function TMainForm.EffectiveWfInterval: Integer;
// Делитель скорости водопада: пользовательский Speed (max-hold накопление
// в TWaterfallView); CPU-путь дополнительно не быстрее полукадровой частоты
// (исторически — экономия на полной перерисовке bitmap).
begin
  Result := Max(1, FController.FWfSpeedDiv);
  if not FUseOpenGLSpectrum then Result := Max(Result, 2);
end;

procedure TMainForm.ApplyFreqMhzDigits(Digits: Integer);
begin
  Digits := EnsureRange(Digits, 3, 5);
  FFreqMhzDigits := Digits;
  FController.FLoadedGlobal.FreqMhzDigits := Digits;  // зеркало в persist-буфер контроллера
  FreqDispA.MinMhzDigits := Digits;
  FreqDispB.MinMhzDigits := Digits;
  UpdateFreqDisplayMax;   // верхняя граница: активный XVTR / Pluto Caps / HF по цифрам
  if Assigned(FWebServer) then
    FWebServer.FreqMhzDigits := Digits;
  LayoutTopVfoBlock;
end;

procedure TMainForm.UpdateFreqDisplayMax;
// Верхняя граница FreqDisplay. Активный трансвертер (XVTR/QO-100) может показывать
// частоты выше диапазона железа (напр. QO-100 — 10.5 ГГц) → берём его FreqEnd.
// Иначе Pluto — из Caps (до 6 ГГц), HPSDR — по числу MHz-цифр.
const
  MaxFreqs: array[3..5] of Int64 = (999999999, 9999999999, 99999999999);
var
  hi: Int64; xi: Integer;
begin
  xi := FController.FCurrentXvtr;
  if (xi >= 0) and (xi < CFG_XVTR_COUNT) then
    hi := Round(FController.FXvtrSettings.Entries[xi].FreqEnd) + 1000000   // +1 МГц запас
  else if FController.IsPluto then
    hi := Round(FController.BackendCaps.MaxFreqHz)
  else
    hi := MaxFreqs[EnsureRange(FFreqMhzDigits, 3, 5)];
  if hi < 1000000 then hi := MaxFreqs[3];
  if FreqDispA <> nil then FreqDispA.MaxFreq := hi;
  if FreqDispB <> nil then FreqDispB.MaxFreq := hi;
end;

procedure TMainForm.UpdateSetupButton;
// Почти всё в окне настроек — свойства КОНКРЕТНОГО устройства: они и грузятся,
// и сохраняются по его MAC (у AD936x — по серийному номеру). Пока устройство не
// подключено, MAC неизвестен, и правки уезжали бы в чужую секцию конфига —
// ровно этим когда-то оборачивался старт по AutoStart с нулевым MAC. Поэтому
// SETUP заперт до первого удачного Connect. ★После Stop не запираем обратно:
// FDevConnected означает «устройство известно», а не «сеть жива», и настройки
// между сеансами править законно.
begin
  if BtnSettings = nil then Exit;
  BtnSettings.Enabled := (FController <> nil) and FController.FDevConnected;
  BtnSettings.ShowHint := True;
  if BtnSettings.Enabled then
    BtnSettings.Hint := ''
  else
    BtnSettings.Hint := 'Connect a radio first — settings are stored per device';
end;

procedure TMainForm.BtnSettingsClick(Sender: TObject);
var
  SF: TSettingsForm;
begin
  // Гейт не только для мыши: сюда же приходит ПКМ по кнопке TX-профиля.
  if (BtnSettings <> nil) and (not BtnSettings.Enabled) then Exit;
  if not Assigned(FSettingsForm) then
  begin
    SF := TSettingsForm.Create(Self);
    FSettingsForm := SF;
    SF.OnDisplayChange    := ApplyDisplayParams;
    SF.OnWaterfallChange  := ApplyWaterfallParams;
    SF.OnWfRenderChange   := ApplyWfRenderParams;
    SF.OnSpecMSAAChange   := ApplySpecMSAA;
    SF.OnGridChange       := ApplyGridParams;
    SF.OnAudioDevChange   := ApplyAudioDevice;
    SF.OnAudioInDevChange := ApplyAudioInputDevice;
    SF.OnAudioBufferChange := ApplyAudioBufferSize;
    SF.OnSendAudioToRadioChange := ApplySendAudioToRadio;
    SF.OnVisibilityChange := ApplyVisibility;
    SF.OnFPSChange        := ApplyFPS;
    SF.OnPAChange         := OnPASettingsChange;
    SF.OnCalibrationChange := OnCalibrationSettingsChange;
    SF.OnPlutoTxChange    := OnPlutoTxMaxAttChange;
    SF.OnAnt936xChange    := OnAnt936xChange;
    SF.OnVHFCalChange     := OnVHFCalSettingsChange;
    SF.OnCATChange        := OnCATSettingsChange;
    SF.OnSliceCATChange   := OnSliceCATSettingsChange;
    SF.OnThemeChange           := SetLightTheme;
    SF.OnFreqMhzDigitsChange   := ApplyFreqMhzDigits;
    SF.OnTXChange              := OnTXSettingsChange;
    SF.OnCWChange              := OnCWSettingsChange;
    SF.OnCWMessagesOpen        := ShowCWMessages;
    SF.OnCWTerminalOpen        := ShowCWTerminal;
    SF.OnTXProfileSelect       := OnTXProfileSelectFromSettings;
    SF.OnTXProfileAdd          := OnTXProfileAddFromSettings;
    SF.OnTXProfileRename       := OnTXProfileRenameFromSettings;
    SF.OnTXProfileDelete       := OnTXProfileDeleteFromSettings;
    SF.OnTXProfileFactory      := OnTXProfileFactoryFromSettings;
    SF.OnAlexChange       := OnAlexSettingsChange;
    SF.OnXvtrChange       := OnXvtrSettingsChange;
    SF.OnOCChange         := OnOCSettingsChange;
    SF.OnWfAGCNFChange    := ApplyWfAGCNF;
    SF.OnADCChange            := ApplyADCSettings;
    SF.OnWebSettingsChange    := ApplyWebSettings;
    SF.OnTCISettingsChange    := ApplyTCISettings;
    SF.OnDXClusterChange      := OnDXClusterSettingsChange;
  end;
  SF := TSettingsForm(FSettingsForm);
  PushTXProfilesToSettings;
  SF.LoadTXSettings(FController.FTXSettings,
    IfThen(FController.FNetwork.Connected, FController.FNetwork.Device.BoardType, FPendingBoardType));
  // Телеграф: есть ли кейер в железе. У AD936x его нет — там телеграф может
  // быть только программным, и настройки прошивки в группе гасятся.
  SF.SetCWBackend(FController.BackendCaps.HasCWKeyer);
  SF.LoadCWSettings(FController.CWSettings);
  SF.LoadAlexSettings(FController.FAlexSettings,
    IfThen(FController.FNetwork.Connected, FController.FNetwork.Device.BoardType, FPendingBoardType));
  // Вкладка Antenna: у AD936x (Pluto/LibreSDR) вместо Alex-таблицы — таблица
  // физических разъёмов на диапазон/трансвертер (если разъёмов две пары).
  SF.SetAntennaBackend(FController.BackendCaps.Kind = bkPluto,
                       FController.BackendCaps.HasAntSwitch);
  // Вкладка Calibration: band-план и состав групп — по активному железу
  // (у AD936x нет моста детектора мощности и телеметрии питания).
  SF.SetCalibrationBackend(FController.BackendCaps.Kind = bkPluto);
  SF.LoadAnt936x(FController.Ant936x, FController.FXvtrSettings);
  SF.LoadXvtrSettings(FController.FXvtrSettings);
  SF.LoadOCSettings(FController.FOCSettings);
  // Вкладка OC Control — только openHPSDR, у Pluto/AD936x нет такого выхода.
  SF.SetOCBackend(FController.BackendCaps.Kind = bkPluto);
  // Вкладка QO-100 — ровно наоборот: транспондер живёт только на AD936x
  // (TRadioController.InQO100 требует IsPluto).
  SF.SetQOBackend(FController.BackendCaps.Kind = bkPluto);
  // Остальные гейты — по объявленным возможностям бэкенда, а не по его виду:
  // мост детектора мощности и drive-байт (PA), микрофонный разъём трансивера,
  // широкополосный АЦП, dither/random. Всё это есть у openHPSDR и нет у AD936x.
  SF.SetPABackend(FController.BackendCaps.HasPA);
  SF.SetTXMicBackend(FController.BackendCaps.HasHWMic);
  SF.SetWidebandBackend(FController.BackendCaps.HasWideband);
  SF.SetADCBackend(FController.BackendCaps.HasDitherRandom);
  SF.LoadPASettings(FController.FPAMaxPower, FController.FPABandCal);
  SF.LoadCalibration(FController.FCal);
  SF.LoadPlutoTxMaxAtt(FController.FPlutoTxMaxAttDb);
  SF.LoadVHFCalSettings(FController.FVHFBandCal);
  SF.LoadCATSettings(
    FCATLastGlobal.CATSerialEnabled,
    FCATLastGlobal.CATSerialPort,
    FCATLastGlobal.CATSerialBaud,
    FCATLastGlobal.CATSerialDataBits,
    FCATLastGlobal.CATSerialStopBits,
    FCATLastGlobal.CATSerialParity,
    FCATLastGlobal.CATSerialAndromeda,
    FCATLastGlobal.CATTcpEnabled,
    FCATLastGlobal.CATTcpPort);
  SF.LoadSliceCATSettings(
    FCATLastGlobal.CATSliceEnabled,
    FCATLastGlobal.CATSlicePort,
    FCATLastGlobal.CATSliceAutoTX,
    FCATLastGlobal.CATSliceOutDev,
    FCATLastGlobal.CATSliceInDev);
  // Перечисляем PA устройства
  SF.RefreshAudioDevices(FController.FAudioOut, FController.FAudioIn);
  SF.LoadVisibility(FController.FShowSpectrum, FController.FShowWaterfall, FController.FShowWideband, FController.FWidebandFill,
    FController.FSpectrumFill);
  SF.LoadWfAGCNF(FController.FWfAGCEnabled, FController.FWfNFEnabled);
  SF.LoadWfRender(FController.FWfPalette, FController.FWfGamma,
    FController.FDisplayPixels, FController.FWfSpeedDiv);
  SF.LoadSpecMSAA(FController.FSpecMSAA);
  SF.LoadADCSettings(FController.FDitherEnabled, FController.FRandomEnabled);
  SF.LoadWebSettings(FWebEnabled, FWebPort, FWebBindAddr, FWebUser, FWebPass,
    FWebSpecPixels);
  SF.LoadTCISettings(FTCICfg.Enabled, FTCICfg.Port, FTCICfg.BindAddr);
  SF.LoadDXClusterSettings(FDXCfg);
  SF.LoadFPS(FDisplayFPS);
  SF.LoadLightTheme(FLightTheme);
  SF.LoadFreqMhzDigits(FFreqMhzDigits);
  SF.LoadAudioBufferSize(FController.FAudioOut.OutputBufferSize);
  SF.LoadSendAudioToRadio(FController.FSendAudioToRadio);
  // Загружаем текущие значения
  if FController.FWDSPReady then
    SF.LoadValues(
      FController.FDSPEngine.FFTSize,
      FController.FDSPEngine.WindowType,
      FController.FDSPEngine.SpecDetector,
      FController.FDSPEngine.SpecAvgMode,
      FController.FDSPEngine.SpecAvgTimeMS,
      FController.FDSPEngine.WfDetector,
      FController.FDSPEngine.WfAvgMode,
      FController.FDSPEngine.WfAvgTimeMS,
      FController.FWfManualHigh,
      FController.FWfManualLow,
      FController.FWfAGCOffset,
      FController.FSpecRefLevel, FController.FSpecRange, FController.FSpecGridStep,
      FController.FAudioOutDevName, FController.FAudioInDevName)
  else
    SF.LoadValues(
      131072, 2, 0, 3, 30.0,
      0,    3, 120.0, FController.FWfManualHigh, FController.FWfManualLow, FController.FWfAGCOffset,
      FController.FSpecRefLevel, FController.FSpecRange, FController.FSpecGridStep,
      FController.FAudioOutDevName, FController.FAudioInDevName);
  SF.Show;
end;

procedure TMainForm.AfterShowTick(Sender: TObject);
begin
  // Вызывается один раз через 200 мс после старта формы
  FAfterShowTimer.Enabled := False;

  // Повторно применяем сохранённую позицию: к этому моменту WM уже декорировал
  // окно и Qt знает frame extents, поэтому move() ставит рамку точно.
  if FHasPendingRestore then
  begin
    SetBounds(FRestoreL, FRestoreT, Width, Height);
    FHasPendingRestore := False;
  end;

  try
    FController.FAudioOut.Open;
  except
  end;

  EnsureWDSPWisdom;
end;


// ===========================================================================
// CAT subsystem
// ===========================================================================

procedure TMainForm.OnCATSettingsChange(
  const SerEnabled: array of Boolean;
  const SerPort: array of string;
  const SerBaud, SerDataBits, SerStopBits, SerParity: array of Integer;
  const SerAndromeda: array of Boolean;
  TcpEnabled: Boolean; TcpPort: Integer);
var i: Integer;
begin
  for i := 0 to 3 do
  begin
    if i <= High(SerEnabled)  then FCATLastGlobal.CATSerialEnabled[i]  := SerEnabled[i];
    if i <= High(SerPort)     then FCATLastGlobal.CATSerialPort[i]     := SerPort[i];
    if i <= High(SerBaud)     then FCATLastGlobal.CATSerialBaud[i]     := SerBaud[i];
    if i <= High(SerDataBits) then FCATLastGlobal.CATSerialDataBits[i] := SerDataBits[i];
    if i <= High(SerStopBits) then FCATLastGlobal.CATSerialStopBits[i] := SerStopBits[i];
    if i <= High(SerParity)   then FCATLastGlobal.CATSerialParity[i]   := SerParity[i];
    if i <= High(SerAndromeda)then FCATLastGlobal.CATSerialAndromeda[i]:= SerAndromeda[i];
  end;
  FCATLastGlobal.CATTcpEnabled := TcpEnabled;
  FCATLastGlobal.CATTcpPort    := TcpPort;
  // Зеркалим CAT-поля в persist-буфер контроллера (BuildGlobalSettings берёт их
  // сквозь из FLoadedGlobal; на connect они грузятся в FLoadedGlobal → FCATLastGlobal).
  FController.FLoadedGlobal.CATSerialEnabled  := FCATLastGlobal.CATSerialEnabled;
  FController.FLoadedGlobal.CATSerialPort     := FCATLastGlobal.CATSerialPort;
  FController.FLoadedGlobal.CATSerialBaud     := FCATLastGlobal.CATSerialBaud;
  FController.FLoadedGlobal.CATSerialDataBits := FCATLastGlobal.CATSerialDataBits;
  FController.FLoadedGlobal.CATSerialStopBits := FCATLastGlobal.CATSerialStopBits;
  FController.FLoadedGlobal.CATSerialParity   := FCATLastGlobal.CATSerialParity;
  FController.FLoadedGlobal.CATSerialAndromeda := FCATLastGlobal.CATSerialAndromeda;
  FController.FLoadedGlobal.CATTcpEnabled     := FCATLastGlobal.CATTcpEnabled;
  FController.FLoadedGlobal.CATTcpPort        := FCATLastGlobal.CATTcpPort;
  FCATAdapter.ApplySettings(FCATLastGlobal);
  FController.FSettings.SaveCATSettings(FCATLastGlobal);
  FController.SaveGlobalSettings;
  FController.FSettings.Save;
end;

procedure TMainForm.OnSliceCATSettingsChange(
  const Enabled: array of Boolean;
  const Port: array of Integer;
  const AutoTX: array of Boolean;
  const OutDev: array of string;
  const InDev: array of string);
// CAT дополнительных слайсов. Хранится там же, где остальные CAT-настройки
// (корневая секция + device-blob), применяется тем же ApplySettings: адаптер
// поднимет/опустит порты эндпоинтов и положит Auto TX в контроллер.
var i: Integer;
begin
  for i := 0 to CFG_CAT_SLICE_COUNT - 1 do
  begin
    if i <= High(Enabled) then FCATLastGlobal.CATSliceEnabled[i] := Enabled[i];
    if i <= High(Port)    then FCATLastGlobal.CATSlicePort[i]    := Port[i];
    if i <= High(AutoTX)  then FCATLastGlobal.CATSliceAutoTX[i]  := AutoTX[i];
    if i <= High(OutDev)  then FCATLastGlobal.CATSliceOutDev[i]  := OutDev[i];
    if i <= High(InDev)   then FCATLastGlobal.CATSliceInDev[i]   := InDev[i];
    // Аудио слота — сразу в контроллер: слайс, стоящий на слоте, переезжает
    // на новое устройство, следующий получит его при создании.
    FController.SetSliceSlotAudio(i, FCATLastGlobal.CATSliceOutDev[i],
                                     FCATLastGlobal.CATSliceInDev[i]);
  end;
  FController.FLoadedGlobal.CATSliceEnabled := FCATLastGlobal.CATSliceEnabled;
  FController.FLoadedGlobal.CATSlicePort    := FCATLastGlobal.CATSlicePort;
  FController.FLoadedGlobal.CATSliceAutoTX  := FCATLastGlobal.CATSliceAutoTX;
  FController.FLoadedGlobal.CATSliceOutDev  := FCATLastGlobal.CATSliceOutDev;
  FController.FLoadedGlobal.CATSliceInDev   := FCATLastGlobal.CATSliceInDev;
  FCATAdapter.ApplySettings(FCATLastGlobal);
  FController.FSettings.SaveCATSettings(FCATLastGlobal);
  FController.SaveGlobalSettings;
  FController.FSettings.Save;
  RefreshTxIndicators;   // бейджи AutoTX на флагах слайсов — сразу
  for i := 0 to MAX_PANS - 1 do    // подсветка текущего устройства во флагах
    if FPans[i] <> nil then FPans[i].PushAllSliceFlagStates;
end;

procedure TMainForm.ApplySliceSlotAudio;
var i: Integer;
begin
  for i := 0 to CFG_CAT_SLICE_COUNT - 1 do
    FController.SetSliceSlotAudio(i, FCATLastGlobal.CATSliceOutDev[i],
                                     FCATLastGlobal.CATSliceInDev[i]);
end;

procedure TMainForm.StoreSliceSlotAudio(SliceId: Integer);
// Слот слайса и его устройства уже обновлены контроллером (SetSliceDevice/
// SetSliceInDevice) — здесь только зеркалим их в конфиг и сохраняем.
var Slot: Integer;
begin
  Slot := FController.SliceSlotOf(SliceId);
  if (Slot < 0) or (Slot >= CFG_CAT_SLICE_COUNT) then Exit;
  FCATLastGlobal.CATSliceOutDev[Slot] := FController.SliceSlotOutDev(Slot);
  FCATLastGlobal.CATSliceInDev[Slot]  := FController.SliceSlotInDev(Slot);
  FController.FLoadedGlobal.CATSliceOutDev := FCATLastGlobal.CATSliceOutDev;
  FController.FLoadedGlobal.CATSliceInDev  := FCATLastGlobal.CATSliceInDev;
  FController.FSettings.SaveCATSettings(FCATLastGlobal);
  FController.FSettings.Save;
end;

end.
