Files
ewsdr/VfoOverlay.pas
T
ew8bakandClaude Opus 5 013d0406ac feat(tx): TX-профиль на слайс — пилюля в шапке флага
Передатчик один, профиль до сих пор был один на устройство: активный профиль
звучал независимо от того, какой слайс взял передачу. С панадаптером на FT8 и
вторым на FM это значит, что компрессия и EQ голосового профиля уходили в
эфир на FT8 (FMRAW прикрыт сам — ApplyTXModeSettings глушит там всю обработку,
а DIGU/DIGL нет). У Flex это решается дисциплиной и внешним API; делаем в радио.

Привязка «источник передачи → профиль» (-1 = не переключать, звучит активный):
TCtrlSlice.TXProfile для B..G, TTXProfileList.MainBind для главного (слайс A).
Персист: ключ tx_profile в слайсе пана и main_bind в секции tx_profiles.
SetTxSlice зовёт ApplyBoundTXProfile ДО пуша режима и цепи, иначе
ApplyTXSettingsToDSP лёг бы на старый режим. Привязали источник, который
передаёт прямо сейчас, — применяем сразу.

Привязки — индексы, поэтому RemapTXProfileBindings: удаление профиля из
середины снимает привязки на него и сдвигает те, что правее; Factory-сброс
снимает все (список заменён целиком).

UI: пилюля с именем профиля в первой строке флага сразу за шириной фильтра
(ЛКМ — fly-out список, первая строка «As active»). Правая граница считается
до SPLIT у главного и до RXM у слайса; не рисуем при пустом списке, в DMR
(передачи нет) и в FMRAW (профиль там ни на что не влияет). Прокрутка списка
общая с AUD — блок стрелок вынесен в DrawScrollArrows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:06:08 +03:00

1989 lines
78 KiB
ObjectPascal
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
unit VfoOverlay;
{$mode objfpc}{$H+}
{
TVfoOverlay — накладка-«флаг слайса» поверх спектра, стиль SmartSDR.
Компоновка (сверху вниз):
шапка : [A] 2.7k SPLIT [TX]
метр : NR NB ANF SNB ▂▃▅▇█▆▄ -20 +20 +40
частота : 7.125.000 S 7
бар : 🔊──○── MODE DSP AUD
Нижний бар компактный; клик по MODE/DSP/AUD раскрывает fly-out панель ПОД баром
(карточка растёт в высоту). Слайдер громкости — click/drag по треку.
Рендер: кэш-битмап (BlendBitmapKey с key-color) + dirty-флаг, как раньше.
}
interface
uses
Classes, SysUtils, Controls, Graphics, Types, Math, FlatButton, AppTheme,
RadioModes;
const
VFO_OVERLAY_ALPHA = 210;
OVL_W = 280; // ширина карточки
// Индексы совпадают с MODE_* контроллера (WDSPEngine.pas).
OVL_MODE_COUNT = RadioModes.MODE_COUNT;
OVL_MODE_FM = RadioModes.MODE_FM;
OVL_MODE_DMR = RadioModes.MODE_DMR;
OVL_MODE_FMRAW = RadioModes.MODE_FMRAW;
OVL_MODE_COLS = 4;
OVL_MODE_ROWS = (OVL_MODE_COUNT + OVL_MODE_COLS - 1) div OVL_MODE_COLS;
OVL_MODE_NAMES: array[0..OVL_MODE_COUNT-1] of string = (
'LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM','DMR','FMRAW');
// Палитра слайсов (TColor = $00BBGGRR): одна на букву — общий цвет для
// бейджа-карточки во флаге и для буквы на полосе фильтра в спектре.
SLICE_COLORS: array[0..7] of TColor = (
TColor($0060E040), // A — зелёный
TColor($0010A8FF), // B — оранжевый
TColor($00F0D030), // C — голубой
TColor($00E070F0), // D — розовый/маджента
TColor($0030E0F0), // E — жёлтый
TColor($00F050B0), // F — фиолетовый (не красный — чтобы не путать с TX)
TColor($00E0C060), // G — бирюзовый
TColor($00C0C0C0)); // H — серый
// Цвет слайса по его букве (A..). За пределами таблицы — по кругу.
function SliceColor(L: Char): TColor;
type
TModeFilterEvent = procedure(Mode: Integer; FilterBW: Integer) of object;
TDSPChangeEvent = procedure(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean) of object;
TSquelchEvent = procedure(SQLOn: Boolean; SQLLevel: Integer) of object;
TAGCChangeEvent = procedure(AGCMode: Integer) of object;
TVolumeEvent = procedure(Vol: Integer) of object;
TAudioDeviceEvent = procedure(DevIndex: Integer; const DevName: string) of object;
TFlyPanel = (fpNone, fpMode, fpDsp, fpAud, fpTxProf);
// Выбран TX-профиль для этого флага: Idx = -1 («как активный») или индекс.
TTXProfilePickEvent = procedure(Idx: Integer) of object;
TVfoOverlay = class(TCustomControl)
private
FMode: Integer;
FFilterBW: Integer;
FFilterLo: Integer; // знаковые кромки (0/0 = не заданы)
FFilterHi: Integer;
FVfoHz: Double;
FSMeterDB: Double;
FModeFilter: array[0..OVL_MODE_COUNT-1] of Integer;
FNRMode: Integer; // 0=off, 1..4
FNBMode: Integer; // 0=off, 1=NB, 2=NB2
FSNBOn: Boolean;
FANFOn: Boolean;
FAGCMode: Integer; // 0=FAST 1=MED 2=SLOW 3=LONG 4=OFF
FSQLOn: Boolean; // FM-шумоподавитель (squelch) включён
FSQLLevel: Integer; // порог squelch 0..100
FVolume: Integer; // 0..100
FMuted: Boolean;
FSplit: Boolean;
FTx: Boolean; // передача идёт (красный)
FTxSel: Boolean; // этот слайс выбран для передачи (янтарный)
FRxMuteTx: Boolean; // RXM: приём слайса глушится на передаче (умолч. True)
FAutoTx: Boolean; // Auto TX: PTT по CAT-порту слайса сама берёт передачу
FSlice: Char; // буква слайса ('A')
FSMeterThreshold: Double; // мин. изменение S-метра (дБ) для перерисовки
FDMRText: string; // стабильный краткий статус рядом с полосой
FDMRSynced: Boolean;
FDMRCandidateText: string; // debounce: не мигать на кратком sync-loss
FDMRCandidateSynced: Boolean;
FDMRCandidateSince: QWord;
FDevNames: TStringList; // имена устройств вывода
FDevIdx: array of Integer; // соответствующие PortAudio-индексы (-1=default)
FCurDev: string; // имя текущего устройства ('' = default)
FInDevNames: TStringList; // имена устройств ВВОДА (микрофон/кабель)
FInDevIdx: array of Integer; // их PortAudio-индексы
FCurInDev: string; // текущий вход слайса ('' = общий вход)
FAudInTab: Boolean; // AUD-панель показывает вход (IN), а не выход
// TX-профили: список имён (общий на все флаги) + привязка ЭТОГО флага.
// -1 = флаг ничего не переключает, передаёт активный профиль.
FProfNames: TStringList;
FProfBound: Integer;
FProfScroll: Integer; // прокрутка списка профилей
FProfListRect: TRect; // область списка профилей (зона колёсика)
FPanel: TFlyPanel; // открытая fly-out панель
FAudScroll: Integer; // прокрутка списка аудио-устройств (верхний индекс)
FAudListRect: TRect; // область списка AUD (лок. коорд.) — зона колёсика
FVolTrack: TRect; // трек громкости (локальные координаты)
FVolDrag: Boolean;
FSqlTrack: TRect; // трек squelch в DSP-панели (лок. координаты)
FSqlDrag: Boolean;
FOnSelect: TModeFilterEvent;
FOnDSPChange: TDSPChangeEvent;
FOnSquelchChange: TSquelchEvent;
FOnAGCChange: TAGCChangeEvent;
FOnVolume: TVolumeEvent;
FOnMute: TNotifyEvent;
FOnAudioDevice: TAudioDeviceEvent;
FOnAudioInDevice: TAudioDeviceEvent;
FOnSplit: TNotifyEvent;
FOnTxSelect: TNotifyEvent;
FOnRxMuteTx: TNotifyEvent;
FOnTXProfile: TTXProfilePickEvent;
FOnInvalidate: TNotifyEvent;
FTheme: TAppTheme; // тема приложения (цвета кнопок и т.п.)
// Структурные цвета карточки, выведенные из FTheme (ApplyThemeColors).
CLR_CARD_BG, CLR_BORDER, CLR_SEP, CLR_TEXT, CLR_DIM,
CLR_ACCENT, CLR_ACCENT_D, CLR_BTN_NORM, CLR_BTN_HOT,
CLR_FREQ, CLR_SVAL, CLR_BAR_BG: TColor;
FSliceId: Integer; // логический id слайса (владелец — MainForm/контроллер)
FIsMain: Boolean; // главный приёмник (слайс A) — без кнопки закрытия
FOnSliceSelect: TNotifyEvent; // клик по бейджу буквы — сделать слайс активным
FOnClose: TNotifyEvent; // клик по × (только неглавные) — удалить слайс
FHitRects: array[0..63] of TRect;
FHitActions: array[0..63] of Integer;
FHitCount: Integer;
FHotIdx: Integer;
FCacheBitmap: TBitmap;
FCacheDirty: Boolean;
FMeterDirty: Boolean; // изменился только S-метр — перерисовать полоску, не весь флаг
FMeterRect: TRect; // прямоугольник сигнал-бара (запоминается при полном рендере)
FSRowTop: Integer; // верх строки частоты (для S-метки справа)
FLastSTextRect: TRect; // последний прямоугольник S-метки (для аккуратной очистки)
procedure DrawSelf(C: TCanvas; W, H: Integer);
procedure DrawButton(C: TCanvas; const R: TRect; const Lbl: string;
Active, Hot: Boolean; FontSz: Integer);
procedure DrawIndicator(C: TCanvas; X, Y: Integer; const Lbl: string; On_: Boolean);
procedure DrawBadge(C: TCanvas; const R: TRect; const Lbl: string;
BgColor, TxtColor: TColor);
procedure DrawSpeaker(C: TCanvas; X, Y: Integer; Muted: Boolean);
procedure DrawMeterBar(C: TCanvas; X, Y, BW, BH: Integer);
procedure DrawModePanel(C: TCanvas; X, Y, RowW: Integer);
procedure DrawDspPanel(C: TCanvas; X, Y, RowW: Integer);
procedure DrawAudPanel(C: TCanvas; X, Y, RowW: Integer);
procedure DrawProfPanel(C: TCanvas; X, Y, RowW: Integer);
// Стрелки прокрутки справа от списка (общие для AUD и профилей).
procedure DrawScrollArrows(C: TCanvas; X, Y, RowW, RowWidth, PanelH: Integer;
CanUp, CanDn: Boolean; HitUp, HitDn: Integer);
function ProfRowCount: Integer; // «— как активный» + профили
function ProfRowName(I: Integer): string;
procedure ScrollProfToCurrent;
// Строки активной вкладки AUD (OUT — устройства вывода, IN — ввода;
// у IN первая строка псевдо: «общий вход» / «нет»).
function AudRowCount: Integer;
function AudRowName(I: Integer): string;
function AudRowCurrent(I: Integer): Boolean;
procedure ScrollAudToCurrent; // прокрутка к выбранному устройству
procedure RegisterHit(const HR: TRect; HV: Integer);
procedure ApplyThemeColors; // раскладывает FTheme → структурные CLR_*
function FormatFreq(Hz: Double): string;
function GetFilterBWForMode(ModeIdx, FilterIdx: Integer): Integer;
function GetFilterLblForMode(ModeIdx, FilterIdx: Integer): string;
function GetFilterCountForMode(ModeIdx: Integer): Integer;
function TxSelectionAllowed: Boolean;
function PanelContentH: Integer;
function ComputeHeight: Integer;
procedure SetPanel(P: TFlyPanel);
procedure ApplyVolFromX(LX: Integer);
procedure ApplySqlFromX(LX: Integer);
function IsFM: Boolean;
procedure RequestInvalidate;
procedure RequestMeterInvalidate;
procedure RefreshMeter(C: TCanvas);
procedure DoHit(HV: Integer);
protected
procedure Paint; override;
procedure MouseDown(Button: TMouseButton; Shift: TShiftState;
X, Y: Integer); override;
procedure MouseMove(Shift: TShiftState; X, Y: Integer); override;
procedure MouseLeave; override;
public
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
procedure DrawOverlay(Target: TBitmap; W, H: Integer);
procedure DrawOverlayBitmap(Target: TBitmap);
// Готовит внутренний кэш-битмап (полный рендер по CacheDirty либо дешёвая
// перерисовка одной полоски метра по MeterDirty) и возвращает его. Один путь
// для CPU (BlendBitmapKey) и GL (UploadBitmap).
function EnsureRendered: TBitmap;
function HandleMouseDown(Button: TMouseButton; X, Y: Integer): Boolean;
function HandleMouseMove(X, Y: Integer): Boolean;
function HandleMouseUp(X, Y: Integer): Boolean;
function HandleMouseLeave: Boolean;
// Колесо над списком устройств AUD (координаты — как у HandleMouseDown).
function HandleMouseWheel(X, Y, WheelDelta: Integer): Boolean;
procedure SetState(AMode, ABW: Integer; AVfoHz, ASMeterDB: Double);
procedure SetDSPState(ANRMode, ANBMode: Integer; ASNBOn, AANFOn: Boolean;
AAGCMode: Integer);
procedure SetSquelchState(AOn: Boolean; ALevel: Integer);
// Список TX-профилей (одинаков у всех флагов) + привязка этого флага.
procedure SetTXProfiles(ANames: TStrings; ABound: Integer);
procedure SetExtState(AVolume: Integer; AMuted, ASplit, ATx, ATxSel: Boolean);
procedure SetRxMuteTxState(AOn: Boolean); // бейдж RXM (self-monitor на TX)
// Auto TX слайса (настройка CAT): бейдж TX превращается в AutoTX.
procedure SetAutoTxState(AOn: Boolean);
procedure SetAudioDevices(Names: TStrings; const Indices: array of Integer;
const CurName: string);
// Список устройств ВВОДА + текущий выбор ('' = общий вход приложения).
procedure SetAudioInDevices(Names: TStrings; const Indices: array of Integer;
const CurName: string);
procedure UpdateSMeter(DB: Double);
procedure UpdateVfo(AVfoHz: Double);
procedure UpdateDMRStatus(const AText: string; Synced: Boolean);
// Тема приложения (цвета кнопок берутся отсюда — как левая панель).
procedure SetTheme(const T: TAppTheme);
// Привязка к слайсу: id, буква флага, признак главного (слайс A).
procedure SetSliceInfo(ASliceId: Integer; ALetter: Char; AIsMain: Boolean);
property SliceId: Integer read FSliceId;
property IsMain: Boolean read FIsMain;
property SliceLetter: Char read FSlice; // буква слайса (A/B/…) для меток на полосе фильтра
// Для маркера фильтра слайса на спектре (читает GL-вьюха).
property DisplayVfoHz: Double read FVfoHz;
property DisplayMode: Integer read FMode;
property DisplayBW: Integer read FFilterBW;
// Настоящие кромки фильтра слайса (Гц от несущей, знаковые). Одной ширины
// мало: пара→ширина→пара — преобразование с потерями, и полоса на экране
// разъезжалась со звуком, когда кромки заданы не по конвенции режима
// (слайс-CAT ZZFL/ZZFH). Hi<=Lo = не заданы, рисуем по режиму и ширине.
property DisplayLo: Integer read FFilterLo;
property DisplayHi: Integer read FFilterHi;
procedure SetFilterEdges(ALo, AHi: Integer);
// Слайс сейчас передаёт (полоса фильтра на спектре краснеет, как у главного).
property TxActive: Boolean read FTx;
// Битмап-кэш требует перерисовки (GL-вьюха решает, перезаливать ли текстуру).
property CacheDirty: Boolean read FCacheDirty;
// Изменился только S-метр (перезалить текстуру, но рендер дешёвый — одна полоска).
property MeterDirty: Boolean read FMeterDirty;
// Порог изменения S-метра (дБ) для перерисовки: у слайсов выше (меньше рендеров).
property SMeterThreshold: Double read FSMeterThreshold write FSMeterThreshold;
property HotIdx: Integer read FHotIdx;
property BaseHeight: Integer read ComputeHeight;
property OnSelect: TModeFilterEvent read FOnSelect write FOnSelect;
property OnDSPChange: TDSPChangeEvent read FOnDSPChange write FOnDSPChange;
property OnSquelchChange: TSquelchEvent read FOnSquelchChange write FOnSquelchChange;
property OnAGCChange: TAGCChangeEvent read FOnAGCChange write FOnAGCChange;
property OnVolume: TVolumeEvent read FOnVolume write FOnVolume;
property OnMute: TNotifyEvent read FOnMute write FOnMute;
property OnAudioDevice: TAudioDeviceEvent read FOnAudioDevice write FOnAudioDevice;
property OnAudioInDevice: TAudioDeviceEvent read FOnAudioInDevice write FOnAudioInDevice;
property OnSplit: TNotifyEvent read FOnSplit write FOnSplit;
property OnTxSelect: TNotifyEvent read FOnTxSelect write FOnTxSelect;
property OnRxMuteTx: TNotifyEvent read FOnRxMuteTx write FOnRxMuteTx;
property OnTXProfile: TTXProfilePickEvent read FOnTXProfile write FOnTXProfile;
property OnSliceSelect: TNotifyEvent read FOnSliceSelect write FOnSliceSelect;
property OnClose: TNotifyEvent read FOnClose write FOnClose;
property OnInvalidate: TNotifyEvent read FOnInvalidate write FOnInvalidate;
end;
implementation
function SliceColor(L: Char): TColor;
var idx: Integer;
begin
if (L >= 'A') and (L <= 'Z') then idx := Ord(L) - Ord('A') else idx := 0;
Result := SLICE_COLORS[idx mod (High(SLICE_COLORS) + 1)];
end;
const
KEY_COLOR = TColor($00FF00FF);
// Значения хит-тестов
// режимы: HIT_MODE_BASE-I
// фильтры: HIT_FILT_BASE+I (индекс, не BW: полосы WFM доходят до 200000
// и налезали бы на HIT_AGC_PICK_BASE/HIT_DEV_BASE)
// AGC: HIT_AGC_PICK_BASE+I
// устройства: HIT_DEV_BASE+I
HIT_NR = -9;
HIT_NB = -10;
HIT_SNB = -11;
HIT_ANF = -12;
HIT_BAR_MODE = -20;
HIT_BAR_DSP = -21;
HIT_BAR_AUD = -22;
HIT_MUTE = -23;
HIT_VOL = -24;
HIT_SPLIT = -25;
HIT_TX = -26;
HIT_AUD_UP = -27;
HIT_AUD_DN = -28;
HIT_AUD_TAB_OUT = -34; // вкладка «OUT» в AUD-панели
HIT_AUD_TAB_IN = -35; // вкладка «IN»
HIT_SLICE_SEL = -29; // клик по бейджу буквы (выбрать слайс активным)
HIT_CLOSE = -30; // × закрытия слайса (неглавные)
HIT_SQL = -31; // тумблер FM-squelch (в DSP-панели)
HIT_SQL_SLIDER = -32; // трек порога FM-squelch
HIT_RXMUTE = -33; // бейдж RXM: приём слайса на своей передаче
HIT_TXPROF = -36; // пилюля TX-профиля в шапке (открыть список)
HIT_PROF_UP = -37; // прокрутка списка профилей
HIT_PROF_DN = -38;
HIT_AGC_PICK_BASE = 100000;
HIT_DEV_BASE = 200000;
HIT_PROF_BASE = 300000; // 300000 + строка списка (0 = «как активный»)
// Свои диапазоны, чтобы не пересекаться с тумблерами (-9..-33) и с базами выше.
HIT_MODE_BASE = -1000; // -1000 .. -1000-(OVL_MODE_COUNT-1)
HIT_FILT_BASE = 1000; // 1000 + индекс кнопки в ряду фильтров
AGC_NAMES: array[0..4] of string = ('FAST', 'MED', 'SLOW', 'LONG', 'OFF');
// Геометрия карточки
PAD = 6;
HDR_H = 16;
HDR_BADGE_H = 12; // высота бейджей A/SPLIT/TX (верх на CurY, низ приподнят)
MET_H = 21;
FRQ_H = 26;
BAR_H = 24;
ROW_H = 20;
IGAP = 2;
AUD_ROW_H = 17; // высота строки устройства в AUD-панели
AUD_MAX_VIS = 6; // максимум видимых устройств (дальше — прокрутка)
PROF_MAX_VIS = 6; // столько же строк у списка TX-профилей
PROF_PILL_W = 92; // ширина пилюли профиля в шапке
PROF_PILL_MIN = 44; // уже этого не рисуем — шапка занята бейджами
OVL_BASE_H = PAD + HDR_H + MET_H + FRQ_H + BAR_H + PAD; // = 96
// Палитра. Структурные цвета (фон/текст/акценты) берутся из темы —
// см. поля CLR_* класса + ApplyThemeColors. Здесь — только статус-акценты,
// которые намеренно одинаковы в обеих темах (яркие бейджи-статусы), и
// тёмный текст поверх них.
CLR_TX_ON = TColor($000030E0); // красный TX идёт (BGR)
CLR_TX_SEL = TColor($0010A8F0); // янтарный: слайс выбран для передачи
CLR_SPLIT_ON = TColor($0020C0FF); // оранжевый SPLIT
CLR_BADGE_TXT = TColor($00181818); // тёмный текст поверх ярких бейджей
SSB_BW: array[0..5] of Integer = (1800,2100,2400,2700,3300,3800);
SSB_LBL: array[0..5] of string = ('1.8K','2.1K','2.4K','2.7K','3.3K','3.8K');
DSB_BW: array[0..5] of Integer = (16000,12000,10000,8000,5200,4000);
DSB_LBL: array[0..5] of string = ('16K','12K','10K','8K','5.2K','4K');
CW_BW: array[0..5] of Integer = (500,250,150,100,50,1000);
CW_LBL: array[0..5] of string = ('500','250','150','100','50','1K');
FM_BW: array[0..1] of Integer = (11000, 16000);
FM_LBL: array[0..1] of string = ('NFM', 'FM');
AM_BW: array[0..5] of Integer = (20000,16000,12000,10000,8000,6000);
AM_LBL: array[0..5] of string = ('20K','16K','12K','10K','8K','6K');
WFM_BW: array[0..2] of Integer = (200000, 160000, 120000);
WFM_LBL: array[0..2] of string = ('200K', '160K', '120K');
DIGI_BW: array[0..5] of Integer = (300, 600, 1200, 1800, 2400, 3000);
DIGI_LBL: array[0..5] of string = ('300','600','1.2K','1.8K','2.4K','3.0K');
RAW_BW: array[0..4] of Integer = (6250,10000,12500,20000,25000);
RAW_LBL: array[0..4] of string = ('6.25K','10K','12.5K','20K','25K');
DB_S: array[1..9] of Double = (-121,-115,-109,-103,-97,-91,-85,-79,-73);
procedure BlendBitmapKey(Target, Source: TBitmap; DstX, DstY: Integer; Alpha: Byte);
// Дворд-блендинг (два 16-битных лейна на умножение, /256 вместо /255 —
// расхождение <0.4%): побайтовый вариант с div 255 на 2 флагах стоил ~0.6мс.
// Keyed-пиксели (магента) пропускаются сравнением цветовых байтов дворда.
var
Y, X, SX0, SY0, SX1, SY1, TY: Integer;
A256, I256, S, D: LongWord;
SrcRow, DstRow: PLongWord;
begin
if (Target = nil) or (Source = nil) or (Alpha = 0) then Exit;
SX0 := 0; SY0 := 0;
SX1 := Source.Width; SY1 := Source.Height;
if DstX < 0 then begin SX0 := -DstX; DstX := 0; end;
if DstY < 0 then begin SY0 := -DstY; DstY := 0; end;
if DstX + (SX1 - SX0) > Target.Width then SX1 := SX0 + Target.Width - DstX;
if DstY + (SY1 - SY0) > Target.Height then SY1 := SY0 + Target.Height - DstY;
if (SX1 <= SX0) or (SY1 <= SY0) then Exit;
A256 := Alpha + (Alpha shr 7); // 0..256
I256 := 256 - A256;
Target.BeginUpdate(False);
Source.BeginUpdate(False);
try
for Y := SY0 to SY1 - 1 do
begin
TY := DstY + (Y - SY0);
SrcRow := PLongWord(Source.ScanLine[Y]);
DstRow := PLongWord(Target.ScanLine[TY]);
if (SrcRow = nil) or (DstRow = nil) then Continue;
Inc(SrcRow, SX0);
Inc(DstRow, DstX);
for X := SX0 to SX1 - 1 do
begin
S := SrcRow^;
{$IFDEF DARWIN}
if (S and $FFFFFF00) <> $FF00FF00 then // ARGB: R=FF,G=00,B=FF
{$ELSE}
if (S and $00FFFFFF) <> $00FF00FF then // BGRA: B=FF,G=00,R=FF
{$ENDIF}
begin
D := DstRow^;
DstRow^ := ((((S and $00FF00FF) * A256 + (D and $00FF00FF) * I256)
shr 8) and $00FF00FF)
or ((((S shr 8) and $00FF00FF) * A256 +
((D shr 8) and $00FF00FF) * I256) and $FF00FF00)
{$IFDEF DARWIN}
or $000000FF;
{$ELSE}
or $FF000000;
{$ENDIF}
end;
Inc(SrcRow);
Inc(DstRow);
end;
end;
finally
Source.EndUpdate(False);
Target.EndUpdate(False);
end;
end;
function DBmToSLabel(DB: Double): string;
var
I, Over: Integer;
begin
if DB >= DB_S[9] then
begin
Over := Round(DB - DB_S[9]);
if Over < 5 then Result := 'S9'
else
begin
Over := ((Over + 5) div 10) * 10;
if Over = 0 then Over := 10;
Result := 'S9+' + IntToStr(Over);
end;
end
else if DB <= DB_S[1] then Result := 'S1'
else
begin
Result := 'S1';
for I := 1 to 8 do
if DB >= DB_S[I] then Result := 'S' + IntToStr(I);
end;
end;
{ ---- Фильтры по режиму ---- }
function TVfoOverlay.GetFilterBWForMode(ModeIdx, FilterIdx: Integer): Integer;
begin
case ModeIdx of
MODE_LSB, MODE_USB: Result := SSB_BW[FilterIdx];
MODE_DSB: Result := DSB_BW[FilterIdx];
MODE_CWL, MODE_CWU: Result := CW_BW[FilterIdx];
OVL_MODE_FM: Result := FM_BW[FilterIdx];
MODE_DIGU, MODE_DIGL: Result := DIGI_BW[FilterIdx]; // полоса от нуля
MODE_WFM: Result := WFM_BW[FilterIdx];
OVL_MODE_DMR: Result := 12500;
OVL_MODE_FMRAW: Result := RAW_BW[FilterIdx];
else Result := AM_BW[FilterIdx];
end;
end;
function TVfoOverlay.GetFilterLblForMode(ModeIdx, FilterIdx: Integer): string;
begin
case ModeIdx of
MODE_LSB, MODE_USB: Result := SSB_LBL[FilterIdx];
MODE_DSB: Result := DSB_LBL[FilterIdx];
MODE_CWL, MODE_CWU: Result := CW_LBL[FilterIdx];
OVL_MODE_FM: Result := FM_LBL[FilterIdx];
MODE_DIGU, MODE_DIGL: Result := DIGI_LBL[FilterIdx];
MODE_WFM: Result := WFM_LBL[FilterIdx];
OVL_MODE_DMR: Result := '12.5k';
OVL_MODE_FMRAW: Result := RAW_LBL[FilterIdx];
else Result := AM_LBL[FilterIdx];
end;
end;
function TVfoOverlay.GetFilterCountForMode(ModeIdx: Integer): Integer;
begin
case ModeIdx of
OVL_MODE_FM: Result := 2; // FM: NFM/FM
MODE_WFM: Result := 3; // WFM: 200K/160K/120K
OVL_MODE_DMR: Result := 0; // DMR: fixed 12.5 kHz, no redundant selector
OVL_MODE_FMRAW: Result := 5; // FM RAW decoder channel widths
else Result := 6;
end;
end;
function TVfoOverlay.TxSelectionAllowed: Boolean;
begin
// Main A remains the normal TX source. DMR slices stay RX-only (no AMBE
// encoder). FM RAW transmits external PCM from its own input, so it is a
// valid TX source.
Result := FIsMain or (FMode <> OVL_MODE_DMR);
end;
{ ---- Lifecycle ---- }
constructor TVfoOverlay.Create(AOwner: TComponent);
begin
inherited Create(AOwner);
ControlStyle := ControlStyle + [csOpaque];
FMode := MODE_USB;
FFilterBW := 2700;
FVfoHz := 14200000;
FSMeterDB := -121;
FHitCount := 0;
FHotIdx := -1;
FModeFilter[0] := 2700; FModeFilter[1] := 2700; FModeFilter[2] := 8000;
FModeFilter[3] := 500; FModeFilter[4] := 500; FModeFilter[5] := 11000;
FModeFilter[6] := 8000; FModeFilter[7] := 8000; FModeFilter[8] := 3000;
FModeFilter[9] := 3000; FModeFilter[10] := 160000;
FModeFilter[11] := 12500; FModeFilter[12] := 12500;
FNRMode := 0; FNBMode := 0; FSNBOn := False; FANFOn := False; FAGCMode := 0;
FSQLOn := False; FSQLLevel := 40; FSqlDrag := False;
FVolume := 70; FMuted := False; FSplit := False; FTx := False;
FTxSel := True; FSlice := 'A'; FRxMuteTx := True;
FDMRText := ''; FDMRSynced := False;
FDMRCandidateText := ''; FDMRCandidateSynced := False;
FDMRCandidateSince := 0;
FSliceId := 0; FIsMain := True; // по умолчанию — главный приёмник (слайс A)
FSMeterThreshold := 0.4; // главный флаг — чувствительный метр
FPanel := fpNone;
FAudScroll := 0;
FVolDrag := False;
FDevNames := TStringList.Create;
FCurDev := '';
FInDevNames := TStringList.Create;
FCurInDev := '';
FAudInTab := False; // AUD открывается на вкладке OUT
FProfNames := TStringList.Create;
FProfBound := -1; // флаг не переключает профиль
FProfScroll := 0;
FProfListRect := Rect(0, 0, 0, 0);
FCacheBitmap := TBitmap.Create;
FCacheBitmap.PixelFormat := pf32bit;
FCacheDirty := True;
FMeterDirty := False;
FLastSTextRect := Rect(0, 0, 0, 0);
FTheme := DarkTheme; // по умолчанию тёмная; MainForm выставит актуальную
ApplyThemeColors;
Width := OVL_W;
Height := OVL_BASE_H;
Cursor := crHandPoint;
end;
// Раскладывает тему в структурные цвета карточки. Статус-акценты (TX/SPLIT)
// намеренно НЕ тематизируются — они одинаковы в обеих темах.
procedure TVfoOverlay.ApplyThemeColors;
begin
CLR_CARD_BG := FTheme.Panel;
CLR_BORDER := FTheme.Border;
CLR_SEP := FTheme.Border;
CLR_TEXT := FTheme.Text;
CLR_DIM := FTheme.TextDim;
CLR_ACCENT := FTheme.BtnTextActive; // зелёный акцент (яркий/тёмный по теме)
CLR_ACCENT_D := FTheme.BtnActive; // фон активной строки
CLR_BTN_NORM := FTheme.BtnNorm;
CLR_BTN_HOT := FTheme.BtnHot;
CLR_FREQ := FTheme.FreqActive;
CLR_SVAL := FTheme.Amber; // числовое значение S-метра
CLR_BAR_BG := FTheme.BG; // фон полоски S-метра
end;
procedure TVfoOverlay.SetTheme(const T: TAppTheme);
begin
FTheme := T;
ApplyThemeColors;
RequestInvalidate; // перерисовать хром флага с новыми цветами
end;
destructor TVfoOverlay.Destroy;
begin
FProfNames.Free;
FInDevNames.Free;
FDevNames.Free;
FCacheBitmap.Free;
inherited Destroy;
end;
procedure TVfoOverlay.RegisterHit(const HR: TRect; HV: Integer);
begin
if FHitCount > High(FHitRects) then Exit;
FHitRects[FHitCount] := HR;
FHitActions[FHitCount] := HV;
Inc(FHitCount);
end;
procedure TVfoOverlay.RequestInvalidate;
begin
FCacheDirty := True;
inherited Invalidate;
if Assigned(FOnInvalidate) then FOnInvalidate(Self);
end;
// Лёгкая инвалидация: изменился только S-метр. НЕ трогает FCacheDirty, поэтому
// хром флага (частота/режим/кнопки) не перерисовывается — перезаливается только
// полоска метра (RefreshMeter в EnsureRendered).
procedure TVfoOverlay.RequestMeterInvalidate;
begin
FMeterDirty := True;
inherited Invalidate;
if Assigned(FOnInvalidate) then FOnInvalidate(Self);
end;
// Дешёвая перерисовка одной полоски метра + S-метки поверх уже отрендеренного
// хрома. Обе зоны самодостаточны и целиком очищаются под фон карточки.
procedure TVfoOverlay.RefreshMeter(C: TCanvas);
var
SStr: string;
STextW: Integer;
ClearR, NewR: TRect;
begin
// Сигнал-бар — самодостаточный прямоугольник, перерисовывается целиком.
C.Brush.Color := CLR_CARD_BG;
C.Brush.Style := bsSolid;
C.Pen.Style := psClear;
C.FillRect(FMeterRect);
DrawMeterBar(C, FMeterRect.Left, FMeterRect.Top,
FMeterRect.Right - FMeterRect.Left, MET_H);
// S-метка в строке частоты (справа). Очищаем объединение старого и нового
// прямоугольника, чтобы не оставалось «хвостов» при сужении текста.
SStr := DBmToSLabel(FSMeterDB);
C.Font.Name := 'Courier New';
C.Font.Style := [fsBold];
C.Font.Size := 9;
STextW := C.TextWidth(SStr);
NewR := Rect(FMeterRect.Right - STextW - 3, FSRowTop + 4, FMeterRect.Right, FSRowTop + FRQ_H);
ClearR := NewR;
if FLastSTextRect.Left < ClearR.Left then ClearR.Left := FLastSTextRect.Left;
if FLastSTextRect.Top < ClearR.Top then ClearR.Top := FLastSTextRect.Top;
C.Brush.Color := CLR_CARD_BG;
C.Brush.Style := bsSolid;
C.Pen.Style := psClear;
C.FillRect(ClearR);
C.Font.Color := CLR_SVAL;
C.Brush.Style := bsClear;
C.TextOut(FMeterRect.Right - STextW - 2, FSRowTop + 6, SStr);
FLastSTextRect := NewR;
end;
function TVfoOverlay.FormatFreq(Hz: Double): string;
var
T, iM, iK, iH: Int64;
begin
// Целочисленная математика (как FreqDispA := Round(FVfoA)): арифметика в double
// на частотах ~1e10 (QO-100) накапливает ошибку в десятки Гц.
T := Round(Hz);
iM := T div 1000000;
iK := (T div 1000) mod 1000;
iH := T mod 1000;
Result := Format('%d.%3.3d.%3.3d', [iM, iK, iH]);
end;
{ ---- Высота с учётом fly-out ---- }
function TVfoOverlay.IsFM: Boolean;
begin
Result := FMode = OVL_MODE_FM;
end;
function TVfoOverlay.PanelContentH: Integer;
begin
case FPanel of
fpMode: begin
Result := OVL_MODE_ROWS*(ROW_H + IGAP);
if GetFilterCountForMode(FMode) > 0 then Inc(Result, ROW_H);
end;
fpDsp: begin
Result := ROW_H + IGAP + ROW_H; // toggles + AGC
if IsFM then Inc(Result, IGAP + ROW_H); // + ряд SQL (только FM)
end;
// ряд вкладок OUT/IN + список устройств активной вкладки
fpAud: Result := ROW_H + IGAP +
Min(Max(1, AudRowCount), AUD_MAX_VIS) * AUD_ROW_H;
fpTxProf: Result := Min(ProfRowCount, PROF_MAX_VIS) * AUD_ROW_H;
else Result := 0;
end;
end;
function TVfoOverlay.ComputeHeight: Integer;
begin
if FPanel = fpNone then Result := OVL_BASE_H
else Result := OVL_BASE_H + IGAP + PanelContentH + PAD;
end;
procedure TVfoOverlay.SetPanel(P: TFlyPanel);
begin
if FPanel = P then FPanel := fpNone else FPanel := P;
if FPanel = fpAud then ScrollAudToCurrent; // сразу показать выбранное
if FPanel = fpTxProf then ScrollProfToCurrent;
Height := ComputeHeight;
RequestInvalidate;
end;
{ ---- Примитивы отрисовки ---- }
procedure TVfoOverlay.DrawButton(C: TCanvas; const R: TRect; const Lbl: string;
Active, Hot: Boolean; FontSz: Integer);
var
Brd: TColor;
begin
// Цвета берём из темы (как StyleButton на левой панели): рамка — по состоянию.
if Active then Brd := FTheme.BtnBorderActive else Brd := FTheme.BtnBorderNorm;
C.Font.Name := 'Courier New';
C.Font.Size := FontSz;
C.Font.Style := [];
// Общая отрисовка (FlatButton) — вид как у кнопок по всей программе.
PaintFlatButton(C, R, Lbl, Active, Hot,
FTheme.BtnNorm, FTheme.BtnActive, FTheme.BtnHot, Brd,
FTheme.BtnText, FTheme.BtnTextActive);
end;
procedure TVfoOverlay.DrawIndicator(C: TCanvas; X, Y: Integer; const Lbl: string;
On_: Boolean);
begin
C.Font.Name := 'Courier New';
C.Font.Size := 7;
C.Font.Style := [fsBold];
C.Brush.Style := bsClear;
if On_ then C.Font.Color := CLR_ACCENT else C.Font.Color := CLR_DIM;
C.TextOut(X, Y, Lbl);
end;
procedure TVfoOverlay.DrawBadge(C: TCanvas; const R: TRect; const Lbl: string;
BgColor, TxtColor: TColor);
var
TS: TTextStyle;
begin
C.Brush.Color := BgColor;
C.Brush.Style := bsSolid;
C.Pen.Style := psSolid;
C.Pen.Color := BgColor;
C.RoundRect(R.Left, R.Top, R.Right, R.Bottom, 4, 4);
C.Font.Name := 'Courier New';
C.Font.Size := 7;
C.Font.Style := [fsBold];
C.Font.Color := TxtColor;
C.Brush.Style := bsClear;
// Центровку доверяем виджетсету (TextRect со стилем) — ручной расчёт по
// TextWidth/Height на подменном шрифте уводил глиф влево-вверх.
FillChar(TS, SizeOf(TS), 0);
TS.Alignment := taCenter;
TS.Layout := tlCenter;
TS.SingleLine := True;
TS.Clipping := True;
C.TextRect(R, R.Left, R.Top, Lbl, TS);
end;
procedure TVfoOverlay.DrawSpeaker(C: TCanvas; X, Y: Integer; Muted: Boolean);
var
Pts: array[0..5] of TPoint;
Col: TColor;
begin
if Muted then Col := CLR_DIM else Col := CLR_TEXT;
Pts[0] := Point(X, Y+4);
Pts[1] := Point(X+3, Y+4);
Pts[2] := Point(X+7, Y);
Pts[3] := Point(X+7, Y+12);
Pts[4] := Point(X+3, Y+8);
Pts[5] := Point(X, Y+8);
C.Brush.Color := Col;
C.Brush.Style := bsSolid;
C.Pen.Color := Col;
C.Pen.Style := psSolid;
C.Polygon(Pts);
if Muted then
begin
C.Pen.Color := CLR_TX_ON;
C.Pen.Width := 2;
C.MoveTo(X+9, Y); C.LineTo(X+14, Y+12);
C.Pen.Width := 1;
end
else
begin
C.Pen.Color := CLR_ACCENT;
C.MoveTo(X+9, Y+3); C.LineTo(X+11, Y+6); C.LineTo(X+9, Y+9);
C.MoveTo(X+11, Y+1); C.LineTo(X+14, Y+6); C.LineTo(X+11, Y+11);
end;
end;
// Цвет S-метра по УРОВНЮ (dBm): тёмно-зелёный → зелёный → жёлтый → красный.
// Опоры привязаны к S-делениям, а не к длине бара: красный достигается уже
// к ~S9+18, чтобы сильные сигналы (S9+10) были ближе к красному.
function MeterGradColor(DB: Double): TColor;
const
DS: array[0..3] of Double = (-121, -85, -73, -55); // S1, S7, S9, S9+18
RS: array[0..3] of Integer = ( 0, 30, 245, 235);
GS: array[0..3] of Integer = ( 85, 205, 215, 35);
BS: array[0..3] of Integer = ( 0, 40, 0, 20);
var
I, R, G, B: Integer;
T: Double;
begin
if DB <= DS[0] then begin R := RS[0]; G := GS[0]; B := BS[0]; end
else if DB >= DS[3] then begin R := RS[3]; G := GS[3]; B := BS[3]; end
else
begin
I := 0;
while (I < 3) and (DB > DS[I+1]) do Inc(I);
T := (DB - DS[I]) / (DS[I+1] - DS[I]);
R := RS[I] + Round((RS[I+1] - RS[I]) * T);
G := GS[I] + Round((GS[I+1] - GS[I]) * T);
B := BS[I] + Round((BS[I+1] - BS[I]) * T);
end;
Result := TColor((B shl 16) or (G shl 8) or R);
end;
procedure TVfoOverlay.DrawMeterBar(C: TCanvas; X, Y, BW, BH: Integer);
const
DB_MIN = -127.0;
DB_MAX = -13.0;
S_POS: array[0..7] of Double = (-121,-109,-97,-85,-73,-53,-33,-13);
S_LBL: array[0..7] of string = ('1','3','5','7','9','+20','+40','+60');
var
I, BarEnd, PX, BarH, TW, Col: Integer;
T: Double;
begin
BarH := BH - 12;
C.Brush.Color := CLR_BAR_BG;
C.Brush.Style := bsSolid;
C.Pen.Style := psClear;
C.FillRect(Rect(X, Y, X+BW, Y+BarH));
T := Max(0.0, Min(1.0, (FSMeterDB - DB_MIN) / (DB_MAX - DB_MIN)));
BarEnd := Min(X + Round(T * BW), X + BW - 1);
// Плавный градиент по всей ширине: цвет колонки = позиция на шкале.
C.Brush.Style := bsSolid;
C.Pen.Style := psClear;
Col := X + 1;
while Col < BarEnd do
begin
C.Brush.Color := MeterGradColor(DB_MIN + (Col - X) / BW * (DB_MAX - DB_MIN));
C.FillRect(Rect(Col, Y+1, Col+1, Y+BarH-1));
Inc(Col);
end;
C.Pen.Style := psSolid;
C.Pen.Color := CLR_BORDER;
C.Brush.Style := bsClear;
C.Rectangle(X, Y, X+BW, Y+BarH);
C.Font.Name := 'Courier New';
C.Font.Size := 7;
C.Font.Style := [];
// Последнюю метку (+60 = правый край) не рисуем — вылезает за границу.
for I := 0 to High(S_POS) - 1 do
begin
T := (S_POS[I] - DB_MIN) / (DB_MAX - DB_MIN);
PX := X + Round(T * BW);
TW := C.TextWidth(S_LBL[I]);
if I < 4 then C.Font.Color := CLR_DIM else C.Font.Color := CLR_ACCENT;
C.Pen.Color := CLR_DIM;
C.MoveTo(PX, Y+BarH); C.LineTo(PX, Y+BarH+2);
C.TextOut(PX - TW div 2, Y+BarH+2, S_LBL[I]);
end;
end;
{ ---- Fly-out панели ---- }
procedure TVfoOverlay.DrawModePanel(C: TCanvas; X, Y, RowW: Integer);
var
I, Col, Row, BX, BY, BW, FiltN, FBX, FBRight, BV: Integer;
HR: TRect;
begin
BW := (RowW - (OVL_MODE_COLS - 1)*3) div OVL_MODE_COLS; // зазор 3
for I := 0 to OVL_MODE_COUNT - 1 do
begin
Col := I mod OVL_MODE_COLS; Row := I div OVL_MODE_COLS;
BX := X + Col * (BW + 3);
BY := Y + Row * (ROW_H + IGAP);
HR := Rect(BX, BY, BX + BW, BY + ROW_H);
DrawButton(C, HR, OVL_MODE_NAMES[I], I = FMode,
(FHotIdx >= 0) and (FHitActions[FHotIdx] = HIT_MODE_BASE - I), 7);
RegisterHit(HR, HIT_MODE_BASE - I);
end;
// Ряд фильтров текущего режима
FiltN := GetFilterCountForMode(FMode);
BY := Y + OVL_MODE_ROWS*(ROW_H + IGAP);
FBX := X;
for I := 0 to FiltN - 1 do
begin
FBRight := X + ((I+1) * RowW) div FiltN;
BV := GetFilterBWForMode(FMode, I);
HR := Rect(FBX, BY, FBRight - 2, BY + ROW_H);
DrawButton(C, HR, GetFilterLblForMode(FMode, I), BV = FFilterBW,
(FHotIdx >= 0) and (FHitActions[FHotIdx] = HIT_FILT_BASE + I), 7);
RegisterHit(HR, HIT_FILT_BASE + I);
FBX := FBRight;
end;
end;
procedure TVfoOverlay.DrawDspPanel(C: TCanvas; X, Y, RowW: Integer);
var
I, BX, BRight: Integer;
HR: TRect;
Lbl: string;
Act: Boolean;
const
TOG_HITS: array[0..3] of Integer = (HIT_NR, HIT_NB, HIT_SNB, HIT_ANF);
begin
// Ряд 1: NR / NB / SNB / ANF (тумблеры)
BX := X;
for I := 0 to 3 do
begin
BRight := X + ((I+1) * RowW) div 4;
HR := Rect(BX, Y, BRight - 2, Y + ROW_H);
case I of
0: begin
case FNRMode of 1: Lbl:='NR'; 2: Lbl:='NR2'; 3: Lbl:='NR3'; 4: Lbl:='NR4';
else Lbl:='NR'; end;
Act := FNRMode <> 0;
end;
1: begin if FNBMode = 2 then Lbl:='NB2' else Lbl:='NB'; Act := FNBMode <> 0; end;
2: begin Lbl:='SNB'; Act := FSNBOn; end;
else begin Lbl:='ANF'; Act := FANFOn; end;
end;
DrawButton(C, HR, Lbl, Act,
(FHotIdx >= 0) and (FHitActions[FHotIdx] = TOG_HITS[I]), 7);
RegisterHit(HR, TOG_HITS[I]);
BX := BRight;
end;
// Ряд 2: AGC режимы
Inc(Y, ROW_H + IGAP);
BX := X;
for I := 0 to 4 do
begin
BRight := X + ((I+1) * RowW) div 5;
HR := Rect(BX, Y, BRight - 2, Y + ROW_H);
DrawButton(C, HR, AGC_NAMES[I], I = FAGCMode,
(FHotIdx >= 0) and (FHitActions[FHotIdx] = HIT_AGC_PICK_BASE + I), 6);
RegisterHit(HR, HIT_AGC_PICK_BASE + I);
BX := BRight;
end;
// Ряд 3 (только FM): тумблер SQL + ползунок порога squelch
if IsFM then
begin
Inc(Y, ROW_H + IGAP);
// кнопка SQL слева (1/4 ширины)
BRight := X + RowW div 4;
HR := Rect(X, Y, BRight - 2, Y + ROW_H);
DrawButton(C, HR, 'SQL', FSQLOn,
(FHotIdx >= 0) and (FHitActions[FHotIdx] = HIT_SQL), 7);
RegisterHit(HR, HIT_SQL);
// ползунок порога справа
BX := BRight + 4;
FSqlTrack := Rect(BX, Y + ROW_H div 2 - 3, X + RowW, Y + ROW_H div 2 + 3);
C.Brush.Color := CLR_BTN_NORM; C.Brush.Style := bsSolid;
C.Pen.Color := CLR_BTN_NORM; C.Pen.Style := psSolid;
C.RoundRect(FSqlTrack.Left, FSqlTrack.Top, FSqlTrack.Right, FSqlTrack.Bottom, 3, 3);
BX := FSqlTrack.Left + Round(FSQLLevel/100.0 * (FSqlTrack.Right - FSqlTrack.Left));
if FSQLOn then
begin
C.Brush.Color := CLR_ACCENT; C.Pen.Color := CLR_ACCENT;
C.RoundRect(FSqlTrack.Left, FSqlTrack.Top, BX, FSqlTrack.Bottom, 3, 3);
end;
C.Brush.Color := CLR_TEXT; C.Pen.Color := CLR_TEXT;
C.Ellipse(BX-4, Y + ROW_H div 2 - 5, BX+4, Y + ROW_H div 2 + 5);
RegisterHit(Rect(FSqlTrack.Left - 4, Y, FSqlTrack.Right + 4, Y + ROW_H),
HIT_SQL_SLIDER);
end;
end;
function TVfoOverlay.AudRowCount: Integer;
begin
// У вкладки IN первая строка — «общий вход» (слайс) / «нет» (главный флаг).
if FAudInTab then Result := FInDevNames.Count + 1
else Result := FDevNames.Count;
end;
function TVfoOverlay.AudRowName(I: Integer): string;
begin
if not FAudInTab then Exit(FDevNames[I]);
if I = 0 then
begin
if FIsMain then Result := '(нет · мик радио)'
else Result := '(общий вход)';
Exit;
end;
Result := FInDevNames[I - 1];
end;
function TVfoOverlay.AudRowCurrent(I: Integer): Boolean;
begin
if FAudInTab then
begin
if I = 0 then Exit(FCurInDev = '');
Result := FInDevNames[I - 1] = FCurInDev;
end
else
// Пустое имя вывода = устройство по умолчанию (первая строка списка).
Result := (FDevNames[I] = FCurDev) or ((FCurDev = '') and (I = 0));
end;
procedure TVfoOverlay.ScrollAudToCurrent;
var
I, Cnt, MaxScroll: Integer;
begin
FAudScroll := 0;
Cnt := AudRowCount;
if Cnt <= AUD_MAX_VIS then Exit;
for I := 0 to Cnt - 1 do
if AudRowCurrent(I) then
begin
// Ставим выбранную строку примерно в середину видимого окна.
MaxScroll := Cnt - AUD_MAX_VIS;
FAudScroll := Min(Max(0, I - AUD_MAX_VIS div 2), MaxScroll);
Exit;
end;
end;
function TVfoOverlay.ProfRowCount: Integer;
// Строка 0 — псевдо «— как активный», дальше сами профили.
begin
Result := FProfNames.Count + 1;
end;
function TVfoOverlay.ProfRowName(I: Integer): string;
begin
if I <= 0 then Result := 'As active'
else if I - 1 < FProfNames.Count then Result := FProfNames[I - 1]
else Result := '';
end;
procedure TVfoOverlay.ScrollProfToCurrent;
var Cnt, Cur: Integer;
begin
FProfScroll := 0;
Cnt := ProfRowCount;
if Cnt <= PROF_MAX_VIS then Exit;
Cur := FProfBound + 1; // -1 → строка 0
FProfScroll := Min(Max(0, Cur - PROF_MAX_VIS div 2), Cnt - PROF_MAX_VIS);
end;
procedure TVfoOverlay.SetTXProfiles(ANames: TStrings; ABound: Integer);
var Same: Boolean;
begin
Same := (ABound = FProfBound) and (ANames <> nil) and
(ANames.Count = FProfNames.Count) and (ANames.Text = FProfNames.Text);
if Same then Exit;
if ANames <> nil then FProfNames.Assign(ANames) else FProfNames.Clear;
if (ABound < -1) or (ABound >= FProfNames.Count) then ABound := -1;
FProfBound := ABound;
if FPanel = fpTxProf then
begin
ScrollProfToCurrent;
Height := ComputeHeight; // список мог стать длиннее/короче
end;
RequestInvalidate;
end;
procedure TVfoOverlay.DrawProfPanel(C: TCanvas; X, Y, RowW: Integer);
const
ARROW_W = 15;
var
I, Slot, VisN, RowWidth, MaxScroll, Cnt: Integer;
HR: TRect;
Nm, Full: string;
Cur, Scrollable: Boolean;
begin
C.Font.Name := 'Courier New';
C.Font.Size := 7;
C.Font.Style := [];
Cnt := ProfRowCount;
VisN := Min(Cnt, PROF_MAX_VIS);
FProfListRect := Rect(X, Y, X + RowW, Y + VisN * AUD_ROW_H);
Scrollable := Cnt > PROF_MAX_VIS;
MaxScroll := Cnt - VisN;
if FProfScroll > MaxScroll then FProfScroll := MaxScroll;
if FProfScroll < 0 then FProfScroll := 0;
if Scrollable then RowWidth := RowW - ARROW_W - 2 else RowWidth := RowW;
for Slot := 0 to VisN - 1 do
begin
I := FProfScroll + Slot;
HR := Rect(X, Y + Slot*AUD_ROW_H, X + RowWidth, Y + Slot*AUD_ROW_H + 16);
Full := ProfRowName(I);
Nm := Full;
Cur := (I - 1) = FProfBound;
if Cur then C.Brush.Color := CLR_ACCENT_D
else if (FHotIdx >= 0) and (FHitActions[FHotIdx] = HIT_PROF_BASE + I) then
C.Brush.Color := CLR_BTN_HOT
else C.Brush.Color := CLR_BTN_NORM;
C.Brush.Style := bsSolid;
C.Pen.Color := C.Brush.Color;
C.Pen.Style := psSolid;
C.RoundRect(HR.Left, HR.Top, HR.Right, HR.Bottom, 4, 4);
if Cur then C.Font.Color := CLR_ACCENT else C.Font.Color := CLR_TEXT;
C.Brush.Style := bsClear;
while (Length(Nm) > 1) and (C.TextWidth(Nm + '…') > RowWidth - 8) do
SetLength(Nm, Length(Nm) - 1);
if Nm <> Full then Nm := Nm + '…';
C.TextOut(HR.Left + 4, HR.Top + (16 - C.TextHeight('A')) div 2, Nm);
RegisterHit(HR, HIT_PROF_BASE + I);
end;
if not Scrollable then Exit;
DrawScrollArrows(C, X, Y, RowW, RowWidth, VisN * AUD_ROW_H - 1,
FProfScroll > 0, FProfScroll < MaxScroll,
HIT_PROF_UP, HIT_PROF_DN);
end;
procedure TVfoOverlay.DrawScrollArrows(C: TCanvas; X, Y, RowW, RowWidth,
PanelH: Integer; CanUp, CanDn: Boolean; HitUp, HitDn: Integer);
// Полоса прокрутки справа от списка: ▲ / ▼. Общая для AUD и TX-профилей.
var
HR: TRect;
ArrowX, MidY, CY: Integer;
Tri: array[0..2] of TPoint;
begin
ArrowX := X + RowWidth + 2;
MidY := Y + PanelH div 2;
HR := Rect(ArrowX, Y, X + RowW, MidY - 1);
C.Brush.Color := CLR_BTN_NORM; C.Brush.Style := bsSolid;
C.Pen.Color := CLR_BTN_NORM; C.Pen.Style := psSolid;
C.RoundRect(HR.Left, HR.Top, HR.Right, HR.Bottom, 4, 4);
CY := (HR.Top + HR.Bottom) div 2;
if CanUp then C.Brush.Color := CLR_TEXT else C.Brush.Color := CLR_DIM;
C.Pen.Color := C.Brush.Color;
Tri[0] := Point((HR.Left+HR.Right) div 2, CY - 3);
Tri[1] := Point((HR.Left+HR.Right) div 2 - 4, CY + 2);
Tri[2] := Point((HR.Left+HR.Right) div 2 + 4, CY + 2);
C.Polygon(Tri);
RegisterHit(HR, HitUp);
HR := Rect(ArrowX, MidY + 1, X + RowW, Y + PanelH);
C.Brush.Color := CLR_BTN_NORM; C.Brush.Style := bsSolid;
C.Pen.Color := CLR_BTN_NORM; C.Pen.Style := psSolid;
C.RoundRect(HR.Left, HR.Top, HR.Right, HR.Bottom, 4, 4);
CY := (HR.Top + HR.Bottom) div 2;
if CanDn then C.Brush.Color := CLR_TEXT else C.Brush.Color := CLR_DIM;
C.Pen.Color := C.Brush.Color;
Tri[0] := Point((HR.Left+HR.Right) div 2, CY + 3);
Tri[1] := Point((HR.Left+HR.Right) div 2 - 4, CY - 2);
Tri[2] := Point((HR.Left+HR.Right) div 2 + 4, CY - 2);
C.Polygon(Tri);
RegisterHit(HR, HitDn);
end;
procedure TVfoOverlay.DrawAudPanel(C: TCanvas; X, Y, RowW: Integer);
const
ARROW_W = 15;
var
I, Slot, VisN, RowWidth, MaxScroll, Cnt: Integer;
HR, TabR: TRect;
Nm: string;
Cur, Scrollable: Boolean;
begin
// Ряд вкладок: OUT (куда отдаём звук) / IN (откуда берём модуляцию).
TabR := Rect(X, Y, X + (RowW - 4) div 2, Y + ROW_H);
DrawButton(C, TabR, 'OUT', not FAudInTab,
(FHotIdx >= 0) and (FHitActions[FHotIdx] = HIT_AUD_TAB_OUT), 7);
RegisterHit(TabR, HIT_AUD_TAB_OUT);
TabR := Rect(TabR.Right + 4, Y, X + RowW, Y + ROW_H);
DrawButton(C, TabR, 'IN', FAudInTab,
(FHotIdx >= 0) and (FHitActions[FHotIdx] = HIT_AUD_TAB_IN), 7);
RegisterHit(TabR, HIT_AUD_TAB_IN);
Inc(Y, ROW_H + IGAP);
C.Font.Name := 'Courier New';
C.Font.Size := 7;
C.Font.Style := [];
Cnt := AudRowCount;
if Cnt = 0 then
begin
C.Font.Color := CLR_DIM;
C.Brush.Style := bsClear;
C.TextOut(X, Y + 2, '(нет устройств)');
Exit;
end;
VisN := Min(Cnt, AUD_MAX_VIS);
FAudListRect := Rect(X, Y, X + RowW, Y + VisN*AUD_ROW_H); // зона колёсика
Scrollable := Cnt > AUD_MAX_VIS;
MaxScroll := Cnt - VisN;
if FAudScroll > MaxScroll then FAudScroll := MaxScroll;
if FAudScroll < 0 then FAudScroll := 0;
if Scrollable then RowWidth := RowW - ARROW_W - 2 else RowWidth := RowW;
for Slot := 0 to VisN - 1 do
begin
I := FAudScroll + Slot;
HR := Rect(X, Y + Slot*AUD_ROW_H, X + RowWidth, Y + Slot*AUD_ROW_H + 16);
Nm := AudRowName(I);
Cur := AudRowCurrent(I);
if Cur then C.Brush.Color := CLR_ACCENT_D
else if (FHotIdx >= 0) and (FHitActions[FHotIdx] = HIT_DEV_BASE + I) then
C.Brush.Color := CLR_BTN_HOT
else C.Brush.Color := CLR_BTN_NORM;
C.Brush.Style := bsSolid;
C.Pen.Color := C.Brush.Color;
C.Pen.Style := psSolid;
C.RoundRect(HR.Left, HR.Top, HR.Right, HR.Bottom, 4, 4);
if Cur then C.Font.Color := CLR_ACCENT else C.Font.Color := CLR_TEXT;
C.Brush.Style := bsClear;
if C.TextWidth(Nm) > RowWidth - 8 then
while (Length(Nm) > 1) and (C.TextWidth(Nm + '…') > RowWidth - 8) do
SetLength(Nm, Length(Nm) - 1);
if Nm <> AudRowName(I) then Nm := Nm + '…';
C.TextOut(HR.Left + 4, HR.Top + (16 - C.TextHeight('A')) div 2, Nm);
RegisterHit(HR, HIT_DEV_BASE + I);
end;
if not Scrollable then Exit;
DrawScrollArrows(C, X, Y, RowW, RowWidth, VisN * AUD_ROW_H - 1,
FAudScroll > 0, FAudScroll < MaxScroll,
HIT_AUD_UP, HIT_AUD_DN);
end;
{ ---- Основная отрисовка ---- }
procedure TVfoOverlay.DrawSelf(C: TCanvas; W, H: Integer);
var
FreqStr, SStr, BWStr, DMRStr, TxStr, PStr: string;
CurY, X, RW, i, DMRX, DMRRight, TxRight, TxW: Integer;
ProfLeft, ProfRight, ProfW: Integer;
R: TRect;
Tri3: array[0..2] of TPoint;
BarL, BarW, MeterX: Integer;
begin
FHitCount := 0;
FAudListRect := Rect(0, 0, 0, 0); // выставит DrawAudPanel, если панель открыта
// Фон карточки
C.Brush.Color := CLR_CARD_BG;
C.Brush.Style := bsSolid;
C.Pen.Color := CLR_BORDER;
C.Pen.Style := psSolid;
C.Pen.Width := 1;
C.RoundRect(0, 0, W, H, 7, 7);
X := PAD;
RW := W - PAD*2;
CurY := PAD;
{ --- Шапка: главный [A] BW ... SPLIT [TX]; слайс [B] BW ... [RXM] [TX] [×] --- }
DrawBadge(C, Rect(X, CurY, X+16, CurY+HDR_BADGE_H), FSlice, SliceColor(FSlice), CLR_BADGE_TXT);
RegisterHit(Rect(X, CurY, X+16, CurY+HDR_BADGE_H), HIT_SLICE_SEL); // бейдж = выбор слайса
// Кнопка закрытия × — только у неглавных слайсов (главный приёмник не закрыть).
// Она крайняя справа, TX и RXM выстраиваются левее неё.
TxRight := W - PAD;
// Слайс с Auto TX (PTT по своему CAT-порту сама берёт передачу) подписан
// AutoTX — бейдж шире, остальные едут левее.
TxStr := 'TX';
TxW := 26;
if FAutoTx and (not FIsMain) then begin TxStr := 'AutoTX'; TxW := 46; end;
if not FIsMain then
begin
R := Rect(W - PAD - 14, CurY, W - PAD, CurY + HDR_BADGE_H);
DrawBadge(C, R, #$C3#$97, CLR_BTN_NORM, CLR_DIM); // UTF-8 '×'
RegisterHit(R, HIT_CLOSE);
TxRight := W - PAD - 14 - 6;
// RXM — приём слайса на СВОЕЙ передаче. Подсвечен (умолч.) = глушим, т.е.
// себя не слышим; погашен = self-monitor (свой downlink через транспондер).
// У главного приёмника то же самое делает кнопка RX MUTE на левой панели.
R := Rect(TxRight - TxW - 4 - 32, CurY, TxRight - TxW - 4, CurY + HDR_BADGE_H);
if FRxMuteTx then DrawBadge(C, R, 'RXM', CLR_ACCENT, CLR_BADGE_TXT)
else DrawBadge(C, R, 'RXM', CLR_BTN_NORM, CLR_DIM);
RegisterHit(R, HIT_RXMUTE);
end;
// ширина фильтра
if FFilterBW >= 1000 then BWStr := Format('%.1fk', [FFilterBW/1000.0])
else BWStr := IntToStr(FFilterBW);
C.Font.Name := 'Courier New'; C.Font.Size := 8; C.Font.Style := [];
C.Font.Color := CLR_DIM; C.Brush.Style := bsClear;
C.TextOut(X + 22, CurY + 1, BWStr);
ProfLeft := X + 22 + C.TextWidth(BWStr) + 8;
// Краткий статус дополнительного DMR-слайса. Он обновляется только при
// устойчивом изменении sync/slot/CC/TG, поэтому не заставляет GL-текстуру
// перезаливаться на каждом burst.
if (not FIsMain) and (FMode = OVL_MODE_DMR) and (FDMRText <> '') then
begin
DMRStr := FDMRText;
DMRX := X + 22 + C.TextWidth(BWStr) + 8;
DMRRight := TxRight - TxW - 4 - 32 - 5; // до бейджей RXM, TX и ×
while (Length(DMRStr) > 1) and
(C.TextWidth(DMRStr) > DMRRight - DMRX) do
SetLength(DMRStr, Length(DMRStr) - 1);
if FDMRSynced then C.Font.Color := CLR_ACCENT
else C.Font.Color := CLR_SVAL;
C.TextOut(DMRX, CurY + 1, DMRStr);
end;
// TX badge: a DMR slice is visibly disabled and has no hit target.
R := Rect(TxRight - TxW, CurY, TxRight, CurY + HDR_BADGE_H);
if not TxSelectionAllowed then
DrawBadge(C, R, TxStr, CLR_CARD_BG, CLR_DIM)
else if FTx then DrawBadge(C, R, TxStr, CLR_TX_ON, clWhite)
else if FTxSel then DrawBadge(C, R, TxStr, CLR_TX_SEL, CLR_BADGE_TXT)
else DrawBadge(C, R, TxStr, CLR_BTN_NORM, CLR_DIM);
if TxSelectionAllowed then RegisterHit(R, HIT_TX);
// SPLIT (левее TX) — кликабельно; только у главного: split = TX на VFO B,
// у слайса своего B-VFO нет (кросс-частотный TX делается TX-бейджем слайса).
if FIsMain then
begin
R := Rect(W - PAD - 26 - 44, CurY, W - PAD - 26 - 4, CurY + HDR_BADGE_H);
if FSplit then DrawBadge(C, R, 'SPLIT', CLR_SPLIT_ON, CLR_BADGE_TXT)
else DrawBadge(C, R, 'SPLIT', CLR_BTN_NORM, CLR_DIM);
RegisterHit(R, HIT_SPLIT);
end;
// Пилюля TX-профиля этого флага — сразу за шириной фильтра. Правая граница:
// до SPLIT (главный) либо до RXM (слайс). У DMR передачи нет вовсе, у FMRAW
// профиль ни на что не влияет (ApplyTXModeSettings глушит всю обработку —
// компрессор, leveler, ALC, phase rot, EQ, mic gain), у пустого списка
// выбирать нечего — не рисуем. Тесная шапка (узкий флаг) — тоже.
if FIsMain then ProfRight := W - PAD - 26 - 44 - 6
else ProfRight := TxRight - TxW - 4 - 32 - 6;
if (FProfNames.Count > 0) and (FMode <> OVL_MODE_DMR) and
(FMode <> OVL_MODE_FMRAW) and
(ProfRight - ProfLeft >= PROF_PILL_MIN) then
begin
ProfW := Min(PROF_PILL_W, ProfRight - ProfLeft);
R := Rect(ProfLeft, CurY, ProfLeft + ProfW, CurY + HDR_BADGE_H);
if FProfBound >= 0 then C.Brush.Color := CLR_ACCENT_D
else if (FHotIdx >= 0) and (FHitActions[FHotIdx] = HIT_TXPROF) then
C.Brush.Color := CLR_BTN_HOT
else C.Brush.Color := CLR_BTN_NORM;
C.Brush.Style := bsSolid;
C.Pen.Color := C.Brush.Color; C.Pen.Style := psSolid;
C.RoundRect(R.Left, R.Top, R.Right, R.Bottom, 4, 4);
if FProfBound >= 0 then PStr := ProfRowName(FProfBound + 1)
else PStr := 'Profile'; // привязки нет — просто ярлык
C.Font.Size := 7;
if FProfBound >= 0 then C.Font.Color := CLR_ACCENT else C.Font.Color := CLR_DIM;
C.Brush.Style := bsClear;
while (Length(PStr) > 1) and (C.TextWidth(PStr) > ProfW - 14) do
SetLength(PStr, Length(PStr) - 1);
C.TextOut(R.Left + 4, R.Top + (HDR_BADGE_H - C.TextHeight('A')) div 2, PStr);
// Треугольник «список» справа — рисуем, а не пишем: в Courier New
// юникодных стрелок может не оказаться.
C.Brush.Color := C.Font.Color; C.Brush.Style := bsSolid;
C.Pen.Color := C.Font.Color;
Tri3[0] := Point(R.Right - 9, CurY + 5);
Tri3[1] := Point(R.Right - 3, CurY + 5);
Tri3[2] := Point(R.Right - 6, CurY + 9);
C.Polygon(Tri3);
C.Font.Size := 8;
RegisterHit(R, HIT_TXPROF);
end;
Inc(CurY, HDR_H);
{ --- Метр: индикаторы + сигнал-бар --- }
DrawIndicator(C, X, CurY + 2, 'NR', FNRMode <> 0);
DrawIndicator(C, X + 24, CurY + 2, 'NB', FNBMode <> 0);
DrawIndicator(C, X + 48, CurY + 2, 'ANF', FANFOn);
DrawIndicator(C, X + 80, CurY + 2, 'SNB', FSNBOn);
MeterX := X + 112;
FMeterRect := Rect(MeterX, CurY, W - PAD, CurY + MET_H); // для RefreshMeter
DrawMeterBar(C, MeterX, CurY, W - PAD - MeterX, MET_H);
Inc(CurY, MET_H);
{ --- Частота + S --- }
FSRowTop := CurY; // для RefreshMeter
FreqStr := FormatFreq(FVfoHz);
C.Font.Name := 'Courier New'; C.Font.Size := 13; C.Font.Style := [fsBold];
C.Font.Color := CLR_FREQ; C.Brush.Style := bsClear;
C.TextOut(X + 2, CurY, FreqStr);
SStr := DBmToSLabel(FSMeterDB);
C.Font.Size := 9; C.Font.Color := CLR_SVAL;
C.TextOut(W - PAD - C.TextWidth(SStr) - 2, CurY + 6, SStr);
FLastSTextRect := Rect(W - PAD - C.TextWidth(SStr) - 3, CurY + 4, W - PAD, CurY + FRQ_H);
Inc(CurY, FRQ_H);
{ --- Нижний бар: 🔊 vol | MODE DSP AUD --- }
DrawSpeaker(C, X, CurY + (BAR_H-12) div 2, FMuted);
RegisterHit(Rect(X-1, CurY, X+16, CurY+BAR_H), HIT_MUTE);
// трек громкости
BarL := X + 20;
BarW := 56;
FVolTrack := Rect(BarL, CurY + BAR_H div 2 - 3, BarL + BarW, CurY + BAR_H div 2 + 3);
C.Brush.Color := CLR_BTN_NORM; C.Brush.Style := bsSolid;
C.Pen.Color := CLR_BTN_NORM; C.Pen.Style := psSolid;
C.RoundRect(FVolTrack.Left, FVolTrack.Top, FVolTrack.Right, FVolTrack.Bottom, 3, 3);
i := BarL + Round(FVolume/100.0 * BarW);
if not FMuted then
begin
C.Brush.Color := CLR_ACCENT; C.Pen.Color := CLR_ACCENT;
C.RoundRect(FVolTrack.Left, FVolTrack.Top, i, FVolTrack.Bottom, 3, 3);
end;
// ручка
C.Brush.Color := CLR_TEXT; C.Pen.Color := CLR_TEXT;
C.Ellipse(i-4, CurY + BAR_H div 2 - 5, i+4, CurY + BAR_H div 2 + 5);
RegisterHit(Rect(BarL-4, CurY, BarL + BarW + 4, CurY + BAR_H), HIT_VOL);
// 3 кнопки бара справа от трека
BarL := X + 20 + BarW + 8;
RW := (W - PAD) - BarL;
R := Rect(BarL, CurY + 2, BarL + (RW - 8) div 3, CurY + BAR_H - 2);
DrawButton(C, R, 'MODE', FPanel = fpMode,
(FHotIdx >= 0) and (FHitActions[FHotIdx] = HIT_BAR_MODE), 7);
RegisterHit(R, HIT_BAR_MODE);
R := Rect(R.Right + 4, CurY + 2, R.Right + 4 + (RW - 8) div 3, CurY + BAR_H - 2);
DrawButton(C, R, 'DSP', FPanel = fpDsp,
(FHotIdx >= 0) and (FHitActions[FHotIdx] = HIT_BAR_DSP), 7);
RegisterHit(R, HIT_BAR_DSP);
R := Rect(R.Right + 4, CurY + 2, W - PAD, CurY + BAR_H - 2);
DrawButton(C, R, 'AUD', FPanel = fpAud,
(FHotIdx >= 0) and (FHitActions[FHotIdx] = HIT_BAR_AUD), 7);
RegisterHit(R, HIT_BAR_AUD);
Inc(CurY, BAR_H);
{ --- Fly-out --- }
if FPanel <> fpNone then
begin
Inc(CurY, IGAP);
C.Pen.Color := CLR_SEP; C.Pen.Style := psSolid;
C.MoveTo(PAD, CurY); C.LineTo(W - PAD, CurY);
Inc(CurY, IGAP + 1);
case FPanel of
fpMode: DrawModePanel(C, PAD, CurY, W - PAD*2);
fpDsp: DrawDspPanel(C, PAD, CurY, W - PAD*2);
fpAud: DrawAudPanel(C, PAD, CurY, W - PAD*2);
fpTxProf: DrawProfPanel(C, PAD, CurY, W - PAD*2);
end;
end;
end;
procedure TVfoOverlay.Paint;
begin
DrawSelf(Canvas, Width, Height);
end;
function TVfoOverlay.EnsureRendered: TBitmap;
begin
if (FCacheBitmap.Width <> Width) or (FCacheBitmap.Height <> Height) then
begin
FCacheBitmap.SetSize(Width, Height);
FCacheDirty := True;
end;
if FCacheDirty then
begin
FCacheBitmap.Canvas.Brush.Color := KEY_COLOR;
FCacheBitmap.Canvas.Brush.Style := bsSolid;
FCacheBitmap.Canvas.Pen.Style := psClear;
FCacheBitmap.Canvas.FillRect(Rect(0, 0, Width, Height));
DrawSelf(FCacheBitmap.Canvas, Width, Height);
FCacheDirty := False;
FMeterDirty := False;
end
else if FMeterDirty then
begin
RefreshMeter(FCacheBitmap.Canvas); // дешёвый апдейт: только полоска метра
FMeterDirty := False;
end;
Result := FCacheBitmap;
end;
procedure TVfoOverlay.DrawOverlay(Target: TBitmap; W, H: Integer);
begin
if (not Visible) or (Target = nil) or (Width <= 0) or (Height <= 0) then Exit;
if (Left >= W) or (Top >= H) then Exit;
EnsureRendered;
BlendBitmapKey(Target, FCacheBitmap, Left, Top, VFO_OVERLAY_ALPHA);
end;
procedure TVfoOverlay.DrawOverlayBitmap(Target: TBitmap);
begin
if (Target = nil) or (Width <= 0) or (Height <= 0) then Exit;
Target.PixelFormat := pf32bit;
Target.SetSize(Width, Height);
Target.Canvas.Brush.Color := KEY_COLOR;
Target.Canvas.Brush.Style := bsSolid;
Target.Canvas.Pen.Style := psClear;
Target.Canvas.FillRect(Rect(0, 0, Width, Height));
DrawSelf(Target.Canvas, Width, Height);
FCacheDirty := False;
FMeterDirty := False;
end;
{ ---- Обработка хитов ---- }
procedure TVfoOverlay.DoHit(HV: Integer);
var
NewMode, NewBW: Integer;
begin
// Кнопки бара
case HV of
HIT_BAR_MODE: begin SetPanel(fpMode); Exit; end;
HIT_BAR_DSP: begin SetPanel(fpDsp); Exit; end;
HIT_BAR_AUD: begin SetPanel(fpAud); Exit; end;
HIT_MUTE: begin FMuted := not FMuted; RequestInvalidate;
if Assigned(FOnMute) then FOnMute(Self); Exit; end;
HIT_SQL: begin FSQLOn := not FSQLOn; RequestInvalidate;
if Assigned(FOnSquelchChange) then
FOnSquelchChange(FSQLOn, FSQLLevel); Exit; end;
HIT_SPLIT: begin if Assigned(FOnSplit) then FOnSplit(Self); Exit; end;
HIT_TX: begin
if TxSelectionAllowed and Assigned(FOnTxSelect) then
FOnTxSelect(Self);
Exit;
end;
HIT_RXMUTE: begin FRxMuteTx := not FRxMuteTx; RequestInvalidate;
if Assigned(FOnRxMuteTx) then FOnRxMuteTx(Self); Exit; end;
HIT_TXPROF: begin SetPanel(fpTxProf); Exit; end;
HIT_PROF_UP: begin if FProfScroll > 0 then Dec(FProfScroll);
RequestInvalidate; Exit; end;
HIT_PROF_DN: begin if FProfScroll < ProfRowCount - PROF_MAX_VIS then
Inc(FProfScroll);
RequestInvalidate; Exit; end;
HIT_SLICE_SEL: begin if Assigned(FOnSliceSelect) then FOnSliceSelect(Self); Exit; end;
HIT_CLOSE: begin if Assigned(FOnClose) then FOnClose(Self); Exit; end;
HIT_AUD_UP: begin if FAudScroll > 0 then Dec(FAudScroll);
RequestInvalidate; Exit; end;
HIT_AUD_DN: begin if FAudScroll < AudRowCount - AUD_MAX_VIS then
Inc(FAudScroll);
RequestInvalidate; Exit; end;
HIT_AUD_TAB_OUT,
HIT_AUD_TAB_IN:
begin
if FAudInTab <> (HV = HIT_AUD_TAB_IN) then
begin
FAudInTab := HV = HIT_AUD_TAB_IN;
ScrollAudToCurrent; // список другой длины
Height := ComputeHeight;
RequestInvalidate;
end;
Exit;
end;
end;
// Строка списка TX-профилей: 0 = «как активный» (-1), дальше индексы профилей.
if HV >= HIT_PROF_BASE then
begin
NewMode := HV - HIT_PROF_BASE - 1; // -1 = «как активный»
if (NewMode < -1) or (NewMode >= FProfNames.Count) then Exit;
FProfBound := NewMode;
FPanel := fpNone; Height := ComputeHeight;
RequestInvalidate;
if Assigned(FOnTXProfile) then FOnTXProfile(NewMode);
Exit;
end;
// Устройство аудио (строка активной вкладки OUT/IN)
if HV >= HIT_DEV_BASE then
begin
NewMode := HV - HIT_DEV_BASE; // индекс строки
if (NewMode < 0) or (NewMode >= AudRowCount) then Exit;
if FAudInTab then
begin
// Строка 0 = «общий вход» (слайс) / «нет устройства» (главный флаг).
if NewMode = 0 then FCurInDev := ''
else FCurInDev := FInDevNames[NewMode - 1];
FPanel := fpNone; Height := ComputeHeight;
RequestInvalidate;
if Assigned(FOnAudioInDevice) then
begin
if NewMode = 0 then FOnAudioInDevice(-1, '')
else FOnAudioInDevice(FInDevIdx[NewMode - 1], FInDevNames[NewMode - 1]);
end;
end
else
begin
FCurDev := FDevNames[NewMode];
FPanel := fpNone; Height := ComputeHeight;
RequestInvalidate;
if Assigned(FOnAudioDevice) then
FOnAudioDevice(FDevIdx[NewMode], FDevNames[NewMode]);
end;
Exit;
end;
// AGC-режим
if HV >= HIT_AGC_PICK_BASE then
begin
FAGCMode := HV - HIT_AGC_PICK_BASE;
RequestInvalidate;
if Assigned(FOnAGCChange) then FOnAGCChange(FAGCMode);
Exit;
end;
// DSP-тумблеры (отрицательные HV — нельзя через set/in)
if (HV = HIT_NR) or (HV = HIT_NB) or (HV = HIT_SNB) or (HV = HIT_ANF) then
begin
case HV of
HIT_NR: FNRMode := (FNRMode + 1) mod 5;
HIT_NB: FNBMode := (FNBMode + 1) mod 3;
HIT_SNB: FSNBOn := not FSNBOn;
HIT_ANF: FANFOn := not FANFOn;
end;
RequestInvalidate;
if Assigned(FOnDSPChange) then FOnDSPChange(FNRMode, FNBMode, FSNBOn, FANFOn);
Exit;
end;
// Режим
if (HV <= HIT_MODE_BASE) and (HV > HIT_MODE_BASE - OVL_MODE_COUNT) then
begin
NewMode := HIT_MODE_BASE - HV;
if NewMode <> FMode then
begin
FModeFilter[FMode] := FFilterBW;
FMode := NewMode;
if FMode = OVL_MODE_DMR then FFilterBW := 12500
else FFilterBW := FModeFilter[FMode];
Height := ComputeHeight; // число фильтров могло смениться (FM=2, WFM=3)
RequestInvalidate;
if Assigned(FOnSelect) then FOnSelect(FMode, FFilterBW);
end;
Exit;
end;
// Фильтр текущего режима (по индексу кнопки, не по BW)
if (HV >= HIT_FILT_BASE) and
(HV < HIT_FILT_BASE + GetFilterCountForMode(FMode)) then
begin
NewBW := GetFilterBWForMode(FMode, HV - HIT_FILT_BASE);
if NewBW <> FFilterBW then
begin
FFilterBW := NewBW;
FModeFilter[FMode] := FFilterBW;
RequestInvalidate;
if Assigned(FOnSelect) then FOnSelect(FMode, FFilterBW);
end;
end;
end;
procedure TVfoOverlay.ApplyVolFromX(LX: Integer);
var
V: Integer;
begin
if FVolTrack.Right <= FVolTrack.Left then Exit;
V := Round((LX - FVolTrack.Left) / (FVolTrack.Right - FVolTrack.Left) * 100);
if V < 0 then V := 0 else if V > 100 then V := 100;
if V <> FVolume then
begin
FVolume := V;
if FMuted then FMuted := False;
RequestInvalidate;
if Assigned(FOnVolume) then FOnVolume(FVolume);
end;
end;
procedure TVfoOverlay.ApplySqlFromX(LX: Integer);
var
V: Integer;
begin
if FSqlTrack.Right <= FSqlTrack.Left then Exit;
V := Round((LX - FSqlTrack.Left) / (FSqlTrack.Right - FSqlTrack.Left) * 100);
if V < 0 then V := 0 else if V > 100 then V := 100;
if V <> FSQLLevel then
begin
FSQLLevel := V;
RequestInvalidate;
if Assigned(FOnSquelchChange) then FOnSquelchChange(FSQLOn, FSQLLevel);
end;
end;
{ ---- Внешний ввод (маршрутизируется MainForm поверх спектра) ---- }
function TVfoOverlay.HandleMouseDown(Button: TMouseButton; X, Y: Integer): Boolean;
begin
Result := False;
if not Visible then Exit;
if (X < Left) or (Y < Top) or (X >= Left + Width) or (Y >= Top + Height) then Exit;
MouseDown(Button, [], X - Left, Y - Top);
Result := True;
end;
function TVfoOverlay.HandleMouseMove(X, Y: Integer): Boolean;
var
I, OldHot, LX, LY: Integer;
begin
Result := False;
if not Visible then Exit;
LX := X - Left; LY := Y - Top;
if FVolDrag then
begin
ApplyVolFromX(LX);
Result := True;
Exit;
end;
if FSqlDrag then
begin
ApplySqlFromX(LX);
Result := True;
Exit;
end;
OldHot := FHotIdx;
FHotIdx := -1;
if (LX >= 0) and (LY >= 0) and (LX < Width) and (LY < Height) then
for I := 0 to FHitCount - 1 do
if PtInRect(FHitRects[I], Point(LX, LY)) then
begin FHotIdx := I; Break; end;
if FHotIdx <> OldHot then
begin
RequestInvalidate;
Result := True;
end;
end;
function TVfoOverlay.HandleMouseUp(X, Y: Integer): Boolean;
begin
Result := False;
if not Visible then Exit;
if FVolDrag then begin FVolDrag := False; Result := True; Exit; end;
if FSqlDrag then begin FSqlDrag := False; Result := True; Exit; end;
if (X >= Left) and (Y >= Top) and (X < Left + Width) and (Y < Top + Height) then
Result := True; // клик был по накладке — не пускаем его в спектр
end;
function TVfoOverlay.HandleMouseWheel(X, Y, WheelDelta: Integer): Boolean;
var
MaxScroll, Old: Integer;
begin
Result := False;
if not Visible then Exit;
if FPanel = fpTxProf then
begin
if not PtInRect(FProfListRect, Point(X - Left, Y - Top)) then Exit;
Result := True;
MaxScroll := ProfRowCount - PROF_MAX_VIS;
if MaxScroll <= 0 then Exit;
Old := FProfScroll;
if WheelDelta > 0 then Dec(FProfScroll) else Inc(FProfScroll);
FProfScroll := EnsureRange(FProfScroll, 0, MaxScroll);
if FProfScroll <> Old then RequestInvalidate;
Exit;
end;
if FPanel <> fpAud then Exit;
if not PtInRect(FAudListRect, Point(X - Left, Y - Top)) then Exit;
Result := True; // зона наша — колесо в спектр не пускаем
MaxScroll := AudRowCount - AUD_MAX_VIS;
if MaxScroll <= 0 then Exit; // список целиком виден — крутить нечего
Old := FAudScroll;
if WheelDelta > 0 then Dec(FAudScroll) else Inc(FAudScroll);
FAudScroll := EnsureRange(FAudScroll, 0, MaxScroll);
if FAudScroll <> Old then RequestInvalidate;
end;
function TVfoOverlay.HandleMouseLeave: Boolean;
begin
Result := False;
FVolDrag := False;
FSqlDrag := False;
if FHotIdx >= 0 then
begin
FHotIdx := -1;
RequestInvalidate;
Result := True;
end;
end;
procedure TVfoOverlay.MouseDown(Button: TMouseButton; Shift: TShiftState;
X, Y: Integer);
var
I, HV: Integer;
begin
inherited;
if Button <> mbLeft then Exit;
for I := 0 to FHitCount - 1 do
if PtInRect(FHitRects[I], Point(X, Y)) then
begin
HV := FHitActions[I];
if HV = HIT_VOL then
begin
FVolDrag := True;
ApplyVolFromX(X);
end
else if HV = HIT_SQL_SLIDER then
begin
FSqlDrag := True;
ApplySqlFromX(X);
end
else
DoHit(HV);
Break;
end;
end;
procedure TVfoOverlay.MouseMove(Shift: TShiftState; X, Y: Integer);
var
I, OldHot: Integer;
begin
inherited;
OldHot := FHotIdx; FHotIdx := -1;
for I := 0 to FHitCount - 1 do
if PtInRect(FHitRects[I], Point(X, Y)) then
begin FHotIdx := I; Break; end;
if FHotIdx <> OldHot then RequestInvalidate;
end;
procedure TVfoOverlay.MouseLeave;
begin
inherited;
if FHotIdx >= 0 then begin FHotIdx := -1; RequestInvalidate; end;
end;
{ ---- Обновление состояния из контроллера ---- }
procedure TVfoOverlay.SetFilterEdges(ALo, AHi: Integer);
begin
if (ALo = FFilterLo) and (AHi = FFilterHi) then Exit;
FFilterLo := ALo;
FFilterHi := AHi;
end;
procedure TVfoOverlay.SetState(AMode, ABW: Integer; AVfoHz, ASMeterDB: Double);
var
OldMode: Integer;
begin
OldMode := FMode;
FMode := EnsureRange(AMode, MODE_MIN, MODE_MAX);
FFilterBW := ABW;
FModeFilter[FMode] := ABW;
FVfoHz := AVfoHz;
FSMeterDB := ASMeterDB;
if FMode <> OVL_MODE_DMR then
begin
FDMRText := '';
FDMRSynced := False;
FDMRCandidateText := '';
FDMRCandidateSince := 0;
end
else if OldMode <> OVL_MODE_DMR then
begin
FDMRText := 'DMR...';
FDMRSynced := False;
FDMRCandidateText := '';
FDMRCandidateSince := 0;
end;
// Ушли в режим, где профиль ни на что не влияет: пилюли уже нет, открытый
// список закрываем сами — иначе он висел бы без своей кнопки.
if (FPanel = fpTxProf) and
((FMode = OVL_MODE_DMR) or (FMode = OVL_MODE_FMRAW)) then
FPanel := fpNone;
Height := ComputeHeight;
RequestInvalidate;
end;
procedure TVfoOverlay.SetDSPState(ANRMode, ANBMode: Integer; ASNBOn, AANFOn: Boolean;
AAGCMode: Integer);
begin
FNRMode := ANRMode;
FNBMode := ANBMode;
FSNBOn := ASNBOn;
FANFOn := AANFOn;
FAGCMode := AAGCMode;
RequestInvalidate;
end;
procedure TVfoOverlay.SetSquelchState(AOn: Boolean; ALevel: Integer);
begin
if (FSQLOn = AOn) and ((FSQLLevel = ALevel) or FSqlDrag) then Exit;
FSQLOn := AOn;
if not FSqlDrag then FSQLLevel := ALevel; // во время drag ведём сами
RequestInvalidate;
end;
procedure TVfoOverlay.SetExtState(AVolume: Integer; AMuted, ASplit, ATx, ATxSel: Boolean);
begin
if (AVolume = FVolume) and (AMuted = FMuted) and (ASplit = FSplit)
and (ATx = FTx) and (ATxSel = FTxSel) then Exit;
if not FVolDrag then FVolume := AVolume; // во время drag ведём сами
FMuted := AMuted;
FSplit := ASplit;
FTx := ATx;
FTxSel := ATxSel;
RequestInvalidate;
end;
procedure TVfoOverlay.SetRxMuteTxState(AOn: Boolean);
begin
if FRxMuteTx = AOn then Exit;
FRxMuteTx := AOn;
RequestInvalidate;
end;
procedure TVfoOverlay.SetAutoTxState(AOn: Boolean);
begin
if FAutoTx = AOn then Exit;
FAutoTx := AOn;
RequestInvalidate;
end;
procedure TVfoOverlay.SetSliceInfo(ASliceId: Integer; ALetter: Char; AIsMain: Boolean);
begin
if (FSliceId = ASliceId) and (FSlice = ALetter) and (FIsMain = AIsMain) then Exit;
FSliceId := ASliceId;
FSlice := ALetter;
FIsMain := AIsMain;
RequestInvalidate;
end;
procedure TVfoOverlay.SetAudioDevices(Names: TStrings; const Indices: array of Integer;
const CurName: string);
var
I: Integer;
begin
FDevNames.Assign(Names);
SetLength(FDevIdx, FDevNames.Count);
for I := 0 to FDevNames.Count - 1 do
if I <= High(Indices) then FDevIdx[I] := Indices[I] else FDevIdx[I] := -1;
FCurDev := CurName;
if FPanel = fpAud then Height := ComputeHeight;
RequestInvalidate;
end;
procedure TVfoOverlay.SetAudioInDevices(Names: TStrings;
const Indices: array of Integer; const CurName: string);
var
I: Integer;
begin
FInDevNames.Assign(Names);
SetLength(FInDevIdx, FInDevNames.Count);
for I := 0 to FInDevNames.Count - 1 do
if I <= High(Indices) then FInDevIdx[I] := Indices[I] else FInDevIdx[I] := -1;
FCurInDev := CurName;
if FPanel = fpAud then Height := ComputeHeight;
RequestInvalidate;
end;
procedure TVfoOverlay.UpdateSMeter(DB: Double);
begin
if Abs(DB - FSMeterDB) > FSMeterThreshold then
begin
FSMeterDB := DB;
RequestMeterInvalidate; // только полоска метра, без ре-рендера всего флага
end;
end;
procedure TVfoOverlay.UpdateVfo(AVfoHz: Double);
begin
if FVfoHz <> AVfoHz then
begin
FVfoHz := AVfoHz;
RequestInvalidate;
end;
end;
procedure TVfoOverlay.UpdateDMRStatus(const AText: string; Synced: Boolean);
var
NowTick, HoldMs: QWord;
begin
if FMode <> OVL_MODE_DMR then Exit;
if (FDMRText = AText) and (FDMRSynced = Synced) then
begin
FDMRCandidateText := '';
FDMRCandidateSince := 0;
Exit;
end;
NowTick := GetTickCount64;
if (FDMRCandidateText <> AText) or
(FDMRCandidateSynced <> Synced) then
begin
FDMRCandidateText := AText;
FDMRCandidateSynced := Synced;
FDMRCandidateSince := NowTick;
Exit;
end;
// Потерю уже показанной синхронизации держим дольше, чем появление/смену
// параметров вызова: короткие замирания не должны мигать в шапке слайса.
if FDMRSynced and not Synced then HoldMs := 1200
else if FDMRSynced and Synced then HoldMs := 750
else HoldMs := 250;
if NowTick - FDMRCandidateSince < HoldMs then Exit;
FDMRText := AText;
FDMRSynced := Synced;
FDMRCandidateText := '';
FDMRCandidateSince := 0;
RequestInvalidate;
end;
end.