From 4a92256e72e96eca61dc7bc846ee04cb8575f5b3 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 1 Jul 2026 14:00:59 +0300 Subject: [PATCH] feat(overlay): redesign VfoOverlay in SmartSDR slice-flag style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Полный редизайн накладки поверх спектра: шапка (слайс-бейдж A/B, ширина фильтра, кликабельный SPLIT, индикатор TX), метр-ряд (индикаторы NR/NB/ANF/ SNB + сигнал-бар), крупная частота + S, нижний бар с fly-out пикерами. - Fly-out движок MODE/DSP/AUD (раскрываются под баром, карточка растёт): MODE — сетка режимов 4×2 + фильтры; DSP — NR/NB/SNB/ANF + AGC; AUD — выбор аудио-устройства вывода (задел под слайсы). - Слайдер громкости (click+drag) + mute; SPLIT-toggle; live TX-индикатор. - Все контролы подключены к RadioController (SetVolume/ToggleMute/SetSplit/ EnumOutputDevices→ApplyAudioDevice); wire в MainForm + HandleMouseUp, чтобы клик по накладке не уходил в спектр. - FormatFreq на Int64 (double накапливал ошибку в десятки Гц на ~10 ГГц QO-100). - S-метр ниже по высоте + плавный градиент по уровню dBm: тёмно-зелёный→ зелёный→жёлтый→красный (красный к ~S9+18). - Кэш+dirty рендер сохранён (CPU-паритет со старой накладкой), GL-путь через DrawOverlayBitmap. Co-Authored-By: Claude Opus 4.8 --- MainForm.pas | 72 +++- VfoOverlay.pas | 1068 ++++++++++++++++++++++++++++++------------------ 2 files changed, 734 insertions(+), 406 deletions(-) diff --git a/MainForm.pas b/MainForm.pas index f1727ca..6f45ce1 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -519,6 +519,12 @@ type procedure OnModeFilterSelect(Mode: Integer; FilterBW: Integer); procedure OnVfoOverlayDSPChange(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean); procedure OnVfoOverlayAGCChange(AGCMode: Integer); + procedure OnVfoOverlayVolume(Vol: Integer); + procedure OnVfoOverlayMute(Sender: TObject); + procedure OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string); + procedure OnVfoOverlaySplit(Sender: TObject); + procedure PushVfoOverlayExtState; + procedure PushVfoOverlayAudioDevices; procedure OnVfoOverlayInvalidate(Sender: TObject); procedure PbSpectrumDblClick(Sender: TObject); procedure TrkDriveChange(Sender: TObject); @@ -956,6 +962,10 @@ begin FVfoOverlay.OnSelect := OnModeFilterSelect; FVfoOverlay.OnDSPChange := OnVfoOverlayDSPChange; FVfoOverlay.OnAGCChange := OnVfoOverlayAGCChange; + FVfoOverlay.OnVolume := OnVfoOverlayVolume; + FVfoOverlay.OnMute := OnVfoOverlayMute; + FVfoOverlay.OnAudioDevice := OnVfoOverlayAudioDevice; + FVfoOverlay.OnSplit := OnVfoOverlaySplit; FVfoOverlay.OnInvalidate := OnVfoOverlayInvalidate; FVfoOverlay.Visible := False; FPanelHidden := False; @@ -3251,6 +3261,7 @@ begin begin FVfoOverlay.UpdateSMeter(FController.FLastSMeter); FVfoOverlay.UpdateVfo(FController.FVfoA); + PushVfoOverlayExtState; PositionVfoOverlay; end; // Панорама и водопад теперь рисуются независимо по факту новых кадров. @@ -4501,6 +4512,12 @@ end; procedure TMainForm.PbSpectrumMouseUp(Sender: TObject; Button: TMouseButton; Shift: TShiftState; X, Y: Integer); begin + if Assigned(FVfoOverlay) and FVfoOverlay.HandleMouseUp(X, Y) then + begin + FSpecDrag := False; + PbSpectrum.Cursor := crDefault; + Exit; // клик/drag по накладке — не пускаем в спектр + end; if Button = mbLeft then begin if (Abs(X - FSpecDragX0) < 4) then @@ -4989,11 +5006,13 @@ begin begin FLeftPanelW := PanelLeft.Width; // сохраняем реальную ширину (учитывает DPI на Win32) PanelLeft.Width := 0; - FVfoOverlay.Width := 260; - FVfoOverlay.Height := 136; // OVL_H_NORM — расширяется сам при открытии AGC-пикера + FVfoOverlay.Width := OVL_W; + FVfoOverlay.Height := FVfoOverlay.BaseHeight; // растёт сам при открытии fly-out FVfoOverlay.SetState(FController.FMode, FController.FFilterBW, FController.FVfoA, FController.FLastSMeter); FVfoOverlay.SetDSPState(FController.FNRMode, FController.FNBMode, FController.FSNB, FController.FANF, FController.FAGCMode); FVfoOverlay.Visible := True; + PushVfoOverlayExtState; + PushVfoOverlayAudioDevices; PositionVfoOverlay; end else @@ -5098,6 +5117,55 @@ begin FController.SetAGCMode(AGCMode); end; +procedure TMainForm.OnVfoOverlayVolume(Vol: Integer); +begin + FController.SetVolume(Vol); +end; + +procedure TMainForm.OnVfoOverlayMute(Sender: TObject); +begin + FController.ToggleMute; +end; + +procedure TMainForm.OnVfoOverlaySplit(Sender: TObject); +begin + FController.SetSplit(not FController.FSplitTxB); + PushVfoOverlayExtState; +end; + +procedure TMainForm.OnVfoOverlayAudioDevice(DevIndex: Integer; const DevName: string); +begin + ApplyAudioDevice(DevIndex, DevName); + PushVfoOverlayAudioDevices; // обновить подсветку текущего устройства +end; + +procedure TMainForm.PushVfoOverlayExtState; +begin + if not (Assigned(FVfoOverlay) and FVfoOverlay.Visible) then Exit; + FVfoOverlay.SetExtState(FController.FVolume, FController.FMuted, + FController.FSplitTxB, FController.FTransmitting); +end; + +procedure TMainForm.PushVfoOverlayAudioDevices; +var + Names: TStringList; + Indices: array[0..63] of Integer; + Cnt, I: Integer; + Idx: array of Integer; +begin + if not (Assigned(FVfoOverlay) and Assigned(FController.FAudioOut)) then Exit; + Names := TStringList.Create; + try + Cnt := 0; + FController.FAudioOut.EnumOutputDevices(Names, Indices, Cnt); + SetLength(Idx, Cnt); + for I := 0 to Cnt - 1 do Idx[I] := Indices[I]; + FVfoOverlay.SetAudioDevices(Names, Idx, FController.FAudioOutDevName); + finally + Names.Free; + end; +end; + procedure TMainForm.OnVfoOverlayInvalidate(Sender: TObject); begin FSpectrumDirty := True; diff --git a/VfoOverlay.pas b/VfoOverlay.pas index 4bc0ed4..60bcfac 100644 --- a/VfoOverlay.pas +++ b/VfoOverlay.pas @@ -2,18 +2,35 @@ 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; const - VFO_OVERLAY_ALPHA = 200; + VFO_OVERLAY_ALPHA = 210; + OVL_W = 280; // ширина карточки type - TModeFilterEvent = procedure(Mode: Integer; FilterBW: Integer) of object; - TDSPChangeEvent = procedure(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean) of object; - TAGCChangeEvent = procedure(AGCMode: Integer) of object; + TModeFilterEvent = procedure(Mode: Integer; FilterBW: Integer) of object; + TDSPChangeEvent = procedure(NRMode, NBMode: Integer; SNBOn, ANFOn: Boolean) 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); TVfoOverlay = class(TCustomControl) private @@ -21,39 +38,66 @@ type FFilterBW: Integer; FVfoHz: Double; FSMeterDB: Double; - FOnSelect: TModeFilterEvent; - FOnDSPChange: TDSPChangeEvent; - FOnAGCChange: TAGCChangeEvent; - FOnInvalidate: TNotifyEvent; FModeFilter: array[0..7] of Integer; - FNRMode: Integer; // 0=off, 1..4=NR/NR2/NR3/NR4 + 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 - FAGCPickerOpen: Boolean; + FAGCMode: Integer; // 0=FAST 1=MED 2=SLOW 3=LONG 4=OFF - FHitRects: array[0..24] of TRect; - FHitActions: array[0..24] of Integer; + FVolume: Integer; // 0..100 + FMuted: Boolean; + FSplit: Boolean; + FTx: Boolean; + FSlice: Char; // буква слайса ('A') + + FDevNames: TStringList; // имена устройств вывода + FDevIdx: array of Integer; // соответствующие PortAudio-индексы (-1=default) + FCurDev: string; // имя текущего устройства ('' = default) + + FPanel: TFlyPanel; // открытая fly-out панель + FVolTrack: TRect; // трек громкости (локальные координаты) + FVolDrag: Boolean; + + FOnSelect: TModeFilterEvent; + FOnDSPChange: TDSPChangeEvent; + FOnAGCChange: TAGCChangeEvent; + FOnVolume: TVolumeEvent; + FOnMute: TNotifyEvent; + FOnAudioDevice: TAudioDeviceEvent; + FOnSplit: TNotifyEvent; + FOnInvalidate: TNotifyEvent; + + FHitRects: array[0..63] of TRect; + FHitActions: array[0..63] of Integer; FHitCount: Integer; FHotIdx: Integer; FCacheBitmap: TBitmap; - FCacheDirty: Boolean; + FCacheDirty: Boolean; procedure DrawSelf(C: TCanvas; W, H: Integer); - procedure DrawSMeterBar(C: TCanvas; X, Y, BW, BH: Integer); - procedure DrawModeRow(C: TCanvas; X, Y, RowW, RowH: Integer); - procedure DrawFilterRow(C: TCanvas; X, Y, RowW, RowH: Integer); - procedure DrawDSPRow(C: TCanvas; X, Y, RowW, RowH: Integer); - procedure DrawAGCPickerRow(C: TCanvas; X, Y, RowW, RowH: Integer); - procedure RegisterHit(HR: TRect; HV: Integer); - function CalcSLabel: string; + 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 RegisterHit(const HR: TRect; HV: Integer); function FormatFreq(Hz: Double): string; function GetFilterBWForMode(ModeIdx, FilterIdx: Integer): Integer; function GetFilterLblForMode(ModeIdx, FilterIdx: Integer): string; function GetFilterCountForMode(ModeIdx: Integer): Integer; + function PanelContentH: Integer; + function ComputeHeight: Integer; + procedure SetPanel(P: TFlyPanel); + procedure ApplyVolFromX(LX: Integer); procedure RequestInvalidate; + procedure DoHit(HV: Integer); protected procedure Paint; override; @@ -69,17 +113,26 @@ type procedure DrawOverlayBitmap(Target: TBitmap); function HandleMouseDown(Button: TMouseButton; X, Y: Integer): Boolean; function HandleMouseMove(X, Y: Integer): Boolean; + function HandleMouseUp(X, Y: Integer): Boolean; function HandleMouseLeave: Boolean; procedure SetState(AMode, ABW: Integer; AVfoHz, ASMeterDB: Double); procedure SetDSPState(ANRMode, ANBMode: Integer; ASNBOn, AANFOn: Boolean; AAGCMode: Integer); + procedure SetExtState(AVolume: Integer; AMuted, ASplit, ATx: Boolean); + procedure SetAudioDevices(Names: TStrings; const Indices: array of Integer; + const CurName: string); procedure UpdateSMeter(DB: Double); procedure UpdateVfo(AVfoHz: Double); property HotIdx: Integer read FHotIdx; - property OnSelect: TModeFilterEvent read FOnSelect write FOnSelect; - property OnDSPChange: TDSPChangeEvent read FOnDSPChange write FOnDSPChange; - property OnAGCChange: TAGCChangeEvent read FOnAGCChange write FOnAGCChange; - property OnInvalidate: TNotifyEvent read FOnInvalidate write FOnInvalidate; + property BaseHeight: Integer read ComputeHeight; + property OnSelect: TModeFilterEvent read FOnSelect write FOnSelect; + property OnDSPChange: TDSPChangeEvent read FOnDSPChange write FOnDSPChange; + 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 OnSplit: TNotifyEvent read FOnSplit write FOnSplit; + property OnInvalidate: TNotifyEvent read FOnInvalidate write FOnInvalidate; end; implementation @@ -87,60 +140,67 @@ implementation const KEY_COLOR = TColor($00FF00FF); - HIT_NR = -9; - HIT_NB = -10; - HIT_SNB = -11; - HIT_ANF = -12; - HIT_AGC = -13; - - HIT_AGC_PICK_BASE = 100000; // 100000+N — выбор режима AGC (>всех BW фильтров) + // Значения хит-тестов + // режимы: -(I+1) = -1..-8 + // фильтры: BW в Гц (>0) + // 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_AGC_PICK_BASE = 100000; + HIT_DEV_BASE = 200000; AGC_NAMES: array[0..4] of string = ('FAST', 'MED', 'SLOW', 'LONG', 'OFF'); - OVL_H_NORM = 136; // высота без пикера AGC - OVL_H_AGC = 159; // высота с открытым пикером (136 + GAP(3) + ROW_H(20)) + // Геометрия карточки + PAD = 6; + HDR_H = 16; + MET_H = 18; + FRQ_H = 26; + BAR_H = 24; + ROW_H = 20; + IGAP = 2; + OVL_BASE_H = PAD + HDR_H + MET_H + FRQ_H + BAR_H + PAD; // = 96 - CLR_PANEL_BG = TColor($00141414); - CLR_BORDER = TColor($00505050); - CLR_TEXT = TColor($00E8E8E8); - CLR_DIM = TColor($00787878); - CLR_ACCENT = TColor($0040FF80); - CLR_BTN_NORM = TColor($00282828); + // Палитра + CLR_CARD_BG = TColor($00191919); + CLR_BORDER = TColor($00484848); + CLR_SEP = TColor($00303030); + CLR_TEXT = TColor($00E4E4E4); + CLR_DIM = TColor($00707070); + CLR_ACCENT = TColor($0040FF80); // зелёный акцент + CLR_ACCENT_D = TColor($00103018); // тёмный акцент (фон активной) + CLR_BTN_NORM = TColor($00262626); CLR_BTN_HOT = TColor($00383838); - CLR_BTN_ACT = TColor($00183828); CLR_FREQ = TColor($0050FF80); CLR_SVAL = TColor($0030C8FF); - CLR_BAR_GRN = TColor($0018A030); - CLR_BAR_RED = TColor($000030C0); CLR_BAR_BG = TColor($00101010); + CLR_TX_ON = TColor($000030E0); // красный TX (BGR) + CLR_SPLIT_ON = TColor($0020C0FF); // оранжевый SPLIT - // Режимы — ТОЧНО как в MainForm.pas - // 0=LSB, 1=USB, 2=DSB, 3=CWL, 4=CWU, 5=FM, 6=AM, 7=SAM OVL_MODE_COUNT = 8; OVL_MODE_NAMES: array[0..7] of string = ( 'LSB','USB','DSB','CWL','CWU','FM','AM','SAM'); - // Фильтры SSB (LSB=0, USB=1) 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 (DSB=2) 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 (CWL=3, CWU=4) 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 (FM=5) — только NFM и FM, как в MainForm FM_BW: array[0..1] of Integer = (11000, 16000); FM_LBL: array[0..1] of string = ('NFM', 'FM'); - - // Фильтры AM/SAM (AM=6, SAM=7) 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'); - // S-шкала: S1..S9 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); @@ -149,13 +209,11 @@ var SrcRow, DstRow: PByte; begin if (Target = nil) or (Source = nil) or (Alpha = 0) then Exit; - SX0 := 0; - SY0 := 0; - SX1 := Source.Width; - SY1 := Source.Height; + 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 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; @@ -174,7 +232,6 @@ begin for X := SX0 to SX1 - 1 do begin {$IFDEF DARWIN} - // ARGB layout: byte0=A, byte1=R, byte2=G, byte3=B if not ((SrcRow[1] = $FF) and (SrcRow[2] = $00) and (SrcRow[3] = $FF)) then begin DstRow[1] := Byte((Alpha * SrcRow[1] + InvA * DstRow[1]) div 255); @@ -182,7 +239,6 @@ begin DstRow[3] := Byte((Alpha * SrcRow[3] + InvA * DstRow[3]) div 255); end; {$ELSE} - // BGRA layout: byte0=B, byte1=G, byte2=R if not ((SrcRow[0] = $FF) and (SrcRow[1] = $00) and (SrcRow[2] = $FF)) then begin DstRow[0] := Byte((Alpha * SrcRow[0] + InvA * DstRow[0]) div 255); @@ -200,34 +256,6 @@ begin end; end; -function TVfoOverlay.GetFilterBWForMode(ModeIdx, FilterIdx: Integer): Integer; -begin - case ModeIdx of - 0,1: Result := SSB_BW[FilterIdx]; - 2: Result := DSB_BW[FilterIdx]; - 3,4: Result := CW_BW[FilterIdx]; - 5: Result := FM_BW[FilterIdx]; - else Result := AM_BW[FilterIdx]; // 6=AM, 7=SAM - end; -end; - -function TVfoOverlay.GetFilterLblForMode(ModeIdx, FilterIdx: Integer): string; -begin - case ModeIdx of - 0,1: Result := SSB_LBL[FilterIdx]; - 2: Result := DSB_LBL[FilterIdx]; - 3,4: Result := CW_LBL[FilterIdx]; - 5: Result := FM_LBL[FilterIdx]; - else Result := AM_LBL[FilterIdx]; - end; -end; - -function TVfoOverlay.GetFilterCountForMode(ModeIdx: Integer): Integer; -begin - if ModeIdx = 5 then Result := 2 // FM: только NFM и FM - else Result := 6; -end; - function DBmToSLabel(DB: Double): string; var I, Over: Integer; @@ -235,8 +263,7 @@ begin if DB >= DB_S[9] then begin Over := Round(DB - DB_S[9]); - if Over < 5 then - Result := 'S9' + if Over < 5 then Result := 'S9' else begin Over := ((Over + 5) div 10) * 10; @@ -244,57 +271,81 @@ begin Result := 'S9+' + IntToStr(Over); end; end - else if DB <= DB_S[1] then - Result := 'S1' + 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); + if DB >= DB_S[I] then Result := 'S' + IntToStr(I); end; end; +{ ---- Фильтры по режиму ---- } + +function TVfoOverlay.GetFilterBWForMode(ModeIdx, FilterIdx: Integer): Integer; +begin + case ModeIdx of + 0,1: Result := SSB_BW[FilterIdx]; + 2: Result := DSB_BW[FilterIdx]; + 3,4: Result := CW_BW[FilterIdx]; + 5: Result := FM_BW[FilterIdx]; + else Result := AM_BW[FilterIdx]; + end; +end; + +function TVfoOverlay.GetFilterLblForMode(ModeIdx, FilterIdx: Integer): string; +begin + case ModeIdx of + 0,1: Result := SSB_LBL[FilterIdx]; + 2: Result := DSB_LBL[FilterIdx]; + 3,4: Result := CW_LBL[FilterIdx]; + 5: Result := FM_LBL[FilterIdx]; + else Result := AM_LBL[FilterIdx]; + end; +end; + +function TVfoOverlay.GetFilterCountForMode(ModeIdx: Integer): Integer; +begin + if ModeIdx = 5 then Result := 2 else Result := 6; +end; + +{ ---- Lifecycle ---- } + constructor TVfoOverlay.Create(AOwner: TComponent); begin inherited Create(AOwner); ControlStyle := ControlStyle + [csOpaque]; - FMode := 1; // USB по умолчанию (как в MainForm) + FMode := 1; FFilterBW := 2700; FVfoHz := 14200000; FSMeterDB := -121; FHitCount := 0; FHotIdx := -1; - // Дефолтные фильтры для каждого режима (запоминаются при переключении) - FModeFilter[0] := 2700; // LSB - FModeFilter[1] := 2700; // USB - FModeFilter[2] := 8000; // DSB - FModeFilter[3] := 500; // CWL - FModeFilter[4] := 500; // CWU - FModeFilter[5] := 11000; // FM (NFM по умолчанию) - FModeFilter[6] := 8000; // AM - FModeFilter[7] := 8000; // SAM - FNRMode := 0; - FNBMode := 0; - FSNBOn := False; - FANFOn := False; - FAGCMode := 0; - FAGCPickerOpen := False; + FModeFilter[0] := 2700; FModeFilter[1] := 2700; FModeFilter[2] := 8000; + FModeFilter[3] := 500; FModeFilter[4] := 500; FModeFilter[5] := 11000; + FModeFilter[6] := 8000; FModeFilter[7] := 8000; + FNRMode := 0; FNBMode := 0; FSNBOn := False; FANFOn := False; FAGCMode := 0; + FVolume := 70; FMuted := False; FSplit := False; FTx := False; FSlice := 'A'; + FPanel := fpNone; + FVolDrag := False; + FDevNames := TStringList.Create; + FCurDev := ''; FCacheBitmap := TBitmap.Create; FCacheBitmap.PixelFormat := pf32bit; FCacheDirty := True; - Width := 260; - Height := OVL_H_NORM; - Cursor := crHandPoint; + Width := OVL_W; + Height := OVL_BASE_H; + Cursor := crHandPoint; end; destructor TVfoOverlay.Destroy; begin + FDevNames.Free; FCacheBitmap.Free; inherited Destroy; end; -procedure TVfoOverlay.RegisterHit(HR: TRect; HV: Integer); +procedure TVfoOverlay.RegisterHit(const HR: TRect; HV: Integer); begin if FHitCount > High(FHitRects) then Exit; FHitRects[FHitCount] := HR; @@ -311,57 +362,179 @@ end; function TVfoOverlay.FormatFreq(Hz: Double): string; var - iM, iK, iH: Integer; + T, iM, iK, iH: Int64; begin - iM := Trunc(Hz / 1e6); - iK := Trunc((Hz - iM * 1e6) / 1000); - iH := Round(Hz - iM * 1e6 - iK * 1000); + // Целочисленная математика (как 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; -function TVfoOverlay.CalcSLabel: string; +{ ---- Высота с учётом fly-out ---- } + +function TVfoOverlay.PanelContentH: Integer; begin - Result := DBmToSLabel(FSMeterDB); + case FPanel of + fpMode: Result := ROW_H*2 + IGAP + ROW_H + IGAP; // 2 ряда режимов + ряд фильтров + fpDsp: Result := ROW_H + IGAP + ROW_H; // toggles + AGC + fpAud: Result := Max(1, FDevNames.Count) * 17; + else Result := 0; + end; end; -procedure TVfoOverlay.DrawSMeterBar(C: TCanvas; X, Y, BW, BH: Integer); +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; + Height := ComputeHeight; + RequestInvalidate; +end; + +{ ---- Примитивы отрисовки ---- } + +procedure TVfoOverlay.DrawButton(C: TCanvas; const R: TRect; const Lbl: string; + Active, Hot: Boolean; FontSz: Integer); +begin + if Active then C.Brush.Color := CLR_ACCENT + else if Hot then C.Brush.Color := CLR_BTN_HOT + else C.Brush.Color := CLR_BTN_NORM; + C.Brush.Style := bsSolid; + C.Pen.Style := psSolid; + C.Pen.Color := C.Brush.Color; + C.RoundRect(R.Left, R.Top, R.Right, R.Bottom, 5, 5); + + if Active then C.Font.Color := CLR_CARD_BG + else C.Font.Color := CLR_TEXT; + C.Font.Name := 'Courier New'; + C.Font.Size := FontSz; + C.Font.Style := []; + C.Brush.Style := bsClear; + C.TextOut(R.Left + (R.Right - R.Left - C.TextWidth(Lbl)) div 2, + R.Top + (R.Bottom - R.Top - C.TextHeight('A')) div 2, Lbl); +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); +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; + C.TextOut(R.Left + (R.Right - R.Left - C.TextWidth(Lbl)) div 2, + R.Top + (R.Bottom - R.Top - C.TextHeight('A')) div 2, Lbl); +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','S9','+20','+40','+60'); + S_LBL: array[0..7] of string = ('1','3','5','7','9','+20','+40','+60'); var - I, BarEnd, S9X, PX, BarH: Integer; + I, BarEnd, PX, BarH, TW, Col: Integer; T: Double; - Lbl: string; - TW: Integer; begin - BarH := BH - 11; - + BarH := BH - 9; C.Brush.Color := CLR_BAR_BG; C.Brush.Style := bsSolid; C.Pen.Style := psClear; C.FillRect(Rect(X, Y, X+BW, Y+BarH)); - S9X := X + Round((-73 - DB_MIN) / (DB_MAX - DB_MIN) * BW); - T := Max(0.0, Min(1.0, (FSMeterDB - DB_MIN) / (DB_MAX - DB_MIN))); - BarEnd := X + Round(T * BW); - - if BarEnd > X + 1 then + 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 - if BarEnd <= S9X then - begin - C.Brush.Color := CLR_BAR_GRN; - C.FillRect(Rect(X+1, Y+1, BarEnd, Y+BarH-1)); - end - else - begin - C.Brush.Color := CLR_BAR_GRN; - C.FillRect(Rect(X+1, Y+1, S9X, Y+BarH-1)); - C.Brush.Color := CLR_BAR_RED; - C.FillRect(Rect(S9X, Y+1, Min(BarEnd, X+BW-1), Y+BarH-1)); - end; + 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; @@ -376,246 +549,245 @@ begin begin T := (S_POS[I] - DB_MIN) / (DB_MAX - DB_MIN); PX := X + Round(T * BW); - Lbl := S_LBL[I]; - TW := C.TextWidth(Lbl); - if I < 4 then C.Font.Color := CLR_DIM - else C.Font.Color := CLR_ACCENT; + 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.Pen.Style := psSolid; C.MoveTo(PX, Y+BarH); C.LineTo(PX, Y+BarH+2); - C.TextOut(PX - TW div 2, Y+BarH+2, Lbl); + C.TextOut(PX - TW div 2, Y+BarH+1, S_LBL[I]); end; end; -procedure TVfoOverlay.DrawModeRow(C: TCanvas; X, Y, RowW, RowH: Integer); +{ ---- Fly-out панели ---- } + +procedure TVfoOverlay.DrawModePanel(C: TCanvas; X, Y, RowW: Integer); var - I, BX, BRight: Integer; + I, Col, Row, BX, BY, BW, FiltN, FBX, FBRight, BV: Integer; HR: TRect; - IsAct, IsHot: Boolean; begin - BX := X; + BW := (RowW - 3*3) div 4; // 4 колонки, зазор 3 for I := 0 to OVL_MODE_COUNT - 1 do begin - BRight := X + ((I + 1) * RowW) div OVL_MODE_COUNT; - HR := Rect(BX, Y, BRight, Y + RowH); - IsAct := (I = FMode); - IsHot := (FHotIdx >= 0) and (FHitActions[FHotIdx] = -(I + 1)); - - if IsAct then C.Brush.Color := CLR_BTN_ACT - else if IsHot then C.Brush.Color := CLR_BTN_HOT - else C.Brush.Color := CLR_BTN_NORM; - C.Brush.Style := bsSolid; C.Pen.Style := psClear; - C.FillRect(HR); - C.Pen.Style := psSolid; C.Pen.Color := CLR_BORDER; - C.Brush.Style := bsClear; C.Rectangle(HR); - - if IsAct then C.Font.Color := CLR_ACCENT - else C.Font.Color := CLR_TEXT; - C.Font.Size := 6; C.Font.Style := []; C.Font.Name := 'Courier New'; - C.Brush.Style := bsClear; - C.TextOut(BX + (BRight - BX - C.TextWidth(OVL_MODE_NAMES[I])) div 2, - Y + (RowH - C.TextHeight('A')) div 2, - OVL_MODE_NAMES[I]); - - RegisterHit(HR, -(I + 1)); // -1=LSB, -2=USB, -3=DSB, -4=CWL, -5=CWU, -6=FM, -7=AM, -8=SAM - BX := BRight + 1; + Col := I mod 4; Row := I div 4; + 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] = -(I+1)), 7); + RegisterHit(HR, -(I+1)); end; -end; -procedure TVfoOverlay.DrawFilterRow(C: TCanvas; X, Y, RowW, RowH: Integer); -var - FILT_COUNT: Integer; - I, BX, BRight, BV: Integer; - HR: TRect; - Lbl: string; - IsAct: Boolean; -begin - FILT_COUNT := GetFilterCountForMode(FMode); - BX := X; - for I := 0 to FILT_COUNT - 1 do + // Ряд фильтров текущего режима + FiltN := GetFilterCountForMode(FMode); + BY := Y + 2*(ROW_H + IGAP); + FBX := X; + for I := 0 to FiltN - 1 do begin - BRight := X + ((I + 1) * RowW) div FILT_COUNT; - HR := Rect(BX, Y, BRight, Y + RowH); - BV := GetFilterBWForMode(FMode, I); - IsAct := (BV = FFilterBW); - Lbl := GetFilterLblForMode(FMode, I); - - if IsAct then C.Brush.Color := CLR_BTN_ACT - else if (FHotIdx >= 0) and (FHitActions[FHotIdx] = BV) then - C.Brush.Color := CLR_BTN_HOT - else C.Brush.Color := CLR_BTN_NORM; - C.Brush.Style := bsSolid; C.Pen.Style := psClear; - C.FillRect(HR); - C.Pen.Style := psSolid; C.Pen.Color := CLR_BORDER; - C.Brush.Style := bsClear; C.Rectangle(HR); - - if IsAct then C.Font.Color := CLR_ACCENT - else C.Font.Color := CLR_TEXT; - C.Font.Size := 7; C.Font.Name := 'Courier New'; - C.Brush.Style := bsClear; - C.TextOut(BX + (BRight - BX - C.TextWidth(Lbl)) div 2, - Y + (RowH - C.TextHeight('A')) div 2, Lbl); - - RegisterHit(HR, BV); // BV всегда > 0 (BW в Гц, минимум 25) - BX := BRight + 1; + 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] = BV), 7); + RegisterHit(HR, BV); + FBX := FBRight; end; end; -procedure TVfoOverlay.DrawDSPRow(C: TCanvas; X, Y, RowW, RowH: Integer); -const - DSP_COUNT = 5; - DSP_HITS: array[0..4] of Integer = (HIT_NR, HIT_NB, HIT_SNB, HIT_ANF, HIT_AGC); +procedure TVfoOverlay.DrawDspPanel(C: TCanvas; X, Y, RowW: Integer); var I, BX, BRight: Integer; HR: TRect; Lbl: string; - IsAct, IsHot: Boolean; + 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 DSP_COUNT - 1 do + for I := 0 to 3 do begin - BRight := X + ((I + 1) * RowW) div DSP_COUNT; - HR := Rect(BX, Y, BRight, Y + RowH); + 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; - IsAct := FNRMode <> 0; + 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'; - IsAct := FNBMode <> 0; - end; - 2: begin Lbl := 'SNB'; IsAct := FSNBOn; end; - 3: begin Lbl := 'ANF'; IsAct := FANFOn; end; - 4: begin Lbl := AGC_NAMES[FAGCMode]; IsAct := FAGCPickerOpen; end; - else Lbl := ''; IsAct := False; + 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; - IsHot := (FHotIdx >= 0) and (FHitActions[FHotIdx] = DSP_HITS[I]); - - if IsAct then C.Brush.Color := CLR_BTN_ACT - else if IsHot then C.Brush.Color := CLR_BTN_HOT - else C.Brush.Color := CLR_BTN_NORM; - C.Brush.Style := bsSolid; C.Pen.Style := psClear; - C.FillRect(HR); - C.Pen.Style := psSolid; C.Pen.Color := CLR_BORDER; - C.Brush.Style := bsClear; C.Rectangle(HR); - - if IsAct then C.Font.Color := CLR_ACCENT - else C.Font.Color := CLR_TEXT; - C.Font.Size := 7; C.Font.Name := 'Courier New'; - C.Brush.Style := bsClear; - C.TextOut(BX + (BRight - BX - C.TextWidth(Lbl)) div 2, - Y + (RowH - C.TextHeight('A')) div 2, Lbl); - - RegisterHit(HR, DSP_HITS[I]); - BX := BRight + 1; + DrawButton(C, HR, Lbl, Act, + (FHotIdx >= 0) and (FHitActions[FHotIdx] = TOG_HITS[I]), 7); + RegisterHit(HR, TOG_HITS[I]); + BX := BRight; end; -end; - -procedure TVfoOverlay.DrawAGCPickerRow(C: TCanvas; X, Y, RowW, RowH: Integer); -const - AGC_COUNT = 5; -var - I, BX, BRight: Integer; - HR: TRect; - IsAct, IsHot: Boolean; -begin + // Ряд 2: AGC режимы + Inc(Y, ROW_H + IGAP); BX := X; - for I := 0 to AGC_COUNT - 1 do + for I := 0 to 4 do begin - BRight := X + ((I + 1) * RowW) div AGC_COUNT; - HR := Rect(BX, Y, BRight, Y + RowH); - IsAct := (I = FAGCMode); - IsHot := (FHotIdx >= 0) and - (FHitActions[FHotIdx] = HIT_AGC_PICK_BASE + I); - - if IsAct then C.Brush.Color := CLR_BTN_ACT - else if IsHot then C.Brush.Color := CLR_BTN_HOT - else C.Brush.Color := CLR_BTN_NORM; - C.Brush.Style := bsSolid; C.Pen.Style := psClear; - C.FillRect(HR); - C.Pen.Style := psSolid; C.Pen.Color := CLR_BORDER; - C.Brush.Style := bsClear; C.Rectangle(HR); - - if IsAct then C.Font.Color := CLR_ACCENT - else C.Font.Color := CLR_TEXT; - C.Font.Size := 7; C.Font.Name := 'Courier New'; - C.Brush.Style := bsClear; - C.TextOut(BX + (BRight - BX - C.TextWidth(AGC_NAMES[I])) div 2, - Y + (RowH - C.TextHeight('A')) div 2, AGC_NAMES[I]); - + 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 + 1; + BX := BRight; end; end; +procedure TVfoOverlay.DrawAudPanel(C: TCanvas; X, Y, RowW: Integer); +var + I: Integer; + HR: TRect; + Nm: string; + Cur: Boolean; +begin + C.Font.Name := 'Courier New'; + C.Font.Size := 7; + C.Font.Style := []; + if FDevNames.Count = 0 then + begin + C.Font.Color := CLR_DIM; + C.Brush.Style := bsClear; + C.TextOut(X, Y + 2, '(нет устройств)'); + Exit; + end; + for I := 0 to FDevNames.Count - 1 do + begin + HR := Rect(X, Y + I*17, X + RowW, Y + I*17 + 16); + Nm := FDevNames[I]; + Cur := (Nm = FCurDev) or ((FCurDev = '') and (I = 0)); + 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) > RowW - 8 then + while (Length(Nm) > 1) and (C.TextWidth(Nm + '…') > RowW - 8) do + SetLength(Nm, Length(Nm) - 1); + if Nm <> FDevNames[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; +end; + +{ ---- Основная отрисовка ---- } + procedure TVfoOverlay.DrawSelf(C: TCanvas; W, H: Integer); -const - PAD = 4; - FREQ_H = 18; - INFO_H = 14; - SM_H = 27; - ROW_H = 20; - GAP = 3; var - FreqStr, SStr: string; - FW, CurY: Integer; + FreqStr, SStr, BWStr: string; + CurY, X, RW, i: Integer; + R: TRect; + BarL, BarW, MeterX: Integer; begin FHitCount := 0; - C.Brush.Color := CLR_PANEL_BG; + // Фон карточки + 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, 6, 6); + C.RoundRect(0, 0, W, H, 7, 7); + X := PAD; + RW := W - PAD*2; CurY := PAD; - // Частота + { --- Шапка: [A] BW ... SPLIT [TX] --- } + DrawBadge(C, Rect(X, CurY, X+16, CurY+HDR_H-1), FSlice, CLR_ACCENT, CLR_CARD_BG); + // ширина фильтра + 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); + // TX badge (правый край) + R := Rect(W - PAD - 26, CurY, W - PAD, CurY + HDR_H - 1); + if FTx then DrawBadge(C, R, 'TX', CLR_TX_ON, clWhite) + else DrawBadge(C, R, 'TX', CLR_BTN_NORM, CLR_DIM); + // SPLIT (левее TX) — кликабельно + R := Rect(W - PAD - 26 - 44, CurY, W - PAD - 26 - 4, CurY + HDR_H - 1); + if FSplit then DrawBadge(C, R, 'SPLIT', CLR_SPLIT_ON, CLR_CARD_BG) + else DrawBadge(C, R, 'SPLIT', CLR_BTN_NORM, CLR_DIM); + RegisterHit(R, HIT_SPLIT); + 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; + DrawMeterBar(C, MeterX, CurY, W - PAD - MeterX, MET_H); + Inc(CurY, MET_H); + + { --- Частота + S --- } FreqStr := FormatFreq(FVfoHz); - C.Font.Name := 'Courier New'; C.Font.Size := 11; C.Font.Style := [fsBold]; - C.Font.Color := CLR_FREQ; - FW := C.TextWidth(FreqStr); - C.Brush.Style := bsClear; - C.TextOut((W - FW) div 2, CurY, FreqStr); - Inc(CurY, FREQ_H); + 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); + Inc(CurY, FRQ_H); - // Режим (слева) + S-value (справа) - SStr := CalcSLabel; - C.Font.Size := 8; C.Font.Style := [fsBold]; - C.Font.Color := CLR_ACCENT; - C.TextOut(PAD + 2, CurY, OVL_MODE_NAMES[FMode]); - C.Font.Color := CLR_SVAL; - C.TextOut(W - PAD - C.TextWidth(SStr) - 2, CurY, SStr); - Inc(CurY, INFO_H); - - // S-метр - DrawSMeterBar(C, PAD + 4, CurY, W - (PAD + 4) * 2, SM_H); - Inc(CurY, SM_H + GAP); - - // Кнопки режимов - DrawModeRow(C, PAD, CurY, W - PAD * 2, ROW_H); - Inc(CurY, ROW_H + GAP); - - // Кнопки фильтров - DrawFilterRow(C, PAD, CurY, W - PAD * 2, ROW_H); - Inc(CurY, ROW_H + GAP); - - // Кнопки DSP (NR, NB, SNB, ANF, AGC) - DrawDSPRow(C, PAD, CurY, W - PAD * 2, ROW_H); - - // Пикер AGC (открывается кнопкой AGC) - if FAGCPickerOpen then + { --- Нижний бар: 🔊 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 - Inc(CurY, ROW_H + GAP); - DrawAGCPickerRow(C, PAD, CurY, W - PAD * 2, ROW_H); + 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); + end; end; end; @@ -659,6 +831,108 @@ begin FCacheDirty := 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_SPLIT: begin if Assigned(FOnSplit) then FOnSplit(Self); Exit; end; + end; + + // Устройство аудио + if HV >= HIT_DEV_BASE then + begin + NewMode := HV - HIT_DEV_BASE; // индекс в списке + if (NewMode >= 0) and (NewMode < FDevNames.Count) then + 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 < 0) and (HV >= -OVL_MODE_COUNT) then + begin + NewMode := (-HV) - 1; + if NewMode <> FMode then + begin + FModeFilter[FMode] := FFilterBW; + FMode := NewMode; + FFilterBW := FModeFilter[FMode]; + Height := ComputeHeight; // число фильтров могло смениться (FM=2) + RequestInvalidate; + if Assigned(FOnSelect) then FOnSelect(FMode, FFilterBW); + end; + Exit; + end; + + // Фильтр (BW>0) + if HV > 0 then + begin + NewBW := HV; + 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; + +{ ---- Внешний ввод (маршрутизируется MainForm поверх спектра) ---- } + function TVfoOverlay.HandleMouseDown(Button: TMouseButton; X, Y: Integer): Boolean; begin Result := False; @@ -674,17 +948,19 @@ var begin Result := False; if not Visible then Exit; - LX := X - Left; - LY := Y - Top; + LX := X - Left; LY := Y - Top; + if FVolDrag then + begin + ApplyVolFromX(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; + begin FHotIdx := I; Break; end; if FHotIdx <> OldHot then begin RequestInvalidate; @@ -692,9 +968,19 @@ begin 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 (X >= Left) and (Y >= Top) and (X < Left + Width) and (Y < Top + Height) then + Result := True; // клик был по накладке — не пускаем его в спектр +end; + function TVfoOverlay.HandleMouseLeave: Boolean; begin Result := False; + FVolDrag := False; if FHotIdx >= 0 then begin FHotIdx := -1; @@ -706,7 +992,7 @@ end; procedure TVfoOverlay.MouseDown(Button: TMouseButton; Shift: TShiftState; X, Y: Integer); var - I, HV, NewMode, NewBW: Integer; + I, HV: Integer; begin inherited; if Button <> mbLeft then Exit; @@ -714,65 +1000,13 @@ begin if PtInRect(FHitRects[I], Point(X, Y)) then begin HV := FHitActions[I]; - if HV < 0 then + if HV = HIT_VOL then begin - NewMode := (-HV) - 1; - if NewMode < OVL_MODE_COUNT then - begin - // Кнопка режима - if NewMode <> FMode then - begin - FModeFilter[FMode] := FFilterBW; - FMode := NewMode; - FFilterBW := FModeFilter[FMode]; - RequestInvalidate; - if Assigned(FOnSelect) then FOnSelect(FMode, FFilterBW); - end; - end - else - begin - // Кнопка DSP - if HV = HIT_AGC then - begin - FAGCPickerOpen := not FAGCPickerOpen; - if FAGCPickerOpen then Height := OVL_H_AGC - else Height := OVL_H_NORM; - RequestInvalidate; - end - else - 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); - end; - end; + FVolDrag := True; + ApplyVolFromX(X); end - else if HV >= HIT_AGC_PICK_BASE then - begin - // Выбор режима AGC из пикера - FAGCMode := HV - HIT_AGC_PICK_BASE; - FAGCPickerOpen := False; - Height := OVL_H_NORM; - RequestInvalidate; - if Assigned(FOnAGCChange) then FOnAGCChange(FAGCMode); - end - else if HV > 0 then - begin - NewBW := HV; - if NewBW <> FFilterBW then - begin - FFilterBW := NewBW; - FModeFilter[FMode] := FFilterBW; - RequestInvalidate; - if Assigned(FOnSelect) then FOnSelect(FMode, FFilterBW); - end; - end; + else + DoHit(HV); Break; end; end; @@ -795,19 +1029,21 @@ begin if FHotIdx >= 0 then begin FHotIdx := -1; RequestInvalidate; end; end; +{ ---- Обновление состояния из контроллера ---- } + procedure TVfoOverlay.SetState(AMode, ABW: Integer; AVfoHz, ASMeterDB: Double); begin FMode := Max(0, Min(OVL_MODE_COUNT - 1, AMode)); FFilterBW := ABW; - // Синхронизируем память фильтра для текущего режима FModeFilter[FMode] := ABW; FVfoHz := AVfoHz; FSMeterDB := ASMeterDB; + Height := ComputeHeight; RequestInvalidate; end; procedure TVfoOverlay.SetDSPState(ANRMode, ANBMode: Integer; ASNBOn, AANFOn: Boolean; - AAGCMode: Integer); + AAGCMode: Integer); begin FNRMode := ANRMode; FNBMode := ANBMode; @@ -817,9 +1053,33 @@ begin RequestInvalidate; end; +procedure TVfoOverlay.SetExtState(AVolume: Integer; AMuted, ASplit, ATx: Boolean); +begin + if (AVolume = FVolume) and (AMuted = FMuted) and (ASplit = FSplit) + and (ATx = FTx) then Exit; + if not FVolDrag then FVolume := AVolume; // во время drag ведём сами + FMuted := AMuted; + FSplit := ASplit; + FTx := ATx; + 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.UpdateSMeter(DB: Double); begin - // Не клипаем — передаём как есть, как в основном S-метре if Abs(DB - FSMeterDB) > 0.4 then begin FSMeterDB := DB;