From 71a97b63f0860409322b7a01d6e287ea600dbfdf Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 14:21:09 +0300 Subject: [PATCH 01/30] =?UTF-8?q?feat(tx):=20TX-=D0=BF=D1=80=D0=BE=D1=84?= =?UTF-8?q?=D0=B8=D0=BB=D0=B8=20+=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0=20Transmit=20=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B2?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Профиль — именованный снимок «как я звучу», переключаемый одним кликом (идиома FlexRadio, состав по таблице TXProfile у Thetis). Граница «профиль ↔ устройство»: - в профиль: микрофонный вход (jack/boost/bias/PTT/Tip-Ring/Line-in gain), MicGain, кромки TX-фильтра, компрессор/leveler/ALC/phase rotator, EQ, AM carrier, Drive и Tune level; - у устройства остаются ATT on TX, FIR/фаза/окно фильтра (это задержка тракта, а не тембр), TUNFreq, FM/CTCSS, TX Display, TX Grid и все параметры PureSignal — смена профиля не должна перерисовывать спектр и ломать калибровку PS. Кнопки «сохранить» нет: активный профиль ведётся за живым состоянием (StoreActiveTXProfile из OnTXSettingsChange и SetDrive) — как и все остальные настройки в EWSDR, применяемые мгновенно. Settings.pas: TTXProfile/TTXProfileList, TXProfileCapture/TXProfileApply (единственные две точки, знающие состав профиля), DefaultTXProfiles (Default/SSB DX/SSB Wide/ESSB 3.5k/ESSB 5k — наследуют mic-вход и мощность от текущих настроек), Load/SaveTXProfiles (секция tx_profiles под MAC). Секции нет — заводской набор строится из текущих настроек, поэтому миграция старого конфига не меняет звук. RadioController: FTXProfiles, rfTXProfile, SelectTXProfile (снимок → DSP + перепосыл DUC Specific при смене mic-байта + SetDrive + персист), Add/Rename/Delete (последний профиль не удаляется). MainForm: кнопка профиля в свободных колонках ряда DUP/RX MUTE — блок TX не вырос по высоте; ЛКМ — список, ПКМ — Settings→Transmit→Profile. SettingsForm: страница Transmit разбита на подвкладки Profile / EQ / Hardware / Display + полоса профиля сверху; свиток 2000 px вместо этого раскладывается на четыре коротких экрана. Проверено: round-trip персиста отдельным тестом (все поля, активный индекс, усечение списка) и offscreen-снимки всех четырёх подвкладок. На железе не проверено. Co-Authored-By: Claude Opus 5 --- MainForm.pas | 149 +++++++++++++++- RadioController.pas | 152 +++++++++++++++++ Settings.pas | 307 +++++++++++++++++++++++++++++++++ SettingsForm.pas | 403 ++++++++++++++++++++++++++++++++++++-------- 4 files changed, 940 insertions(+), 71 deletions(-) diff --git a/MainForm.pas b/MainForm.pas index de6fb79..a0fc03b 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -356,6 +356,10 @@ type TrkAtt: TFlatSlider; BtnChannels: TFlatButton; FChannelsDropDown: TFlatDropDown; + // TX-профиль: кнопка с именем активного (блок TX, ряд DUP) + список. + // ЛКМ — список, ПКМ — вкладка Transmit в настройках. + BtnTXProfile: TFlatButton; + FTXProfileDropDown: TFlatDropDown; FChannelsForm: TObject; // TChannelsForm (cast при использовании) FBeaconScopeForm: TBeaconScopeForm; // окно констелляции маяка (ПКМ по BEACON) @@ -517,6 +521,17 @@ type Shift: TShiftState; X, Y: Integer); procedure OnChannelsDropDownSelect(Sender: TObject; Idx: Integer); procedure RefreshChannelsDropDown; + // TX-профили: рендер кнопки/списка + обработчики блока TX и вкладки Transmit. + procedure RenderTXProfile; + procedure PushTXProfilesToSettings; + procedure BtnTXProfileClick(Sender: TObject); + procedure BtnTXProfileMouseDown(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); + procedure OnTXProfileDropDownSelect(Sender: TObject; Idx: Integer); + procedure OnTXProfileSelectFromSettings(Idx: Integer); + procedure OnTXProfileAddFromSettings(const AName: string); + procedure OnTXProfileRenameFromSettings(Idx: Integer; const AName: string); + procedure OnTXProfileDeleteFromSettings(Idx: Integer); procedure OnChannelStoreChanged(Sender: TObject); procedure OnChannelActiveChanged(Idx: Integer; const Name: string; Active: Boolean); procedure PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton; @@ -1245,6 +1260,9 @@ begin FController.FNetwork.OnWideband := OnWidebandCB; // Дефолты TX, Alex и XVTR (на случай если устройство ещё не выбрано). TSettingsManager.DefaultTX(FController.FTXSettings); + // Заводской набор TX-профилей до подключения (ConnectDevice перечитает свой). + DefaultTXProfiles(FController.FTXSettings, FController.FDrivePercent, + FController.FTXProfiles); TSettingsManager.DefaultAlex(FController.FAlexSettings); TSettingsManager.DefaultOC(FController.FOCSettings); TSettingsManager.DefaultXvtr(FController.FXvtrSettings); @@ -1582,7 +1600,12 @@ begin TrkDrive.Position := FController.FDrivePercent; // слайдер ← drive% UpdateDriveLabel; // % / дБ рядом с ползунком FChannelController.OnDriveChanged; // внешнее изменение снимает память канала + // Мощность — часть TX-профиля: держим подпись в настройках свежей. + if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then + TSettingsForm(FSettingsForm).SetTXProfileDrive(FController.FDrivePercent); end; + rfTXProfile: + RenderTXProfile; rfAGCTop: begin TrkAGC.Position := FController.FAGCTop; @@ -1739,6 +1762,7 @@ begin FWidebandView.SetFillSpectrum(FController.FWidebandFill); FSpecView.FillSpectrum := FController.FSpectrumFill; if BtnDUP <> nil then StyleButton(BtnDUP, FController.FDisplayDuplex); + RenderTXProfile; // список профилей этого устройства (загружен по MAC) if FController.FLoadedGlobal.DisplayFPS > 0 then ApplyFPS(FController.FLoadedGlobal.DisplayFPS); ApplyFreqMhzDigits(EnsureRange(FController.FLoadedGlobal.FreqMhzDigits, 3, 5)); @@ -2093,6 +2117,21 @@ begin StyleButton(BtnRxMute, FController.FRxMuteOnTx); UpdateRxMuteButton; // видимость/активность по DUP + // TX-профиль — свободные колонки 2..3 того же ряда (панель не растёт по + // высоте). Подпись = имя активного профиля, ЛКМ — список, ПКМ — настройки. + BtnTXProfile := MakeBtn(PanelTX, 'Default', + LeftPanelButtonLeft(LEFT_W, 4, 2), 44, + LeftPanelButtonLeft(LEFT_W, 4, 3) + + LeftPanelButtonWidth(LEFT_W, 4, 3) + - LeftPanelButtonLeft(LEFT_W, 4, 2), + BTN_SM, BtnTXProfileClick); + BtnTXProfile.OnMouseDown := BtnTXProfileMouseDown; + StyleButton(BtnTXProfile, False); + + FTXProfileDropDown := TFlatDropDown.Create(BtnTXProfile, PanelLeft, 1, BTN_SM); + FTXProfileDropDown.OnSelect := OnTXProfileDropDownSelect; + RenderTXProfile; + LblDrv := TLabel.Create(Self); LblDrv.Parent := PanelTX; LblDrv.Left := 4; LblDrv.Top := 77; LblDrv.Caption := 'DRV'; LblDrv.Font.Color := CLR_TEXTDIM; @@ -3188,6 +3227,8 @@ begin end; if BtnChannels <> nil then StyleButton(BtnChannels, False); if FChannelsDropDown <> nil then FChannelsDropDown.ApplyStyle(StyleButton, T.Panel); + if BtnTXProfile <> nil then StyleButton(BtnTXProfile, False); + if FTXProfileDropDown <> nil then FTXProfileDropDown.ApplyStyle(StyleButton, T.Panel); StyleButton(BtnNR, BtnNR.Active); StyleButton(BtnNB, BtnNB.Active); StyleButton(BtnSNB, BtnSNB.Active); @@ -5486,7 +5527,8 @@ procedure TMainForm.CloseCTCSSPopup; begin if FCTCSSDropDown <> nil then FCTCSSDropDown.ClosePopup; CloseFMStepPopup; - if FChannelsDropDown <> nil then FChannelsDropDown.ClosePopup; + if FChannelsDropDown <> nil then FChannelsDropDown.ClosePopup; + if FTXProfileDropDown <> nil then FTXProfileDropDown.ClosePopup; end; procedure TMainForm.BtnFMRptMinusClick(Sender: TObject); @@ -5592,6 +5634,7 @@ begin if FController.FChannelStore.Count = 0 then Exit; FCTCSSDropDown.ClosePopup; FStepDropDown.ClosePopup; + if FTXProfileDropDown <> nil then FTXProfileDropDown.ClosePopup; FChannelsDropDown.Toggle; end; @@ -5627,6 +5670,101 @@ begin FChannelController.Select(Idx); end; +// --------------------------------------------------------------------------- +// TX-профили (кнопка блока TX + список) +// --------------------------------------------------------------------------- + +procedure TMainForm.RenderTXProfile; +// Единая точка рендера: подпись кнопки, состав списка и — если открыт диалог +// настроек — его вкладка Transmit (профиль переписал живые TX-настройки). +var + Names: array of string; + i, Act, Cnt: Integer; +begin + if BtnTXProfile = nil then Exit; + Cnt := FController.TXProfileCount; + Act := FController.ActiveTXProfile; + if (Act >= 0) and (Act < Cnt) then + BtnTXProfile.Caption := FController.TXProfileName(Act) + else + BtnTXProfile.Caption := 'TX Profile'; + BtnTXProfile.Invalidate; + if FTXProfileDropDown <> nil then + begin + SetLength(Names, Cnt); + for i := 0 to Cnt - 1 do Names[i] := FController.TXProfileName(i); + FTXProfileDropDown.SetItems(Names); + if (Act >= 0) and (Act < Cnt) then FTXProfileDropDown.SetItemIndex(Act); + end; + if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then + begin + TSettingsForm(FSettingsForm).LoadTXSettings(FController.FTXSettings, + IfThen(FController.FNetwork.Connected, + FController.FNetwork.Device.BoardType, FPendingBoardType)); + PushTXProfilesToSettings; + end; +end; + +procedure TMainForm.PushTXProfilesToSettings; +var + Names: array of string; + i: Integer; +begin + if FSettingsForm = nil then Exit; + SetLength(Names, FController.TXProfileCount); + for i := 0 to FController.TXProfileCount - 1 do + Names[i] := FController.TXProfileName(i); + TSettingsForm(FSettingsForm).LoadTXProfiles(Names, FController.ActiveTXProfile, + FController.FDrivePercent); +end; + +procedure TMainForm.BtnTXProfileClick(Sender: TObject); +begin + if FController.TXProfileCount = 0 then Exit; + // Чужие списки гасим поимённо: общий CloseCTCSSPopup закрыл бы и наш, и + // повторный клик по кнопке никогда не сворачивал бы список. + if FCTCSSDropDown <> nil then FCTCSSDropDown.ClosePopup; + CloseFMStepPopup; + FTXProfileDropDown.Toggle; +end; + +procedure TMainForm.BtnTXProfileMouseDown(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); +begin + if Button <> mbRight then Exit; + // ПКМ — прямо в редактор профиля (вкладка Transmit), как ПКМ по кнопке фильтра. + if FTXProfileDropDown <> nil then FTXProfileDropDown.ClosePopup; + BtnSettingsClick(nil); + if FSettingsForm <> nil then TSettingsForm(FSettingsForm).ShowTransmitPage; +end; + +procedure TMainForm.OnTXProfileDropDownSelect(Sender: TObject; Idx: Integer); +begin + // Применение (WDSP + mic-байт + drive) и персист — в контроллере; + // рендер придёт назад событием rfTXProfile. + FController.SelectTXProfile(Idx); +end; + +procedure TMainForm.OnTXProfileSelectFromSettings(Idx: Integer); +begin + FController.SelectTXProfile(Idx); +end; + +procedure TMainForm.OnTXProfileAddFromSettings(const AName: string); +begin + FController.AddTXProfile(AName); +end; + +procedure TMainForm.OnTXProfileRenameFromSettings(Idx: Integer; const AName: string); +begin + FController.RenameTXProfile(Idx, AName); +end; + +procedure TMainForm.OnTXProfileDeleteFromSettings(Idx: Integer); +begin + FController.DeleteTXProfile(Idx); +end; + procedure TMainForm.OnChannelActiveChanged(Idx: Integer; const Name: string; Active: Boolean); begin // Рендер кнопки Channel по событию адаптера каналов (Active=True → подсветка+имя). @@ -7812,10 +7950,14 @@ begin end; end; end; + // Правка «звуковых» полей — это правка активного профиля: отдельной кнопки + // «сохранить» нет, профиль ведётся за живым состоянием (см. TTXProfile). + FController.StoreActiveTXProfile; // Сохраняем сразу — настройки per-device, попадают в hpsdr_settings.json if FController.FDevConnected then begin FController.FSettings.SaveTX(FController.FDevMAC, FController.FTXSettings); + FController.FSettings.SaveTXProfiles(FController.FDevMAC, FController.FTXProfiles); FController.FSettings.Save; end; end; @@ -8624,6 +8766,10 @@ begin SF.OnThemeChange := SetLightTheme; SF.OnFreqMhzDigitsChange := ApplyFreqMhzDigits; SF.OnTXChange := OnTXSettingsChange; + SF.OnTXProfileSelect := OnTXProfileSelectFromSettings; + SF.OnTXProfileAdd := OnTXProfileAddFromSettings; + SF.OnTXProfileRename := OnTXProfileRenameFromSettings; + SF.OnTXProfileDelete := OnTXProfileDeleteFromSettings; SF.OnAlexChange := OnAlexSettingsChange; SF.OnXvtrChange := OnXvtrSettingsChange; SF.OnOCChange := OnOCSettingsChange; @@ -8632,6 +8778,7 @@ begin SF.OnWebSettingsChange := ApplyWebSettings; end; SF := TSettingsForm(FSettingsForm); + PushTXProfilesToSettings; SF.LoadTXSettings(FController.FTXSettings, IfThen(FController.FNetwork.Connected, FController.FNetwork.Device.BoardType, FPendingBoardType)); SF.LoadAlexSettings(FController.FAlexSettings, diff --git a/RadioController.pas b/RadioController.pas index 356ffcf..fceae6a 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -80,6 +80,7 @@ type rfFMDeviation, rfFMCTCSS, rfFMCTCSSTone, rfFMSQ, rfFMSQLevel, rfFMStep, rfFMStepIdx, rfFMRpt, rfChannel, + rfTXProfile, // сменился активный TX-профиль rfSMeter, rfPower, rfSWR, rfSupply, rfPLL, // телеметрия rfADCOverload, // ADC overload (рендер индикатора) rfBeaconLock, // QO-100 beacon lock: статус/коррекция @@ -424,6 +425,10 @@ type // ---- Конфиги / устройство (per-MAC) ---- FTXSettings: TTXSettings; + // Именованные TX-профили («звуковая» часть FTXSettings + drive, см. + // TTXProfile). Истина для эфира — всегда живые FTXSettings/FDrivePercent; + // активный профиль ведётся за ними (StoreActiveTXProfile). + FTXProfiles: TTXProfileList; FAlexSettings: TAlexSettings; FOCSettings: TOCSettings; // OC Control (Open Collector, openHPSDR-only) // Антенные разъёмы AD936x (Pluto/LibreSDR) на диапазон/трансвертер. @@ -577,6 +582,21 @@ type procedure ApplyTXSettingsToDSP; // TX-цепь (фильтр/gain/EQ/comp/...) → WDSP function PSOutlierSigmaEff: Double; // σ outlier-фильтра calcc (авто по плате) + // ---- TX-профили -------------------------------------------------------- + // Профиль — именованный снимок «звуковой» части TX-цепи (состав в + // TTXProfile). Отдельной кнопки «сохранить» нет: правки ручками пишутся в + // активный профиль сразу (StoreActiveTXProfile), как и всё остальное в + // EWSDR. Переключение применяет снимок в эфир немедленно. + function TXProfileCount: Integer; + function TXProfileName(Idx: Integer): string; + function ActiveTXProfile: Integer; + procedure SelectTXProfile(Idx: Integer); + procedure StoreActiveTXProfile(Persist: Boolean = False); + function AddTXProfile(const AName: string): Integer; // форк текущего звука + procedure RenameTXProfile(Idx: Integer; const AName: string); + procedure DeleteTXProfile(Idx: Integer); + procedure SaveTXProfilesToConfig; + // ---- Управление диапазонами (состояние + DSP; рендер через события) ---- // MakeBandSettings — снимок текущего состояния в запись диапазона. // SaveCurrentBand — сохраняет текущее состояние в кэш+конфиг (или в XVTR-слот). @@ -4222,6 +4242,10 @@ procedure TRadioController.SetDrive(V: Integer); begin if V < 0 then V := 0 else if V > 100 then V := 100; FDrivePercent := V; + // Мощность входит в профиль: ведём её в активном без записи на диск (слайдер + // шлёт события пачками; на диск профили уходят на переключении/остановке). + if (FTXProfiles.ActiveIdx >= 0) and (FTXProfiles.ActiveIdx < FTXProfiles.Count) then + FTXProfiles.Items[FTXProfiles.ActiveIdx].DrivePercent := FDrivePercent; FDriveLevel := CalcDriveByte; ApplyWDSPDrive(FDrivePercent); ApplyPlutoTxAtten; // Pluto: drive живьём меняет TX-аттенюацию во время передачи @@ -4904,6 +4928,124 @@ begin FNetwork.SendDUCSpecific(DUCPkt); end; +// --------------------------------------------------------------------------- +// TX-профили +// --------------------------------------------------------------------------- + +function TRadioController.TXProfileCount: Integer; +begin + Result := FTXProfiles.Count; +end; + +function TRadioController.TXProfileName(Idx: Integer): string; +begin + if (Idx < 0) or (Idx >= FTXProfiles.Count) then Result := '' + else Result := FTXProfiles.Items[Idx].Name; +end; + +function TRadioController.ActiveTXProfile: Integer; +begin + Result := FTXProfiles.ActiveIdx; +end; + +procedure TRadioController.SaveTXProfilesToConfig; +begin + if (not FDevConnected) or (not Assigned(FSettings)) then Exit; + FSettings.SaveTXProfiles(FDevMAC, FTXProfiles); + FSettings.Save; +end; + +procedure TRadioController.StoreActiveTXProfile(Persist: Boolean = False); +// Живое состояние → активный профиль. Зовётся на каждой правке TX-настроек и +// на смене drive; Persist=False не трогает диск (drive ездит слайдером). +var Idx: Integer; +begin + Idx := FTXProfiles.ActiveIdx; + if (Idx < 0) or (Idx >= FTXProfiles.Count) then Exit; + TXProfileCapture(FTXSettings, FDrivePercent, FTXProfiles.Items[Idx]); + if Persist then SaveTXProfilesToConfig; +end; + +procedure TRadioController.SelectTXProfile(Idx: Integer); +var + OldMic: Byte; + NewDrive: Integer; +begin + if (Idx < 0) or (Idx >= FTXProfiles.Count) then Exit; + // Уходящий профиль дописываем живыми значениями — ручные правки не теряются. + StoreActiveTXProfile; + OldMic := BuildMicLineSelectByte; + NewDrive := FDrivePercent; + FTXProfiles.ActiveIdx := Idx; + TXProfileApply(FTXProfiles.Items[Idx], FTXSettings, NewDrive); + ApplyTXSettingsToDSP; + // Маршрутизация микрофона живёт в железе (DUC Specific byte 50) — при смене + // Mic In/Line In/boost/bias пакет надо перепослать, WDSP тут ни при чём. + if BuildMicLineSelectByte <> OldMic then SendDUCSpecificFromSettings; + // SetDrive пересчитает калиброванный байт, толкнёт WDSP/Pluto и HP-кадр; + // зовём безусловно — при активном TUN уровень берётся из TUNLevel профиля. + SetDrive(NewDrive); + if FTuning and FWDSPReady then + FDSPEngine.SetDriveLevel(EnsureRange(FTXSettings.TUNLevel, 0, 100) / 100.0); + if FDevConnected and Assigned(FSettings) then + begin + FSettings.SaveTX(FDevMAC, FTXSettings); + FSettings.SaveTXProfiles(FDevMAC, FTXProfiles); + FSettings.Save; + end; + Changed(rfTXProfile); +end; + +function TRadioController.AddTXProfile(const AName: string): Integer; +// Форк: новый профиль = текущий звук под новым именем, он же становится активным. +begin + Result := -1; + if FTXProfiles.Count >= TXPROF_MAX then Exit; + Result := FTXProfiles.Count; + TXProfileCapture(FTXSettings, FDrivePercent, FTXProfiles.Items[Result]); + FTXProfiles.Items[Result].Name := Copy(Trim(AName), 1, 31); + if FTXProfiles.Items[Result].Name = '' then + FTXProfiles.Items[Result].Name := 'Profile ' + IntToStr(Result + 1); + Inc(FTXProfiles.Count); + FTXProfiles.ActiveIdx := Result; + SaveTXProfilesToConfig; + Changed(rfTXProfile); +end; + +procedure TRadioController.RenameTXProfile(Idx: Integer; const AName: string); +var S: string; +begin + if (Idx < 0) or (Idx >= FTXProfiles.Count) then Exit; + S := Copy(Trim(AName), 1, 31); + if S = '' then Exit; + FTXProfiles.Items[Idx].Name := S; + SaveTXProfilesToConfig; + Changed(rfTXProfile); +end; + +procedure TRadioController.DeleteTXProfile(Idx: Integer); +// Последний профиль не удаляем: список без активного элемента сломал бы +// авто-сохранение (StoreActiveTXProfile писать некуда). +var i: Integer; +begin + if (Idx < 0) or (Idx >= FTXProfiles.Count) or (FTXProfiles.Count <= 1) then Exit; + for i := Idx to FTXProfiles.Count - 2 do + FTXProfiles.Items[i] := FTXProfiles.Items[i + 1]; + Dec(FTXProfiles.Count); + if FTXProfiles.ActiveIdx > Idx then Dec(FTXProfiles.ActiveIdx) + else if FTXProfiles.ActiveIdx = Idx then + begin + // Удалили активный: встаём на соседний и применяем его в эфир. ActiveIdx + // гасим — иначе StoreActiveTXProfile внутри Select затрёт соседа живым + // звуком удалённого профиля. + FTXProfiles.ActiveIdx := -1; + SelectTXProfile(EnsureRange(Idx, 0, FTXProfiles.Count - 1)); + Exit; // SelectTXProfile уже сохранил и отрендерил + end; + SaveTXProfilesToConfig; + Changed(rfTXProfile); +end; + procedure TRadioController.ApplyTXSettingsToDSP; begin if not FWDSPReady then Exit; @@ -5847,6 +5989,12 @@ begin // drive% (0..100) — раньше грузился через TrkDrive.Position; теперь напрямую, // слайдер выставит rfDevice-рендер. Калиброванный байт считаем после FCurrentBand. FDrivePercent := EnsureRange(FLoadedGlobal.DriveLevel, 0, 100); + // TX-профили. Секции нет (конфиг до этой версии) — строим заводской набор из + // ТЕКУЩИХ настроек: «Default» повторяет их один в один, звук после + // обновления не меняется. Активный профиль на старте не применяем — живая + // секция "tx" уже хранит последнее состояние, оно и есть истина. + if not FSettings.LoadTXProfiles(FDevMAC, FTXProfiles) then + DefaultTXProfiles(FTXSettings, FDrivePercent, FTXProfiles); // Клампим LastBand — иначе битый/старый конфиг → RestoreBand выйдет по гарду, // а последующие SetMode/SetFilterIdx запишут в FBandCache[невалид] за массив. FCurrentBand := EnsureRange(FLoadedGlobal.LastBand, 0, CFG_BAND_COUNT - 1); @@ -5999,6 +6147,10 @@ begin FSettings.SaveAnt936x(FDevMAC, FAnt936x); FSettings.SaveOC(FDevMAC, FOCSettings); FSettings.SaveFilters(FDevMAC, FFilterTable); + // Активный TX-профиль дописываем живыми значениями (drive/громкость микро- + // фона ездят мимо диалога настроек) и пишем список целиком. + StoreActiveTXProfile; + FSettings.SaveTXProfiles(FDevMAC, FTXProfiles); // XVTR LastFreq → актуальная видимая частота в момент остановки. if (FCurrentXvtr >= 0) and (FCurrentXvtr < CFG_XVTR_COUNT) then FXvtrSettings.Entries[FCurrentXvtr].LastFreq := FVfoA; diff --git a/Settings.pas b/Settings.pas index ae1f861..7bb30da 100644 --- a/Settings.pas +++ b/Settings.pas @@ -28,6 +28,7 @@ const CFG_PANS_MAX = 4; // потолок панадаптеров (пан 0 + доп.) — = WDSPEngine.MAX_PANS CFG_CAT_SLICE_COUNT = 6; // слотов доп. слайсов (B..G) — = WDSPEngine.MAX_SLICES CFG_CAT_SLICE_PORT0 = 19091; // порт слайса B по умолчанию (главный — 19090) + TXPROF_MAX = 16; // потолок именованных TX-профилей на устройство QO100_SLOT = CFG_XVTR_COUNT - 1; // последний слот зарезервирован под QO-100 // (split-LO транспондер; настраивается на // отдельной странице, не в общей XVTR-таблице) @@ -298,6 +299,59 @@ type // (Orion MkII → 5.0, иначе выкл.), 0 = выкл. end; + // ---- TX-профили (именованные снимки «как я звучу») ---------------------- + // Профиль хранит ТОЛЬКО то, что определяет звук в эфире: маршрутизацию + // микрофонного входа, усиление, кромки TX-фильтра, динамическую обработку, + // эквалайзер, уровень несущей AM и мощность. Всё остальное из TTXSettings + // (FIR-длина/фаза/окно фильтра, ATT on TX, FM/CTCSS, параметры анализатора + // и сетки, калибровка PureSignal) — свойство устройства, от профиля НЕ + // зависит: переключение профиля не должно перерисовывать спектр, менять + // задержку тракта или ломать калибровку PS. + // Разбиение — по образцу таблицы TXProfile у Thetis (database.cs), но без + // её хвоста из VAC/DSP-буферов, которых у нас нет. + TTXProfile = record + Name: string[31]; + // Микрофонный вход (гарнитура / микшер-ESSB — физически разные входы) + MicLineIn: Boolean; + MicBoost: Boolean; + MicPTTEnabled: Boolean; + MicTipRing: Boolean; + MicBias: Boolean; + LineInGainDB: Double; + MicGainDB: Double; + // Кромки TX-фильтра (FIR-длина/фаза/окно остаются у устройства) + FilterLow: Integer; + FilterHigh: Integer; + // Динамическая обработка + CompressorOn: Boolean; + CompressorGain: Double; + LevelerOn: Boolean; + LevelerTop: Double; + LevelerDecay: Integer; + ALCOn: Boolean; + ALCMaxGain: Double; + ALCDecay: Integer; + PhaseRotOn: Boolean; + PhaseRotStages: Integer; + PhaseRotFreq: Double; + // Эквалайзер + EQOn: Boolean; + EQNumBands: Integer; + EQGains: array[0..10] of Double; + EQFreqs: array[0..10] of Double; + // Модуляция AM (несущая — часть «звука», в отличие от девиации FM) + AMCarrierLevel: Double; + // Мощность (Thetis держит Power/Tune_Power в профиле) + DrivePercent: Integer; // 0..100 + TUNLevel: Integer; // 0..100 + end; + + TTXProfileList = record + Count: Integer; + ActiveIdx: Integer; + Items: array[0..TXPROF_MAX-1] of TTXProfile; + end; + // Web-сервер — глобальные настройки (не привязаны к устройству, секция "web"). TWebSettings = record Enabled: Boolean; @@ -625,8 +679,25 @@ type // только режимы, отличающиеся от заводских, — конфиг не пухнет. function LoadFilters(const MAC: array of Byte; out T: TFilterTable): Boolean; procedure SaveFilters(const MAC: array of Byte; const T: TFilterTable); + // TX-профили per-device (JSON-секция "tx_profiles" под MAC). LoadTXProfiles + // возвращает False, если секции ещё нет: вызывающий строит заводской набор + // из текущих TX-настроек (DefaultTXProfiles) — миграция старого конфига. + function LoadTXProfiles(const MAC: array of Byte; out L: TTXProfileList): Boolean; + procedure SaveTXProfiles(const MAC: array of Byte; const L: TTXProfileList); end; +// ---- TX-профили ------------------------------------------------------------ +// Снимок «звуковых» полей живых настроек в профиль (имя не трогается) и +// обратное применение. Единственные две точки, знающие состав профиля, — +// добавляя поле в TTXProfile, правь обе. +procedure TXProfileCapture(const T: TTXSettings; DrivePct: Integer; var P: TTXProfile); +procedure TXProfileApply(const P: TTXProfile; var T: TTXSettings; var DrivePct: Integer); +// Заводской набор профилей. Base — текущие настройки устройства: из них +// наследуются микрофонный вход, мощность и всё, что профиль не переопределяет +// (иначе переключение на «ESSB» сбрасывало бы Mic In/Line In и drive). +procedure DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer; + out L: TTXProfileList); + // Сколько пресетов реально есть у режима (VAR1/VAR2 идут сверх этого числа). function FilterPresetCount(Mode: Integer): Integer; // Заводской индекс фильтра режима. @@ -1715,6 +1786,242 @@ begin JW(O,'ps_outlier_sigma',T.PSOutlierSigma); end; +// --------------------------------------------------------------------------- +// TX-профили: снимок/применение + заводской набор + персист +// --------------------------------------------------------------------------- + +procedure TXProfileCapture(const T: TTXSettings; DrivePct: Integer; var P: TTXProfile); +var i: Integer; +begin + P.MicLineIn := T.MicLineIn; + P.MicBoost := T.MicBoost; + P.MicPTTEnabled := T.MicPTTEnabled; + P.MicTipRing := T.MicTipRing; + P.MicBias := T.MicBias; + P.LineInGainDB := T.LineInGainDB; + P.MicGainDB := T.MicGainDB; + P.FilterLow := T.FilterLow; + P.FilterHigh := T.FilterHigh; + P.CompressorOn := T.CompressorOn; + P.CompressorGain := T.CompressorGain; + P.LevelerOn := T.LevelerOn; + P.LevelerTop := T.LevelerTop; + P.LevelerDecay := T.LevelerDecay; + P.ALCOn := T.ALCOn; + P.ALCMaxGain := T.ALCMaxGain; + P.ALCDecay := T.ALCDecay; + P.PhaseRotOn := T.PhaseRotOn; + P.PhaseRotStages := T.PhaseRotStages; + P.PhaseRotFreq := T.PhaseRotFreq; + P.EQOn := T.EQOn; + P.EQNumBands := T.EQNumBands; + for i := 0 to 10 do + begin + P.EQGains[i] := T.EQGains[i]; + P.EQFreqs[i] := T.EQFreqs[i]; + end; + P.AMCarrierLevel := T.AMCarrierLevel; + P.DrivePercent := EnsureRange(DrivePct, 0, 100); + P.TUNLevel := EnsureRange(T.TUNLevel, 0, 100); +end; + +procedure TXProfileApply(const P: TTXProfile; var T: TTXSettings; var DrivePct: Integer); +var i: Integer; +begin + T.MicLineIn := P.MicLineIn; + T.MicBoost := P.MicBoost; + T.MicPTTEnabled := P.MicPTTEnabled; + T.MicTipRing := P.MicTipRing; + T.MicBias := P.MicBias; + T.LineInGainDB := EnsureRange(P.LineInGainDB, -34.5, 12.0); + T.MicGainDB := P.MicGainDB; + T.FilterLow := P.FilterLow; + T.FilterHigh := P.FilterHigh; + T.CompressorOn := P.CompressorOn; + T.CompressorGain := P.CompressorGain; + T.LevelerOn := P.LevelerOn; + T.LevelerTop := P.LevelerTop; + T.LevelerDecay := P.LevelerDecay; + T.ALCOn := P.ALCOn; + T.ALCMaxGain := P.ALCMaxGain; + T.ALCDecay := P.ALCDecay; + T.PhaseRotOn := P.PhaseRotOn; + T.PhaseRotStages := P.PhaseRotStages; + T.PhaseRotFreq := P.PhaseRotFreq; + T.EQOn := P.EQOn; + T.EQNumBands := P.EQNumBands; + for i := 0 to 10 do + begin + T.EQGains[i] := P.EQGains[i]; + T.EQFreqs[i] := P.EQFreqs[i]; + end; + T.AMCarrierLevel := P.AMCarrierLevel; + T.TUNLevel := EnsureRange(P.TUNLevel, 0, 100); + DrivePct := EnsureRange(P.DrivePercent, 0, 100); +end; + +procedure DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer; + out L: TTXProfileList); + + procedure Add(const AName: string; ALow, AHigh: Integer; + AComp: Boolean; ACompGain: Double); + begin + if L.Count >= TXPROF_MAX then Exit; + TXProfileCapture(Base, DrivePct, L.Items[L.Count]); + L.Items[L.Count].Name := AName; + L.Items[L.Count].FilterLow := ALow; + L.Items[L.Count].FilterHigh := AHigh; + L.Items[L.Count].CompressorOn := AComp; + L.Items[L.Count].CompressorGain := ACompGain; + Inc(L.Count); + end; + +begin + FillChar(L, SizeOf(L), 0); + L.Count := 0; + L.ActiveIdx := 0; + // «Default» — ровно текущее состояние устройства: после миграции старого + // конфига звук в эфире не меняется ни на йоту. + TXProfileCapture(Base, DrivePct, L.Items[0]); + L.Items[0].Name := 'Default'; + L.Count := 1; + // Остальные заводские отличаются от Default ТОЛЬКО полосой и компрессором; + // микрофонный вход, EQ и мощность наследуются (см. коммент к DefaultTXProfiles). + Add('SSB DX', 300, 2700, True, 8.0); + Add('SSB Wide', 200, 3100, False, 5.0); + Add('ESSB 3.5k', 100, 3500, False, 5.0); + Add('ESSB 5k', 100, 5000, False, 5.0); +end; + +function TSettingsManager.LoadTXProfiles(const MAC: array of Byte; + out L: TTXProfileList): Boolean; +var + MacStr: string; + DevObj, Root, O: TJSONObject; + Node: TJSONData; + Arr: TJSONArray; + BaseTX: TTXSettings; + DefP: TTXProfile; + i, j: Integer; +begin + FillChar(L, SizeOf(L), 0); + L.Count := 0; + L.ActiveIdx := 0; + Result := False; + MacStr := MacToStr(MAC); + if FRoot.Find(MacStr) = nil then Exit; + DevObj := GetDevObj(MacStr); + if DevObj.Find('tx_profiles') = nil then Exit; + Root := EnsureObj(DevObj, 'tx_profiles'); + Node := Root.Find('list'); + if not (Node is TJSONArray) then Exit; + Arr := TJSONArray(Node); + // Заводской профиль как источник значений по умолчанию для отсутствующих + // ключей: конфиг, записанный старой версией, дочитывается без дыр. + DefaultTX(BaseTX); + TXProfileCapture(BaseTX, 50, DefP); + DefP.Name := 'Profile'; + for i := 0 to Arr.Count - 1 do + begin + if L.Count >= TXPROF_MAX then Break; + if not (Arr.Items[i] is TJSONObject) then Continue; + O := TJSONObject(Arr.Items[i]); + L.Items[L.Count] := DefP; + with L.Items[L.Count] do + begin + Name := Copy(JS(O,'name', DefP.Name), 1, 31); + MicLineIn := JB(O,'mic_line_in', MicLineIn); + MicBoost := JB(O,'mic_boost', MicBoost); + MicPTTEnabled := JB(O,'mic_ptt', MicPTTEnabled); + MicTipRing := JB(O,'mic_tip_ring', MicTipRing); + MicBias := JB(O,'mic_bias', MicBias); + LineInGainDB := EnsureRange(JD(O,'line_in_gain_db', LineInGainDB), -34.5, 12.0); + MicGainDB := JD(O,'mic_gain_db', MicGainDB); + FilterLow := JI(O,'filter_low', FilterLow); + FilterHigh := JI(O,'filter_high', FilterHigh); + CompressorOn := JB(O,'comp_on', CompressorOn); + CompressorGain := JD(O,'comp_gain', CompressorGain); + LevelerOn := JB(O,'lev_on', LevelerOn); + LevelerTop := JD(O,'lev_top', LevelerTop); + LevelerDecay := JI(O,'lev_decay', LevelerDecay); + ALCOn := JB(O,'alc_on', ALCOn); + ALCMaxGain := JD(O,'alc_max_gain', ALCMaxGain); + ALCDecay := JI(O,'alc_decay', ALCDecay); + PhaseRotOn := JB(O,'phr_on', PhaseRotOn); + PhaseRotStages := JI(O,'phr_stages', PhaseRotStages); + PhaseRotFreq := JD(O,'phr_freq', PhaseRotFreq); + EQOn := JB(O,'eq_on', EQOn); + EQNumBands := JI(O,'eq_bands', EQNumBands); + for j := 0 to 10 do + begin + EQGains[j] := JD(O,'eq_g_'+IntToStr(j), EQGains[j]); + EQFreqs[j] := JD(O,'eq_f_'+IntToStr(j), EQFreqs[j]); + end; + AMCarrierLevel := JD(O,'am_carrier', AMCarrierLevel); + DrivePercent := EnsureRange(JI(O,'drive', DrivePercent), 0, 100); + TUNLevel := EnsureRange(JI(O,'tun_level', TUNLevel), 0, 100); + if Name = '' then Name := 'Profile ' + IntToStr(L.Count + 1); + end; + Inc(L.Count); + end; + if L.Count = 0 then Exit; // пустой список = как будто секции нет + L.ActiveIdx := JI(Root, 'active', 0); + if (L.ActiveIdx < 0) or (L.ActiveIdx >= L.Count) then L.ActiveIdx := 0; + Result := True; +end; + +procedure TSettingsManager.SaveTXProfiles(const MAC: array of Byte; + const L: TTXProfileList); +var + Root, O: TJSONObject; + Arr: TJSONArray; + i, j, Idx: Integer; +begin + Root := EnsureObj(GetDevObj(MacToStr(MAC)), 'tx_profiles'); + JW(Root, 'active', EnsureRange(L.ActiveIdx, 0, Max(0, L.Count - 1))); + // Список пересоздаётся целиком: удаление профиля не должно оставлять хвост. + Idx := Root.IndexOfName('list'); + if Idx >= 0 then Root.Delete(Idx); + Arr := TJSONArray.Create; + Root.Add('list', Arr); + for i := 0 to L.Count - 1 do + begin + O := TJSONObject.Create; + Arr.Add(O); + JWS(O,'name', L.Items[i].Name); + JW(O,'mic_line_in', L.Items[i].MicLineIn); + JW(O,'mic_boost', L.Items[i].MicBoost); + JW(O,'mic_ptt', L.Items[i].MicPTTEnabled); + JW(O,'mic_tip_ring', L.Items[i].MicTipRing); + JW(O,'mic_bias', L.Items[i].MicBias); + JW(O,'line_in_gain_db', L.Items[i].LineInGainDB); + JW(O,'mic_gain_db', L.Items[i].MicGainDB); + JW(O,'filter_low', L.Items[i].FilterLow); + JW(O,'filter_high', L.Items[i].FilterHigh); + JW(O,'comp_on', L.Items[i].CompressorOn); + JW(O,'comp_gain', L.Items[i].CompressorGain); + JW(O,'lev_on', L.Items[i].LevelerOn); + JW(O,'lev_top', L.Items[i].LevelerTop); + JW(O,'lev_decay', L.Items[i].LevelerDecay); + JW(O,'alc_on', L.Items[i].ALCOn); + JW(O,'alc_max_gain', L.Items[i].ALCMaxGain); + JW(O,'alc_decay', L.Items[i].ALCDecay); + JW(O,'phr_on', L.Items[i].PhaseRotOn); + JW(O,'phr_stages', L.Items[i].PhaseRotStages); + JW(O,'phr_freq', L.Items[i].PhaseRotFreq); + JW(O,'eq_on', L.Items[i].EQOn); + JW(O,'eq_bands', L.Items[i].EQNumBands); + for j := 0 to 10 do + begin + JW(O,'eq_g_'+IntToStr(j), L.Items[i].EQGains[j]); + JW(O,'eq_f_'+IntToStr(j), L.Items[i].EQFreqs[j]); + end; + JW(O,'am_carrier', L.Items[i].AMCarrierLevel); + JW(O,'drive', L.Items[i].DrivePercent); + JW(O,'tun_level', L.Items[i].TUNLevel); + end; +end; + class procedure TSettingsManager.DefaultAlex(out A: TAlexSettings); var i: Integer; begin diff --git a/SettingsForm.pas b/SettingsForm.pas index c5308c4..31bf755 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -31,6 +31,13 @@ uses FlatRadioButton, EqualizerControl, AudioOutput, AudioInput, AppTheme, Settings, BoardUtils, DpiUtils; +const + // Подвкладки страницы Transmit: Profile / EQ / Hardware / Display. + TXTAB_COUNT = 4; + // Tag контейнера подвкладки: ApplyTheme красит такие панели фоном страницы, + // а не цветом группы (иначе подложка выглядела бы как одна гигантская группа). + TAG_PAGE_BG = 909; + type // PA: MaxPower в Вт, BandCal[0..10] — калибровка 38.8..100.0 на диапазон TOnPASettingsChange = procedure(MaxPower: Double; @@ -45,6 +52,10 @@ type TOnCalibrationChange = procedure(const C: TCalibration) of object; // TX-настройки целиком (любая правка → один callback с актуальной структурой) TOnTXSettingsChange = procedure(const T: TTXSettings) of object; + // TX-профили: выбор/переименование/удаление по индексу, создание — по имени. + TOnTXProfileIdx = procedure(Idx: Integer) of object; + TOnTXProfileName = procedure(const AName: string) of object; + TOnTXProfileIdxName = procedure(Idx: Integer; const AName: string) of object; TOnFreqMhzDigitsChange = procedure(Digits: Integer) of object; TOnDisplayParamChange = procedure(FFTSize, WindowType, SpecDetector, @@ -204,6 +215,18 @@ type // ---- Transmit tab controls ---- // Текущее состояние, отдаётся в OnTXChange целиком при любой правке. FTX: TTXSettings; + // Подвкладки страницы Transmit: Profile / EQ / Hardware / Display. + // Первые две — то, что хранится в TX-профиле, вторые две — свойства + // устройства и экрана (от профиля не зависят). См. TTXProfile в Settings. + FTXSubPage: array[0..TXTAB_COUNT-1] of TPanel; + FTXSubBtn: array[0..TXTAB_COUNT-1] of TFlatButton; + FTXSubIdx: Integer; + // Полоса профиля наверху страницы + FCmbTXProfile: TFlatComboBox; + FBtnTXProfNew: TFlatButton; + FBtnTXProfRen: TFlatButton; + FBtnTXProfDel: TFlatButton; + FLblTXProfDrive: TLabel; // «Drive профиля: N %» (ползунок — в главном окне) FCmbTXMicJack: TFlatComboBox; // "Mic In" / "Line In" FChkTXMicBoost: TFlatCheckBox; // +20 dB (только Mic In) FLblLineInGain: TLabel; // метка "Line In gain" (только Line In) @@ -411,6 +434,10 @@ type FOnWfAGCNFChange: TOnWfAGCNFChange; FOnFreqMhzDigitsChange: TOnFreqMhzDigitsChange; FOnTXChange: TOnTXSettingsChange; + FOnTXProfileSelect: TOnTXProfileIdx; + FOnTXProfileAdd: TOnTXProfileName; + FOnTXProfileRename: TOnTXProfileIdxName; + FOnTXProfileDelete: TOnTXProfileIdx; FLoading: Boolean; @@ -462,6 +489,13 @@ type procedure OnTXAnyChange(Sender: TObject); procedure OnMicJackChange(Sender: TObject); procedure UpdateMicJackVisibility; + // Подвкладки и полоса профиля на странице Transmit + procedure SelectTXSubPage(Idx: Integer); + procedure TXSubTabClick(Sender: TObject); + procedure OnTXProfileComboChange(Sender: TObject); + procedure BtnTXProfNewClick(Sender: TObject); + procedure BtnTXProfRenClick(Sender: TObject); + procedure BtnTXProfDelClick(Sender: TObject); procedure ApplyTheme(const T: TAppTheme); procedure NavClick(Sender: TObject); procedure SelectPage(APage: TWinControl; ANav: TFlatButton); @@ -571,6 +605,15 @@ type const SerAndromeda: array of Boolean; TcpEnabled: Boolean; TcpPort: Integer); procedure LoadTXSettings(const T: TTXSettings; BoardType: Integer = 0); + // Список TX-профилей + активный + drive активного (ползунок DRV живёт в + // главном окне, здесь показываем его значение как часть профиля). + procedure LoadTXProfiles(const Names: array of string; + ActiveIdx, DrivePct: Integer); + // Живое обновление подписи «Drive профиля» (ползунок DRV в главном окне). + procedure SetTXProfileDrive(DrivePct: Integer); + // Открыть диалог сразу на странице Transmit (ПКМ по кнопке профиля). + // SubIdx: 0=Profile, 1=EQ, 2=Hardware, 3=Display. + procedure ShowTransmitPage(SubIdx: Integer = 0); procedure LoadAlexSettings(const A: TAlexSettings; BoardType: Integer); procedure LoadXvtrSettings(const X: TXvtrSettings); procedure LoadOCSettings(const O: TOCSettings); @@ -608,6 +651,10 @@ type property OnThemeChange: TOnThemeChange read FOnThemeChange write FOnThemeChange; property OnFreqMhzDigitsChange: TOnFreqMhzDigitsChange read FOnFreqMhzDigitsChange write FOnFreqMhzDigitsChange; property OnTXChange: TOnTXSettingsChange read FOnTXChange write FOnTXChange; + property OnTXProfileSelect: TOnTXProfileIdx read FOnTXProfileSelect write FOnTXProfileSelect; + property OnTXProfileAdd: TOnTXProfileName read FOnTXProfileAdd write FOnTXProfileAdd; + property OnTXProfileRename: TOnTXProfileIdxName read FOnTXProfileRename write FOnTXProfileRename; + property OnTXProfileDelete: TOnTXProfileIdx read FOnTXProfileDelete write FOnTXProfileDelete; property OnWfAGCNFChange: TOnWfAGCNFChange read FOnWfAGCNFChange write FOnWfAGCNFChange; property OnADCChange: TOnADCChange read FOnADCChange write FOnADCChange; property OnWebSettingsChange: TOnWebSettingsChange read FOnWebSettingsChange write FOnWebSettingsChange; @@ -1352,6 +1399,40 @@ const var Grp: TPanel; Y, i: Integer; + SubTop: Integer; // общий (немасштабированный) Top всех четырёх подвкладок + + // Подложка подвкладки: страница Transmit — четыре независимых экрана, + // переключаемых сегментами (свиток на 2000 px не листают, в нём ищут). + function SubPage(Idx: Integer; ATop: Integer): TPanel; + begin + Result := TPanel.Create(Self); + Result.Parent := FPageTransmit; + Result.BevelOuter := bvNone; + Result.Color := CLR_BG; + Result.Tag := TAG_PAGE_BG; // ApplyTheme: фон страницы, не группа + Result.SetBounds(0, DpiScale(ATop), + DpiScale(GRP_W + 2 * MARGIN), DpiScale(400)); + Result.Visible := False; + FTXSubPage[Idx] := Result; + end; + + function MkBtn(AParent: TWinControl; AX, AY, AW: Integer; + const ACap: string; AClick: TNotifyEvent): TFlatButton; + begin + Result := TFlatButton.Create(Self); + Result.Parent := AParent; + Result.Caption := ACap; + Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(BTN_H)); + Result.ClrNorm := CLR_PANEL; + Result.ClrBorder := CLR_BORDER; + Result.ClrHot := TColor($00242424); + Result.ClrActive := TColor($00204030); + Result.ClrText := CLR_TEXT; + Result.ClrTextAct := CLR_ACCENT; + Result.Font.Name := UI_FONT; + Result.Font.Size := 9; + Result.OnClick := AClick; + end; function MkSpin(AParent: TWinControl; AX, AY: Integer; AMin, AMax, AVal: Integer): TFlatSpinEdit; begin @@ -1417,17 +1498,46 @@ begin with TLabel.Create(Self) do begin Parent := FPageTransmit; - Caption := 'TX-цепь WDSP: микрофон, фильтр, динамическая обработка, модуляция.'; - SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(700), DpiScale(20)); + Caption := 'Profile и EQ — содержимое активного TX-профиля; ' + + 'Hardware и Display — свойства устройства, от профиля не зависят.'; + SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(GRP_W), DpiScale(20)); Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; end; - // ---- Mic options ---- - // Mic source (Radio vs SoundCard) определяется автоматически по источнику PTT. - Y := 86; - Grp := MakeGroupPanel(FPageTransmit, 'Microphone', MARGIN, Y, GRP_W, 188); + // ---- Полоса профиля (над подвкладками, видна всегда) -------------------- + Grp := MakeGroupPanel(FPageTransmit, 'TX Profile', MARGIN, 78, GRP_W, 124); + + MakeLbl(Grp, 'Active', PAD, R1 + 5, 60); + FCmbTXProfile := MakeCombo(Grp, PAD + 60, R1, 230, OnTXProfileComboChange); + FBtnTXProfNew := MkBtn(Grp, PAD + 302, R1, 100, 'New...', BtnTXProfNewClick); + FBtnTXProfRen := MkBtn(Grp, PAD + 408, R1, 100, 'Rename...', BtnTXProfRenClick); + FBtnTXProfDel := MkBtn(Grp, PAD + 514, R1, 100, 'Delete', BtnTXProfDelClick); + + MakeLbl(Grp, 'Правки на вкладках Profile и EQ пишутся в активный профиль сразу — ' + + 'отдельной кнопки «сохранить» нет.', PAD, R1 + 30, GRP_W - 2 * PAD); + MakeLbl(Grp, 'New... создаёт копию текущего звука под новым именем.', + PAD, R1 + 48, GRP_W - 2 * PAD); + + // ---- Сегменты подвкладок ------------------------------------------------ + Y := 78 + 124 + 12; + FTXSubBtn[0] := MkBtn(FPageTransmit, MARGIN, Y, 132, 'Profile', TXSubTabClick); + FTXSubBtn[1] := MkBtn(FPageTransmit, MARGIN + 136, Y, 132, 'EQ', TXSubTabClick); + FTXSubBtn[2] := MkBtn(FPageTransmit, MARGIN + 272, Y, 132, 'Hardware', TXSubTabClick); + FTXSubBtn[3] := MkBtn(FPageTransmit, MARGIN + 408, Y, 132, 'Display', TXSubTabClick); + for i := 0 to TXTAB_COUNT - 1 do FTXSubBtn[i].Tag := i; + + Inc(Y, BTN_H + 14); + SubTop := Y; // ★ немасштабированный: FTXSubPage[].Top уже в пикселях + + // ========================================================================= + // Подвкладка 0 — Profile: всё, что определяет звук в эфире + // ========================================================================= + SubPage(0, SubTop); + + // ---- Микрофонный вход (часть профиля: гарнитура vs микшер-ESSB) ---- + Grp := MakeGroupPanel(FTXSubPage[0], 'Microphone', MARGIN, 0, GRP_W, 176); // Row 0: Mic jack selector | Boost (Mic In) или LineInGain (Line In) | Bias | PTT MakeLbl(Grp, 'Mic jack', PAD, R1 + 5, 65); @@ -1444,58 +1554,35 @@ begin FChkTXMicBias := MkChk(Grp, PAD + 375, R1, 105, 'Mic bias'); FChkTXMicPTT := MkChk(Grp, PAD + 490, R1, 160, 'Mic-jack PTT'); - // Row 1: Connector Tip/Ring (только Orion boards 4 и 5) - FLblTipRing := MakeLbl(Grp, 'Connector', PAD, R1 + STEP + 5, 70); - FCmbTXMicTR := MkCmb(Grp, PAD + 73, R1 + STEP, 90); + // Row 1: Mic gain + MakeLbl(Grp, 'Mic gain (dB)', PAD, R1 + STEP + 5, LW); + FEdTXMicGain := MkFloat(Grp, CX, R1 + STEP, 10.0, -20.0, 60.0, 0.5); + + // Row 2: Connector Tip/Ring (только Orion boards 4 и 5). Стоит последним — + // на не-Orion строка скрыта и не оставляет дыру посреди группы. + FLblTipRing := MakeLbl(Grp, 'Connector', PAD, R1 + 2 * STEP + 5, 70); + FCmbTXMicTR := MkCmb(Grp, PAD + 73, R1 + 2 * STEP, 90); FCmbTXMicTR.Items.Add('Tip'); // MicTipRing=False, bit3=0 FCmbTXMicTR.Items.Add('Ring'); // MicTipRing=True, bit3=1 FCmbTXMicTR.ItemIndex := 0; FLblTipRing.Visible := False; FCmbTXMicTR.Visible := False; - // Row 2: Mic gain | ATT on TX - MakeLbl(Grp, 'Mic gain (dB)', PAD, R1 + 2 * STEP + 5, LW); - FEdTXMicGain := MkFloat(Grp, CX, R1 + 2 * STEP, 10.0, -20.0, 60.0, 0.5); - MakeLbl(Grp, 'ATT on TX (dB)', PAD + 320, R1 + 2 * STEP + 5, 110); - FEdTXAttOnTX := MkSpin(Grp, PAD + 320 + 120, R1 + 2 * STEP, 0, 31, 31); - - // ---- Tune (TUN button) ---- - Inc(Y, 188 + 12); - Grp := MakeGroupPanel(FPageTransmit, 'Tune (TUN)', MARGIN, Y, GRP_W, 100); - - MakeLbl(Grp, 'Tune level (%)', PAD, R1 + 5, LW); - FEdTUNLevel := MkSpin(Grp, CX, R1, 0, 100, 10); - MakeLbl(Grp, 'Tone freq (Hz)', PAD + 320, R1 + 5, 110); - FEdTUNFreq := MkSpin(Grp, PAD + 320 + 120, R1, 100, 5000, 750); - - // ---- TX bandpass filter ---- - Inc(Y, 100 + 12); - Grp := MakeGroupPanel(FPageTransmit, 'TX Filter (bandpass)', MARGIN, Y, GRP_W, 220); + // ---- Кромки TX-фильтра (FIR/фаза/окно — на вкладке Hardware) ---- + Y := 176 + 12; + Grp := MakeGroupPanel(FTXSubPage[0], 'TX Filter (bandpass)', MARGIN, Y, GRP_W, 108); MakeLbl(Grp, 'Low cut (Hz)', PAD, R1 + 5, LW); FEdTXFiltLow := MkSpin(Grp, CX, R1, 0, 8000, 200); MakeLbl(Grp, 'High cut (Hz)', PAD + 320, R1 + 5, 90); FEdTXFiltHigh := MkSpin(Grp, PAD + 320 + 100, R1, 100, 12000, 3100); + MakeLbl(Grp, 'Ширина полосы голоса. ESSB — просто High cut 3500..5000; ' + + 'знак боковой ставит движок, здесь всегда магнитуда.', + PAD, R1 + 32, GRP_W - 2 * PAD); - MakeLbl(Grp, 'FIR length', PAD, R1 + STEP + 5, LW); - FCmbTXFiltNC := MkCmb(Grp, CX, R1 + STEP, 140); - FCmbTXFiltNC.Items.AddStrings(['256','512','1024','2048','4096']); - FCmbTXFiltNC.ItemIndex := 2; - - MakeLbl(Grp, 'Phase mode', PAD, R1 + 2 * STEP + 5, LW); - FCmbTXFiltMP := MkCmb(Grp, CX, R1 + 2 * STEP, 200); - FCmbTXFiltMP.Items.AddStrings(['Linear Phase','Low Latency']); - FCmbTXFiltMP.ItemIndex := 1; - - MakeLbl(Grp, 'Window', PAD, R1 + 3 * STEP + 5, LW); - FCmbTXFiltWindow := MkCmb(Grp, CX, R1 + 3 * STEP, 240); - for i := 0 to High(WINDOW_NAMES) do - FCmbTXFiltWindow.Items.Add(WINDOW_NAMES[i]); - FCmbTXFiltWindow.ItemIndex := 1; - - // ---- Compressor / Leveler / ALC / Phase Rotator ---- - Inc(Y, 220 + 12); - Grp := MakeGroupPanel(FPageTransmit, 'Dynamic processing', MARGIN, Y, GRP_W, 220); + // ---- Динамическая обработка ---- + Inc(Y, 108 + 12); + Grp := MakeGroupPanel(FTXSubPage[0], 'Dynamic processing', MARGIN, Y, GRP_W, 220); FChkTXCompOn := MkChk(Grp, PAD, R1, 220, 'Compressor'); MakeLbl(Grp, 'Gain (dB)', PAD + 230, R1 + 5, 80); @@ -1519,9 +1606,28 @@ begin MakeLbl(Grp, 'Corner (Hz)', PAD + 430, R1 + 3 * STEP + 5, 80); FEdTXPHRFreq := MkFloat(Grp, PAD + 520, R1 + 3 * STEP, 338.0, 50.0, 3000.0, 1.0); - // ---- TX EQ ---- + // ---- Мощность и несущая AM (тоже часть профиля) ---- Inc(Y, 220 + 12); - Grp := MakeGroupPanel(FPageTransmit, 'TX Equalizer', MARGIN, Y, GRP_W, 390); + Grp := MakeGroupPanel(FTXSubPage[0], 'Power / AM carrier', MARGIN, Y, GRP_W, 148); + + MakeLbl(Grp, 'Tune level (%)', PAD, R1 + 5, LW); + FEdTUNLevel := MkSpin(Grp, CX, R1, 0, 100, 10); + MakeLbl(Grp, 'AM carrier level', PAD + 320, R1 + 5, 110); + FEdTXAMCarrier := MkFloat(Grp, PAD + 320 + 120, R1, 0.5, 0.0, 1.0, 0.05); + + FLblTXProfDrive := MakeLbl(Grp, 'Drive профиля: —', PAD, R1 + STEP + 5, 300); + MakeLbl(Grp, 'Мощность — часть профиля, но крутится ползунком DRV в главном окне: ' + + 'текущее значение уходит в активный профиль само.', + PAD, R1 + STEP + 28, GRP_W - 2 * PAD); + + FTXSubPage[0].Height := DpiScale(Y + 148 + 12); + + // ========================================================================= + // Подвкладка 1 — EQ (тоже профильный, но занимает целый экран) + // ========================================================================= + SubPage(1, SubTop); + + Grp := MakeGroupPanel(FTXSubPage[1], 'TX Equalizer', MARGIN, 0, GRP_W, 390); FTXEQControl := TEqualizerControl.Create(Self); FTXEQControl.Parent := Grp; @@ -1532,33 +1638,75 @@ begin TColor($00383838), CLR_TEXT, CLR_TEXTDIM, CLR_ACCENT, TColor($0055DDEE)); FTXEQControl.OnChange := OnTXAnyChange; - // ---- AM / FM / CTCSS ---- - Inc(Y, 390 + 12); - Grp := MakeGroupPanel(FPageTransmit, 'AM / FM / CTCSS', MARGIN, Y, GRP_W, 260); + FTXSubPage[1].Height := DpiScale(390 + 12); - MakeLbl(Grp, 'AM carrier level', PAD, R1 + 5, LW); - FEdTXAMCarrier := MkFloat(Grp, CX, R1, 0.5, 0.0, 1.0, 0.05); + // ========================================================================= + // Подвкладка 2 — Hardware: свойства устройства, профилем не переключаются + // ========================================================================= + SubPage(2, SubTop); - MakeLbl(Grp, 'FM deviation (Hz)', PAD, R1 + STEP + 5, LW); - FEdTXFMDev := MkFloat(Grp, CX, R1 + STEP, 5000.0, 1000.0, 25000.0, 100.0); + Grp := MakeGroupPanel(FTXSubPage[2], 'Radio', MARGIN, 0, GRP_W, 108); - MakeLbl(Grp, 'FM AF low cut', PAD, R1 + 2 * STEP + 5, LW); - FEdTXFMLow := MkSpin(Grp, CX, R1 + 2 * STEP, 0, 5000, 300); - MakeLbl(Grp, 'FM AF high cut', PAD + 320, R1 + 2 * STEP + 5, 100); - FEdTXFMHigh := MkSpin(Grp, PAD + 320 + 110, R1 + 2 * STEP, 100, 8000, 3000); + MakeLbl(Grp, 'ATT on TX (dB)', PAD, R1 + 5, LW); + FEdTXAttOnTX := MkSpin(Grp, CX, R1, 0, 31, 31); + MakeLbl(Grp, 'Tune tone (Hz)', PAD + 320, R1 + 5, 110); + FEdTUNFreq := MkSpin(Grp, PAD + 320 + 120, R1, 100, 5000, 750); + MakeLbl(Grp, 'ATT on TX — ослабление RX-ADC на передаче (защита входа), ' + + 'не путать с мощностью.', PAD, R1 + 32, GRP_W - 2 * PAD); - MakeLbl(Grp, 'FM emphasis', PAD, R1 + 3 * STEP + 5, LW); - FCmbTXFMEmph := MkCmb(Grp, CX, R1 + 3 * STEP, 180); + Y := 108 + 12; + Grp := MakeGroupPanel(FTXSubPage[2], 'TX Filter (DSP)', MARGIN, Y, GRP_W, 190); + + MakeLbl(Grp, 'FIR length', PAD, R1 + 5, LW); + FCmbTXFiltNC := MkCmb(Grp, CX, R1, 140); + FCmbTXFiltNC.Items.AddStrings(['256','512','1024','2048','4096']); + FCmbTXFiltNC.ItemIndex := 2; + + MakeLbl(Grp, 'Phase mode', PAD, R1 + STEP + 5, LW); + FCmbTXFiltMP := MkCmb(Grp, CX, R1 + STEP, 200); + FCmbTXFiltMP.Items.AddStrings(['Linear Phase','Low Latency']); + FCmbTXFiltMP.ItemIndex := 1; + + MakeLbl(Grp, 'Window', PAD, R1 + 2 * STEP + 5, LW); + FCmbTXFiltWindow := MkCmb(Grp, CX, R1 + 2 * STEP, 240); + for i := 0 to High(WINDOW_NAMES) do + FCmbTXFiltWindow.Items.Add(WINDOW_NAMES[i]); + FCmbTXFiltWindow.ItemIndex := 1; + + MakeLbl(Grp, 'Крутизна и задержка тракта, а не тембр — поэтому вне профиля.', + PAD, R1 + 3 * STEP + 5, GRP_W - 2 * PAD); + + Inc(Y, 190 + 12); + Grp := MakeGroupPanel(FTXSubPage[2], 'FM / CTCSS', MARGIN, Y, GRP_W, 250); + + MakeLbl(Grp, 'FM deviation (Hz)', PAD, R1 + 5, LW); + FEdTXFMDev := MkFloat(Grp, CX, R1, 5000.0, 1000.0, 25000.0, 100.0); + + MakeLbl(Grp, 'FM AF low cut', PAD, R1 + STEP + 5, LW); + FEdTXFMLow := MkSpin(Grp, CX, R1 + STEP, 0, 5000, 300); + MakeLbl(Grp, 'FM AF high cut', PAD + 320, R1 + STEP + 5, 100); + FEdTXFMHigh := MkSpin(Grp, PAD + 320 + 110, R1 + STEP, 100, 8000, 3000); + + MakeLbl(Grp, 'FM emphasis', PAD, R1 + 2 * STEP + 5, LW); + FCmbTXFMEmph := MkCmb(Grp, CX, R1 + 2 * STEP, 180); FCmbTXFMEmph.Items.AddStrings(['Pre-EQ','Post-EQ']); FCmbTXFMEmph.ItemIndex := 1; - FChkTXCTCSSOn := MkChk(Grp, PAD, R1 + 4 * STEP, 220, 'CTCSS tone'); - MakeLbl(Grp, 'Freq (Hz)', PAD + 230, R1 + 4 * STEP + 5, 80); - FEdTXCTCSSFreq := MkFloat(Grp, PAD + 320, R1 + 4 * STEP, 100.0, 50.0, 300.0, 0.1); + FChkTXCTCSSOn := MkChk(Grp, PAD, R1 + 3 * STEP, 220, 'CTCSS tone'); + MakeLbl(Grp, 'Freq (Hz)', PAD + 230, R1 + 3 * STEP + 5, 80); + FEdTXCTCSSFreq := MkFloat(Grp, PAD + 320, R1 + 3 * STEP, 100.0, 50.0, 300.0, 0.1); - // ---- TX Display (отдельный analyzer для TX-спектра/водопада) ---- - Inc(Y, 260 + 12); - Grp := MakeGroupPanel(FPageTransmit, 'TX Display (Analyzer)', MARGIN, Y, GRP_W, 318); + MakeLbl(Grp, 'Свойство канала/репитера — профилем голоса не переключается.', + PAD, R1 + 4 * STEP + 5, GRP_W - 2 * PAD); + + FTXSubPage[2].Height := DpiScale(Y + 250 + 12); + + // ========================================================================= + // Подвкладка 3 — Display: анализатор и сетка TX-спектра + // ========================================================================= + SubPage(3, SubTop); + + Grp := MakeGroupPanel(FTXSubPage[3], 'TX Display (Analyzer)', MARGIN, 0, GRP_W, 318); MakeLbl(Grp, 'FFT size', PAD, R1 + 5, LW); FCmbTXFFT := MkCmb(Grp, CX, R1, 140); @@ -1595,8 +1743,8 @@ begin FEdTXWfAvgTime := MkFloat(Grp, PAD + 540, R1 + 5 * STEP, 120.0, 1.0, 5000.0, 1.0); // ---- TX Grid (RefLevel / Range / GridStep — отдельные от RX) ---- - Inc(Y, 318 + 12); - Grp := MakeGroupPanel(FPageTransmit, 'TX Grid', MARGIN, Y, GRP_W, 170); + Y := 318 + 12; + Grp := MakeGroupPanel(FTXSubPage[3], 'TX Grid', MARGIN, Y, GRP_W, 170); MakeLbl(Grp, 'Reference level', PAD, R1 + 5, LW); FEdTXRefLevel := TFlatEdit.Create(Self); @@ -1624,8 +1772,12 @@ begin FCmbTXGridStep := MkCmb(Grp, CX, R1 + 2 * STEP, 130); for i := 0 to High(GRID_STEP_NAMES) do FCmbTXGridStep.Items.Add(GRID_STEP_NAMES[i]); FCmbTXGridStep.ItemIndex := 1; -end; + FTXSubPage[3].Height := DpiScale(Y + 170 + 12); + + FTXSubIdx := -1; + SelectTXSubPage(0); +end; procedure TSettingsForm.CollectTXFromUI; var i: Integer; begin @@ -1729,6 +1881,115 @@ begin FireTXChange; end; +// --------------------------------------------------------------------------- +// Transmit: подвкладки и полоса TX-профиля +// --------------------------------------------------------------------------- + +procedure TSettingsForm.SelectTXSubPage(Idx: Integer); +var i: Integer; +begin + if (Idx < 0) or (Idx >= TXTAB_COUNT) then Exit; + FTXSubIdx := Idx; + for i := 0 to TXTAB_COUNT - 1 do + begin + if FTXSubPage[i] <> nil then FTXSubPage[i].Visible := (i = Idx); + if FTXSubBtn[i] <> nil then FTXSubBtn[i].Active := (i = Idx); + end; +end; + +procedure TSettingsForm.TXSubTabClick(Sender: TObject); +begin + if Sender is TFlatButton then SelectTXSubPage(TFlatButton(Sender).Tag); +end; + +procedure TSettingsForm.OnTXProfileComboChange(Sender: TObject); +begin + if FLoading then Exit; + if (FCmbTXProfile = nil) or (FCmbTXProfile.ItemIndex < 0) then Exit; + // Применение снимка в эфир — снаружи (контроллер); назад придёт LoadTXSettings + // с уже новыми значениями, форма перерисуется целиком. + if Assigned(FOnTXProfileSelect) then FOnTXProfileSelect(FCmbTXProfile.ItemIndex); +end; + +procedure TSettingsForm.BtnTXProfNewClick(Sender: TObject); +var S: string; +begin + if FCmbTXProfile = nil then Exit; + if FCmbTXProfile.Items.Count >= TXPROF_MAX then + begin + MessageDlg('TX Profile', + Format('Больше %d профилей на устройство не поддерживается.', [TXPROF_MAX]), + mtInformation, [mbOK], 0); + Exit; + end; + S := 'Profile ' + IntToStr(FCmbTXProfile.Items.Count + 1); + if not InputQuery('TX Profile', 'Имя нового профиля (копия текущего звука):', S) then Exit; + if Trim(S) = '' then Exit; + if Assigned(FOnTXProfileAdd) then FOnTXProfileAdd(Trim(S)); +end; + +procedure TSettingsForm.BtnTXProfRenClick(Sender: TObject); +var S: string; Idx: Integer; +begin + if FCmbTXProfile = nil then Exit; + Idx := FCmbTXProfile.ItemIndex; + if (Idx < 0) or (Idx >= FCmbTXProfile.Items.Count) then Exit; + S := FCmbTXProfile.Items[Idx]; + if not InputQuery('TX Profile', 'Новое имя профиля:', S) then Exit; + if Trim(S) = '' then Exit; + if Assigned(FOnTXProfileRename) then FOnTXProfileRename(Idx, Trim(S)); +end; + +procedure TSettingsForm.BtnTXProfDelClick(Sender: TObject); +var Idx: Integer; +begin + if FCmbTXProfile = nil then Exit; + Idx := FCmbTXProfile.ItemIndex; + // Последний профиль не удаляем — активному профилю всегда есть куда писать. + if (Idx < 0) or (FCmbTXProfile.Items.Count <= 1) then Exit; + if MessageDlg('TX Profile', + 'Удалить профиль «' + FCmbTXProfile.Items[Idx] + '»?', + mtConfirmation, [mbYes, mbNo], 0) <> mrYes then Exit; + if Assigned(FOnTXProfileDelete) then FOnTXProfileDelete(Idx); +end; + +procedure TSettingsForm.LoadTXProfiles(const Names: array of string; + ActiveIdx, DrivePct: Integer); +var + i: Integer; + WasLoading: Boolean; +begin + if FCmbTXProfile = nil then Exit; + WasLoading := FLoading; + FLoading := True; // заполнение списка не должно шибать OnChange + try + FCmbTXProfile.Items.Clear; + for i := 0 to High(Names) do FCmbTXProfile.Items.Add(Names[i]); + if (ActiveIdx >= 0) and (ActiveIdx < FCmbTXProfile.Items.Count) then + FCmbTXProfile.ItemIndex := ActiveIdx; + if FBtnTXProfDel <> nil then + FBtnTXProfDel.Enabled := FCmbTXProfile.Items.Count > 1; + if FLblTXProfDrive <> nil then + FLblTXProfDrive.Caption := Format('Drive профиля: %d %%', [DrivePct]); + finally + FLoading := WasLoading; + end; +end; + +procedure TSettingsForm.SetTXProfileDrive(DrivePct: Integer); +begin + if FLblTXProfDrive <> nil then + FLblTXProfDrive.Caption := Format('Drive профиля: %d %%', [DrivePct]); +end; + +procedure TSettingsForm.ShowTransmitPage(SubIdx: Integer = 0); +begin + SelectPage(FPageTransmit, FNavTransmit); + SelectTXSubPage(SubIdx); + Show; + BringToFront; +end; + procedure TSettingsForm.LoadTXSettings(const T: TTXSettings; BoardType: Integer = 0); var i: Integer; IsOrion: Boolean; begin @@ -2708,6 +2969,8 @@ procedure TSettingsForm.ApplyTheme(const T: TAppTheme); begin if TPanel(Ctrl).Height = 1 then TPanel(Ctrl).Color := T.Border + else if TPanel(Ctrl).Tag = TAG_PAGE_BG then + TPanel(Ctrl).Color := T.BG // подложка подвкладки, не группа else TPanel(Ctrl).Color := T.Panel; WalkControls(TPanel(Ctrl)); From c8781bee6aa7e0c80a69e7fca6d3107a479500bf Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 14:50:06 +0300 Subject: [PATCH 02/30] =?UTF-8?q?fix(tx):=20=D0=BF=D0=BE=D0=BB=D0=BE=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=20TX-=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D1=80=D0=B8=D1=81=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D0=B5=D1=82=D1=81=D1=8F=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BC=D0=B5=D0=BD=D0=B5=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B5=20?= =?UTF-8?q?Low/High?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Кромки TX-полосы в SpectrumView обновляла только PushFilterEdgesToView, которую звали из rfMOX/rfMode/rfFilter. Смена TX-профиля (rfTXProfile) и правка Low/High во вкладке Transmit перешивали WDSP, но вьюха продолжала рисовать старую полосу до следующего MOX или смены режима — на передаче эти события не приходят вовсе. Зовём PushFilterEdgesToView из RenderTXProfile и из OnTXSettingsChange (кромки берутся из движка уже со знаком боковой, TXSignedEdges). Кадр форсируем через MainForm.FSpectrumDirty — он же покрывает TX со слайса, где FTXOverlay=False, а полоса слайса читает те же FTXEdgeLo/Hi. Co-Authored-By: Claude Opus 5 --- MainForm.pas | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/MainForm.pas b/MainForm.pas index a0fc03b..c487c9a 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -5689,6 +5689,10 @@ begin else BtnTXProfile.Caption := 'TX Profile'; BtnTXProfile.Invalidate; + // Профиль несёт кромки TX-фильтра: на передаче красная полоска обязана + // переехать сразу, а не ждать следующего rfMOX/rfFilter (их на TX не будет). + PushFilterEdgesToView; + FSpectrumDirty := True; if FTXProfileDropDown <> nil then begin SetLength(Names, Cnt); @@ -7930,6 +7934,10 @@ begin if GridChanged and FController.FTransmitting then ApplySpecViewGridFromState; FController.ApplyTXSettingsToDSP; + // Low/High из вкладки Transmit — та же полоска фильтра на спектре: кромки + // берём уже пересчитанными движком (знак боковой) и на передаче перерисовываем. + PushFilterEdgesToView; + if FController.FTransmitting then FSpectrumDirty := True; if (OldMicByte <> NewMicByte) or (OldAtt <> FController.FTXSettings.AttOnTX) then FController.SendDUCSpecificFromSettings; // TUN-параметры live: если идёт tune — обновить тон/drive, иначе просто запомнить. From 8563c4e4cc5339fc6f51367c0e71aa14e059be47 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 15:47:12 +0300 Subject: [PATCH 03/30] =?UTF-8?q?feat(tx):=20=D0=B7=D0=B0=D0=B2=D0=BE?= =?UTF-8?q?=D0=B4=D1=81=D0=BA=D0=B8=D0=B5=20EQ-=D0=BA=D1=80=D0=B8=D0=B2?= =?UTF-8?q?=D1=8B=D0=B5=20ESSB,=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=90=D0=A7=D0=A5=20=D0=B2=20=D1=80=D0=B5=D0=B4=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80=D0=B5,=20=D1=84=D0=B8=D0=BA=D1=81=203-?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BE=D1=81=D0=BD=D0=BE=D0=B3=D0=BE=20EQ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ключ ко всему — семантика EQ в WDSP (wdsp/eq.c, eq_impulse + TXA.c): F[1..n] это УЗЛЫ ломаной АЧХ, между ними интерполяция по децибелам, а за крайними узлами при ctfmode=0 (так создаётся eqp у TXA, мы его не меняем) идёт кумулятивный скат (f/f0)^4 на каждый бин — фактически обрыв. Значит включённый EQ работает как второй полосовой фильтр, и верхний узел обязан лежать за верхней кромкой TX-фильтра. Settings: заводские ESSB несут свою кривую (AddEQ) — ESSB 3.5k узлы 100..3600, ESSB 5k узлы 100..5100, преамп в минус (EQ стоит до компрессора и ALC). EQ у Default/SSB DX/SSB Wide сбрасывается в заводской ноль (FlatEQ), а не наследует живой, иначе «сброс к заводским» не сбрасывал бы тембр. Сетка узлов вынесена в модульную DEF_EQ_FREQS. WDSPEngine: единая точка пуша EQ (PushTXEQProfile). Починен 3-полосный режим: раньше в WDSP уходили первые три узла 10-полосной сетки (100/200/400) и включённый EQ убивал весь голос выше 400 Гц; теперь legacy-раскладка самого WDSP (150/400/1500/6000, как SetTXAGrphEQ). EqualizerControl: кривая — точный порт eq_impulse вместо гауссовых горбов (сверено с C численно, расхождение 0.0 дБ), рисуется без клампа ±15, чтобы обрыв за крайними узлами был виден. В 3-полосном режиме ручки стоят на 150/1500/6000 и по частоте не таскаются, сетка профиля при этом цела. RadioController/SettingsForm/MainForm: кнопка Factory — пересборка заводского набора с применением Default. Без неё обновлённые заводские профили не доедут до тех, у кого секция tx_profiles уже записана: она читается, а не создаётся. Co-Authored-By: Claude Opus 5 --- EqualizerControl.pas | 158 +++++++++++++++++++++++++++++++++++++------ MainForm.pas | 7 ++ RadioController.pas | 17 +++++ Settings.pas | 89 ++++++++++++++++++++---- SettingsForm.pas | 37 +++++++++- WDSPEngine.pas | 59 +++++++++++----- 6 files changed, 312 insertions(+), 55 deletions(-) diff --git a/EqualizerControl.pas b/EqualizerControl.pas index 1102e89..428e64d 100644 --- a/EqualizerControl.pas +++ b/EqualizerControl.pas @@ -31,6 +31,7 @@ type FBtnBands3: TFlatButton; FBtnBands10: TFlatButton; FButtonTheme: TAppTheme; + FRespDB: array of Double; // АЧХ по бинам (дБ), пересчитывается в Paint function GetBandGain(Index: Integer): Double; function GetBandFreq(Index: Integer): Double; procedure SetEnabledEQ(V: Boolean); @@ -47,7 +48,9 @@ type function BandX(Index: Integer; const R: TRect): Integer; function XToFreq(X: Integer; const R: TRect): Double; function BandColor(Index: Integer): TColor; + function GridBandFreq(Index: Integer): Double; function InternalBandFreq(Index: Integer): Double; + procedure BuildResponse; function ResponseAtFreq(AFreq: Double): Double; function FormatFreq(AFreq: Double): string; function HitTestControl(X, Y: Integer): Integer; @@ -93,6 +96,20 @@ const MAX_FREQ = 20000.0; TOP_H = 44; BOTTOM_H = 82; + // Кривая = точное повторение расчёта WDSP (wdsp/eq.c, eq_impulse) под + // геометрию НАШЕГО TXA: канал открыт с dsp_rate = FAudioRate (48 кГц), + // eqp создаётся с nc = max(2048, dsp_size) и ctfmode = 0 (TXA.c) — + // SetTXAEQNC/SetTXAEQCtfmode мы не зовём. Отсюда N = 2048 ⇒ N/2 бинов + // до Найквиста. Рисовать «колокольчики», как принято у графических EQ, + // нельзя: WDSP строит ломаную по точкам и обрывает всё за крайними. + RESP_RATE = 48000.0; + RESP_MID = 1024; // N/2 + RESP_BIN_HZ = RESP_RATE / 2.0 / RESP_MID; // 23.4375 Hz + RESP_FLOOR = -2000.0; // 1e-100 в амплитуде — пол WDSP + // Legacy-раскладка 3-полосного EQ у самого WDSP (SetTXAGrphEQ) — её же + // отдаёт движок (WDSPEngine.PushTXEQProfile). Частоты фиксированы, ручка + // Low держит полку 150..400 Гц, High тянется до 6 кГц. + EQ3_F: array[1..3] of Double = (150.0, 1500.0, 6000.0); constructor TEqualizerControl.Create(AOwner: TComponent); var @@ -300,7 +317,8 @@ begin else Result := FClrAccent; end; -function TEqualizerControl.InternalBandFreq(Index: Integer): Double; +function TEqualizerControl.GridBandFreq(Index: Integer): Double; +// Узел 10-полосной сетки профиля (то, что реально хранится и сохраняется). begin if (Index >= 1) and (Index <= 10) and (FFreqs[Index] > 0.0) then Result := FFreqs[Index] @@ -308,24 +326,113 @@ begin Result := MIN_FREQ * Power(MAX_FREQ / MIN_FREQ, EnsureRange((Index - 1) / 9, 0.0, 1.0)); end; -function TEqualizerControl.ResponseAtFreq(AFreq: Double): Double; -var - i, N: Integer; - D, W, Sum, LF, LBand: Double; +function TEqualizerControl.InternalBandFreq(Index: Integer): Double; +// Частота, на которой ручка ДЕЙСТВУЕТ. В 3-полосном режиме это не наша сетка, +// а legacy-раскладка WDSP; FFreqs при этом не трогается, поэтому возврат к +// 10 полосам отдаёт пользователю его же кривую. begin - Result := FPreampGain; - LF := Ln(EnsureRange(AFreq, MIN_FREQ, MAX_FREQ)); - Sum := 0.0; - N := FBandCount; - for i := 1 to N do + if FBandCount = 3 then + Result := EQ3_F[EnsureRange(Index, 1, 3)] + else + Result := GridBandFreq(Index); +end; + +procedure TEqualizerControl.BuildResponse; +// Порт eq_impulse (wdsp/eq.c) в дБ, без построения самого FIR. Точки F/G — +// узлы ломаной: между ними линейная интерполяция ПО ДЕЦИБЕЛАМ, ниже первой и +// выше последней точки ctfmode=0 даёт кумулятивный скат (f/f0)^4 НА КАЖДЫЙ +// бин — то есть фактически обрыв. Именно поэтому включённый EQ работает как +// второй полосовой фильтр, и верхняя точка обязана лежать за краем TX-фильтра. +var + N, i, j, k, Lo, Hi: Integer; + Fp, Gp: array[0..11] of Double; + TF, TG, F, Frac, Preamp, MagDB, RefDB: Double; +begin + if Length(FRespDB) <> RESP_MID then SetLength(FRespDB, RESP_MID); + Preamp := FPreampGain; + if FBandCount = 3 then begin - LBand := Ln(InternalBandFreq(i)); - D := (LF - LBand) / 0.30; - W := Exp(-0.5 * D * D); - Sum := Sum + FGains[i] * W; + // Ровно то, что уходит в WDSP из PushTXEQProfile: 4 узла, полка на низах. + N := 4; + Fp[1] := 2.0 * 150.0 / RESP_RATE; Gp[1] := FGains[1]; + Fp[2] := 2.0 * 400.0 / RESP_RATE; Gp[2] := FGains[1]; + Fp[3] := 2.0 * 1500.0 / RESP_RATE; Gp[3] := FGains[2]; + Fp[4] := 2.0 * 6000.0 / RESP_RATE; Gp[4] := FGains[3]; + end + else + begin + N := EnsureRange(FBandCount, 1, 10); + for i := 1 to N do + begin + Fp[i] := EnsureRange(2.0 * InternalBandFreq(i) / RESP_RATE, 0.0, 1.0); + Gp[i] := FGains[i]; + end; + end; + // WDSP сортирует точки по частоте (qsort) — порядок ручек значения не имеет. + for i := 1 to N - 1 do + for j := 1 to N - i do + if Fp[j] > Fp[j + 1] then + begin + TF := Fp[j]; Fp[j] := Fp[j + 1]; Fp[j + 1] := TF; + TG := Gp[j]; Gp[j] := Gp[j + 1]; Gp[j + 1] := TG; + end; + Fp[0] := 0.0; Gp[0] := Gp[1]; + Fp[N + 1] := 1.0; Gp[N + 1] := Gp[N]; + j := 0; + for i := 0 to RESP_MID - 1 do + begin + F := (i + 0.5) / RESP_MID; + while (j < N) and (F > Fp[j + 1]) do Inc(j); + if Fp[j + 1] > Fp[j] then Frac := (F - Fp[j]) / (Fp[j + 1] - Fp[j]) + else Frac := 0.0; + FRespDB[i] := Frac * Gp[j + 1] + (1.0 - Frac) * Gp[j] + Preamp; + end; + // Скаты за крайними точками (ctfmode = 0, чётная ветка N). Множитель на бин + // — (f/f0)^4 в АМПЛИТУДЕ, в децибелах 80*log10(f/f0), и он накапливается. + Lo := Trunc(Fp[1] * RESP_MID - 0.5); + Hi := Trunc(Fp[N] * RESP_MID - 0.5); + if (Lo > 0) and (Lo < RESP_MID) then + begin + MagDB := FRespDB[Lo]; + RefDB := 80.0 * Log10(Lo / RESP_MID); + for k := Lo - 1 downto 0 do + begin + if k > 0 then MagDB := MagDB + 80.0 * Log10(k / RESP_MID) - RefDB + else MagDB := RESP_FLOOR; + if MagDB < RESP_FLOOR then MagDB := RESP_FLOOR; + FRespDB[k] := MagDB; + end; + end; + if (Hi >= 0) and (Hi < RESP_MID - 1) then + begin + MagDB := FRespDB[Hi]; + RefDB := 80.0 * Log10(Hi / RESP_MID); + for k := Hi + 1 to RESP_MID - 1 do + begin + MagDB := MagDB + RefDB - 80.0 * Log10(k / RESP_MID); + if MagDB < RESP_FLOOR then MagDB := RESP_FLOOR; + FRespDB[k] := MagDB; + end; + end; +end; + +function TEqualizerControl.ResponseAtFreq(AFreq: Double): Double; +// Значение готовой АЧХ на частоте: линейная интерполяция между центрами бинов. +var + Pos: Double; + K: Integer; +begin + if Length(FRespDB) <> RESP_MID then BuildResponse; + Pos := AFreq / RESP_BIN_HZ - 0.5; + if Pos <= 0.0 then + Result := FRespDB[0] + else if Pos >= RESP_MID - 1 then + Result := FRespDB[RESP_MID - 1] + else + begin + K := Trunc(Pos); + Result := FRespDB[K] + (Pos - K) * (FRespDB[K + 1] - FRespDB[K]); end; - Result := Result + Sum; - Result := EnsureRange(Result, -24.0, 24.0); end; function TEqualizerControl.FormatFreq(AFreq: Double): string; @@ -387,7 +494,10 @@ begin Canvas.Brush.Style := bsClear; Canvas.Font.Color := FClrTextDim; - S := FormatFreq(InternalBandFreq(i)); + // Активные плитки подписаны рабочей частотой, погашенные — своим узлом + // сетки: иначе в 3-полосном режиме все семь показывали бы 6 кГц. + if i <= FBandCount then S := FormatFreq(InternalBandFreq(i)) + else S := FormatFreq(GridBandFreq(i)); Canvas.TextOut(TR.Left + 6, TR.Top + 34, S); Canvas.Font.Color := C; Canvas.TextOut(TR.Left + 6, TR.Top + 49, FormatFloat('0.0', FGains[i])); @@ -527,6 +637,7 @@ begin Canvas.Pen.Color := FClrBorder; Canvas.Line(R.Left + 1, ZeroY, R.Right - 1, ZeroY); + BuildResponse; // кривая считается заново на каждую отрисовку (1024 бина) Canvas.Brush.Color := TColor($00304438); Canvas.Pen.Color := TColor($00304438); FillBottom := ZeroY; @@ -537,7 +648,9 @@ begin begin F := XToFreq(GX, R); G := ResponseAtFreq(F); - GY := GainToY(EnsureRange(G, MIN_GAIN, MAX_GAIN), R); + // Клампа по ±15 дБ нет намеренно: за крайними точками WDSP обрывает АЧХ, + // и кривая обязана уходить в пол графика — иначе обрыв не виден. + GY := GainToY(G, R); if GX = R.Left then LastFillY := GY else @@ -547,13 +660,13 @@ begin end; PrevX := CurveL; - PrevY := GainToY(EnsureRange(ResponseAtFreq(XToFreq(CurveL, R)), MIN_GAIN, MAX_GAIN), R); + PrevY := GainToY(ResponseAtFreq(XToFreq(CurveL, R)), R); Canvas.Pen.Color := FClrCurve; Canvas.Pen.Width := 2; for GX := CurveL + 1 to CurveR do begin F := XToFreq(GX, R); - GY := GainToY(EnsureRange(ResponseAtFreq(F), MIN_GAIN, MAX_GAIN), R); + GY := GainToY(ResponseAtFreq(F), R); Canvas.Line(PrevX, PrevY, GX, GY); PrevX := GX; PrevY := GY; @@ -621,7 +734,8 @@ begin begin FDragging := H; BandGain[H] := YToGain(Y, GraphRect); - BandFreq[H] := XToFreq(X, GraphRect); + // В 3-полосном режиме частоты задаёт WDSP (EQ3_F) — двигаем только гейн. + if FBandCount <> 3 then BandFreq[H] := XToFreq(X, GraphRect); end; end; end; @@ -636,7 +750,7 @@ begin else if FDragging > 0 then begin BandGain[FDragging] := YToGain(Y, GraphRect); - BandFreq[FDragging] := XToFreq(X, GraphRect); + if FBandCount <> 3 then BandFreq[FDragging] := XToFreq(X, GraphRect); end else begin diff --git a/MainForm.pas b/MainForm.pas index c487c9a..35b3513 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -532,6 +532,7 @@ type procedure OnTXProfileAddFromSettings(const AName: string); procedure OnTXProfileRenameFromSettings(Idx: Integer; const AName: string); procedure OnTXProfileDeleteFromSettings(Idx: Integer); + procedure OnTXProfileFactoryFromSettings(Sender: TObject); procedure OnChannelStoreChanged(Sender: TObject); procedure OnChannelActiveChanged(Idx: Integer; const Name: string; Active: Boolean); procedure PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton; @@ -5769,6 +5770,11 @@ begin FController.DeleteTXProfile(Idx); end; +procedure TMainForm.OnTXProfileFactoryFromSettings(Sender: TObject); +begin + FController.ResetTXProfilesToFactory; +end; + procedure TMainForm.OnChannelActiveChanged(Idx: Integer; const Name: string; Active: Boolean); begin // Рендер кнопки Channel по событию адаптера каналов (Active=True → подсветка+имя). @@ -8778,6 +8784,7 @@ begin SF.OnTXProfileAdd := OnTXProfileAddFromSettings; SF.OnTXProfileRename := OnTXProfileRenameFromSettings; SF.OnTXProfileDelete := OnTXProfileDeleteFromSettings; + SF.OnTXProfileFactory := OnTXProfileFactoryFromSettings; SF.OnAlexChange := OnAlexSettingsChange; SF.OnXvtrChange := OnXvtrSettingsChange; SF.OnOCChange := OnOCSettingsChange; diff --git a/RadioController.pas b/RadioController.pas index fceae6a..17126a9 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -595,6 +595,7 @@ type function AddTXProfile(const AName: string): Integer; // форк текущего звука procedure RenameTXProfile(Idx: Integer; const AName: string); procedure DeleteTXProfile(Idx: Integer); + procedure ResetTXProfilesToFactory; // заводской набор заново procedure SaveTXProfilesToConfig; // ---- Управление диапазонами (состояние + DSP; рендер через события) ---- @@ -5046,6 +5047,22 @@ begin Changed(rfTXProfile); end; +procedure TRadioController.ResetTXProfilesToFactory; +// Пересобирает заводской набор поверх текущего списка. База — ЖИВЫЕ настройки, +// поэтому «Default» = микрофонный вход, полоса, динамика и мощность как сейчас, +// но с заводским (ровным) EQ. Единственный способ доехать до обновлённых +// заводских профилей с уже записанной секцией tx_profiles: она читается, а не +// пересоздаётся. +begin + DefaultTXProfiles(FTXSettings, FDrivePercent, FTXProfiles); + // ActiveIdx гасим перед Select: иначе StoreActiveTXProfile внутри него + // впишет в свежий «Default» живой EQ и сброс тембра тут же отменится. + FTXProfiles.ActiveIdx := -1; + // Применяем «Default» в эфир — иначе живой EQ остался бы кривым, а профиль + // говорил бы «ровный», и первая же правка TX-настроек вернула бы кривую. + SelectTXProfile(0); // сам сохранит и отрендерит +end; + procedure TRadioController.ApplyTXSettingsToDSP; begin if not FWDSPReady then Exit; diff --git a/Settings.pas b/Settings.pas index 7bb30da..8a0bc18 100644 --- a/Settings.pas +++ b/Settings.pas @@ -1536,6 +1536,14 @@ begin end; end; +const + // Заводская 10-полосная голосовая сетка EQ (Hz). [0]=слот преампа (частота + // не используется), [1..10]=узлы кривой. Подобрано под голос, а не под + // музыку: больше точек в 100..3500 Hz. Нужна и DefaultTX, и заводским + // профилям (сброс тембра в ноль), поэтому живёт на уровне модуля. + DEF_EQ_FREQS: array[0..10] of Double = ( + 0, 100, 200, 400, 700, 1100, 1500, 2000, 2500, 3000, 3500); + // --------------------------------------------------------------------------- // TX settings (per-device, JSON-секция "tx" под MAC) // --------------------------------------------------------------------------- @@ -1543,12 +1551,6 @@ end; class procedure TSettingsManager.DefaultTX(out T: TTXSettings); var i: Integer; -const - // 10-полосный голосовой EQ (Hz). [0]=preamp slot, [1..10]=bands. - // Подобрано под голос, а не под музыку: больше точек в 100..3500 Hz. - DefaultEQFreqs: array[0..10] of Double = ( - 0, // [0] preamp slot — частота не используется - 100, 200, 400, 700, 1100, 1500, 2000, 2500, 3000, 3500); begin FillChar(T, SizeOf(T), 0); T.MicLineIn := False; @@ -1590,7 +1592,7 @@ begin for i := 0 to 10 do begin T.EQGains[i] := 0.0; - T.EQFreqs[i] := DefaultEQFreqs[i]; + T.EQFreqs[i] := DEF_EQ_FREQS[i]; end; // AM T.AMCarrierLevel := 0.5; @@ -1862,6 +1864,36 @@ end; procedure DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer; out L: TTXProfileList); +const + // Кривые EQ для заводских ESSB. Значения — УЗЛЫ ломаной АЧХ WDSP (eq.c), + // а не «полосы»: между узлами интерполяция по дБ, а ЗА крайними узлами + // ctfmode=0 обрывает АЧХ на нет. Отсюда железное правило, по которому + // подобраны частоты: нижний узел ≤ FilterLow, верхний ≥ FilterHigh, иначе + // включённый EQ сам срежет полосу, ради которой ESSB и включают. + // Форма: срез бубнения снизу, ровная середина, мягкий подъём присутствия + // к верху. Преамп в минус — EQ стоит ДО компрессора и ALC, и плюсовые + // гейны иначе просто загоняют ALC в ограничение. + ESSB35_F: array[1..10] of Double = (100, 160, 250, 400, 700, 1200, 1800, 2500, 3100, 3600); + ESSB35_G: array[1..10] of Double = ( -6, -4, -2, 0, 0, 1, 2, 3, 4, 4); + ESSB35_PRE = -2.0; + ESSB50_F: array[1..10] of Double = (100, 180, 300, 550, 950, 1600, 2400, 3300, 4200, 5100); + ESSB50_G: array[1..10] of Double = ( -5, -3, -1, 0, 0, 1, 2, 3.5, 5, 5); + ESSB50_PRE = -3.0; + + // Тембр у заводского профиля — заводской: EQ выключен, все усиления и + // преамп в нуле, сетка узлов штатная. Наследовать сюда живой EQ нельзя — + // иначе «сброс к заводским» не сбрасывал бы то, ради чего его жмут. + procedure FlatEQ(var P: TTXProfile); + var i: Integer; + begin + P.EQOn := False; + P.EQNumBands := 10; + for i := 0 to 10 do + begin + P.EQGains[i] := 0.0; + P.EQFreqs[i] := DEF_EQ_FREQS[i]; + end; + end; procedure Add(const AName: string; ALow, AHigh: Integer; AComp: Boolean; ACompGain: Double); @@ -1873,24 +1905,55 @@ procedure DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer; L.Items[L.Count].FilterHigh := AHigh; L.Items[L.Count].CompressorOn := AComp; L.Items[L.Count].CompressorGain := ACompGain; + FlatEQ(L.Items[L.Count]); Inc(L.Count); end; + // То же плюс своя кривая EQ: у ESSB тембр — часть профиля, наследовать + // сетку 100..3500 от текущих настроек здесь нельзя (см. правило выше). + procedure AddEQ(const AName: string; ALow, AHigh: Integer; + AComp: Boolean; ACompGain: Double; + const AF, AG: array of Double; APreamp: Double); + var + i, Before: Integer; + begin + Before := L.Count; + Add(AName, ALow, AHigh, AComp, ACompGain); + if L.Count = Before then Exit; // упёрлись в TXPROF_MAX — ничего не добавлено + with L.Items[L.Count - 1] do + begin + EQOn := True; + EQNumBands := 10; + EQFreqs[0] := 0.0; // слот преампа — частота не используется + EQGains[0] := APreamp; + for i := 1 to 10 do + begin + EQFreqs[i] := AF[i - 1]; + EQGains[i] := AG[i - 1]; + end; + end; + end; + begin FillChar(L, SizeOf(L), 0); L.Count := 0; L.ActiveIdx := 0; - // «Default» — ровно текущее состояние устройства: после миграции старого - // конфига звук в эфире не меняется ни на йоту. + // «Default» — текущее состояние устройства (микрофонный вход, полоса, + // динамика, мощность), но с заводским EQ: после миграции старого конфига + // звук в эфире не меняется, а сброс к заводским возвращает ровный тембр. TXProfileCapture(Base, DrivePct, L.Items[0]); L.Items[0].Name := 'Default'; + FlatEQ(L.Items[0]); L.Count := 1; - // Остальные заводские отличаются от Default ТОЛЬКО полосой и компрессором; - // микрофонный вход, EQ и мощность наследуются (см. коммент к DefaultTXProfiles). + // SSB-профили отличаются от Default только полосой и компрессором; + // микрофонный вход и мощность наследуются, EQ у всех троих заводской-ровный. + // ESSB несут ещё и свою кривую: без неё «широкий» профиль звучит тем же + // телефонным тембром, а с чужой сеткой (верхний узел 3500) EQ обрезал бы + // верх ESSB 5k. Add('SSB DX', 300, 2700, True, 8.0); Add('SSB Wide', 200, 3100, False, 5.0); - Add('ESSB 3.5k', 100, 3500, False, 5.0); - Add('ESSB 5k', 100, 5000, False, 5.0); + AddEQ('ESSB 3.5k', 100, 3500, False, 5.0, ESSB35_F, ESSB35_G, ESSB35_PRE); + AddEQ('ESSB 5k', 100, 5000, False, 5.0, ESSB50_F, ESSB50_G, ESSB50_PRE); end; function TSettingsManager.LoadTXProfiles(const MAC: array of Byte; diff --git a/SettingsForm.pas b/SettingsForm.pas index 31bf755..912e99a 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -226,6 +226,7 @@ type FBtnTXProfNew: TFlatButton; FBtnTXProfRen: TFlatButton; FBtnTXProfDel: TFlatButton; + FBtnTXProfFactory: TFlatButton; FLblTXProfDrive: TLabel; // «Drive профиля: N %» (ползунок — в главном окне) FCmbTXMicJack: TFlatComboBox; // "Mic In" / "Line In" FChkTXMicBoost: TFlatCheckBox; // +20 dB (только Mic In) @@ -438,6 +439,7 @@ type FOnTXProfileAdd: TOnTXProfileName; FOnTXProfileRename: TOnTXProfileIdxName; FOnTXProfileDelete: TOnTXProfileIdx; + FOnTXProfileFactory: TNotifyEvent; FLoading: Boolean; @@ -496,6 +498,7 @@ type procedure BtnTXProfNewClick(Sender: TObject); procedure BtnTXProfRenClick(Sender: TObject); procedure BtnTXProfDelClick(Sender: TObject); + procedure BtnTXProfFactoryClick(Sender: TObject); procedure ApplyTheme(const T: TAppTheme); procedure NavClick(Sender: TObject); procedure SelectPage(APage: TWinControl; ANav: TFlatButton); @@ -655,6 +658,7 @@ type property OnTXProfileAdd: TOnTXProfileName read FOnTXProfileAdd write FOnTXProfileAdd; property OnTXProfileRename: TOnTXProfileIdxName read FOnTXProfileRename write FOnTXProfileRename; property OnTXProfileDelete: TOnTXProfileIdx read FOnTXProfileDelete write FOnTXProfileDelete; + property OnTXProfileFactory: TNotifyEvent read FOnTXProfileFactory write FOnTXProfileFactory; property OnWfAGCNFChange: TOnWfAGCNFChange read FOnWfAGCNFChange write FOnWfAGCNFChange; property OnADCChange: TOnADCChange read FOnADCChange write FOnADCChange; property OnWebSettingsChange: TOnWebSettingsChange read FOnWebSettingsChange write FOnWebSettingsChange; @@ -1514,10 +1518,12 @@ begin FBtnTXProfNew := MkBtn(Grp, PAD + 302, R1, 100, 'New...', BtnTXProfNewClick); FBtnTXProfRen := MkBtn(Grp, PAD + 408, R1, 100, 'Rename...', BtnTXProfRenClick); FBtnTXProfDel := MkBtn(Grp, PAD + 514, R1, 100, 'Delete', BtnTXProfDelClick); + FBtnTXProfFactory := MkBtn(Grp, PAD + 620, R1, 100, 'Factory', BtnTXProfFactoryClick); MakeLbl(Grp, 'Правки на вкладках Profile и EQ пишутся в активный профиль сразу — ' + 'отдельной кнопки «сохранить» нет.', PAD, R1 + 30, GRP_W - 2 * PAD); - MakeLbl(Grp, 'New... создаёт копию текущего звука под новым именем.', + MakeLbl(Grp, 'New... создаёт копию текущего звука под новым именем. ' + + 'Factory — заводской набор заново, эквалайзер в ноль.', PAD, R1 + 48, GRP_W - 2 * PAD); // ---- Сегменты подвкладок ------------------------------------------------ @@ -1627,7 +1633,7 @@ begin // ========================================================================= SubPage(1, SubTop); - Grp := MakeGroupPanel(FTXSubPage[1], 'TX Equalizer', MARGIN, 0, GRP_W, 390); + Grp := MakeGroupPanel(FTXSubPage[1], 'TX Equalizer', MARGIN, 0, GRP_W, 434); FTXEQControl := TEqualizerControl.Create(Self); FTXEQControl.Parent := Grp; @@ -1638,7 +1644,17 @@ begin TColor($00383838), CLR_TEXT, CLR_TEXTDIM, CLR_ACCENT, TColor($0055DDEE)); FTXEQControl.OnChange := OnTXAnyChange; - FTXSubPage[1].Height := DpiScale(390 + 12); + // Главное, что надо знать про этот EQ: точки — не «полосы», а узлы кривой, + // и за крайними узлами WDSP обрывает АЧХ (см. WDSPEngine.PushTXEQProfile). + MakeLbl(Grp, 'Точки — узлы кривой: между ними интерполяция по дБ, ' + + 'за крайними точками АЧХ обрывается.', PAD, R1 + 332, GRP_W - 2 * PAD); + MakeLbl(Grp, 'Нижняя точка должна быть не выше нижней кромки TX-фильтра, ' + + 'верхняя — не ниже верхней, иначе EQ сам срежет полосу.', + PAD, R1 + 352, GRP_W - 2 * PAD); + MakeLbl(Grp, 'В режиме 3 полос частоты задаёт WDSP (150 / 1500 / 6000 Гц) — ' + + 'двигается только усиление.', PAD, R1 + 372, GRP_W - 2 * PAD); + + FTXSubPage[1].Height := DpiScale(434 + 12); // ========================================================================= // Подвкладка 2 — Hardware: свойства устройства, профилем не переключаются @@ -1953,6 +1969,21 @@ begin if Assigned(FOnTXProfileDelete) then FOnTXProfileDelete(Idx); end; +procedure TSettingsForm.BtnTXProfFactoryClick(Sender: TObject); +// Пересоздать заводской набор. Нужен не только «на случай чего»: заводские +// профили пишутся в конфиг один раз при первом подключении устройства, и без +// этой кнопки обновлённые заводские настройки (например кривые EQ у ESSB) +// не доехали бы до тех, у кого секция tx_profiles уже записана. +begin + if MessageDlg('TX Profile', + 'Заменить весь список профилей заводским набором?'#13#10 + + 'Свои профили и правки заводских будут потеряны.'#13#10 + + 'Активным станет «Default»: микрофон, полоса, динамика и мощность ' + + 'останутся как сейчас, эквалайзер вернётся в ноль.', + mtConfirmation, [mbYes, mbNo], 0) <> mrYes then Exit; + if Assigned(FOnTXProfileFactory) then FOnTXProfileFactory(Self); +end; + procedure TSettingsForm.LoadTXProfiles(const Names: array of string; ActiveIdx, DrivePct: Integer); var diff --git a/WDSPEngine.pas b/WDSPEngine.pas index f717232..46c3a2b 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -554,6 +554,7 @@ type procedure ProcessTXBlock; // вызывается из TTXDSPThread procedure FeedTXDisplaySample(const I, Q: Double); procedure ApplyTXChainSettings; // прокидывает все FTX* поля в WDSP + procedure PushTXEQProfile; // кривая EQ в WDSP (узлы + legacy 3 полосы) procedure ApplyPSParams; // прокидывает FPS*-параметры в WDSP calcc procedure ApplyRXAnalyzerSettings; // применяет RX FFT/Window/Det/Avg к RX_DISP_ID procedure ApplyTXAnalyzerSettings; // применяет TX FFT/Window/Det/Avg к TX_DISP_ID @@ -3542,8 +3543,6 @@ procedure TWDSPEngine.ApplyTXChainSettings; // Вызывается после OpenChannel(TXA) и при старте TX. Прокидывает все // FTX*-поля в WDSP. Идемпотентна, ничего не меняет если канал не открыт. var - i: Integer; - Gains, Freqs, Qs: array[0..10] of Double; BpLo, BpHi: Double; begin if not FInitialized then Exit; @@ -3584,15 +3583,8 @@ begin SetTXAPHROTNstages(TXA_CHAN, FTXPhaseRotStages); SetTXAPHROTCorner(TXA_CHAN, FTXPhaseRotFreq); - // EQ TX (10-band: [0]=preamp, [1..10]=bands) - for i := 0 to 10 do - begin - Gains[i] := FTXEQGains[i]; - Freqs[i] := FTXEQFreqs[i]; - Qs[i] := 0.707; // умолчание Q - end; - SetTXAEQProfile(TXA_CHAN, FTXEQNumBands, @Freqs[0], @Gains[0]); - SetTXAEQRun(TXA_CHAN, Ord(FTXEQOn)); + // EQ TX ([0]=preamp, [1..10]=точки кривой) + PushTXEQProfile; // AM SetTXAAMCarrierLevel(TXA_CHAN, FTXAMCarrier); @@ -3791,11 +3783,48 @@ begin SetTXAPHROTRun(TXA_CHAN, Ord(Enable)); end; +procedure TWDSPEngine.PushTXEQProfile; +// Единственная точка, отдающая кривую EQ в WDSP. +// F[1..n] — УЗЛЫ ломаной АЧХ (eq.c/eq_impulse), между ними интерполяция по +// децибелам, а за крайними узлами при ctfmode=0 (так создаётся eqp в TXA.c) +// идёт кумулятивный скат (f/f0)^4 на бин — фактически обрыв. Поэтому: +// • 10 полос — узлы берём из профиля как есть (за краями обязан быть запас, +// см. заводские ESSB-профили в Settings.DefaultTXProfiles); +// • 3 полосы — legacy-раскладка самого WDSP (SetTXAGrphEQ): 150/400/1500/ +// 6000, ручка Low держит полку 150..400, High тянет до 6 кГц. Отдавать +// здесь первые три узла 10-полосной сетки (100/200/400) нельзя — весь +// голос выше 400 Гц ушёл бы в ноль. +var + F, G: array[0..10] of Double; + i, NF: Integer; +begin + if not FInitialized then Exit; + if FTXEQNumBands = 3 then + begin + NF := 4; + F[0] := 0.0; G[0] := FTXEQGains[0]; // preamp + F[1] := 150.0; G[1] := FTXEQGains[1]; // Low + F[2] := 400.0; G[2] := FTXEQGains[1]; + F[3] := 1500.0; G[3] := FTXEQGains[2]; // Mid + F[4] := 6000.0; G[4] := FTXEQGains[3]; // High + end + else + begin + NF := 10; + for i := 0 to 10 do + begin + F[i] := FTXEQFreqs[i]; + G[i] := FTXEQGains[i]; + end; + end; + SetTXAEQProfile(TXA_CHAN, NF, @F[0], @G[0]); + SetTXAEQRun(TXA_CHAN, Ord(FTXEQOn)); +end; + procedure TWDSPEngine.SetTXEQ(Enable: Boolean; NumBands: Integer; const Gains, Freqs: array of Double); var i, n: Integer; - G, F: array[0..10] of Double; begin FTXEQOn := Enable; FTXEQNumBands := NumBands; @@ -3804,12 +3833,8 @@ begin begin FTXEQGains[i] := Gains[i]; FTXEQFreqs[i] := Freqs[i]; - G[i] := Gains[i]; - F[i] := Freqs[i]; end; - if not FInitialized then Exit; - SetTXAEQProfile(TXA_CHAN, NumBands, @F[0], @G[0]); - SetTXAEQRun(TXA_CHAN, Ord(Enable)); + PushTXEQProfile; end; procedure TWDSPEngine.SetTXAMCarrierLevel(Level: Double); From 013d0406ac613ab0d6ddc8680c6e6a40318d8cee Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 17:06:08 +0300 Subject: [PATCH 04/30] =?UTF-8?q?feat(tx):=20TX-=D0=BF=D1=80=D0=BE=D1=84?= =?UTF-8?q?=D0=B8=D0=BB=D1=8C=20=D0=BD=D0=B0=20=D1=81=D0=BB=D0=B0=D0=B9?= =?UTF-8?q?=D1=81=20=E2=80=94=20=D0=BF=D0=B8=D0=BB=D1=8E=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B2=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B5=20=D1=84=D0=BB=D0=B0?= =?UTF-8?q?=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Передатчик один, профиль до сих пор был один на устройство: активный профиль звучал независимо от того, какой слайс взял передачу. С панадаптером на 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 --- MainForm.pas | 18 +++ PanafallPanel.pas | 32 +++++ RadioController.pas | 84 +++++++++++++ Settings.pas | 12 ++ VfoOverlay.pas | 292 +++++++++++++++++++++++++++++++++++++------- 5 files changed, 397 insertions(+), 41 deletions(-) diff --git a/MainForm.pas b/MainForm.pas index 35b3513..249e6b2 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -533,6 +533,7 @@ type procedure OnTXProfileRenameFromSettings(Idx: Integer; const AName: string); procedure OnTXProfileDeleteFromSettings(Idx: Integer); procedure OnTXProfileFactoryFromSettings(Sender: TObject); + procedure OnVfoOverlayTXProfile(Idx: Integer); procedure OnChannelStoreChanged(Sender: TObject); procedure OnChannelActiveChanged(Idx: Integer; const Name: string; Active: Boolean); procedure PbSpectrumMouseDown(Sender: TObject; Button: TMouseButton; @@ -5708,6 +5709,10 @@ begin FController.FNetwork.Device.BoardType, FPendingBoardType)); PushTXProfilesToSettings; end; + // Пилюли профиля на флагах: список общий, привязки могли съехать после + // удаления/сброса — обновляем на всех панах. + for i := 0 to MAX_PANS - 1 do + if FPans[i] <> nil then FPans[i].PushAllFlagTXProfiles; end; procedure TMainForm.PushTXProfilesToSettings; @@ -6098,6 +6103,16 @@ begin PushFlagStateAllPans(CurSliceId); end; +procedure TMainForm.OnVfoOverlayTXProfile(Idx: Integer); +// Пилюля TX-профиля во флаге: «этот источник передачи звучит вот так». +// Idx = -1 — привязки нет, передаёт активный профиль. +begin + // Привязка главного живёт в секции профилей (контроллер её уже сохранил), + // привязка слайса уедет в конфиг с общим персистом панов — как RxMuteOnTx. + FController.SetSliceTXProfile(CurSliceId, Idx); + PushFlagStateAllPans(CurSliceId); +end; + procedure TMainForm.RefreshTxIndicators; // Обновить TX-бейджи (TxSel/Tx) на всех флагах всех панов после смены // TX-слайса / состояния передачи. @@ -6198,6 +6213,7 @@ begin O.OnSplit := OnVfoOverlaySplit; O.OnTxSelect := OnVfoOverlayTxSelect; O.OnRxMuteTx := OnVfoOverlayRxMuteTx; + O.OnTXProfile := OnVfoOverlayTXProfile; O.OnSliceSelect := OnVfoOverlaySliceSelect; O.OnClose := OnVfoOverlayClose; O.OnInvalidate := OnVfoOverlayInvalidate; @@ -7160,6 +7176,7 @@ begin Cfg.Pans[i].Slices[n].FMSQLevel := FController.FSlices[s].FMSQLevel; Cfg.Pans[i].Slices[n].DevName := FController.FSlices[s].DevName; Cfg.Pans[i].Slices[n].InDevName := FController.FSlices[s].InDevName; + Cfg.Pans[i].Slices[n].TXProfile := FController.FSlices[s].TXProfile; Inc(n); end; end; @@ -7186,6 +7203,7 @@ begin FController.FAudioIn.FindDeviceByName(SC.InDevName), SC.InDevName); if SC.Muted then FController.SetSliceMute(Id, True); FController.SetSliceRxMuteOnTx(Id, SC.RxMuteOnTx); + FController.SetSliceTXProfile(Id, SC.TXProfile); // «этот слайс звучит вот так» if SC.FMSQOn then FController.SetSliceFMSquelch(Id, True, SC.FMSQLevel); P.CreateSliceFlag(Id); FActiveSliceId := Id; diff --git a/PanafallPanel.pas b/PanafallPanel.pas index f00d3e8..1580211 100644 --- a/PanafallPanel.pas +++ b/PanafallPanel.pas @@ -286,6 +286,8 @@ type procedure PushAllSliceFlagStates; // все флаги панели (после смены rate и т.п.) procedure PushMainFlagExtState; procedure PushMainFlagAudioDevices; + procedure PushFlagTXProfiles(O: TVfoOverlay; SliceId: Integer); + procedure PushAllFlagTXProfiles; procedure UpdateSliceDMRFlags; // Тик: S-метр/частота главного флага + позиции + троттленный S-метр слайсов. procedure TickFlags; @@ -1321,6 +1323,23 @@ begin end; end; +// Список TX-профилей (общий) + привязка ЭТОГО флага (SliceId 0 = главный). +procedure TPanafallPanel.PushFlagTXProfiles(O: TVfoOverlay; SliceId: Integer); +var + L: TStringList; + i: Integer; +begin + if (O = nil) or (FController = nil) then Exit; + L := TStringList.Create; + try + for i := 0 to FController.TXProfileCount - 1 do + L.Add(FController.TXProfileName(i)); + O.SetTXProfiles(L, FController.SliceTXProfile(SliceId)); + finally + L.Free; + end; +end; + // Заливает во флаг слайса его текущее состояние из контроллера. procedure TPanafallPanel.PushSliceFlagState(SliceId: Integer); var @@ -1352,6 +1371,7 @@ begin // Auto TX (настройка CAT-порта слайса) — бейдж TX подписан AutoTX. O.SetAutoTxState(FController.SliceAutoTx(SliceId)); O.SetRxMuteTxState(S.RxMuteOnTx); // бейдж RXM + PushFlagTXProfiles(O, SliceId); // пилюля TX-профиля слайса // Список аудио-устройств (общий PA-список) + текущее устройство слайса. if Assigned(FController.FAudioOut) then begin @@ -1399,6 +1419,18 @@ begin FController.FSplitTxB and (FController.FTxSliceId = 0), FController.FTransmitting and (FController.FTxSliceId = 0), FController.FTxSliceId = 0); + PushFlagTXProfiles(FMainFlag, 0); +end; + +// Обновить пилюли TX-профиля на всех флагах панели (список профилей общий: +// переименовали/удалили — видно везде). +procedure TPanafallPanel.PushAllFlagTXProfiles; +var i: Integer; +begin + if Assigned(FMainFlag) then PushFlagTXProfiles(FMainFlag, 0); + for i := 0 to High(FSliceFlags) do + if FSliceFlags[i] <> nil then + PushFlagTXProfiles(FSliceFlags[i], FSliceFlags[i].SliceId); end; procedure TPanafallPanel.PushMainFlagAudioDevices; diff --git a/RadioController.pas b/RadioController.pas index 17126a9..abc3a59 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -178,6 +178,9 @@ type InDeviceIndex: Integer; // PortAudio-индекс ввода (-1 = общий) InDevName: string; AudioIn: TAudioInput; // НЕ владеет: ссылка на поток из mic-пула + // TX-профиль слайса: индекс в FTXProfiles или -1 = «не переключать». + // Применяется, когда слайс становится TX-источником (SetTxSlice). + TXProfile: Integer; DMR: TDMRSliceDecoder; // владеет; nil для не-DMR слайса end; @@ -596,6 +599,11 @@ type procedure RenameTXProfile(Idx: Integer; const AName: string); procedure DeleteTXProfile(Idx: Integer); procedure ResetTXProfilesToFactory; // заводской набор заново + // Профиль на TX-источник: -1 = не переключать (звучит активный). + function SliceTXProfile(SliceId: Integer): Integer; + procedure SetSliceTXProfile(SliceId, ProfIdx: Integer); + procedure RemapTXProfileBindings(Deleted: Integer); // после удаления/замены списка + procedure ApplyBoundTXProfile; // профиль текущего TX-источника → в эфир procedure SaveTXProfilesToConfig; // ---- Управление диапазонами (состояние + DSP; рендер через события) ---- @@ -2148,6 +2156,7 @@ begin FSlices[slot].RxMuteOnTx := True; // себя на передаче по умолчанию не слышим FSlices[slot].FMSQOn := False; // squelch по умолчанию выключен FSlices[slot].FMSQLevel := 40; + FSlices[slot].TXProfile := -1; // звучит тот профиль, что активен FSlices[slot].NRMode := 0; // DSP слайса стартует выключенным FSlices[slot].NBMode := 0; FSlices[slot].SNB := False; @@ -2940,6 +2949,9 @@ begin else if FTransmitting then SetMOX(False); end; FTxSliceId := SliceId; + // Профиль, привязанный к новому источнику, — ДО пуша режима и цепи: иначе + // ApplyTXSettingsToDSP внутри SelectTXProfile лёг бы на старый режим. + ApplyBoundTXProfile; // Передающий тракт под режим нового источника (idle-канал, применится к PTT). if FWDSPReady and Assigned(FDSPEngine) then begin @@ -5033,6 +5045,9 @@ begin for i := Idx to FTXProfiles.Count - 2 do FTXProfiles.Items[i] := FTXProfiles.Items[i + 1]; Dec(FTXProfiles.Count); + // Привязки флагов — это ИНДЕКСЫ: после сдвига списка их надо переписать, + // иначе слайс молча начнёт включать соседний профиль. + RemapTXProfileBindings(Idx); if FTXProfiles.ActiveIdx > Idx then Dec(FTXProfiles.ActiveIdx) else if FTXProfiles.ActiveIdx = Idx then begin @@ -5047,6 +5062,74 @@ begin Changed(rfTXProfile); end; +procedure TRadioController.RemapTXProfileBindings(Deleted: Integer); +// Профиль удалён из середины списка: привязку на него снимаем, привязки на +// профили правее — сдвигаем. Deleted < 0 = список заменён целиком, снимаем все. + + procedure Fix(var B: Integer); + begin + if B < 0 then Exit; + if Deleted < 0 then B := -1 + else if B = Deleted then B := -1 + else if B > Deleted then Dec(B); + end; + +var i: Integer; +begin + Fix(FTXProfiles.MainBind); + for i := 0 to High(FSlices) do + if FSlices[i].Active then Fix(FSlices[i].TXProfile); +end; + +function TRadioController.SliceTXProfile(SliceId: Integer): Integer; +// Привязка «этот источник передачи → этот профиль». -1 = не переключать. +var idx: Integer; +begin + Result := -1; + if SliceId <= 0 then + Result := FTXProfiles.MainBind + else + begin + idx := FindSliceIndex(SliceId); + if idx >= 0 then Result := FSlices[idx].TXProfile; + end; + if (Result < 0) or (Result >= FTXProfiles.Count) then Result := -1; +end; + +procedure TRadioController.SetSliceTXProfile(SliceId, ProfIdx: Integer); +var idx: Integer; +begin + if (ProfIdx < -1) or (ProfIdx >= FTXProfiles.Count) then ProfIdx := -1; + if SliceId <= 0 then + begin + if FTXProfiles.MainBind = ProfIdx then Exit; + FTXProfiles.MainBind := ProfIdx; + SaveTXProfilesToConfig; + end + else + begin + idx := FindSliceIndex(SliceId); + if (idx < 0) or (FSlices[idx].TXProfile = ProfIdx) then Exit; + FSlices[idx].TXProfile := ProfIdx; + // Персист слайсов делает MainForm на общем сохранении панов — здесь только + // состояние. Привязку главного пишем сразу: она живёт в секции профилей. + end; + // Привязали источник, который передаёт прямо сейчас, — применяем немедленно, + // иначе профиль подхватился бы только при следующей смене TX-источника. + if SliceId = FTxSliceId then ApplyBoundTXProfile; + Changed(rfTXProfile); +end; + +procedure TRadioController.ApplyBoundTXProfile; +// Профиль текущего TX-источника → в эфир. Ничего не делает, если источник ни к +// чему не привязан (-1) или нужный профиль уже активен. +var P: Integer; +begin + P := SliceTXProfile(FTxSliceId); + if (P < 0) or (P = FTXProfiles.ActiveIdx) then Exit; + SelectTXProfile(P); +end; + procedure TRadioController.ResetTXProfilesToFactory; // Пересобирает заводской набор поверх текущего списка. База — ЖИВЫЕ настройки, // поэтому «Default» = микрофонный вход, полоса, динамика и мощность как сейчас, @@ -5055,6 +5138,7 @@ procedure TRadioController.ResetTXProfilesToFactory; // пересоздаётся. begin DefaultTXProfiles(FTXSettings, FDrivePercent, FTXProfiles); + RemapTXProfileBindings(-1); // список другой — старые привязки бессмысленны // ActiveIdx гасим перед Select: иначе StoreActiveTXProfile внутри него // впишет в свежий «Default» живой EQ и сброс тембра тут же отменится. FTXProfiles.ActiveIdx := -1; diff --git a/Settings.pas b/Settings.pas index 8a0bc18..c3064f5 100644 --- a/Settings.pas +++ b/Settings.pas @@ -177,6 +177,7 @@ type FMSQLevel: Integer; DevName: string; // имя PortAudio-устройства вывода ('' = default) InDevName: string; // имя устройства ВВОДА слайса ('' = общий вход) + TXProfile: Integer; // TX-профиль слайса: -1 = какой активен, тот и звучит end; TPanCfg = record @@ -349,6 +350,9 @@ type TTXProfileList = record Count: Integer; ActiveIdx: Integer; + // Привязка профиля к ГЛАВНОМУ приёмнику (слайс A). У слайсов B..G то же + // самое лежит в TSliceCfg.TXProfile; -1 = ничего не переключать. + MainBind: Integer; Items: array[0..TXPROF_MAX-1] of TTXProfile; end; @@ -1938,6 +1942,7 @@ begin FillChar(L, SizeOf(L), 0); L.Count := 0; L.ActiveIdx := 0; + L.MainBind := -1; // главный приёмник профиль не переключает // «Default» — текущее состояние устройства (микрофонный вход, полоса, // динамика, мощность), но с заводским EQ: после миграции старого конфига // звук в эфире не меняется, а сброс к заводским возвращает ровный тембр. @@ -1970,6 +1975,7 @@ begin FillChar(L, SizeOf(L), 0); L.Count := 0; L.ActiveIdx := 0; + L.MainBind := -1; Result := False; MacStr := MacToStr(MAC); if FRoot.Find(MacStr) = nil then Exit; @@ -2030,6 +2036,8 @@ begin if L.Count = 0 then Exit; // пустой список = как будто секции нет L.ActiveIdx := JI(Root, 'active', 0); if (L.ActiveIdx < 0) or (L.ActiveIdx >= L.Count) then L.ActiveIdx := 0; + L.MainBind := JI(Root, 'main_bind', -1); + if (L.MainBind < -1) or (L.MainBind >= L.Count) then L.MainBind := -1; Result := True; end; @@ -2042,6 +2050,7 @@ var begin Root := EnsureObj(GetDevObj(MacToStr(MAC)), 'tx_profiles'); JW(Root, 'active', EnsureRange(L.ActiveIdx, 0, Max(0, L.Count - 1))); + JW(Root, 'main_bind', EnsureRange(L.MainBind, -1, Max(-1, L.Count - 1))); // Список пересоздаётся целиком: удаление профиля не должно оставлять хвост. Idx := Root.IndexOfName('list'); if Idx >= 0 then Root.Delete(Idx); @@ -2732,6 +2741,7 @@ begin SliceObj.Add('fmsq_level', P.Pans[i].Slices[j].FMSQLevel); SliceObj.Add('dev_name', P.Pans[i].Slices[j].DevName); SliceObj.Add('in_dev_name', P.Pans[i].Slices[j].InDevName); + SliceObj.Add('tx_profile', P.Pans[i].Slices[j].TXProfile); end; end; end; @@ -2812,6 +2822,8 @@ begin P.Pans[id].Slices[n].FMSQLevel := JI(SliceObj, 'fmsq_level', 30); P.Pans[id].Slices[n].DevName := JS(SliceObj, 'dev_name', ''); P.Pans[id].Slices[n].InDevName := JS(SliceObj, 'in_dev_name', ''); + P.Pans[id].Slices[n].TXProfile := EnsureRange( + JI(SliceObj, 'tx_profile', -1), -1, TXPROF_MAX - 1); end; end; end; diff --git a/VfoOverlay.pas b/VfoOverlay.pas index 7c2dec5..b1f937d 100644 --- a/VfoOverlay.pas +++ b/VfoOverlay.pas @@ -57,7 +57,10 @@ type TVolumeEvent = procedure(Vol: Integer) of object; TAudioDeviceEvent = procedure(DevIndex: Integer; const DevName: string) of object; - TFlyPanel = (fpNone, fpMode, fpDsp, fpAud); + TFlyPanel = (fpNone, fpMode, fpDsp, fpAud, fpTxProf); + + // Выбран TX-профиль для этого флага: Idx = -1 («как активный») или индекс. + TTXProfilePickEvent = procedure(Idx: Integer) of object; TVfoOverlay = class(TCustomControl) private @@ -102,6 +105,13 @@ type 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 (лок. коорд.) — зона колёсика @@ -121,6 +131,7 @@ type FOnSplit: TNotifyEvent; FOnTxSelect: TNotifyEvent; FOnRxMuteTx: TNotifyEvent; + FOnTXProfile: TTXProfilePickEvent; FOnInvalidate: TNotifyEvent; FTheme: TAppTheme; // тема приложения (цвета кнопок и т.п.) @@ -156,6 +167,13 @@ type 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; @@ -206,6 +224,8 @@ type 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. @@ -257,6 +277,7 @@ type 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; @@ -300,8 +321,12 @@ const 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 + индекс кнопки в ряду фильтров @@ -319,6 +344,9 @@ const 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 // Палитра. Структурные цвета (фон/текст/акценты) берутся из темы — @@ -519,6 +547,10 @@ begin 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; @@ -558,6 +590,7 @@ end; destructor TVfoOverlay.Destroy; begin + FProfNames.Free; FInDevNames.Free; FDevNames.Free; FCacheBitmap.Free; @@ -660,6 +693,7 @@ begin // ряд вкладок 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; @@ -674,6 +708,7 @@ 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; @@ -996,15 +1031,143 @@ begin 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, PanelH, ArrowX, MidY, CY, Cnt: Integer; + I, Slot, VisN, RowWidth, MaxScroll, Cnt: Integer; HR, TabR: TRect; Nm: string; - Cur, Scrollable, CanUp, CanDn: Boolean; - Tri: array[0..2] of TPoint; + Cur, Scrollable: Boolean; begin // Ряд вкладок: OUT (куда отдаём звук) / IN (откуда берём модуляцию). TabR := Rect(X, Y, X + (RowW - 4) div 2, Y + ROW_H); @@ -1062,50 +1225,20 @@ begin end; if not Scrollable then Exit; - - // Полоса прокрутки справа: ▲ (верх) / ▼ (низ) + счётчик позиции. - PanelH := VisN * AUD_ROW_H - 1; - ArrowX := X + RowWidth + 2; - MidY := Y + PanelH div 2; - CanUp := FAudScroll > 0; - CanDn := FAudScroll < MaxScroll; - - // верхняя стрелка - 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, HIT_AUD_UP); - - // нижняя стрелка - 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, HIT_AUD_DN); + 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: string; + 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; @@ -1154,6 +1287,7 @@ begin 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. @@ -1186,6 +1320,45 @@ begin 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); { --- Метр: индикаторы + сигнал-бар --- } @@ -1259,6 +1432,7 @@ begin 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; @@ -1339,6 +1513,12 @@ begin 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); @@ -1360,6 +1540,18 @@ begin 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 @@ -1533,7 +1725,20 @@ var MaxScroll, Old: Integer; begin Result := False; - if (not Visible) or (FPanel <> fpAud) then Exit; + 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; @@ -1635,6 +1840,11 @@ begin FDMRCandidateText := ''; FDMRCandidateSince := 0; end; + // Ушли в режим, где профиль ни на что не влияет: пилюли уже нет, открытый + // список закрываем сами — иначе он висел бы без своей кнопки. + if (FPanel = fpTxProf) and + ((FMode = OVL_MODE_DMR) or (FMode = OVL_MODE_FMRAW)) then + FPanel := fpNone; Height := ComputeHeight; RequestInvalidate; end; From 36af031767bebbd9fed2f9a3d11aad4c46ff1aad Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 17:58:46 +0300 Subject: [PATCH 05/30] =?UTF-8?q?feat(tx):=20=D0=BF=D1=80=D0=B8=D0=B2?= =?UTF-8?q?=D1=8F=D0=B7=D0=BA=D0=B0=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=BA=20=D0=BF=D0=B0=D1=80=D0=B5=20=C2=AB=D1=84=D0=BB?= =?UTF-8?q?=D0=B0=D0=B3=20+=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Одного значения на флаг мало: на слайсе с FT8 и на нём же голосом нужен разный звук, а слайс/главный меняют модуляцию на лету. Привязка стала таблицей TTXProfModeTable (режим → индекс профиля, -1 = не переключать), своя у каждого флага: MainByMode у главного (одна на устройство, общая для КВ и трансвертера) и TXProfByMode у слайса (сама разъезжается по контекстам — записи слайсов хранятся отдельно для КВ и трансвертера). Ключ — режим САМОГО ФЛАГА (FlagMode): у главного FMode, у слайса свой. Выбор в пилюле пишется в ячейку текущего режима, поэтому смена модуляции достаёт профиль, с которым в этом виде работали в прошлый раз. Применение — в трёх точках, все ДО пуша режима в WDSP (ApplyTXChainSettings внутри SetTXMode перешьёт цепь уже новыми значениями): SetTxSlice, SetMode (если передаём с главного) и SetSliceMode (если источник — этот слайс). Персист: tx_prof_modes в записи слайса, main_prof_modes в секции tx_profiles. Чтение защитное — индекс вне текущего списка профилей читается как -1. Round-trip проверен прогоном: обе таблицы переживают запись/чтение, незаданные режимы -1. Co-Authored-By: Claude Opus 5 --- MainForm.pas | 10 ++++- RadioController.pas | 93 +++++++++++++++++++++++++++++++++++++-------- Settings.pas | 70 ++++++++++++++++++++++++++++------ 3 files changed, 143 insertions(+), 30 deletions(-) diff --git a/MainForm.pas b/MainForm.pas index 249e6b2..b8ba0dd 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -1570,6 +1570,10 @@ begin SyncSpecViewFreq; // иначе полоса фильтра рисуется под старый режим UpdateTXControlAvailability; UpdateDMRStatus; + // Пилюля профиля во флаге A: привязка ключуется режимом, значит на + // смене модуляции она показывает уже другой профиль. Флаг главного + // приёмника живёт только на пане 0 — как в RefreshTxIndicators. + FPan.PushMainFlagExtState; end; rfFilter: begin @@ -7176,7 +7180,7 @@ begin Cfg.Pans[i].Slices[n].FMSQLevel := FController.FSlices[s].FMSQLevel; Cfg.Pans[i].Slices[n].DevName := FController.FSlices[s].DevName; Cfg.Pans[i].Slices[n].InDevName := FController.FSlices[s].InDevName; - Cfg.Pans[i].Slices[n].TXProfile := FController.FSlices[s].TXProfile; + Cfg.Pans[i].Slices[n].TXProfByMode := FController.FSlices[s].TXProfByMode; Inc(n); end; end; @@ -7203,7 +7207,9 @@ begin FController.FAudioIn.FindDeviceByName(SC.InDevName), SC.InDevName); if SC.Muted then FController.SetSliceMute(Id, True); FController.SetSliceRxMuteOnTx(Id, SC.RxMuteOnTx); - FController.SetSliceTXProfile(Id, SC.TXProfile); // «этот слайс звучит вот так» + // Таблица «режим → профиль» этого слайса целиком (не через SetSliceTXProfile: + // тот пишет только ячейку текущего режима). + FController.SetSliceTXProfileTable(Id, SC.TXProfByMode); if SC.FMSQOn then FController.SetSliceFMSquelch(Id, True, SC.FMSQLevel); P.CreateSliceFlag(Id); FActiveSliceId := Id; diff --git a/RadioController.pas b/RadioController.pas index abc3a59..af3a08b 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -178,9 +178,10 @@ type InDeviceIndex: Integer; // PortAudio-индекс ввода (-1 = общий) InDevName: string; AudioIn: TAudioInput; // НЕ владеет: ссылка на поток из mic-пула - // TX-профиль слайса: индекс в FTXProfiles или -1 = «не переключать». - // Применяется, когда слайс становится TX-источником (SetTxSlice). - TXProfile: Integer; + // TX-профиль слайса ПО РЕЖИМУ: индекс в FTXProfiles или -1 = «не + // переключать». Применяется, когда слайс становится TX-источником + // (SetTxSlice) и когда он меняет режим, будучи источником. + TXProfByMode: TTXProfModeTable; DMR: TDMRSliceDecoder; // владеет; nil для не-DMR слайса end; @@ -599,9 +600,14 @@ type procedure RenameTXProfile(Idx: Integer; const AName: string); procedure DeleteTXProfile(Idx: Integer); procedure ResetTXProfilesToFactory; // заводской набор заново - // Профиль на TX-источник: -1 = не переключать (звучит активный). + // Профиль на TX-источник, ключ — режим этого флага (-1 = не переключать, + // звучит активный). Таблица у каждого флага своя: главный в FTXProfiles, + // слайсы в своих записях. + function FlagMode(SliceId: Integer): Integer; function SliceTXProfile(SliceId: Integer): Integer; procedure SetSliceTXProfile(SliceId, ProfIdx: Integer); + function SliceTXProfileTable(SliceId: Integer): TTXProfModeTable; + procedure SetSliceTXProfileTable(SliceId: Integer; const T: TTXProfModeTable); procedure RemapTXProfileBindings(Deleted: Integer); // после удаления/замены списка procedure ApplyBoundTXProfile; // профиль текущего TX-источника → в эфир procedure SaveTXProfilesToConfig; @@ -2156,7 +2162,7 @@ begin FSlices[slot].RxMuteOnTx := True; // себя на передаче по умолчанию не слышим FSlices[slot].FMSQOn := False; // squelch по умолчанию выключен FSlices[slot].FMSQLevel := 40; - FSlices[slot].TXProfile := -1; // звучит тот профиль, что активен + ClearTXProfModeTable(FSlices[slot].TXProfByMode); // звучит активный профиль FSlices[slot].NRMode := 0; // DSP слайса стартует выключенным FSlices[slot].NBMode := 0; FSlices[slot].SNB := False; @@ -2231,6 +2237,8 @@ begin // Этот слайс — источник передачи: передающий тракт идёт за его режимом // (иначе после смены режима в эфир ушёл бы прежний вид модуляции). Вход FM // RAW — внешний PCM, поэтому заодно пересматриваем источник модуляции. + // Профиль этого слайса в НОВОМ режиме — до пуша цепи (см. SetMode). + if (FTxSliceId = Id) and (Mode <> OldMode) then ApplyBoundTXProfile; if (FTxSliceId = Id) and (Mode <> OldMode) and FWDSPReady and Assigned(FDSPEngine) then begin @@ -3996,6 +4004,10 @@ begin if Assigned(FDMRDec) then FDMRDec.SetEnabled(FMode = MODE_DMR); ApplyModeDefaults; // дефолтный фильтр/девиация под режим FBandCache[FCurrentBand].Mode := FMode; + // Профиль привязан к паре «флаг + модуляция»: если передаём с главного, + // новый режим приносит свой профиль. До пуша в WDSP — ApplyTXChainSettings + // внутри SetTXMode ниже перешьёт цепь уже с новыми значениями. + if FTxSliceId = 0 then ApplyBoundTXProfile; if FWDSPReady and Assigned(FDSPEngine) then begin FDSPEngine.SetMode(FMode); @@ -5074,43 +5086,61 @@ procedure TRadioController.RemapTXProfileBindings(Deleted: Integer); else if B > Deleted then Dec(B); end; -var i: Integer; +var i, m: Integer; begin - Fix(FTXProfiles.MainBind); + for m := 0 to CFG_MODE_MAX do Fix(FTXProfiles.MainByMode[m]); for i := 0 to High(FSlices) do - if FSlices[i].Active then Fix(FSlices[i].TXProfile); + if FSlices[i].Active then + for m := 0 to CFG_MODE_MAX do Fix(FSlices[i].TXProfByMode[m]); +end; + +function TRadioController.FlagMode(SliceId: Integer): Integer; +// Режим, в котором сейчас стоит этот флаг: у главного — FMode, у слайса — свой. +var idx: Integer; +begin + Result := FMode; + if SliceId > 0 then + begin + idx := FindSliceIndex(SliceId); + if idx >= 0 then Result := FSlices[idx].Mode; + end; + Result := EnsureRange(Result, 0, CFG_MODE_MAX); end; function TRadioController.SliceTXProfile(SliceId: Integer): Integer; -// Привязка «этот источник передачи → этот профиль». -1 = не переключать. +// Привязка «этот источник передачи В ЭТОМ РЕЖИМЕ → этот профиль». +// -1 = не переключать. Ключ — режим самого флага, поэтому смена модуляции +// сама достаёт профиль, с которым в этом виде работали в прошлый раз. var idx: Integer; begin Result := -1; if SliceId <= 0 then - Result := FTXProfiles.MainBind + Result := FTXProfiles.MainByMode[FlagMode(SliceId)] else begin idx := FindSliceIndex(SliceId); - if idx >= 0 then Result := FSlices[idx].TXProfile; + if idx >= 0 then Result := FSlices[idx].TXProfByMode[FlagMode(SliceId)]; end; if (Result < 0) or (Result >= FTXProfiles.Count) then Result := -1; end; procedure TRadioController.SetSliceTXProfile(SliceId, ProfIdx: Integer); -var idx: Integer; +// Пишем в ячейку ТЕКУЩЕГО режима этого флага: выбор запоминается за модуляцией. +var idx, m: Integer; begin if (ProfIdx < -1) or (ProfIdx >= FTXProfiles.Count) then ProfIdx := -1; + m := FlagMode(SliceId); if SliceId <= 0 then begin - if FTXProfiles.MainBind = ProfIdx then Exit; - FTXProfiles.MainBind := ProfIdx; + if FTXProfiles.MainByMode[m] = ProfIdx then Exit; + FTXProfiles.MainByMode[m] := ProfIdx; SaveTXProfilesToConfig; end else begin idx := FindSliceIndex(SliceId); - if (idx < 0) or (FSlices[idx].TXProfile = ProfIdx) then Exit; - FSlices[idx].TXProfile := ProfIdx; + if (idx < 0) or (FSlices[idx].TXProfByMode[m] = ProfIdx) then Exit; + FSlices[idx].TXProfByMode[m] := ProfIdx; // Персист слайсов делает MainForm на общем сохранении панов — здесь только // состояние. Привязку главного пишем сразу: она живёт в секции профилей. end; @@ -5120,6 +5150,37 @@ begin Changed(rfTXProfile); end; +procedure TRadioController.SetSliceTXProfileTable(SliceId: Integer; + const T: TTXProfModeTable); +// Восстановление сохранённой таблицы целиком (персист слайсов/панов). +var idx, m: Integer; +begin + if SliceId <= 0 then + begin + FTXProfiles.MainByMode := T; + Exit; + end; + idx := FindSliceIndex(SliceId); + if idx < 0 then Exit; + for m := 0 to CFG_MODE_MAX do + if (T[m] >= 0) and (T[m] < FTXProfiles.Count) then + FSlices[idx].TXProfByMode[m] := T[m] + else + FSlices[idx].TXProfByMode[m] := -1; +end; + +function TRadioController.SliceTXProfileTable(SliceId: Integer): TTXProfModeTable; +var idx: Integer; +begin + ClearTXProfModeTable(Result); + if SliceId <= 0 then Result := FTXProfiles.MainByMode + else + begin + idx := FindSliceIndex(SliceId); + if idx >= 0 then Result := FSlices[idx].TXProfByMode; + end; +end; + procedure TRadioController.ApplyBoundTXProfile; // Профиль текущего TX-источника → в эфир. Ничего не делает, если источник ни к // чему не привязан (-1) или нужный профиль уже активен. diff --git a/Settings.pas b/Settings.pas index c3064f5..52caeac 100644 --- a/Settings.pas +++ b/Settings.pas @@ -164,6 +164,10 @@ type // Персист панадаптеров и слайсов (секция "pans" под MAC). Восстанавливается // один раз при выходе в Run. Слайс = приёмный WDSP-канал внутри пана; частоты // абсолютные (видимые). См. [[project_slices_plan]]. + // Привязка «режим → индекс TX-профиля» (-1 = не переключать). Такая таблица + // есть у каждого флага: у главного в TTXProfileList, у слайса в TSliceCfg. + TTXProfModeTable = array[0..CFG_MODE_MAX] of Integer; + TSliceCfg = record TargetHz: Double; // абсолютная видимая частота слайса Mode: Integer; @@ -177,7 +181,9 @@ type FMSQLevel: Integer; DevName: string; // имя PortAudio-устройства вывода ('' = default) InDevName: string; // имя устройства ВВОДА слайса ('' = общий вход) - TXProfile: Integer; // TX-профиль слайса: -1 = какой активен, тот и звучит + // TX-профиль слайса ПО РЕЖИМУ: -1 = какой активен, тот и звучит. Таблица, + // а не одно значение: слайс на FT8 и слайс голосом помнят каждый своё. + TXProfByMode: TTXProfModeTable; end; TPanCfg = record @@ -350,9 +356,12 @@ type TTXProfileList = record Count: Integer; ActiveIdx: Integer; - // Привязка профиля к ГЛАВНОМУ приёмнику (слайс A). У слайсов B..G то же - // самое лежит в TSliceCfg.TXProfile; -1 = ничего не переключать. - MainBind: Integer; + // Привязка профиля к ГЛАВНОМУ приёмнику (слайс A) ПО РЕЖИМУ; -1 = ничего + // не переключать. У слайсов B..G своя такая же в TSliceCfg.TXProfByMode. + // Таблица главного одна на устройство — общая для КВ и трансвертера + // (решение пользователя); у слайсов она сама разъезжается по контекстам, + // потому что их записи хранятся отдельно для КВ и трансвертера. + MainByMode: TTXProfModeTable; Items: array[0..TXPROF_MAX-1] of TTXProfile; end; @@ -701,6 +710,11 @@ procedure TXProfileApply(const P: TTXProfile; var T: TTXSettings; var DrivePct: // (иначе переключение на «ESSB» сбрасывало бы Mic In/Line In и drive). procedure DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer; out L: TTXProfileList); +// Таблица «режим → профиль» ↔ JSON-массив. Все элементы вне 0..TXPROF_MAX-1 +// читаются как -1 («не переключать»), поэтому чужой/старый конфиг безопасен. +procedure ClearTXProfModeTable(var T: TTXProfModeTable); +function ModeTableToJSON(const T: TTXProfModeTable): TJSONArray; +procedure JSONToModeTable(Node: TJSONData; var T: TTXProfModeTable); // Сколько пресетов реально есть у режима (VAR1/VAR2 идут сверх этого числа). function FilterPresetCount(Mode: Integer): Integer; @@ -1866,6 +1880,34 @@ begin DrivePct := EnsureRange(P.DrivePercent, 0, 100); end; +procedure ClearTXProfModeTable(var T: TTXProfModeTable); +var i: Integer; +begin + for i := 0 to CFG_MODE_MAX do T[i] := -1; +end; + +function ModeTableToJSON(const T: TTXProfModeTable): TJSONArray; +var i: Integer; +begin + Result := TJSONArray.Create; + for i := 0 to CFG_MODE_MAX do Result.Add(T[i]); +end; + +procedure JSONToModeTable(Node: TJSONData; var T: TTXProfModeTable); +var + Arr: TJSONArray; + i, V: Integer; +begin + ClearTXProfModeTable(T); + if not (Node is TJSONArray) then Exit; + Arr := TJSONArray(Node); + for i := 0 to Min(CFG_MODE_MAX, Arr.Count - 1) do + begin + V := Arr.Integers[i]; + if (V >= 0) and (V < TXPROF_MAX) then T[i] := V; + end; +end; + procedure DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer; out L: TTXProfileList); const @@ -1942,7 +1984,7 @@ begin FillChar(L, SizeOf(L), 0); L.Count := 0; L.ActiveIdx := 0; - L.MainBind := -1; // главный приёмник профиль не переключает + ClearTXProfModeTable(L.MainByMode); // главный ничего не переключает // «Default» — текущее состояние устройства (микрофонный вход, полоса, // динамика, мощность), но с заводским EQ: после миграции старого конфига // звук в эфире не меняется, а сброс к заводским возвращает ровный тембр. @@ -1975,7 +2017,7 @@ begin FillChar(L, SizeOf(L), 0); L.Count := 0; L.ActiveIdx := 0; - L.MainBind := -1; + ClearTXProfModeTable(L.MainByMode); Result := False; MacStr := MacToStr(MAC); if FRoot.Find(MacStr) = nil then Exit; @@ -2036,8 +2078,9 @@ begin if L.Count = 0 then Exit; // пустой список = как будто секции нет L.ActiveIdx := JI(Root, 'active', 0); if (L.ActiveIdx < 0) or (L.ActiveIdx >= L.Count) then L.ActiveIdx := 0; - L.MainBind := JI(Root, 'main_bind', -1); - if (L.MainBind < -1) or (L.MainBind >= L.Count) then L.MainBind := -1; + JSONToModeTable(Root.Find('main_prof_modes'), L.MainByMode); + for i := 0 to CFG_MODE_MAX do + if L.MainByMode[i] >= L.Count then L.MainByMode[i] := -1; Result := True; end; @@ -2050,7 +2093,9 @@ var begin Root := EnsureObj(GetDevObj(MacToStr(MAC)), 'tx_profiles'); JW(Root, 'active', EnsureRange(L.ActiveIdx, 0, Max(0, L.Count - 1))); - JW(Root, 'main_bind', EnsureRange(L.MainBind, -1, Max(-1, L.Count - 1))); + Idx := Root.IndexOfName('main_prof_modes'); + if Idx >= 0 then Root.Delete(Idx); + Root.Add('main_prof_modes', ModeTableToJSON(L.MainByMode)); // Список пересоздаётся целиком: удаление профиля не должно оставлять хвост. Idx := Root.IndexOfName('list'); if Idx >= 0 then Root.Delete(Idx); @@ -2741,7 +2786,8 @@ begin SliceObj.Add('fmsq_level', P.Pans[i].Slices[j].FMSQLevel); SliceObj.Add('dev_name', P.Pans[i].Slices[j].DevName); SliceObj.Add('in_dev_name', P.Pans[i].Slices[j].InDevName); - SliceObj.Add('tx_profile', P.Pans[i].Slices[j].TXProfile); + SliceObj.Add('tx_prof_modes', + ModeTableToJSON(P.Pans[i].Slices[j].TXProfByMode)); end; end; end; @@ -2822,8 +2868,8 @@ begin P.Pans[id].Slices[n].FMSQLevel := JI(SliceObj, 'fmsq_level', 30); P.Pans[id].Slices[n].DevName := JS(SliceObj, 'dev_name', ''); P.Pans[id].Slices[n].InDevName := JS(SliceObj, 'in_dev_name', ''); - P.Pans[id].Slices[n].TXProfile := EnsureRange( - JI(SliceObj, 'tx_profile', -1), -1, TXPROF_MAX - 1); + JSONToModeTable(SliceObj.Find('tx_prof_modes'), + P.Pans[id].Slices[n].TXProfByMode); end; end; end; From 8f95795bbb26dd4bc9a7633b2f0288b3f6fab890 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 18:08:59 +0300 Subject: [PATCH 06/30] =?UTF-8?q?ui(tx):=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4?= =?UTF-8?q?=D0=BA=D0=B0=20Transmit=20=D1=86=D0=B5=D0=BB=D0=B8=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=20=D0=BD=D0=B0=20=D0=B0=D0=BD=D0=B3=D0=BB=D0=B8=D0=B9?= =?UTF-8?q?=D1=81=D0=BA=D0=BE=D0=BC=20+=20=D1=81=D0=B5=D1=82=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B=20Microphone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Страница была единственной с русскими подписями — переведены подсказки всех четырёх подвкладок, надпись «Profile drive: N %» и диалоги профиля (New / Rename / Delete / Factory / лимит). Строковых литералов с кириллицей в SettingsForm больше нет. Группа Microphone приведена к сетке страницы: подписи в колонке PAD, поля в колонке CX (комбобокс Mic jack и спин Mic gain теперь в одной колонке), Connector переехал во вторую пару колонок к Mic gain — на не-Orion он скрыт и дыры не оставляет, поэтому отдельная третья строка не нужна (176 → 134 px). Тумблеры boost/bias/PTT расставлены с равным шагом; пара «Line In gain» занимает место чекбокса boost и заканчивается до «Mic bias». Английские подписи длиннее русских — все проверены рендером формы offscreen, в группы вписываются. Co-Authored-By: Claude Opus 5 --- SettingsForm.pas | 103 +++++++++++++++++++++++++---------------------- 1 file changed, 55 insertions(+), 48 deletions(-) diff --git a/SettingsForm.pas b/SettingsForm.pas index 912e99a..54f99b8 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -1502,8 +1502,8 @@ begin with TLabel.Create(Self) do begin Parent := FPageTransmit; - Caption := 'Profile и EQ — содержимое активного TX-профиля; ' - + 'Hardware и Display — свойства устройства, от профиля не зависят.'; + Caption := 'Profile and EQ hold the active TX profile; ' + + 'Hardware and Display are radio properties and do not follow it.'; SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(GRP_W), DpiScale(20)); Font.Name := UI_FONT; Font.Size := 9; @@ -1520,10 +1520,11 @@ begin FBtnTXProfDel := MkBtn(Grp, PAD + 514, R1, 100, 'Delete', BtnTXProfDelClick); FBtnTXProfFactory := MkBtn(Grp, PAD + 620, R1, 100, 'Factory', BtnTXProfFactoryClick); - MakeLbl(Grp, 'Правки на вкладках Profile и EQ пишутся в активный профиль сразу — ' - + 'отдельной кнопки «сохранить» нет.', PAD, R1 + 30, GRP_W - 2 * PAD); - MakeLbl(Grp, 'New... создаёт копию текущего звука под новым именем. ' - + 'Factory — заводской набор заново, эквалайзер в ноль.', + MakeLbl(Grp, 'Edits on the Profile and EQ tabs go into the active profile ' + + 'immediately — there is no separate Save button.', + PAD, R1 + 30, GRP_W - 2 * PAD); + MakeLbl(Grp, 'New... forks the current sound under a new name. ' + + 'Factory rebuilds the stock set and flattens the equalizer.', PAD, R1 + 48, GRP_W - 2 * PAD); // ---- Сегменты подвкладок ------------------------------------------------ @@ -1543,31 +1544,34 @@ begin SubPage(0, SubTop); // ---- Микрофонный вход (часть профиля: гарнитура vs микшер-ESSB) ---- - Grp := MakeGroupPanel(FTXSubPage[0], 'Microphone', MARGIN, 0, GRP_W, 176); + // Две строки, обе по общей сетке страницы: подпись в колонке PAD (ширина LW), + // поле в колонке CX, вторая пара — от PAD+320. Тумблеры справа с равным + // шагом. Connector скрыт на не-Orion и потому делит строку с Mic gain — + // спрятанная пара не оставляет дыры. + Grp := MakeGroupPanel(FTXSubPage[0], 'Microphone', MARGIN, 0, GRP_W, 134); - // Row 0: Mic jack selector | Boost (Mic In) или LineInGain (Line In) | Bias | PTT - MakeLbl(Grp, 'Mic jack', PAD, R1 + 5, 65); - FCmbTXMicJack := MkCmb(Grp, PAD + 68, R1, 110); + // Row 0: Mic jack | Boost (Mic In) либо Line In gain (Line In) | Bias | PTT + MakeLbl(Grp, 'Mic jack', PAD, R1 + 5, LW); + FCmbTXMicJack := MkCmb(Grp, CX, R1, 110); FCmbTXMicJack.Items.Add('Mic In'); FCmbTXMicJack.Items.Add('Line In'); FCmbTXMicJack.ItemIndex := 0; FCmbTXMicJack.OnChange := OnMicJackChange; - FChkTXMicBoost := MkChk(Grp, PAD + 190, R1, 165, 'Mic boost +20 dB'); - FLblLineInGain := MakeLbl(Grp, 'Line In gain', PAD + 190, R1 + 5, 80); - FEdLineInGain := MkFloat(Grp, PAD + 190 + 82, R1, 0.0, -34.5, 12.0, 1.5); + // Boost и пара «Line In gain» занимают одно и то же место (видима одна из них). + FChkTXMicBoost := MkChk(Grp, PAD + 302, R1, 170, 'Mic boost +20 dB'); + FLblLineInGain := MakeLbl(Grp, 'Line In gain', PAD + 302, R1 + 5, 76); + FEdLineInGain := MkFloat(Grp, PAD + 382, R1, 0.0, -34.5, 12.0, 1.5); FEdLineInGain.DecimalPlaces := 1; - FChkTXMicBias := MkChk(Grp, PAD + 375, R1, 105, 'Mic bias'); - FChkTXMicPTT := MkChk(Grp, PAD + 490, R1, 160, 'Mic-jack PTT'); + FChkTXMicBias := MkChk(Grp, PAD + 490, R1, 100, 'Mic bias'); + FChkTXMicPTT := MkChk(Grp, PAD + 596, R1, 130, 'Mic-jack PTT'); - // Row 1: Mic gain + // Row 1: Mic gain | Connector Tip/Ring (только Orion boards 4 и 5) MakeLbl(Grp, 'Mic gain (dB)', PAD, R1 + STEP + 5, LW); FEdTXMicGain := MkFloat(Grp, CX, R1 + STEP, 10.0, -20.0, 60.0, 0.5); - // Row 2: Connector Tip/Ring (только Orion boards 4 и 5). Стоит последним — - // на не-Orion строка скрыта и не оставляет дыру посреди группы. - FLblTipRing := MakeLbl(Grp, 'Connector', PAD, R1 + 2 * STEP + 5, 70); - FCmbTXMicTR := MkCmb(Grp, PAD + 73, R1 + 2 * STEP, 90); + FLblTipRing := MakeLbl(Grp, 'Connector', PAD + 302, R1 + STEP + 5, 76); + FCmbTXMicTR := MkCmb(Grp, PAD + 382, R1 + STEP, 96); FCmbTXMicTR.Items.Add('Tip'); // MicTipRing=False, bit3=0 FCmbTXMicTR.Items.Add('Ring'); // MicTipRing=True, bit3=1 FCmbTXMicTR.ItemIndex := 0; @@ -1575,15 +1579,15 @@ begin FCmbTXMicTR.Visible := False; // ---- Кромки TX-фильтра (FIR/фаза/окно — на вкладке Hardware) ---- - Y := 176 + 12; + Y := 134 + 12; Grp := MakeGroupPanel(FTXSubPage[0], 'TX Filter (bandpass)', MARGIN, Y, GRP_W, 108); MakeLbl(Grp, 'Low cut (Hz)', PAD, R1 + 5, LW); FEdTXFiltLow := MkSpin(Grp, CX, R1, 0, 8000, 200); MakeLbl(Grp, 'High cut (Hz)', PAD + 320, R1 + 5, 90); FEdTXFiltHigh := MkSpin(Grp, PAD + 320 + 100, R1, 100, 12000, 3100); - MakeLbl(Grp, 'Ширина полосы голоса. ESSB — просто High cut 3500..5000; ' - + 'знак боковой ставит движок, здесь всегда магнитуда.', + MakeLbl(Grp, 'Voice bandwidth. ESSB is simply a high cut of 3500..5000 Hz; ' + + 'sideband sign is applied by the engine, these are magnitudes.', PAD, R1 + 32, GRP_W - 2 * PAD); // ---- Динамическая обработка ---- @@ -1621,9 +1625,9 @@ begin MakeLbl(Grp, 'AM carrier level', PAD + 320, R1 + 5, 110); FEdTXAMCarrier := MkFloat(Grp, PAD + 320 + 120, R1, 0.5, 0.0, 1.0, 0.05); - FLblTXProfDrive := MakeLbl(Grp, 'Drive профиля: —', PAD, R1 + STEP + 5, 300); - MakeLbl(Grp, 'Мощность — часть профиля, но крутится ползунком DRV в главном окне: ' - + 'текущее значение уходит в активный профиль само.', + FLblTXProfDrive := MakeLbl(Grp, 'Profile drive: —', PAD, R1 + STEP + 5, 300); + MakeLbl(Grp, 'Drive belongs to the profile, but you set it with the DRV slider ' + + 'in the main window — the active profile follows it.', PAD, R1 + STEP + 28, GRP_W - 2 * PAD); FTXSubPage[0].Height := DpiScale(Y + 148 + 12); @@ -1646,13 +1650,15 @@ begin // Главное, что надо знать про этот EQ: точки — не «полосы», а узлы кривой, // и за крайними узлами WDSP обрывает АЧХ (см. WDSPEngine.PushTXEQProfile). - MakeLbl(Grp, 'Точки — узлы кривой: между ними интерполяция по дБ, ' - + 'за крайними точками АЧХ обрывается.', PAD, R1 + 332, GRP_W - 2 * PAD); - MakeLbl(Grp, 'Нижняя точка должна быть не выше нижней кромки TX-фильтра, ' - + 'верхняя — не ниже верхней, иначе EQ сам срежет полосу.', + MakeLbl(Grp, 'Points are curve nodes: the response is interpolated in dB ' + + 'between them and cut off beyond the outer ones.', + PAD, R1 + 332, GRP_W - 2 * PAD); + MakeLbl(Grp, 'Keep the lowest node at or below the TX filter low cut and the ' + + 'highest at or above the high cut, or the EQ will clip the passband.', PAD, R1 + 352, GRP_W - 2 * PAD); - MakeLbl(Grp, 'В режиме 3 полос частоты задаёт WDSP (150 / 1500 / 6000 Гц) — ' - + 'двигается только усиление.', PAD, R1 + 372, GRP_W - 2 * PAD); + MakeLbl(Grp, 'In 3-band mode the frequencies are fixed by WDSP ' + + '(150 / 1500 / 6000 Hz) — only the gains move.', + PAD, R1 + 372, GRP_W - 2 * PAD); FTXSubPage[1].Height := DpiScale(434 + 12); @@ -1667,8 +1673,9 @@ begin FEdTXAttOnTX := MkSpin(Grp, CX, R1, 0, 31, 31); MakeLbl(Grp, 'Tune tone (Hz)', PAD + 320, R1 + 5, 110); FEdTUNFreq := MkSpin(Grp, PAD + 320 + 120, R1, 100, 5000, 750); - MakeLbl(Grp, 'ATT on TX — ослабление RX-ADC на передаче (защита входа), ' - + 'не путать с мощностью.', PAD, R1 + 32, GRP_W - 2 * PAD); + MakeLbl(Grp, 'ATT on TX attenuates the RX ADC while transmitting (input ' + + 'protection) — not to be confused with output power.', + PAD, R1 + 32, GRP_W - 2 * PAD); Y := 108 + 12; Grp := MakeGroupPanel(FTXSubPage[2], 'TX Filter (DSP)', MARGIN, Y, GRP_W, 190); @@ -1689,8 +1696,8 @@ begin FCmbTXFiltWindow.Items.Add(WINDOW_NAMES[i]); FCmbTXFiltWindow.ItemIndex := 1; - MakeLbl(Grp, 'Крутизна и задержка тракта, а не тембр — поэтому вне профиля.', - PAD, R1 + 3 * STEP + 5, GRP_W - 2 * PAD); + MakeLbl(Grp, 'Skirt steepness and path latency, not timbre — hence outside ' + + 'the profile.', PAD, R1 + 3 * STEP + 5, GRP_W - 2 * PAD); Inc(Y, 190 + 12); Grp := MakeGroupPanel(FTXSubPage[2], 'FM / CTCSS', MARGIN, Y, GRP_W, 250); @@ -1712,8 +1719,8 @@ begin MakeLbl(Grp, 'Freq (Hz)', PAD + 230, R1 + 3 * STEP + 5, 80); FEdTXCTCSSFreq := MkFloat(Grp, PAD + 320, R1 + 3 * STEP, 100.0, 50.0, 300.0, 0.1); - MakeLbl(Grp, 'Свойство канала/репитера — профилем голоса не переключается.', - PAD, R1 + 4 * STEP + 5, GRP_W - 2 * PAD); + MakeLbl(Grp, 'A property of the channel or repeater — a voice profile does ' + + 'not switch it.', PAD, R1 + 4 * STEP + 5, GRP_W - 2 * PAD); FTXSubPage[2].Height := DpiScale(Y + 250 + 12); @@ -1934,12 +1941,12 @@ begin if FCmbTXProfile.Items.Count >= TXPROF_MAX then begin MessageDlg('TX Profile', - Format('Больше %d профилей на устройство не поддерживается.', [TXPROF_MAX]), + Format('No more than %d profiles per radio.', [TXPROF_MAX]), mtInformation, [mbOK], 0); Exit; end; S := 'Profile ' + IntToStr(FCmbTXProfile.Items.Count + 1); - if not InputQuery('TX Profile', 'Имя нового профиля (копия текущего звука):', S) then Exit; + if not InputQuery('TX Profile', 'Name for the new profile (copy of the current sound):', S) then Exit; if Trim(S) = '' then Exit; if Assigned(FOnTXProfileAdd) then FOnTXProfileAdd(Trim(S)); end; @@ -1951,7 +1958,7 @@ begin Idx := FCmbTXProfile.ItemIndex; if (Idx < 0) or (Idx >= FCmbTXProfile.Items.Count) then Exit; S := FCmbTXProfile.Items[Idx]; - if not InputQuery('TX Profile', 'Новое имя профиля:', S) then Exit; + if not InputQuery('TX Profile', 'New profile name:', S) then Exit; if Trim(S) = '' then Exit; if Assigned(FOnTXProfileRename) then FOnTXProfileRename(Idx, Trim(S)); end; @@ -1964,7 +1971,7 @@ begin // Последний профиль не удаляем — активному профилю всегда есть куда писать. if (Idx < 0) or (FCmbTXProfile.Items.Count <= 1) then Exit; if MessageDlg('TX Profile', - 'Удалить профиль «' + FCmbTXProfile.Items[Idx] + '»?', + 'Delete profile "' + FCmbTXProfile.Items[Idx] + '"?', mtConfirmation, [mbYes, mbNo], 0) <> mrYes then Exit; if Assigned(FOnTXProfileDelete) then FOnTXProfileDelete(Idx); end; @@ -1976,10 +1983,10 @@ procedure TSettingsForm.BtnTXProfFactoryClick(Sender: TObject); // не доехали бы до тех, у кого секция tx_profiles уже записана. begin if MessageDlg('TX Profile', - 'Заменить весь список профилей заводским набором?'#13#10 - + 'Свои профили и правки заводских будут потеряны.'#13#10 - + 'Активным станет «Default»: микрофон, полоса, динамика и мощность ' - + 'останутся как сейчас, эквалайзер вернётся в ноль.', + 'Replace the whole profile list with the factory set?'#13#10 + + 'Your own profiles and edits to the stock ones will be lost.'#13#10 + + '"Default" becomes active: microphone, bandwidth, dynamics and ' + + 'drive stay as they are, the equalizer returns to flat.', mtConfirmation, [mbYes, mbNo], 0) <> mrYes then Exit; if Assigned(FOnTXProfileFactory) then FOnTXProfileFactory(Self); end; @@ -2001,7 +2008,7 @@ begin if FBtnTXProfDel <> nil then FBtnTXProfDel.Enabled := FCmbTXProfile.Items.Count > 1; if FLblTXProfDrive <> nil then - FLblTXProfDrive.Caption := Format('Drive профиля: %d %%', [DrivePct]); + FLblTXProfDrive.Caption := Format('Profile drive: %d %%', [DrivePct]); finally FLoading := WasLoading; end; @@ -2010,7 +2017,7 @@ end; procedure TSettingsForm.SetTXProfileDrive(DrivePct: Integer); begin if FLblTXProfDrive <> nil then - FLblTXProfDrive.Caption := Format('Drive профиля: %d %%', [DrivePct]); + FLblTXProfDrive.Caption := Format('Profile drive: %d %%', [DrivePct]); end; procedure TSettingsForm.ShowTransmitPage(SubIdx: Integer = 0); From f320ef1bcf71a40a5e418a18829a40b3172c0744 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 18:20:52 +0300 Subject: [PATCH 07/30] =?UTF-8?q?fix(tx):=20=D0=BF=D0=BE=D0=BB=D0=BE=D1=81?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B0=D1=8E=D1=89=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=81=D0=BB=D0=B0=D0=B9=D1=81=D0=B0=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=BE=D0=BF.=20=D0=BF=D0=B0=D0=BD=D0=B0=D1=85?= =?UTF-8?q?=20=D1=80=D0=B8=D1=81=D0=BE=D0=B2=D0=B0=D0=BB=D0=B0=D1=81=D1=8C?= =?UTF-8?q?=20=D0=BF=D0=BE=20RX-=D1=88=D0=B8=D1=80=D0=B8=D0=BD=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CalcSliceBandX берёт кромки TX из FTXEdge* ТОЙ вьюхи, где живёт слайс, а PushFilterEdgesToView отдавала их только FSpecView (пан 0). У панов N поля оставались нулями, ветка TX не срабатывала, и слайс на передаче показывал свою RX-полосу независимо от профиля и TX-фильтра. Кромки TX уходят во вьюхи всех панов, кадр каждого помечается грязным. RX-кромки главного туда не отдаём: на панах N главный VFO не рисуется. Подтверждено на железе. Co-Authored-By: Claude Opus 5 --- MainForm.pas | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/MainForm.pas b/MainForm.pas index b8ba0dd..345051c 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -5437,12 +5437,22 @@ procedure TMainForm.PushFilterEdgesToView; // Кромки в спектр: RX — то, что реально стоит в приёмнике (фильтр может быть // произвольным), TX — из TXSignedEdges движка. Полоса на экране обязана // совпадать с тем, что слышно и что уходит в эфир. -var Lo, Hi: Integer; +var Lo, Hi, i: Integer; begin if FSpecView = nil then Exit; FSpecView.SetRXEdges(FController.FilterLo, FController.FilterHi); FController.TXFilterEdges(Lo, Hi); FSpecView.SetTXEdges(Lo, Hi); + // Доп. паны рисуют полосу передающего слайса теми же кромками + // (CalcSliceBandX). Без этого их FTXEdge* оставались нулями, ветка TX не + // срабатывала, и слайс на передаче показывал RX-ширину. RX-кромки главного + // сюда не отдаём: на панах N главный VFO не рисуется. + for i := 1 to MAX_PANS - 1 do + if FPans[i] <> nil then + begin + FPans[i].View.SetTXEdges(Lo, Hi); + FPanSpecDirty[i] := True; + end; end; procedure TMainForm.BtnFilterMouseDown(Sender: TObject; Button: TMouseButton; From 0ba808e8868cd5b0539e7c0fdd6273f76e753b3c Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 18:34:36 +0300 Subject: [PATCH 08/30] =?UTF-8?q?fix(tx):=20Factory=20=D0=B2=D0=BE=D0=B7?= =?UTF-8?q?=D0=B2=D1=80=D0=B0=D1=89=D0=B0=D0=B5=D1=82=20=D0=B7=D0=B0=D0=B2?= =?UTF-8?q?=D0=BE=D0=B4=D1=81=D0=BA=D0=BE=D0=B9=20=D0=B7=D0=B2=D1=83=D0=BA?= =?UTF-8?q?,=20=D0=B0=20=D0=BD=D0=B5=20=D1=81=D0=BD=D0=B8=D0=BC=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=B6=D0=B8=D0=B2=D1=8B=D1=85=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit «Default» строился через TXProfileCapture(живые настройки), поэтому сброс, сделанный на профиле ESSB 5k, оставлял в нём High cut 5000 — то есть Factory не сбрасывал ровно то, ради чего его жмут. Правило «заводское значит заводское» было применено только к EQ (FlatEQ), теперь ко всему тембру. База заводских профилей — DefaultTX, поверх копируется только живая обвязка микрофона: разъём и режим (Mic In/Line In, boost, bias, PTT, Tip/Ring), Line In gain и Mic gain. Их сброс сорвал бы настройку микшера. Мощность тоже остаётся живой — скачок drive ушёл бы в усилитель. Всё остальное (полоса, компрессор, leveler, ALC, phase rotator, EQ, AM carrier, TUN level) заводское. Текст подтверждения и подсказка обещали, что полоса и динамика останутся как есть, — переписаны под новое поведение. Проверено прогоном: с живыми ESSB 5k + компрессор 12 дБ + Line In + mic gain 22.5 заводской Default выходит 200/3100, comp off, EQ ровный, а Line In, mic gain и drive сохраняются. Co-Authored-By: Claude Opus 5 --- Settings.pas | 27 ++++++++++++++++++++++----- SettingsForm.pas | 9 +++++---- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/Settings.pas b/Settings.pas index 52caeac..dbe5cbe 100644 --- a/Settings.pas +++ b/Settings.pas @@ -1910,6 +1910,15 @@ end; procedure DefaultTXProfiles(const Base: TTXSettings; DrivePct: Integer; out L: TTXProfileList); +var + // Заводской звук + ЖИВАЯ обвязка микрофона. Всё, что определяет тембр + // (полоса, динамика, EQ, AM carrier, TUN level), берётся из DefaultTX: + // «сброс к заводским» обязан сбрасывать именно это, иначе Default наследует + // ту полосу, на которой оператора застали (например 5000 от ESSB 5k). + // Не сбрасываем то, что описывает ЖЕЛЕЗО и безопасность: разъём и режим + // микрофона с его усилением (иначе слетит Line In у микшера) и мощность + // (внезапный скачок drive пошёл бы в усилитель). + Stock: TTXSettings; const // Кривые EQ для заводских ESSB. Значения — УЗЛЫ ломаной АЧХ WDSP (eq.c), // а не «полосы»: между узлами интерполяция по дБ, а ЗА крайними узлами @@ -1945,7 +1954,7 @@ const AComp: Boolean; ACompGain: Double); begin if L.Count >= TXPROF_MAX then Exit; - TXProfileCapture(Base, DrivePct, L.Items[L.Count]); + TXProfileCapture(Stock, DrivePct, L.Items[L.Count]); L.Items[L.Count].Name := AName; L.Items[L.Count].FilterLow := ALow; L.Items[L.Count].FilterHigh := AHigh; @@ -1985,10 +1994,18 @@ begin L.Count := 0; L.ActiveIdx := 0; ClearTXProfModeTable(L.MainByMode); // главный ничего не переключает - // «Default» — текущее состояние устройства (микрофонный вход, полоса, - // динамика, мощность), но с заводским EQ: после миграции старого конфига - // звук в эфире не меняется, а сброс к заводским возвращает ровный тембр. - TXProfileCapture(Base, DrivePct, L.Items[0]); + // База всех заводских профилей: заводской звук, живая обвязка микрофона. + TSettingsManager.DefaultTX(Stock); + Stock.MicLineIn := Base.MicLineIn; + Stock.MicBoost := Base.MicBoost; + Stock.MicBias := Base.MicBias; + Stock.MicPTTEnabled := Base.MicPTTEnabled; + Stock.MicTipRing := Base.MicTipRing; + Stock.LineInGainDB := Base.LineInGainDB; + Stock.MicGainDB := Base.MicGainDB; + // «Default» — ровно заводской звук: полоса 200..3100, компрессор выкл, + // leveler/ALC как в DefaultTX, EQ ровный. + TXProfileCapture(Stock, DrivePct, L.Items[0]); L.Items[0].Name := 'Default'; FlatEQ(L.Items[0]); L.Count := 1; diff --git a/SettingsForm.pas b/SettingsForm.pas index 54f99b8..4629344 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -1523,8 +1523,8 @@ begin MakeLbl(Grp, 'Edits on the Profile and EQ tabs go into the active profile ' + 'immediately — there is no separate Save button.', PAD, R1 + 30, GRP_W - 2 * PAD); - MakeLbl(Grp, 'New... forks the current sound under a new name. ' - + 'Factory rebuilds the stock set and flattens the equalizer.', + MakeLbl(Grp, 'New... forks the current sound under a new name. Factory rebuilds ' + + 'the stock set; it keeps the microphone wiring and drive, not the sound.', PAD, R1 + 48, GRP_W - 2 * PAD); // ---- Сегменты подвкладок ------------------------------------------------ @@ -1985,8 +1985,9 @@ begin if MessageDlg('TX Profile', 'Replace the whole profile list with the factory set?'#13#10 + 'Your own profiles and edits to the stock ones will be lost.'#13#10 - + '"Default" becomes active: microphone, bandwidth, dynamics and ' - + 'drive stay as they are, the equalizer returns to flat.', + + '"Default" becomes active and restores the stock sound: ' + + 'bandwidth, dynamics and a flat equalizer. Microphone wiring, ' + + 'its gain and drive are kept as they are.', mtConfirmation, [mbYes, mbNo], 0) <> mrYes then Exit; if Assigned(FOnTXProfileFactory) then FOnTXProfileFactory(Self); end; From 82df4dfb8ce8dad116816d441f2926f5f2a94f7f Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 18:47:22 +0300 Subject: [PATCH 09/30] =?UTF-8?q?feat(tx):=20=D0=B7=D0=B0=D0=B2=D0=BE?= =?UTF-8?q?=D0=B4=D1=81=D0=BA=D0=BE=D0=B9=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=20FM=20=E2=80=94=20=D0=BF=D0=BB=D0=BE=D1=82=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8F=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=81=20=D0=BA=D0=BE=D0=BC=D0=BF=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=81=D0=B8=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Шестой заводской профиль: компрессор 8 дБ + своя кривая EQ. Для FM компрессия работает иначе, чем для SSB: девиация ограничена жёстко, громкость даёт средний уровень, а не пики. По цепи TXA (wdsp/TXA.c:xtxa) eqp → leveler → bp0 → compressor → ALC идут ДО xfmmod, то есть профиль на FM-звук влияет целиком. Кривая намеренно не задирает верх: предыскажение (xemphp) по умолчанию стоит вторым вариантом — после ALC, прямо перед модулятором, — поэтому компрессор его не удержит и лишний буст ушёл бы в перемодуляцию на свистящих. Снизу срез бубнения, лёгкий подъём разборчивости 1.4..2.3 кГц, спад к 3.4 кГц. Крайние узлы 200 и 3400 взяты с запасом вокруг AF-полосы модулятора 300..3000, чтобы обрыв EQ не попал внутрь неё (проверено расчётом по формуле eq.c). Полоса 300/3000 в профиле для самого FM косметическая — края bp0 движок считает по Карсону; она сыграет, только если привязать профиль к голосовому режиму. Девиация, AF-срез и позиция предыскажения остаются свойствами аппарата (вкладка Hardware) и профилем не переключаются. Co-Authored-By: Claude Opus 5 --- Settings.pas | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Settings.pas b/Settings.pas index dbe5cbe..9ffbd2d 100644 --- a/Settings.pas +++ b/Settings.pas @@ -1934,6 +1934,15 @@ const ESSB50_F: array[1..10] of Double = (100, 180, 300, 550, 950, 1600, 2400, 3300, 4200, 5100); ESSB50_G: array[1..10] of Double = ( -5, -3, -1, 0, 0, 1, 2, 3.5, 5, 5); ESSB50_PRE = -3.0; + // FM: узлы охватывают AF-полосу модулятора (штатно 300..3000, вкладка + // Hardware), крайние с запасом — за ними EQ обрывает АЧХ. Верх НЕ поднимаем: + // предыскажение (pre-emphasis) само задирает его 6 дБ/окт и по умолчанию + // стоит ПОСЛЕ динамики (xemphp вторым, сразу перед xfmmod), то есть + // компрессор его уже не удержит — лишний буст ушёл бы в перемодуляцию на + // свистящих. Снизу режем бубнение, лёгкий подъём разборчивости 1.4..2.3 кГц. + FM_F: array[1..10] of Double = (200, 300, 450, 700, 1000, 1400, 1800, 2300, 2800, 3400); + FM_G: array[1..10] of Double = ( -8, -4, -1, 0, 0, 1, 2, 2, 1, -2); + FM_PRE = -1.0; // Тембр у заводского профиля — заводской: EQ выключен, все усиления и // преамп в нуле, сетка узлов штатная. Наследовать сюда живой EQ нельзя — @@ -2018,6 +2027,13 @@ begin Add('SSB Wide', 200, 3100, False, 5.0); AddEQ('ESSB 3.5k', 100, 3500, False, 5.0, ESSB35_F, ESSB35_G, ESSB35_PRE); AddEQ('ESSB 5k', 100, 5000, False, 5.0, ESSB50_F, ESSB50_G, ESSB50_PRE); + // FM: плотная модуляция за счёт компрессора — девиация ограничена жёстко, + // поэтому громкость даёт средний уровень, а не пики. Полоса 300..3000 здесь + // косметическая: в FM края bp0 считает сам движок по Карсону (девиация + + // AF-срез), профильные значения работают, только если привязать этот профиль + // к голосовому режиму. Девиация и AF-срез — свойства аппарата (вкладка + // Hardware), профилем не переключаются. + AddEQ('FM', 300, 3000, True, 8.0, FM_F, FM_G, FM_PRE); end; function TSettingsManager.LoadTXProfiles(const MAC: array of Byte; From 249881a5797217c400d3134c913e97c61cd0b697 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 19:23:49 +0300 Subject: [PATCH 10/30] =?UTF-8?q?fix(tx):=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=20=D0=BF=D0=BE=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB?= =?UTF-8?q?=D1=8F=D1=86=D0=B8=D0=B8=20=E2=80=94=20=D0=B4=D0=B5=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=BC=D0=B8=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D0=BE?= =?UTF-8?q?=20+=20=D0=BF=D1=80=D0=BE=D0=B1=D0=B5=D0=BB=D1=8B=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B7=D0=BE=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три связанных правки по отзывам с эфира. 1. Активный профиль однозначно определяется парой «TX-источник + его модуляция»: ячейка таблицы заполнена — её профиль, пуста — базовый (первый в списке). Прежняя «точка возврата» (запомнить, что было активно до автоматики) ломалась в самом частом случае: заходя в FM, оператор обычно УЖЕ имел активным профиль FM с прошлого захода, точка возврата записывала его же, и выход в USB ничего не менял. Плюс она жила только в сессии и после перезапуска не работала вовсе. Поле FTXProfPreAuto удалено. Ручной выбор (дропдаун TX, список настроек, пилюля флага) дополнительно ЗАПОМИНАЕТСЯ за текущей парой — это и есть обещанное «профиль помнится по виду модуляции»: раньше в таблицу писала только пилюля, а выбор дропдауном следа не оставлял. В DMR и FMRAW автоматика активный профиль не трогает вовсе. 2. Смена диапазона и вход/выход трансвертера меняют режим главного в ApplyBandDSP (FMode := B.Mode) МИМО SetMode, поэтому автоматика там не вызывалась и при переходе КВ↔трансвертер оставался профиль прошлого режима. Зовём и оттуда. Заодно: FDSPEngine.SetMode насильно ставит FTXMode в режим главного, а SetMode контроллера следом возвращает режим источника — ApplyBandDSP этого не делала, и передача со слайса после смены диапазона уезжала в чужую модуляцию. 3. Кнопка выбора TX-профиля гаснет, когда режим ИСТОЧНИКА передачи DMR или FMRAW (в DMR передатчика нет, в FMRAW профиль обходится); открытый список закрывается. Гейт по источнику, а не по главному: с главным в DMR можно передавать со слайса. Смена режима на слайсе теперь тоже пересчитывает доступность TX-контролов — это чинит и MOX/TUN/PS. Co-Authored-By: Claude Opus 5 --- MainForm.pas | 14 +++++++++++ RadioController.pas | 60 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/MainForm.pas b/MainForm.pas index 345051c..73dff86 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -3840,6 +3840,18 @@ begin TuneAllowed := MOXAllowed and (FController.ActiveTXMode <> MODE_FMRAW); BtnMOX.Enabled := MOXAllowed; BtnTUN.Enabled := TuneAllowed; + // Выбор TX-профиля: в DMR передатчика нет вовсе, в FM RAW профиль ни на что + // не влияет (ApplyTXModeSettings глушит там всю обработку). Гейт по режиму + // ИСТОЧНИКА передачи, а не главного приёмника: с главным в DMR можно + // передавать со слайса, и там профиль по-прежнему нужен. Run не требуем — + // профиль выбирается и оффлайн, как и на вкладке настроек. + if BtnTXProfile <> nil then + begin + BtnTXProfile.Enabled := + not (FController.ActiveTXMode in [MODE_DMR, MODE_FMRAW]); + if (not BtnTXProfile.Enabled) and (FTXProfileDropDown <> nil) then + FTXProfileDropDown.ClosePopup; // открытый список закрываем сами + end; UpdatePSButtons; end; @@ -6026,6 +6038,8 @@ begin FController.SetSliceModeBW(CurSliceId, Mode, FilterBW); PushFlagStateAllPans(CurSliceId); LayoutFlagsAllPans; + // Режим ИСТОЧНИКА передачи мог смениться — MOX/TUN/PS/профиль гейтятся им. + UpdateTXControlAvailability; Exit; end; // Main flag A and the left panel edit the same receiver. Use the public diff --git a/RadioController.pas b/RadioController.pas index af3a08b..a352dcf 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -594,7 +594,10 @@ type function TXProfileCount: Integer; function TXProfileName(Idx: Integer): string; function ActiveTXProfile: Integer; - procedure SelectTXProfile(Idx: Integer); + // Auto=True — переключение сделала автоматика привязок; ручной выбор + // (дропдаун, список настроек) дополнительно ЗАПОМИНАЕТСЯ за текущей парой + // «источник + модуляция» (BindTXProfileToCurrentMode). + procedure SelectTXProfile(Idx: Integer; Auto: Boolean = False); procedure StoreActiveTXProfile(Persist: Boolean = False); function AddTXProfile(const AName: string): Integer; // форк текущего звука procedure RenameTXProfile(Idx: Integer; const AName: string); @@ -604,6 +607,7 @@ type // звучит активный). Таблица у каждого флага своя: главный в FTXProfiles, // слайсы в своих записях. function FlagMode(SliceId: Integer): Integer; + procedure BindTXProfileToCurrentMode(Idx: Integer); // ручной выбор → в таблицу function SliceTXProfile(SliceId: Integer): Integer; procedure SetSliceTXProfile(SliceId, ProfIdx: Integer); function SliceTXProfileTable(SliceId: Integer): TTXProfModeTable; @@ -3631,6 +3635,16 @@ begin FFMRptOffsetHz := B.FMRptOffsetHz; if FFMRptOffsetHz <= 0 then FFMRptOffsetHz := RptDefaultOffsetHz(B.VfoA); + // --- TX-профиль под новый режим --- + // Смена диапазона и вход/выход трансвертера меняют режим ГЛАВНОГО здесь, а + // не через SetMode, поэтому автоматику привязок надо звать и отсюда — иначе + // при переходе КВ↔трансвертер оставался профиль прошлого режима. + if FTxSliceId = 0 then ApplyBoundTXProfile; + // FDSPEngine.SetMode выше насильно выставил TXA в режим главного приёмника, + // но передавать можем со слайса — возвращаем режим настоящего источника. + if FWDSPReady and Assigned(FDSPEngine) and (ActiveTXMode <> FMode) then + FDSPEngine.SetTXMode(ActiveTXMode); + // --- CTUN --- FCTun := B.CTun; if FWDSPReady and Assigned(FDSPEngine) and not FCTun then FDSPEngine.SetShift(0.0); @@ -4991,7 +5005,7 @@ begin if Persist then SaveTXProfilesToConfig; end; -procedure TRadioController.SelectTXProfile(Idx: Integer); +procedure TRadioController.SelectTXProfile(Idx: Integer; Auto: Boolean); var OldMic: Byte; NewDrive: Integer; @@ -5012,6 +5026,11 @@ begin SetDrive(NewDrive); if FTuning and FWDSPReady then FDSPEngine.SetDriveLevel(EnsureRange(FTXSettings.TUNLevel, 0, 100) / 100.0); + // ★Ручной выбор ЗАПОМИНАЕТСЯ за парой «источник передачи + его модуляция» — + // это и есть обещанное «профиль помнится по виду модуляции». Без этого в + // таблице оказывалось только то, что назначено пилюлей во флаге, а профиль, + // выбранный дропдауном в блоке TX, нигде не оставался следа. + if not Auto then BindTXProfileToCurrentMode(Idx); if FDevConnected and Assigned(FSettings) then begin FSettings.SaveTX(FDevMAC, FTXSettings); @@ -5094,6 +5113,23 @@ begin for m := 0 to CFG_MODE_MAX do Fix(FSlices[i].TXProfByMode[m]); end; +procedure TRadioController.BindTXProfileToCurrentMode(Idx: Integer); +// Пишет профиль в ячейку «текущий TX-источник + его модуляция». Отдельно от +// SetSliceTXProfile: там ещё и применение в эфир, а здесь профиль уже применён +// (нас зовут из SelectTXProfile), повторный ApplyBoundTXProfile был бы циклом. +var m, Slot: Integer; +begin + if (Idx < 0) or (Idx >= FTXProfiles.Count) then Exit; + m := FlagMode(FTxSliceId); + if FTxSliceId <= 0 then + FTXProfiles.MainByMode[m] := Idx // персист — общим блоком в SelectTXProfile + else + begin + Slot := FindSliceIndex(FTxSliceId); + if Slot >= 0 then FSlices[Slot].TXProfByMode[m] := Idx; + end; +end; + function TRadioController.FlagMode(SliceId: Integer): Integer; // Режим, в котором сейчас стоит этот флаг: у главного — FMode, у слайса — свой. var idx: Integer; @@ -5182,13 +5218,23 @@ begin end; procedure TRadioController.ApplyBoundTXProfile; -// Профиль текущего TX-источника → в эфир. Ничего не делает, если источник ни к -// чему не привязан (-1) или нужный профиль уже активен. -var P: Integer; +// Активный профиль ОДНОЗНАЧНО определяется парой «TX-источник + его модуляция»: +// ячейка таблицы заполнена → её профиль; +// ячейка пуста (−1) → базовый профиль (первый в списке). +// Фолбэк обязателен: без него профиль прошлого режима оставался висеть в новом +// (симптом «перешёл из FM в USB, а звучит FM»). Правило детерминированное — +// работает и после перезапуска, в отличие от «вернуть, что было до автоматики». +var P, M: Integer; begin + if FTXProfiles.Count = 0 then Exit; + M := FlagMode(FTxSliceId); + // В DMR передатчика нет, в FM RAW профиль ни на что не влияет + // (ApplyTXModeSettings глушит там всю обработку) — активный не трогаем вовсе, + // иначе заход в эти режимы молча перебивал бы выбор оператора. + if (M = MODE_DMR) or (M = MODE_FMRAW) then Exit; P := SliceTXProfile(FTxSliceId); - if (P < 0) or (P = FTXProfiles.ActiveIdx) then Exit; - SelectTXProfile(P); + if P < 0 then P := 0; + if P <> FTXProfiles.ActiveIdx then SelectTXProfile(P, True); end; procedure TRadioController.ResetTXProfilesToFactory; From 199d0ffa85fbefc77f3be2ef90df9cf7e2d199d2 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 19:30:54 +0300 Subject: [PATCH 11/30] =?UTF-8?q?ui(tx):=20=D1=82=D1=80=D0=B5=D1=83=D0=B3?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=BD=D0=B8=D0=BA=20=D0=BF=D0=B8=D0=BB=D1=8E?= =?UTF-8?q?=D0=BB=D0=B8=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B2=D1=8B=D1=80=D0=BE=D0=B2=D0=BD=D0=B5=D0=BD=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D0=BD=D0=B0=D0=B4=D0=BF=D0=B8=D1=81=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Треугольник центрировался по прямоугольнику пилюли, а текст — по TextHeight, который включает выносные элементы вниз. В подписи одни заглавные, они сидят в верхней части бокса, поэтому треугольник визуально проваливался: по пикселям текст занимал строки 7..13, треугольник 11..15. Считаем от метрики самой надписи (TxtY + TextHeight div 2 - 2), а не от бокса, чтобы выравнивание пережило смену шрифта или кегля. После правки текст 7..13, треугольник 8..12. Co-Authored-By: Claude Opus 5 --- VfoOverlay.pas | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/VfoOverlay.pas b/VfoOverlay.pas index b1f937d..804a649 100644 --- a/VfoOverlay.pas +++ b/VfoOverlay.pas @@ -1236,7 +1236,7 @@ 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; + ProfLeft, ProfRight, ProfW, TxtY, TriY: Integer; R: TRect; Tri3: array[0..2] of TPoint; BarL, BarW, MeterX: Integer; @@ -1347,14 +1347,19 @@ begin 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); + TxtY := R.Top + (HDR_BADGE_H - C.TextHeight('A')) div 2; + C.TextOut(R.Left + 4, TxtY, PStr); // Треугольник «список» справа — рисуем, а не пишем: в Courier New - // юникодных стрелок может не оказаться. + // юникодных стрелок может не оказаться. Центрируем не по прямоугольнику + // текста, а по САМИМ буквам: TextHeight включает выносные элементы, а в + // подписи одни заглавные, они сидят в верхней части бокса — иначе + // треугольник визуально проваливается ниже надписи. + TriY := TxtY + C.TextHeight('A') div 2 - 2; 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); + Tri3[0] := Point(R.Right - 9, TriY - 2); + Tri3[1] := Point(R.Right - 3, TriY - 2); + Tri3[2] := Point(R.Right - 6, TriY + 2); C.Polygon(Tri3); C.Font.Size := 8; RegisterHit(R, HIT_TXPROF); From 4e6703e59050ab8e8c7dd177f7ad9b21a3a9a810 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 19:40:59 +0300 Subject: [PATCH 12/30] =?UTF-8?q?fix(xvtr):=20TUN=20=D0=B8=D0=B4=D1=91?= =?UTF-8?q?=D1=82=20=D0=BE=D1=82=20=D0=B3=D0=BB=D0=BE=D0=B1=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD?= =?UTF-8?q?=D1=8F,=20=D0=BC=D0=BE=D1=89=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20?= =?UTF-8?q?=D1=81=D0=BB=D0=BE=D1=82=D0=B0=20=E2=80=94=20=D0=BC=D0=BD=D0=BE?= =?UTF-8?q?=D0=B6=D0=B8=D1=82=D0=B5=D0=BB=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При включённой опции TUN в трансвертере брал мощность слота ВМЕСТО глобального TUN Level, из-за чего ручка «Tune level» на вкладке Transmit в трансвертере была мёртвой, а настройка уходила в эфир на мощности слота (у 70cm и QO-100 — все 100 %). Теперь уровень всегда от TUN Level, а слотовая TX Power работает множителем: Pos = TUNLevel * слот/100 — та же формула, по которой в трансвертере уже масштабировался обычный drive. Смысл поля становится единым: «сколько мощности трансивера разрешено этому трансвертеру», и к настройке это относится так же, как к передаче. Галка сохранена (кому нужен неотмасштабированный TUN), но переименована в «Scale TUN by XVTR power» — прежнее название описывало снятое поведение. Co-Authored-By: Claude Opus 5 --- RadioController.pas | 11 ++++++++--- SettingsForm.pas | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/RadioController.pas b/RadioController.pas index a352dcf..7ed9f52 100644 --- a/RadioController.pas +++ b/RadioController.pas @@ -3385,10 +3385,15 @@ begin if FTuning then begin + // Уровень настройки всегда идёт от глобального TUN Level — иначе ручка в + // Transmit в трансвертере оказывалась мёртвой. Мощность слота (если + // включена галка) работает МНОЖИТЕЛЕМ, ровно как для обычного drive ниже: + // это «сколько мощности трансиверa разрешено этому трансвертеру», и к + // настройке оно относится так же, как к передаче. + Pos := EnsureRange(FTXSettings.TUNLevel, 0, 100); if XvtrActive and FXvtrSettings.UseXVTRTunePower then - Pos := EnsureRange(FXvtrSettings.Entries[FCurrentXvtr].TXPower, 0, 100) - else - Pos := EnsureRange(FTXSettings.TUNLevel, 0, 100); + Pos := Round(Pos * EnsureRange(FXvtrSettings.Entries[FCurrentXvtr].TXPower, + 0, 100) / 100.0); end else begin diff --git a/SettingsForm.pas b/SettingsForm.pas index 4629344..effcf52 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -4896,8 +4896,8 @@ begin MARGIN, GrpY, GRP_W, GRP_TITLE_H + 36); FChkXvtrTunPwr := TFlatCheckBox.Create(Self); FChkXvtrTunPwr.Parent := GrpOpts; - FChkXvtrTunPwr.Caption := 'Use XVTR power for TUN ' - + '(TUN button uses the per-slot TX Power instead of the global TUN Level)'; + FChkXvtrTunPwr.Caption := 'Scale TUN by XVTR power ' + + '(TUN level from Transmit is multiplied by the per-slot TX Power)'; FChkXvtrTunPwr.SetBounds(DpiScale(14), DpiScale(GRP_TITLE_H + 8), DpiScale(GRP_W - 28), DpiScale(20)); FChkXvtrTunPwr.Font.Color := CLR_TEXT; FChkXvtrTunPwr.Font.Name := UI_FONT; From 304e574592bc7fcca50e5ae0285f3eb67b278f05 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 20:23:33 +0300 Subject: [PATCH 13/30] ui(settings): normalize layout and system fonts --- SettingsForm.pas | 299 ++++++++++++++++++++++------------------------- 1 file changed, 141 insertions(+), 158 deletions(-) diff --git a/SettingsForm.pas b/SettingsForm.pas index effcf52..ecafc40 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -24,7 +24,7 @@ unit SettingsForm; interface uses - Classes, SysUtils, Math, + Classes, SysUtils, Math, Types, Forms, Controls, Graphics, Dialogs, StdCtrls, ExtCtrls, LCLType, FlatButton, FlatCheckBox, FlatComboBox, FlatEdit, FlatSpinEdit, FlatFloatSpinEdit, @@ -104,6 +104,7 @@ type // ---- Layout ---- FNavPanel: TPanel; FContentPanel: TPanel; + FFooterPanel: TPanel; FPageAudio: TScrollBox; FPageDisplay: TScrollBox; FPageSpectrum: TScrollBox; @@ -510,6 +511,7 @@ type function MakeGroupPanel(AParent: TWinControl; const Cap: string; ALeft, ATop, AW, AH: Integer): TPanel; function MakeScrollPage: TScrollBox; + procedure MakeNavSection(const Cap: string; ATop: Integer); function MakeNavButton(const Cap: string; ATop: Integer): TFlatButton; procedure OnFFTSizeChange(Sender: TObject); @@ -675,13 +677,10 @@ const CLR_ACCENT = TColor($0040FF80); CLR_INPUT = TColor($00FFFFFF); CLR_INPUT_TEXT = TColor($00202020); - {$IFDEF MSWINDOWS} - UI_FONT = 'Segoe UI'; - {$ELSE} - UI_FONT = 'Sans'; - {$ENDIF} BTN_H = 24; GRP_TITLE_H = 22; // высота заголовка в group-панелях (title label + divider) + SETTINGS_CARD_W = 760; + SETTINGS_FOOTER_H = 52; // Первый пункт комбо аудио слайса = «как у приложения» (пустое имя в конфиге). SLICE_DEV_DEFAULT = 'Default (app output)'; @@ -738,7 +737,7 @@ const CAL_LBL_W = 54; CAL_SM_LBL_W = 68; // подписи трансвертеров длиннее ('70cm', 'QO-100') CAL_COLS = 4; - CAL_GRP_W = 700; + CAL_GRP_W = SETTINGS_CARD_W; CAL_GRP1_H = 236; // Power / SWR detector CAL_GRP2_H = 156; // Supply voltage / current CAL_VA_LBL_W = 220; @@ -746,16 +745,31 @@ const { TSettingsForm } constructor TSettingsForm.Create(AOwner: TComponent); +var + WorkArea: TRect; + MaxW, MaxH: Integer; begin inherited CreateNew(AOwner); Caption := 'Settings'; - Width := DpiScale(1180); - Height := DpiScale(720); - Position := poScreenCenter; + if AOwner is TCustomForm then + begin + WorkArea := TCustomForm(AOwner).BoundsRect; + WorkArea := Screen.MonitorFromRect(WorkArea).WorkareaRect; + Position := poOwnerFormCenter; + end + else + begin + WorkArea := Screen.PrimaryMonitor.WorkareaRect; + Position := poScreenCenter; + end; + MaxW := WorkArea.Right - WorkArea.Left - DpiScale(48); + MaxH := WorkArea.Bottom - WorkArea.Top - DpiScale(48); + Width := Min(DpiScale(1180), MaxW); + Height := Min(DpiScale(720), MaxH); + Constraints.MinWidth := Min(DpiScale(760), Width); + Constraints.MinHeight := Min(DpiScale(620), Height); BorderStyle := bsSizeable; Color := CLR_BG; - Font.Name := UI_FONT; - Font.Size := 9; Font.Color := CLR_TEXT; FLoading := False; FRXDevCount := 0; @@ -773,11 +787,19 @@ begin Result := TLabel.Create(Self); Result.Parent := AParent; Result.Caption := Cap; - Result.Left := DpiScale(ALeft); - Result.Top := DpiScale(ATop); - if AW > 0 then Result.Width := DpiScale(AW); + Result.AutoSize := AW <= 0; + if AW > 0 then + begin + Result.SetBounds(DpiScale(ALeft), DpiScale(ATop), + DpiScale(AW), DpiScale(18)); + Result.Layout := tlCenter; + end + else + begin + Result.Left := DpiScale(ALeft); + Result.Top := DpiScale(ATop); + end; Result.Font.Color := CLR_TEXTDIM; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; end; @@ -792,7 +814,6 @@ begin Result.Height := DpiScale(BTN_H); Result.Color := CLR_INPUT; Result.Font.Color := CLR_INPUT_TEXT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; Result.OnChange := OnChange; end; @@ -816,15 +837,17 @@ begin TitleLbl := TLabel.Create(Self); TitleLbl.Parent := Result; TitleLbl.Caption := Cap; - TitleLbl.SetBounds(DpiScale(8), DpiScale(5), SAW - DpiScale(16), DpiScale(14)); + TitleLbl.AutoSize := False; + TitleLbl.SetBounds(DpiScale(10), DpiScale(2), + SAW - DpiScale(20), DpiScale(GRP_TITLE_H - 3)); + TitleLbl.Layout := tlCenter; TitleLbl.Font.Color := CLR_ACCENT; - TitleLbl.Font.Name := UI_FONT; TitleLbl.Font.Size := 9; TitleLbl.Font.Style := [fsBold]; Divider := TPanel.Create(Self); Divider.Parent := Result; - Divider.SetBounds(0, DpiScale(GRP_TITLE_H), SAW + DpiScale(200), 1); + Divider.SetBounds(0, DpiScale(GRP_TITLE_H), SAW, 1); Divider.BevelOuter := bvNone; Divider.Color := CLR_BORDER; end; @@ -840,12 +863,27 @@ begin Result.Visible := False; end; +procedure TSettingsForm.MakeNavSection(const Cap: string; ATop: Integer); +begin + with TLabel.Create(Self) do + begin + Parent := FNavPanel; + Caption := UpperCase(Cap); + AutoSize := False; + SetBounds(DpiScale(14), DpiScale(ATop), DpiScale(160), DpiScale(18)); + Layout := tlCenter; + Font.Size := 8; + Font.Style := [fsBold]; + Font.Color := CLR_TEXTDIM; + end; +end; + function TSettingsForm.MakeNavButton(const Cap: string; ATop: Integer): TFlatButton; begin Result := TFlatButton.Create(Self); Result.Parent := FNavPanel; Result.Caption := Cap; - Result.SetBounds(DpiScale(10), DpiScale(ATop), DpiScale(166), DpiScale(32)); + Result.SetBounds(DpiScale(10), DpiScale(ATop), DpiScale(166), DpiScale(30)); Result.Anchors := [akLeft, akTop, akRight]; Result.OnClick := NavClick; Result.ClrNorm := CLR_PANEL; @@ -854,7 +892,6 @@ begin Result.ClrActive := TColor($00204030); Result.ClrText := CLR_TEXT; Result.ClrTextAct := CLR_ACCENT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; Result.Font.Style := []; end; @@ -877,7 +914,6 @@ begin Parent := FNavPanel; Caption := 'Settings'; SetBounds(DpiScale(14), DpiScale(14), DpiScale(160), DpiScale(24)); - Font.Name := UI_FONT; Font.Size := 14; Font.Style := [fsBold]; Font.Color := CLR_TEXT; @@ -888,25 +924,29 @@ begin Parent := FNavPanel; Caption := 'EWSDR'; SetBounds(DpiScale(14), DpiScale(39), DpiScale(160), DpiScale(18)); - Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; end; - FNavAudio := MakeNavButton('Audio', 74); - FNavDisplay := MakeNavButton('Display', 110); - FNavSpectrum := MakeNavButton('Spectrum', 146); - FNavWaterfall := MakeNavButton('Waterfall', 182); - FNavTransmit := MakeNavButton('Transmit', 218); - FNavPA := MakeNavButton('Power Amplifier', 254); - FNavCalib := MakeNavButton('Calibration', 290); - FNavAdvanced := MakeNavButton('Advanced', 326); - FNavCAT := MakeNavButton('CAT', 362); - FNavSlices := MakeNavButton('Slices', 398); - FNavAlex := MakeNavButton('Antenna / Alex', 434); - FNavXvtr := MakeNavButton('Transverter', 470); - FNavOC := MakeNavButton('OC Control', 506); - FNavQO := MakeNavButton('QO-100', 542); + MakeNavSection('General', 68); + FNavAudio := MakeNavButton('Audio', 88); + FNavDisplay := MakeNavButton('Display', 120); + FNavSpectrum := MakeNavButton('Spectrum', 152); + FNavWaterfall := MakeNavButton('Waterfall', 184); + + MakeNavSection('Radio', 224); + FNavTransmit := MakeNavButton('Transmit', 244); + FNavPA := MakeNavButton('Power Amplifier', 276); + FNavCalib := MakeNavButton('Calibration', 308); + FNavAlex := MakeNavButton('Antenna / Alex', 340); + FNavXvtr := MakeNavButton('Transverter', 372); + FNavOC := MakeNavButton('OC Control', 404); + FNavQO := MakeNavButton('QO-100', 436); + + MakeNavSection('Integration', 476); + FNavCAT := MakeNavButton('CAT', 496); + FNavSlices := MakeNavButton('Slices', 528); + FNavAdvanced := MakeNavButton('Advanced', 560); FContentPanel := TPanel.Create(Self); FContentPanel.Parent := Self; @@ -914,6 +954,13 @@ begin FContentPanel.BevelOuter := bvNone; FContentPanel.Color := CLR_BG; + FFooterPanel := TPanel.Create(Self); + FFooterPanel.Parent := FContentPanel; + FFooterPanel.Align := alBottom; + FFooterPanel.Height := DpiScale(SETTINGS_FOOTER_H); + FFooterPanel.BevelOuter := bvNone; + FFooterPanel.Color := CLR_BG; + FPageAudio := MakeScrollPage; FPageDisplay := MakeScrollPage; FPageSpectrum := MakeScrollPage; @@ -949,9 +996,10 @@ begin BuildQOTab; FBtnClose := TFlatButton.Create(Self); - FBtnClose.Parent := Self; + FBtnClose.Parent := FFooterPanel; FBtnClose.Caption := 'Close'; - FBtnClose.SetBounds(Width - DpiScale(106), Height - DpiScale(40), DpiScale(92), DpiScale(28)); + FBtnClose.SetBounds(FFooterPanel.ClientWidth - DpiScale(110), DpiScale(10), + DpiScale(92), DpiScale(30)); FBtnClose.Anchors := [akRight, akBottom]; FBtnClose.OnClick := BtnCloseClick; FBtnClose.ClrNorm := CLR_PANEL; @@ -960,7 +1008,6 @@ begin FBtnClose.ClrActive := TColor($00003300); FBtnClose.ClrText := CLR_TEXT; FBtnClose.ClrTextAct:= CLR_ACCENT; - FBtnClose.Font.Name := UI_FONT; FBtnClose.Font.Size := 9; SelectPage(FPageAudio, FNavAudio); @@ -1039,7 +1086,6 @@ begin Parent := FPageAudio; Caption := 'Audio'; SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Name := UI_FONT; Font.Size := 16; Font.Style := [fsBold]; Font.Color := CLR_TEXT; @@ -1050,12 +1096,11 @@ begin Parent := FPageAudio; Caption := 'Input and output devices used by the receiver and transmitter.'; SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(560), DpiScale(20)); - Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; end; - Grp := MakeGroupPanel(FPageAudio, 'Audio Devices', MARGIN, 86, 650, 184); + Grp := MakeGroupPanel(FPageAudio, 'Audio Devices', MARGIN, 86, SETTINGS_CARD_W, 184); MakeLbl(Grp, 'TX input device', PAD, R1 + 5, LW); FCmbTXDev := MakeCombo(Grp, CX, R1, CMB_W, OnTXDevChange); @@ -1072,7 +1117,7 @@ begin FCmbAudioBuffer.Items.Add('512 frames'); FCmbAudioBuffer.ItemIndex := 0; - Grp := MakeGroupPanel(FPageAudio, 'Transceiver Audio', MARGIN, 290, 650, 92); + Grp := MakeGroupPanel(FPageAudio, 'Transceiver Audio', MARGIN, 290, SETTINGS_CARD_W, 92); FChkSendAudioToRadio := TFlatCheckBox.Create(Self); FChkSendAudioToRadio.Parent := Grp; @@ -1080,7 +1125,6 @@ begin FChkSendAudioToRadio.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(420), DpiScale(22)); FChkSendAudioToRadio.Checked := False; FChkSendAudioToRadio.Font.Color := CLR_TEXT; - FChkSendAudioToRadio.Font.Name := UI_FONT; FChkSendAudioToRadio.Font.Size := 9; FChkSendAudioToRadio.OnChange := OnSendAudioToRadioChkChange; end; @@ -1107,7 +1151,6 @@ begin Parent := FPageDisplay; Caption := 'Display'; SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Name := UI_FONT; Font.Size := 16; Font.Style := [fsBold]; Font.Color := CLR_TEXT; @@ -1118,12 +1161,11 @@ begin Parent := FPageDisplay; Caption := 'General visual options. Spectrum and waterfall processing are split into their own pages.'; SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(680), DpiScale(20)); - Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; end; - Grp := MakeGroupPanel(FPageDisplay, 'Visible Panes', MARGIN, 86, 650, 112); + Grp := MakeGroupPanel(FPageDisplay, 'Visible Panes', MARGIN, 86, SETTINGS_CARD_W, 112); FChkShowSpectrum := TFlatCheckBox.Create(Self); FChkShowSpectrum.Parent := Grp; @@ -1131,7 +1173,6 @@ begin FChkShowSpectrum.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(CHKW), DpiScale(22)); FChkShowSpectrum.Checked := True; FChkShowSpectrum.Font.Color := CLR_TEXT; - FChkShowSpectrum.Font.Name := UI_FONT; FChkShowSpectrum.Font.Size := 9; FChkShowSpectrum.OnChange := OnVisibilityChkChange; @@ -1141,7 +1182,6 @@ begin FChkShowWaterfall.SetBounds(DpiScale(PAD + CHKW + 20), DpiScale(R1), DpiScale(CHKW), DpiScale(22)); FChkShowWaterfall.Checked := True; FChkShowWaterfall.Font.Color := CLR_TEXT; - FChkShowWaterfall.Font.Name := UI_FONT; FChkShowWaterfall.Font.Size := 9; FChkShowWaterfall.OnChange := OnVisibilityChkChange; @@ -1151,7 +1191,6 @@ begin FChkShowWideband.SetBounds(DpiScale(PAD + (CHKW + 20) * 2), DpiScale(R1), DpiScale(CHKW), DpiScale(22)); FChkShowWideband.Checked := False; FChkShowWideband.Font.Color := CLR_TEXT; - FChkShowWideband.Font.Name := UI_FONT; FChkShowWideband.Font.Size := 9; FChkShowWideband.OnChange := OnVisibilityChkChange; @@ -1161,7 +1200,6 @@ begin FChkWidebandFill.SetBounds(DpiScale(PAD + (CHKW + 20) * 2), DpiScale(R1 + 28), DpiScale(CHKW), DpiScale(22)); FChkWidebandFill.Checked := False; FChkWidebandFill.Font.Color := CLR_TEXT; - FChkWidebandFill.Font.Name := UI_FONT; FChkWidebandFill.Font.Size := 9; FChkWidebandFill.OnChange := OnVisibilityChkChange; @@ -1171,11 +1209,10 @@ begin FChkSpectrumFill.SetBounds(DpiScale(PAD), DpiScale(R1 + 28), DpiScale(CHKW), DpiScale(22)); FChkSpectrumFill.Checked := True; FChkSpectrumFill.Font.Color := CLR_TEXT; - FChkSpectrumFill.Font.Name := UI_FONT; FChkSpectrumFill.Font.Size := 9; FChkSpectrumFill.OnChange := OnVisibilityChkChange; - Grp := MakeGroupPanel(FPageDisplay, 'Performance', MARGIN, 212, 650, 92); + Grp := MakeGroupPanel(FPageDisplay, 'Performance', MARGIN, 212, SETTINGS_CARD_W, 92); MakeLbl(Grp, 'Refresh rate', PAD, R1 + 5, LW); FCmbFPS := MakeCombo(Grp, CX, R1, 140, OnFPSCmbChange); @@ -1183,7 +1220,7 @@ begin FCmbFPS.Items.Add(FPS_NAMES[i]); FCmbFPS.ItemIndex := 8; - Grp := MakeGroupPanel(FPageDisplay, 'Appearance', MARGIN, 318, 650, 68); + Grp := MakeGroupPanel(FPageDisplay, 'Appearance', MARGIN, 318, SETTINGS_CARD_W, 68); FChkLightTheme := TFlatCheckBox.Create(Self); FChkLightTheme.Parent := Grp; @@ -1191,11 +1228,10 @@ begin FChkLightTheme.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(CHKW), DpiScale(22)); FChkLightTheme.Checked := False; FChkLightTheme.Font.Color := CLR_TEXT; - FChkLightTheme.Font.Name := UI_FONT; FChkLightTheme.Font.Size := 9; FChkLightTheme.OnChange := OnLightThemeChkChange; - Grp := MakeGroupPanel(FPageDisplay, 'VFO Display', MARGIN, 404, 650, 92); + Grp := MakeGroupPanel(FPageDisplay, 'VFO Display', MARGIN, 404, SETTINGS_CARD_W, 92); MakeLbl(Grp, 'Frequency range', PAD, R1 + 5, LW); FCmbFreqMhzDigits := MakeCombo(Grp, CX, R1, 240, OnFreqMhzDigitsCmbChange); @@ -1236,7 +1272,7 @@ var Result.MaxValue := AMax; Result.Increment := AInc; Result.DecimalPlaces := 1; Result.Color := CLR_INPUT; Result.Font.Color := CLR_INPUT_TEXT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; + Result.Font.Size := 9; Result.OnChange := AOnChange; end; @@ -1247,7 +1283,7 @@ var Result.Parent := Grp; Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(BTN_H)); Result.Color := CLR_INPUT; Result.Font.Color := CLR_INPUT_TEXT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; + Result.Font.Size := 9; Result.Text := AText; Result.OnExit := AOnExit; end; @@ -1257,7 +1293,6 @@ begin Parent := FPageSpectrum; Caption := 'Spectrum'; SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Name := UI_FONT; Font.Size := 16; Font.Style := [fsBold]; Font.Color := CLR_TEXT; @@ -1268,12 +1303,11 @@ begin Parent := FPageSpectrum; Caption := 'Analyzer, detector, averaging and grid parameters for RX1.'; SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(620), DpiScale(20)); - Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; end; - Grp := MakeGroupPanel(FPageSpectrum, 'Analyzer', MARGIN, 86, 650, 128); + Grp := MakeGroupPanel(FPageSpectrum, 'Analyzer', MARGIN, 86, SETTINGS_CARD_W, 128); MakeLbl(Grp, 'FFT Size:', PAD, R1 + 5, LW); FCmbFFTSize := MakeCombo(Grp, CX, R1, CMB_W, OnFFTSizeChange); for i := 0 to High(FFT_NAMES) do FCmbFFTSize.Items.Add(FFT_NAMES[i]); @@ -1281,7 +1315,7 @@ begin FCmbWindow := MakeCombo(Grp, CX, R1 + STEP, CMB_W_LONG, OnWindowChange); for i := 0 to High(WINDOW_NAMES) do FCmbWindow.Items.Add(WINDOW_NAMES[i]); - Grp := MakeGroupPanel(FPageSpectrum, 'Detection && Averaging', MARGIN, 230, 650, 170); + Grp := MakeGroupPanel(FPageSpectrum, 'Detection && Averaging', MARGIN, 230, SETTINGS_CARD_W, 170); MakeLbl(Grp, 'Detector:', PAD, R1 + 5, LW); FCmbSpecDet := MakeCombo(Grp, CX, R1, CMB_W, OnSpecDetChange); for i := 0 to High(DETECTOR_NAMES) do FCmbSpecDet.Items.Add(DETECTOR_NAMES[i]); @@ -1291,14 +1325,14 @@ begin MakeLbl(Grp, 'Avg Time (ms):', PAD, R1 + 2 * STEP + 5, LW); FSpecAvgTime := MkFloat(CX, R1 + 2 * STEP, SPW, 30.0, 0.0, 5000.0, 10.0, OnSpecAvgTimeChange); - Grp := MakeGroupPanel(FPageSpectrum, 'Rendering', MARGIN, 602, 650, 128); + Grp := MakeGroupPanel(FPageSpectrum, 'Rendering', MARGIN, 602, SETTINGS_CARD_W, 128); MakeLbl(Grp, 'Anti-aliasing:', PAD, R1 + 5, LW); FCmbSpecMSAA := MakeCombo(Grp, CX, R1, 130, OnSpecMSAACtlChange); for i := 0 to High(MSAA_NAMES) do FCmbSpecMSAA.Items.Add(MSAA_NAMES[i]); FCmbSpecMSAA.ItemIndex := 2; // 4x MakeLbl(Grp, '(GL spectrum curve smoothing, MSAA)', CX + 140, R1 + 5, 300); - Grp := MakeGroupPanel(FPageSpectrum, 'Grid', MARGIN, 416, 650, 170); + Grp := MakeGroupPanel(FPageSpectrum, 'Grid', MARGIN, 416, SETTINGS_CARD_W, 170); MakeLbl(Grp, 'Reference level:', PAD, R1 + 5, LW); FEdRefLevel := MkEdit(CX, R1, EDT_W, '-20', OnRefLevelChange); MakeLbl(Grp, 'Range (dB):', PAD, R1 + STEP + 5, LW); @@ -1313,7 +1347,6 @@ begin Parent := FPageWaterfall; Caption := 'Waterfall'; SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Name := UI_FONT; Font.Size := 16; Font.Style := [fsBold]; Font.Color := CLR_TEXT; @@ -1324,12 +1357,11 @@ begin Parent := FPageWaterfall; Caption := 'Waterfall detector, smoothing and color scale thresholds.'; SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(620), DpiScale(20)); - Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; end; - Grp := MakeGroupPanel(FPageWaterfall, 'Detection && Averaging', MARGIN, 86, 650, 170); + Grp := MakeGroupPanel(FPageWaterfall, 'Detection && Averaging', MARGIN, 86, SETTINGS_CARD_W, 170); MakeLbl(Grp, 'Detector:', PAD, R1 + 5, LW); FCmbWfDet := MakeCombo(Grp, CX, R1, CMB_W, OnWfDetChange); for i := 0 to High(DETECTOR_NAMES) do FCmbWfDet.Items.Add(DETECTOR_NAMES[i]); @@ -1339,7 +1371,7 @@ begin MakeLbl(Grp, 'Avg Time (ms):', PAD, R1 + 2 * STEP + 5, LW); FWfAvgTime := MkFloat(CX, R1 + 2 * STEP, SPW, 120.0, 0.0, 5000.0, 10.0, OnWfAvgTimeChange); - Grp := MakeGroupPanel(FPageWaterfall, 'Color Scale', MARGIN, 272, 650, 170); + Grp := MakeGroupPanel(FPageWaterfall, 'Color Scale', MARGIN, 272, SETTINGS_CARD_W, 170); MakeLbl(Grp, 'High (dBm):', PAD, R1 + 5, LW); FWfHigh := MkFloat(CX, R1, SPW, -80.0, -160.0, 20.0, 1.0, OnWfThresholdChange); MakeLbl(Grp, 'Low (dBm):', PAD, R1 + STEP + 5, LW); @@ -1347,14 +1379,13 @@ begin MakeLbl(Grp, 'AGC Offset (dB):', PAD, R1 + 2 * STEP + 5, LW); FWfAGCOffset := MkFloat(CX, R1 + 2 * STEP, SPW, 0.0, -40.0, 40.0, 0.5, OnWfThresholdChange); - Grp := MakeGroupPanel(FPageWaterfall, 'AGC && Noise Floor', MARGIN, 458, 650, 140); + Grp := MakeGroupPanel(FPageWaterfall, 'AGC && Noise Floor', MARGIN, 458, SETTINGS_CARD_W, 140); FChkWfAGC := TFlatCheckBox.Create(Self); FChkWfAGC.Parent := Grp; FChkWfAGC.Caption := 'Waterfall AGC'; FChkWfAGC.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(200), DpiScale(22)); FChkWfAGC.Checked := True; FChkWfAGC.Font.Color := CLR_TEXT; - FChkWfAGC.Font.Name := UI_FONT; FChkWfAGC.Font.Size := 9; FChkWfAGC.OnChange := OnWfAGCNFChkChange; FChkWfNF := TFlatCheckBox.Create(Self); @@ -1363,11 +1394,10 @@ begin FChkWfNF.SetBounds(DpiScale(PAD), DpiScale(R1 + STEP), DpiScale(200), DpiScale(22)); FChkWfNF.Checked := False; FChkWfNF.Font.Color := CLR_TEXT; - FChkWfNF.Font.Name := UI_FONT; FChkWfNF.Font.Size := 9; FChkWfNF.OnChange := OnWfAGCNFChkChange; - Grp := MakeGroupPanel(FPageWaterfall, 'Rendering', MARGIN, 614, 650, 212); + Grp := MakeGroupPanel(FPageWaterfall, 'Rendering', MARGIN, 614, SETTINGS_CARD_W, 212); MakeLbl(Grp, 'Palette:', PAD, R1 + 5, LW); FCmbWfPalette := MakeCombo(Grp, CX, R1, CMB_W, OnWfRenderCtlChange); for i := 0 to High(WF_PALETTE_NAMES) do FCmbWfPalette.Items.Add(WF_PALETTE_NAMES[i]); @@ -1433,7 +1463,6 @@ var Result.ClrActive := TColor($00204030); Result.ClrText := CLR_TEXT; Result.ClrTextAct := CLR_ACCENT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; Result.OnClick := AClick; end; @@ -1448,7 +1477,6 @@ var Result.Value := AVal; Result.Color := CLR_INPUT; Result.Font.Color := CLR_INPUT_TEXT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; Result.OnChange := OnTXAnyChange; end; @@ -1466,7 +1494,6 @@ var Result.DecimalPlaces := 2; Result.Color := CLR_INPUT; Result.Font.Color := CLR_INPUT_TEXT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; Result.OnChange := OnTXAnyChange; end; @@ -1478,7 +1505,6 @@ var Result.Caption := ACap; Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(22)); Result.Font.Color := CLR_TEXT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; Result.OnChange := OnTXAnyChange; end; @@ -1494,7 +1520,6 @@ begin Parent := FPageTransmit; Caption := 'Transmit'; SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Name := UI_FONT; Font.Size := 16; Font.Style := [fsBold]; Font.Color := CLR_TEXT; @@ -1505,7 +1530,6 @@ begin Caption := 'Profile and EQ hold the active TX profile; ' + 'Hardware and Display are radio properties and do not follow it.'; SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(GRP_W), DpiScale(20)); - Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; end; @@ -1642,7 +1666,6 @@ begin FTXEQControl := TEqualizerControl.Create(Self); FTXEQControl.Parent := Grp; FTXEQControl.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(GRP_W - 2 * PAD), DpiScale(326)); - FTXEQControl.Font.Name := UI_FONT; FTXEQControl.Font.Size := 9; FTXEQControl.SetThemeColors(CLR_PANEL, TColor($00202020), CLR_BORDER, TColor($00383838), CLR_TEXT, CLR_TEXTDIM, CLR_ACCENT, TColor($0055DDEE)); @@ -1775,7 +1798,6 @@ begin FEdTXRefLevel.SetBounds(DpiScale(CX), DpiScale(R1), DpiScale(96), DpiScale(BTN_H)); FEdTXRefLevel.Color := CLR_INPUT; FEdTXRefLevel.Font.Color := CLR_INPUT_TEXT; - FEdTXRefLevel.Font.Name := UI_FONT; FEdTXRefLevel.Font.Size := 9; FEdTXRefLevel.Text := '0'; FEdTXRefLevel.OnExit := OnTXAnyChange; @@ -1786,7 +1808,6 @@ begin FEdTXRange.SetBounds(DpiScale(CX), DpiScale(R1 + STEP), DpiScale(96), DpiScale(BTN_H)); FEdTXRange.Color := CLR_INPUT; FEdTXRange.Font.Color := CLR_INPUT_TEXT; - FEdTXRange.Font.Name := UI_FONT; FEdTXRange.Font.Size := 9; FEdTXRange.Text := '80'; FEdTXRange.OnExit := OnTXAnyChange; @@ -2172,7 +2193,6 @@ const E.SetBounds(DpiScale(ALeft), DpiScale(ATop), DpiScale(ED_W), DpiScale(BTN_H + 2)); E.Color := CLR_INPUT; E.Font.Color := CLR_INPUT_TEXT; - E.Font.Name := UI_FONT; E.Font.Size := 9; E.MinValue := 38.8; E.MaxValue := 100.0; @@ -2194,7 +2214,6 @@ begin Parent := FPagePA; Caption := 'Power Amplifier'; SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Name := UI_FONT; Font.Size := 16; Font.Style := [fsBold]; Font.Color := CLR_TEXT; @@ -2205,12 +2224,11 @@ begin Parent := FPagePA; Caption := 'Output limit and per-band calibration values.'; SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(620), DpiScale(20)); - Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; end; - Grp := MakeGroupPanel(FPagePA, 'Power && Calibration', MARGIN, 86, 700, GRP_H); + Grp := MakeGroupPanel(FPagePA, 'Power && Calibration', MARGIN, 86, SETTINGS_CARD_W, GRP_H); // Max Power Lbl := TLabel.Create(Self); @@ -2218,7 +2236,6 @@ begin Lbl.Caption := 'Max power (W)'; Lbl.SetBounds(DpiScale(GRP_PAD), DpiScale(R1 + 6), DpiScale(132), DpiScale(18)); Lbl.Font.Color := CLR_TEXTDIM; - Lbl.Font.Name := UI_FONT; Lbl.Font.Size := 9; Spin := TFlatSpinEdit.Create(Self); @@ -2226,7 +2243,6 @@ begin Spin.SetBounds(DpiScale(GRP_PAD + 150), DpiScale(R1), DpiScale(ED_W), DpiScale(BTN_H + 2)); Spin.Color := CLR_INPUT; Spin.Font.Color := CLR_INPUT_TEXT; - Spin.Font.Name := UI_FONT; Spin.Font.Size := 9; Spin.MinValue := 5; Spin.MaxValue := 200; @@ -2241,7 +2257,6 @@ begin Lbl.Caption := 'Pluto TX att @100% (dB)'; Lbl.SetBounds(DpiScale(GRP_PAD + 320), DpiScale(R1 + 6), DpiScale(180), DpiScale(18)); Lbl.Font.Color := CLR_TEXTDIM; - Lbl.Font.Name := UI_FONT; Lbl.Font.Size := 9; FEdPlutoTxMaxAtt := TFlatFloatSpinEdit.Create(Self); @@ -2249,7 +2264,6 @@ begin FEdPlutoTxMaxAtt.SetBounds(DpiScale(GRP_PAD + 510), DpiScale(R1), DpiScale(ED_W), DpiScale(BTN_H + 2)); FEdPlutoTxMaxAtt.Color := CLR_INPUT; FEdPlutoTxMaxAtt.Font.Color := CLR_INPUT_TEXT; - FEdPlutoTxMaxAtt.Font.Name := UI_FONT; FEdPlutoTxMaxAtt.Font.Size := 9; FEdPlutoTxMaxAtt.DecimalPlaces := 2; FEdPlutoTxMaxAtt.MinValue := -50; @@ -2264,7 +2278,6 @@ begin Lbl.Caption := 'Calibration per band, 38.8 ... 100.0'; Lbl.SetBounds(DpiScale(GRP_PAD), DpiScale(R1 + ROW_H + 12), DpiScale(320), DpiScale(18)); Lbl.Font.Color := CLR_TEXTDIM; - Lbl.Font.Name := UI_FONT; Lbl.Font.Size := 9; // Band grid 4 columns × 3 rows @@ -2280,7 +2293,6 @@ begin Lbl.Caption := PA_BAND_NAMES[i] + ':'; Lbl.SetBounds(DpiScale(X), DpiScale(Y + 5), DpiScale(LBL_W), DpiScale(18)); Lbl.Font.Color := CLR_TEXTDIM; - Lbl.Font.Name := UI_FONT; Lbl.Font.Size := 9; FEdBandCal[i] := MakeFloatSpin(Grp, X + LBL_W, Y, OnBandCalChange); @@ -2289,14 +2301,13 @@ begin // ── VHF Calibration (Transverters) ────────────────────────────────────── Grp := MakeGroupPanel(FPagePA, 'VHF Calibration (Transverters)', - MARGIN, 86 + GRP_H + 14, 700, R1 + 4 * ROW_H + 44); + MARGIN, 86 + GRP_H + 14, SETTINGS_CARD_W, R1 + 4 * ROW_H + 44); Lbl := TLabel.Create(Self); Lbl.Parent := Grp; Lbl.Caption := 'Calibration per transverter slot, 38.8 ... 100.0'; Lbl.SetBounds(DpiScale(GRP_PAD), DpiScale(R1), DpiScale(420), DpiScale(18)); Lbl.Font.Color := CLR_TEXTDIM; - Lbl.Font.Name := UI_FONT; Lbl.Font.Size := 9; for i := 0 to CFG_XVTR_COUNT - 1 do @@ -2312,7 +2323,6 @@ begin Lbl.AutoSize := False; Lbl.SetBounds(DpiScale(X), DpiScale(Y + 5), DpiScale(VHF_LBL_W), DpiScale(18)); Lbl.Font.Color := CLR_TEXTDIM; - Lbl.Font.Name := UI_FONT; Lbl.Font.Size := 9; Lbl.ShowHint := True; FVHFCalLabels[i] := Lbl; @@ -2353,7 +2363,6 @@ const E.SetBounds(DpiScale(ALeft), DpiScale(ATop), DpiScale(CAL_ED_W), DpiScale(BTN_H + 2)); E.Color := CLR_INPUT; E.Font.Color := CLR_INPUT_TEXT; - E.Font.Name := UI_FONT; E.Font.Size := 9; E.MinValue := AMin; E.MaxValue := AMax; @@ -2385,7 +2394,6 @@ begin Parent := FPageCalib; Caption := 'Calibration'; SetBounds(DpiScale(CAL_MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Name := UI_FONT; Font.Size := 16; Font.Style := [fsBold]; Font.Color := CLR_TEXT; @@ -2396,7 +2404,6 @@ begin Parent := FPageCalib; Caption := 'Power/SWR detector, supply voltage/current and S-meter calibration.'; SetBounds(DpiScale(CAL_MARGIN), DpiScale(48), DpiScale(620), DpiScale(20)); - Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; end; @@ -2508,7 +2515,6 @@ begin FBtnDispFromSM.ClrActive := TColor($00003300); FBtnDispFromSM.ClrText := CLR_TEXT; FBtnDispFromSM.ClrTextAct := CLR_ACCENT; - FBtnDispFromSM.Font.Name := UI_FONT; FBtnDispFromSM.Font.Size := 9; FBtnDispFromSM.Hint := 'Copy the S-meter offsets into this table'; FBtnDispFromSM.ShowHint := True; @@ -2806,20 +2812,18 @@ begin Parent := FPageAdvanced; Caption := 'Advanced'; SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Name := UI_FONT; Font.Size := 16; Font.Style := [fsBold]; Font.Color := CLR_TEXT; end; // ── ADC ─────────────────────────────────────────────────────────────────── - Grp := MakeGroupPanel(FPageAdvanced, 'ADC', MARGIN, 86, 420, 128); + Grp := MakeGroupPanel(FPageAdvanced, 'ADC', MARGIN, 86, SETTINGS_CARD_W, 128); Chk := TFlatCheckBox.Create(Self); Chk.Parent := Grp; Chk.Caption := 'Dither Enabled'; Chk.SetBounds(DpiScale(GRP_PAD), DpiScale(R1), DpiScale(260), DpiScale(22)); - Chk.Font.Name := UI_FONT; Chk.Font.Size := 9; Chk.Font.Color := CLR_TEXT; Chk.Checked := True; @@ -2830,7 +2834,6 @@ begin Chk.Parent := Grp; Chk.Caption := 'Random Enabled'; Chk.SetBounds(DpiScale(GRP_PAD), DpiScale(R1 + ROW_H), DpiScale(260), DpiScale(22)); - Chk.Font.Name := UI_FONT; Chk.Font.Size := 9; Chk.Font.Color := CLR_TEXT; Chk.Checked := True; @@ -2838,14 +2841,13 @@ begin FChkRandom := Chk; // ── Web Server ──────────────────────────────────────────────────────────── - Grp := MakeGroupPanel(FPageAdvanced, 'Web Server', MARGIN, 230, 500, 318); + Grp := MakeGroupPanel(FPageAdvanced, 'Web Server', MARGIN, 230, SETTINGS_CARD_W, 318); Y := R1; Chk := TFlatCheckBox.Create(Self); Chk.Parent := Grp; Chk.Caption := 'Enabled'; Chk.SetBounds(DpiScale(GRP_PAD), DpiScale(Y), DpiScale(260), DpiScale(22)); - Chk.Font.Name := UI_FONT; Chk.Font.Size := 9; Chk.Font.Color := CLR_TEXT; Chk.Checked := True; @@ -2859,7 +2861,6 @@ begin Spin.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(90), DpiScale(BTN_H)); Spin.Color := CLR_INPUT; Spin.Font.Color := CLR_INPUT_TEXT; - Spin.Font.Name := UI_FONT; Spin.Font.Size := 9; Spin.MinValue := 1; Spin.MaxValue := 65535; @@ -2874,7 +2875,6 @@ begin Ed.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(ED_W), DpiScale(BTN_H)); Ed.Color := CLR_INPUT; Ed.Font.Color := CLR_INPUT_TEXT; - Ed.Font.Name := UI_FONT; Ed.Font.Size := 9; Ed.Text := '0.0.0.0'; Ed.OnChange := OnWebAnyChange; @@ -2887,7 +2887,6 @@ begin Ed.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(ED_W), DpiScale(BTN_H)); Ed.Color := CLR_INPUT; Ed.Font.Color := CLR_INPUT_TEXT; - Ed.Font.Name := UI_FONT; Ed.Font.Size := 9; Ed.Text := 'admin'; Ed.OnChange := OnWebAnyChange; @@ -2900,7 +2899,6 @@ begin Ed.SetBounds(DpiScale(GRP_PAD + LBL_W + 12), DpiScale(Y), DpiScale(ED_W), DpiScale(BTN_H)); Ed.Color := CLR_INPUT; Ed.Font.Color := CLR_INPUT_TEXT; - Ed.Font.Name := UI_FONT; Ed.Font.Size := 9; Ed.PasswordChar := '*'; Ed.Text := 'hpsdr'; @@ -3764,9 +3762,9 @@ const PAD = 18; LW = 120; CX = PAD + LW + 8; - GRP_W = 320; - GRP_H = 272; GRP_GAP = 16; + GRP_W = (SETTINGS_CARD_W - GRP_GAP) div 2; + GRP_H = 272; CMB_W = 140; EDT_W = 160; R1 = 42; @@ -3784,7 +3782,7 @@ begin Parent := FPageCAT; Caption := 'CAT'; SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Name := UI_FONT; Font.Size := 16; + Font.Size := 16; Font.Style := [fsBold]; Font.Color := CLR_TEXT; end; with TLabel.Create(Self) do @@ -3792,7 +3790,7 @@ begin Parent := FPageCAT; Caption := 'Computer Aided Transceiver — serial port and TCP server settings.'; SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(680), DpiScale(20)); - Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; + Font.Size := 9; Font.Color := CLR_TEXTDIM; end; for i := 0 to 3 do @@ -3809,7 +3807,7 @@ begin Chk.Caption := 'Enable'; Chk.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(GRP_W - PAD * 2), DpiScale(22)); Chk.Font.Color := CLR_TEXT; - Chk.Font.Name := UI_FONT; Chk.Font.Size := 9; + Chk.Font.Size := 9; Chk.Tag := i; Chk.OnChange := OnCATSerialChange; FCATSerialEn[i] := Chk; @@ -3819,7 +3817,7 @@ begin Ed.Parent := Grp; Ed.SetBounds(DpiScale(CX), DpiScale(R1 + STEP), DpiScale(EDT_W), DpiScale(BTN_H)); Ed.Color := CLR_INPUT; Ed.Font.Color := CLR_INPUT_TEXT; - Ed.Font.Name := UI_FONT; Ed.Font.Size := 9; + Ed.Font.Size := 9; Ed.Tag := i; Ed.OnChange := OnCATSerialChange; {$IFDEF MSWINDOWS} @@ -3862,7 +3860,7 @@ begin Chk.Caption := 'Andromeda / G2 front panel'; Chk.SetBounds(DpiScale(PAD), DpiScale(R1 + 6 * STEP), DpiScale(GRP_W - PAD * 2), DpiScale(22)); Chk.Font.Color := CLR_TEXT; - Chk.Font.Name := UI_FONT; Chk.Font.Size := 9; + Chk.Font.Size := 9; Chk.Tag := i; Chk.OnChange := OnCATSerialChange; FCATSerialAndromeda[i] := Chk; @@ -3877,7 +3875,7 @@ begin FCATTcpEn.Caption := 'Enable TCP server (default port 19090)'; FCATTcpEn.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(300), DpiScale(22)); FCATTcpEn.Font.Color := CLR_TEXT; - FCATTcpEn.Font.Name := UI_FONT; FCATTcpEn.Font.Size := 9; + FCATTcpEn.Font.Size := 9; FCATTcpEn.OnChange := OnCATTcpChange; MakeLbl(Grp, 'Port:', PAD, R1 + STEP + 6, LW); @@ -3885,7 +3883,7 @@ begin Spin.Parent := Grp; Spin.SetBounds(DpiScale(CX), DpiScale(R1 + STEP), DpiScale(100), DpiScale(BTN_H + 2)); Spin.Color := CLR_INPUT; Spin.Font.Color := CLR_INPUT_TEXT; - Spin.Font.Name := UI_FONT; Spin.Font.Size := 9; + Spin.Font.Size := 9; Spin.MinValue := 1; Spin.MaxValue := 65535; Spin.Value := 19090; Spin.OnChange := OnCATTcpChange; @@ -4007,7 +4005,7 @@ begin Parent := FPageSlices; Caption := 'Slices'; SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Name := UI_FONT; Font.Size := 16; + Font.Size := 16; Font.Style := [fsBold]; Font.Color := CLR_TEXT; end; with TLabel.Create(Self) do @@ -4016,7 +4014,7 @@ begin Caption := 'A separate CAT TCP port per additional slice — external software ' + 'controls its own slice as a standalone transceiver.'; SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(760), DpiScale(20)); - Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; + Font.Size := 9; Font.Color := CLR_TEXTDIM; end; with TLabel.Create(Self) do begin @@ -4024,7 +4022,7 @@ begin Caption := 'Slice A is the main receiver — it is configured on the CAT tab ' + '(serial ports and the main TCP port).'; SetBounds(DpiScale(MARGIN), DpiScale(66), DpiScale(760), DpiScale(20)); - Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; + Font.Size := 9; Font.Color := CLR_TEXTDIM; end; for i := 0 to CFG_CAT_SLICE_COUNT - 1 do @@ -4041,7 +4039,7 @@ begin Chk.Caption := 'Enable CAT TCP server'; Chk.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(GRP_W - PAD * 2), DpiScale(22)); Chk.Font.Color := CLR_TEXT; - Chk.Font.Name := UI_FONT; Chk.Font.Size := 9; + Chk.Font.Size := 9; Chk.Tag := i; Chk.OnChange := OnSliceCATCtrlChange; FSliceCATEn[i] := Chk; @@ -4051,7 +4049,7 @@ begin Spin.Parent := Grp; Spin.SetBounds(DpiScale(CX), DpiScale(R1 + STEP), DpiScale(100), DpiScale(BTN_H + 2)); Spin.Color := CLR_INPUT; Spin.Font.Color := CLR_INPUT_TEXT; - Spin.Font.Name := UI_FONT; Spin.Font.Size := 9; + Spin.Font.Size := 9; Spin.MinValue := 1; Spin.MaxValue := 65535; Spin.Value := CFG_CAT_SLICE_PORT0 + i; Spin.Tag := i; @@ -4067,7 +4065,7 @@ begin Chk.Caption := 'Auto TX'; Chk.SetBounds(DpiScale(PAD), DpiScale(R1 + 2 * STEP), DpiScale(GRP_W - PAD * 2), DpiScale(22)); Chk.Font.Color := CLR_TEXT; - Chk.Font.Name := UI_FONT; Chk.Font.Size := 9; + Chk.Font.Size := 9; Chk.Tag := i; Chk.OnChange := OnSliceCATCtrlChange; FSliceCATAutoTx[i] := Chk; @@ -4099,7 +4097,7 @@ begin Caption := 'Audio out/in belong to the slice letter: the setting moves the ' + 'slice sitting on that slot right away and the next one inherits it.'; SetBounds(DpiScale(MARGIN), DpiScale(gy), DpiScale(760), DpiScale(20)); - Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; + Font.Size := 9; Font.Color := CLR_TEXTDIM; end; Inc(gy, 18); with TLabel.Create(Self) do @@ -4108,7 +4106,7 @@ begin Caption := 'Only one slice transmits at a time: while anything is already on ' + 'the air the request is ignored — no take-over.'; SetBounds(DpiScale(MARGIN), DpiScale(gy), DpiScale(760), DpiScale(20)); - Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; + Font.Size := 9; Font.Color := CLR_TEXTDIM; end; with TLabel.Create(Self) do begin @@ -4116,7 +4114,7 @@ begin Caption := 'Without Auto TX a slice keys up over CAT only when it is already ' + 'the transmit source (TX badge on the slice flag).'; SetBounds(DpiScale(MARGIN), DpiScale(gy + 18), DpiScale(760), DpiScale(20)); - Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; + Font.Size := 9; Font.Color := CLR_TEXTDIM; end; with TLabel.Create(Self) do begin @@ -4124,7 +4122,7 @@ begin Caption := 'The port is opened even when the slice does not exist yet — until ' + 'it is created CAT answers "radio off" (PS0).'; SetBounds(DpiScale(MARGIN), DpiScale(gy + 36), DpiScale(760), DpiScale(20)); - Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; + Font.Size := 9; Font.Color := CLR_TEXTDIM; end; end; @@ -4257,7 +4255,6 @@ var Result.Caption := ''; Result.SetBounds(DpiScale(AX), DpiScale(0), DpiScale(22), DpiScale(20)); Result.Font.Color := CLR_TEXT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; Result.OnChange := OnAlexAnyChange; end; @@ -4270,7 +4267,6 @@ var Result.Caption := ACap; Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(20)); Result.Font.Color := CLR_TEXT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; Result.OnChange := OnAlexAnyChange; end; @@ -4283,7 +4279,6 @@ var Result.Caption := ''; Result.SetBounds(DpiScale(AX), DpiScale(0), DpiScale(22), DpiScale(20)); Result.Font.Color := CLR_TEXT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; Result.OnChange := Ant936xUIChange; end; @@ -4295,7 +4290,6 @@ var Result.Parent := AParent; Result.Caption := ACap; Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(84), DpiScale(18)); - Result.Font.Name := UI_FONT; Result.Font.Size := 9; Result.Font.Color := CLR_TEXT; end; @@ -4307,7 +4301,6 @@ var Parent := AParent; Caption := ACap; SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(30), DpiScale(16)); - Font.Name := UI_FONT; Font.Size := 9; Font.Color := CLR_TEXTDIM; end; @@ -4321,7 +4314,7 @@ begin Parent := FPageAlex; Caption := 'Antenna / Alex'; SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(500), DpiScale(28)); - Font.Name := UI_FONT; Font.Size := 16; + Font.Size := 16; Font.Style := [fsBold]; Font.Color := CLR_TEXT; end; FAlexSubtitle := TLabel.Create(Self); @@ -4329,7 +4322,6 @@ begin FAlexSubtitle.Caption := 'Per-band RX/TX antenna and secondary path routing for the Alex filter board.'; FAlexSubtitle.SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(680), DpiScale(20)); - FAlexSubtitle.Font.Name := UI_FONT; FAlexSubtitle.Font.Size := 9; FAlexSubtitle.Font.Color := CLR_TEXTDIM; @@ -4748,7 +4740,6 @@ var Result.DecimalPlaces := ADec; Result.Color := CLR_INPUT; Result.Font.Color := CLR_INPUT_TEXT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; Result.OnChange := OnXvtrAnyChange; end; @@ -4763,7 +4754,6 @@ var Result.MaxValue := AMax; Result.Color := CLR_INPUT; Result.Font.Color := CLR_INPUT_TEXT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; Result.OnChange := OnXvtrAnyChange; end; @@ -4776,7 +4766,6 @@ var Result.Caption := ''; Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(22), DpiScale(20)); Result.Font.Color := CLR_TEXT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; Result.OnChange := OnXvtrAnyChange; end; @@ -4788,7 +4777,6 @@ var Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(BTN_H)); Result.Color := CLR_INPUT; Result.Font.Color := CLR_INPUT_TEXT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; Result.MaxLength := 15; Result.OnChange := OnXvtrAnyChange; @@ -4801,7 +4789,6 @@ var Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(AW), DpiScale(BTN_H)); Result.Color := CLR_INPUT; Result.Font.Color := CLR_INPUT_TEXT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; Result.OnChange := OnXvtrAnyChange; end; @@ -4819,7 +4806,6 @@ var Result.Layout := tlCenter; Result.WordWrap := True; Result.Font.Color := CLR_TEXTDIM; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; end; @@ -4831,7 +4817,7 @@ begin L.Parent := FPageXvtr; L.Caption := 'Transverters'; L.SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(500), DpiScale(28)); - L.Font.Name := UI_FONT; L.Font.Size := 16; + L.Font.Size := 16; L.Font.Style := [fsBold]; L.Font.Color := CLR_TEXT; L := TLabel.Create(Self); @@ -4840,7 +4826,7 @@ begin 'the VFO shows the displayed (RF) frequency.' + LineEnding + 'The radio is internally tuned to the IF: f_IF = f_displayed - LO + LO Error.'; L.SetBounds(DpiScale(MARGIN), DpiScale(50), DpiScale(GRP_W), DpiScale(34)); - L.Font.Name := UI_FONT; L.Font.Size := 9; L.Font.Color := CLR_TEXTDIM; + L.Font.Size := 9; L.Font.Color := CLR_TEXTDIM; L.WordWrap := True; // --- Group "Slots" --- (последний слот зарезервирован под QO-100 — не строим) @@ -4900,7 +4886,6 @@ begin + '(TUN level from Transmit is multiplied by the per-slot TX Power)'; FChkXvtrTunPwr.SetBounds(DpiScale(14), DpiScale(GRP_TITLE_H + 8), DpiScale(GRP_W - 28), DpiScale(20)); FChkXvtrTunPwr.Font.Color := CLR_TEXT; - FChkXvtrTunPwr.Font.Name := UI_FONT; FChkXvtrTunPwr.Font.Size := 9; FChkXvtrTunPwr.OnChange := OnXvtrAnyChange; @@ -4915,7 +4900,7 @@ begin 'Disable PA - keep the internal PA in RX position during TX. ' + 'RX Antenna - override the per-band antenna for this slot.'; L.SetBounds(DpiScale(MARGIN), DpiScale(GrpY + GRP_TITLE_H + 36 + 16), DpiScale(GRP_W), DpiScale(52)); - L.Font.Name := UI_FONT; L.Font.Size := 9; L.Font.Color := CLR_TEXTDIM; + L.Font.Size := 9; L.Font.Color := CLR_TEXTDIM; L.WordWrap := True; // Применяем начальное состояние disabled-строк @@ -5070,7 +5055,6 @@ var Result.Caption := ''; Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(PIN_W), DpiScale(20)); Result.Font.Color := CLR_TEXT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; Result.OnChange := OnOCAnyChange; end; @@ -5083,7 +5067,7 @@ begin Parent := FPageOC; Caption := 'OC Control'; SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(500), DpiScale(28)); - Font.Name := UI_FONT; Font.Size := 16; + Font.Size := 16; Font.Style := [fsBold]; Font.Color := CLR_TEXT; end; FOCSubtitle := TLabel.Create(Self); @@ -5091,7 +5075,6 @@ begin FOCSubtitle.Caption := 'Open Collector outputs (Penny/Alex board), per-band and per-transverter. openHPSDR only.'; FOCSubtitle.SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(680), DpiScale(20)); - FOCSubtitle.Font.Name := UI_FONT; FOCSubtitle.Font.Size := 9; FOCSubtitle.Font.Color := CLR_TEXTDIM; @@ -5100,7 +5083,6 @@ begin FOCEnabled.Caption := 'Enable OC Control'; FOCEnabled.SetBounds(DpiScale(MARGIN), DpiScale(76), DpiScale(220), DpiScale(22)); FOCEnabled.Font.Color := CLR_TEXT; - FOCEnabled.Font.Name := UI_FONT; FOCEnabled.Font.Size := 9; FOCEnabled.OnChange := OnOCAnyChange; @@ -5290,7 +5272,7 @@ var Result.MinValue := AMin; Result.MaxValue := AMax; Result.Increment := AStep; Result.DecimalPlaces := ADec; Result.Color := CLR_INPUT; Result.Font.Color := CLR_INPUT_TEXT; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; + Result.Font.Size := 9; Result.OnChange := OnQOAnyChange; end; @@ -5299,7 +5281,7 @@ var L := TLabel.Create(Self); L.Parent := Grp; L.Caption := Cap; L.SetBounds(DpiScale(LBL_X), DpiScale(AY + 4), DpiScale(LBL_W), DpiScale(18)); - L.Font.Color := CLR_TEXTDIM; L.Font.Name := UI_FONT; L.Font.Size := 9; + L.Font.Color := CLR_TEXTDIM; L.Font.Size := 9; end; begin @@ -5308,7 +5290,7 @@ begin L := TLabel.Create(Self); L.Parent := FPageQO; L.Caption := 'QO-100 (Es''hail-2 NB transponder)'; L.SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(600), DpiScale(28)); - L.Font.Name := UI_FONT; L.Font.Size := 16; + L.Font.Size := 16; L.Font.Style := [fsBold]; L.Font.Color := CLR_TEXT; L := TLabel.Create(Self); @@ -5317,16 +5299,17 @@ begin 'TX uplink (~2400 MHz) direct. VFO is displayed in downlink terms; ' + 'RX and TX use separate LOs. Drift is corrected by the beacon lock.'; L.SetBounds(DpiScale(MARGIN), DpiScale(50), DpiScale(700), DpiScale(34)); - L.Font.Name := UI_FONT; L.Font.Size := 9; L.Font.Color := CLR_TEXTDIM; + L.Font.Size := 9; L.Font.Color := CLR_TEXTDIM; L.WordWrap := True; - Grp := MakeGroupPanel(FPageQO, 'Transponder', MARGIN, 100, 560, GRP_TITLE_H + 9 * 30 + 16); + Grp := MakeGroupPanel(FPageQO, 'Transponder', MARGIN, 100, + SETTINGS_CARD_W, GRP_TITLE_H + 9 * 30 + 16); Y := GRP_TITLE_H + 8; FQoEn := TFlatCheckBox.Create(Self); FQoEn.Parent := Grp; FQoEn.Caption := 'Enable QO-100 band'; FQoEn.SetBounds(DpiScale(LBL_X), DpiScale(Y), DpiScale(300), DpiScale(20)); - FQoEn.Font.Color := CLR_TEXT; FQoEn.Font.Name := UI_FONT; FQoEn.Font.Size := 9; + FQoEn.Font.Color := CLR_TEXT; FQoEn.Font.Size := 9; FQoEn.OnChange := OnQOAnyChange; Inc(Y, 32); QoLabel('LNB LO (MHz)', Y); @@ -5363,13 +5346,13 @@ begin FQoPower.Parent := Grp; FQoPower.SetBounds(DpiScale(ED_X), DpiScale(Y), DpiScale(ED_W), DpiScale(BTN_H)); FQoPower.MinValue := 0; FQoPower.MaxValue := 100; FQoPower.Value := 100; FQoPower.Color := CLR_INPUT; FQoPower.Font.Color := CLR_INPUT_TEXT; - FQoPower.Font.Name := UI_FONT; FQoPower.Font.Size := 9; + FQoPower.Font.Size := 9; FQoPower.OnChange := OnQOAnyChange; FQoInfo := TLabel.Create(Self); FQoInfo.Parent := FPageQO; FQoInfo.SetBounds(DpiScale(MARGIN), DpiScale(100 + GRP_TITLE_H + 9 * 30 + 28), DpiScale(700), DpiScale(52)); - FQoInfo.Font.Color := CLR_TEXTDIM; FQoInfo.Font.Name := 'Courier New'; + FQoInfo.Font.Color := CLR_TEXTDIM; FQoInfo.Font.Size := 9; FQoInfo.WordWrap := True; finally FLoading := False; From 612bc736879e78c90d006f6e721978fad3a29532 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 20:36:17 +0300 Subject: [PATCH 14/30] ui(settings): add responsive page layout --- SettingsForm.pas | 534 ++++++++++++++++++++--------------------------- 1 file changed, 227 insertions(+), 307 deletions(-) diff --git a/SettingsForm.pas b/SettingsForm.pas index ecafc40..5995ab2 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -37,6 +37,10 @@ const // Tag контейнера подвкладки: ApplyTheme красит такие панели фоном страницы, // а не цветом группы (иначе подложка выглядела бы как одна гигантская группа). TAG_PAGE_BG = 909; + TAG_RESPONSIVE_CARD = 910; + TAG_PAGE_HEADER = 911; + TAG_RESPONSIVE_HALF_LEFT = 912; + TAG_RESPONSIVE_HALF_RIGHT = 913; type // PA: MaxPower в Вт, BandCal[0..10] — калибровка 38.8..100.0 на диапазон @@ -501,11 +505,17 @@ type procedure BtnTXProfDelClick(Sender: TObject); procedure BtnTXProfFactoryClick(Sender: TObject); procedure ApplyTheme(const T: TAppTheme); + procedure SettingsResize(Sender: TObject); + procedure RelayoutResponsiveCards; procedure NavClick(Sender: TObject); procedure SelectPage(APage: TWinControl; ANav: TFlatButton); function MakeLbl(AParent: TWinControl; const Cap: string; ALeft, ATop, AW: Integer): TLabel; + function MakeTextBlock(AParent: TWinControl; const Cap: string; + ALeft, ATop, AW, AH: Integer): TLabel; + procedure MakePageHeader(AParent: TWinControl; const ATitle, ASubtitle: string; + ASubtitleHeight: Integer = 20); function MakeCombo(AParent: TWinControl; ALeft, ATop, AW: Integer; OnChange: TNotifyEvent): TFlatComboBox; function MakeGroupPanel(AParent: TWinControl; const Cap: string; @@ -679,7 +689,9 @@ const CLR_INPUT_TEXT = TColor($00202020); BTN_H = 24; GRP_TITLE_H = 22; // высота заголовка в group-панелях (title label + divider) + SETTINGS_MARGIN = 22; SETTINGS_CARD_W = 760; + SETTINGS_CARD_MAX_W = 934; SETTINGS_FOOTER_H = 52; // Первый пункт комбо аудио слайса = «как у приложения» (пустое имя в конфиге). @@ -774,6 +786,8 @@ begin FLoading := False; FRXDevCount := 0; BuildUI; + OnResize := SettingsResize; + RelayoutResponsiveCards; ApplyTheme(DarkTheme); end; @@ -803,6 +817,44 @@ begin Result.Font.Size := 9; end; +function TSettingsForm.MakeTextBlock(AParent: TWinControl; const Cap: string; + ALeft, ATop, AW, AH: Integer): TLabel; +begin + Result := TLabel.Create(Self); + Result.Parent := AParent; + Result.Caption := Cap; + Result.AutoSize := False; + Result.SetBounds(DpiScale(ALeft), DpiScale(ATop), + DpiScale(AW), DpiScale(AH)); + Result.Layout := tlTop; + Result.WordWrap := True; + Result.Font.Size := 9; + Result.Font.Color := CLR_TEXTDIM; +end; + +procedure TSettingsForm.MakePageHeader(AParent: TWinControl; + const ATitle, ASubtitle: string; ASubtitleHeight: Integer); +var + L: TLabel; +begin + L := TLabel.Create(Self); + L.Parent := AParent; + L.Caption := ATitle; + L.AutoSize := False; + L.SetBounds(DpiScale(SETTINGS_MARGIN), DpiScale(16), + DpiScale(SETTINGS_CARD_W), DpiScale(32)); + L.Tag := TAG_PAGE_HEADER; + L.Layout := tlCenter; + L.Font.Size := 16; + L.Font.Style := [fsBold]; + L.Font.Color := CLR_TEXT; + + if ASubtitle = '' then Exit; + L := MakeTextBlock(AParent, ASubtitle, SETTINGS_MARGIN, 48, + SETTINGS_CARD_W, ASubtitleHeight); + L.Tag := TAG_PAGE_HEADER; +end; + function TSettingsForm.MakeCombo(AParent: TWinControl; ALeft, ATop, AW: Integer; OnChange: TNotifyEvent): TFlatComboBox; begin @@ -833,6 +885,8 @@ begin Result.SetBounds(DpiScale(ALeft), DpiScale(ATop), SAW, DpiScale(AH)); Result.BevelOuter := bvNone; Result.Color := CLR_PANEL; + if AW = SETTINGS_CARD_W then + Result.Tag := TAG_RESPONSIVE_CARD; TitleLbl := TLabel.Create(Self); TitleLbl.Parent := Result; @@ -840,6 +894,7 @@ begin TitleLbl.AutoSize := False; TitleLbl.SetBounds(DpiScale(10), DpiScale(2), SAW - DpiScale(20), DpiScale(GRP_TITLE_H - 3)); + TitleLbl.Anchors := [akLeft, akTop, akRight]; TitleLbl.Layout := tlCenter; TitleLbl.Font.Color := CLR_ACCENT; TitleLbl.Font.Size := 9; @@ -848,6 +903,7 @@ begin Divider := TPanel.Create(Self); Divider.Parent := Result; Divider.SetBounds(0, DpiScale(GRP_TITLE_H), SAW, 1); + Divider.Anchors := [akLeft, akTop, akRight]; Divider.BevelOuter := bvNone; Divider.Color := CLR_BORDER; end; @@ -913,7 +969,9 @@ begin begin Parent := FNavPanel; Caption := 'Settings'; + AutoSize := False; SetBounds(DpiScale(14), DpiScale(14), DpiScale(160), DpiScale(24)); + Layout := tlCenter; Font.Size := 14; Font.Style := [fsBold]; Font.Color := CLR_TEXT; @@ -923,7 +981,9 @@ begin begin Parent := FNavPanel; Caption := 'EWSDR'; + AutoSize := False; SetBounds(DpiScale(14), DpiScale(39), DpiScale(160), DpiScale(18)); + Layout := tlCenter; Font.Size := 9; Font.Color := CLR_TEXTDIM; end; @@ -1013,6 +1073,74 @@ begin SelectPage(FPageAudio, FNavAudio); end; +procedure TSettingsForm.SettingsResize(Sender: TObject); +begin + RelayoutResponsiveCards; +end; + +procedure TSettingsForm.RelayoutResponsiveCards; + + procedure ResizeTree(AParent: TWinControl; ACardWidth: Integer); + var + i, HalfWidth: Integer; + Ctrl: TControl; + begin + HalfWidth := (ACardWidth - DpiScale(16)) div 2; + for i := 0 to AParent.ControlCount - 1 do + begin + Ctrl := AParent.Controls[i]; + if Ctrl.Tag = TAG_PAGE_HEADER then + Ctrl.Width := ACardWidth + else if (Ctrl is TPanel) and (Ctrl.Tag = TAG_RESPONSIVE_CARD) then + Ctrl.Width := ACardWidth + else if (Ctrl is TPanel) and (Ctrl.Tag = TAG_RESPONSIVE_HALF_LEFT) then + begin + Ctrl.Left := DpiScale(SETTINGS_MARGIN); + Ctrl.Width := HalfWidth; + end + else if (Ctrl is TPanel) and (Ctrl.Tag = TAG_RESPONSIVE_HALF_RIGHT) then + begin + Ctrl.Left := DpiScale(SETTINGS_MARGIN) + HalfWidth + DpiScale(16); + Ctrl.Width := HalfWidth; + end + else if (Ctrl is TPanel) and (Ctrl.Tag = TAG_PAGE_BG) then + Ctrl.Width := ACardWidth + DpiScale(2 * SETTINGS_MARGIN); + + if Ctrl is TWinControl then + ResizeTree(TWinControl(Ctrl), ACardWidth); + end; + end; + + procedure ResizePage(APage: TScrollBox); + var + CardWidth: Integer; + begin + if (APage = nil) or (APage.ClientWidth <= 0) then Exit; + CardWidth := APage.ClientWidth + - DpiScale(2 * SETTINGS_MARGIN) - DpiScale(20); + CardWidth := EnsureRange(CardWidth, DpiScale(SETTINGS_CARD_W), + DpiScale(SETTINGS_CARD_MAX_W)); + ResizeTree(APage, CardWidth); + end; + +begin + if FContentPanel = nil then Exit; + ResizePage(FPageAudio); + ResizePage(FPageDisplay); + ResizePage(FPageSpectrum); + ResizePage(FPageWaterfall); + ResizePage(FPageTransmit); + ResizePage(FPagePA); + ResizePage(FPageCalib); + ResizePage(FPageAdvanced); + ResizePage(FPageCAT); + ResizePage(FPageSlices); + ResizePage(FPageAlex); + ResizePage(FPageXvtr); + ResizePage(FPageOC); + ResizePage(FPageQO); +end; + procedure TSettingsForm.NavClick(Sender: TObject); begin if Sender = FNavAudio then SelectPage(FPageAudio, FNavAudio) @@ -1062,6 +1190,10 @@ begin FNavXvtr.Active := ANav = FNavXvtr; FNavOC.Active := ANav = FNavOC; FNavQO.Active := ANav = FNavQO; + + // Скрытые alClient-страницы LCL не всегда перекладывает до показа. + // После переключения берём уже актуальный ClientWidth выбранной страницы. + RelayoutResponsiveCards; end; // --------------------------------------------------------------------------- @@ -1081,24 +1213,8 @@ const var Grp: TPanel; begin - with TLabel.Create(Self) do - begin - Parent := FPageAudio; - Caption := 'Audio'; - SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Size := 16; - Font.Style := [fsBold]; - Font.Color := CLR_TEXT; - end; - - with TLabel.Create(Self) do - begin - Parent := FPageAudio; - Caption := 'Input and output devices used by the receiver and transmitter.'; - SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(560), DpiScale(20)); - Font.Size := 9; - Font.Color := CLR_TEXTDIM; - end; + MakePageHeader(FPageAudio, 'Audio', + 'Input and output devices used by the receiver and transmitter.'); Grp := MakeGroupPanel(FPageAudio, 'Audio Devices', MARGIN, 86, SETTINGS_CARD_W, 184); @@ -1146,24 +1262,8 @@ var i: Integer; Grp: TPanel; begin - with TLabel.Create(Self) do - begin - Parent := FPageDisplay; - Caption := 'Display'; - SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Size := 16; - Font.Style := [fsBold]; - Font.Color := CLR_TEXT; - end; - - with TLabel.Create(Self) do - begin - Parent := FPageDisplay; - Caption := 'General visual options. Spectrum and waterfall processing are split into their own pages.'; - SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(680), DpiScale(20)); - Font.Size := 9; - Font.Color := CLR_TEXTDIM; - end; + MakePageHeader(FPageDisplay, 'Display', + 'General visual options. Spectrum and waterfall processing are split into their own pages.'); Grp := MakeGroupPanel(FPageDisplay, 'Visible Panes', MARGIN, 86, SETTINGS_CARD_W, 112); @@ -1288,24 +1388,8 @@ var end; begin - with TLabel.Create(Self) do - begin - Parent := FPageSpectrum; - Caption := 'Spectrum'; - SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Size := 16; - Font.Style := [fsBold]; - Font.Color := CLR_TEXT; - end; - - with TLabel.Create(Self) do - begin - Parent := FPageSpectrum; - Caption := 'Analyzer, detector, averaging and grid parameters for RX1.'; - SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(620), DpiScale(20)); - Font.Size := 9; - Font.Color := CLR_TEXTDIM; - end; + MakePageHeader(FPageSpectrum, 'Spectrum', + 'Analyzer, detector, averaging and grid parameters for RX1.'); Grp := MakeGroupPanel(FPageSpectrum, 'Analyzer', MARGIN, 86, SETTINGS_CARD_W, 128); MakeLbl(Grp, 'FFT Size:', PAD, R1 + 5, LW); @@ -1342,24 +1426,8 @@ begin for i := 0 to High(GRID_STEP_NAMES) do FCmbGridStep.Items.Add(GRID_STEP_NAMES[i]); FCmbGridStep.ItemIndex := 1; - with TLabel.Create(Self) do - begin - Parent := FPageWaterfall; - Caption := 'Waterfall'; - SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Size := 16; - Font.Style := [fsBold]; - Font.Color := CLR_TEXT; - end; - - with TLabel.Create(Self) do - begin - Parent := FPageWaterfall; - Caption := 'Waterfall detector, smoothing and color scale thresholds.'; - SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(620), DpiScale(20)); - Font.Size := 9; - Font.Color := CLR_TEXTDIM; - end; + MakePageHeader(FPageWaterfall, 'Waterfall', + 'Waterfall detector, smoothing and color scale thresholds.'); Grp := MakeGroupPanel(FPageWaterfall, 'Detection && Averaging', MARGIN, 86, SETTINGS_CARD_W, 170); MakeLbl(Grp, 'Detector:', PAD, R1 + 5, LW); @@ -1515,24 +1583,9 @@ var end; begin - with TLabel.Create(Self) do - begin - Parent := FPageTransmit; - Caption := 'Transmit'; - SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Size := 16; - Font.Style := [fsBold]; - Font.Color := CLR_TEXT; - end; - with TLabel.Create(Self) do - begin - Parent := FPageTransmit; - Caption := 'Profile and EQ hold the active TX profile; ' - + 'Hardware and Display are radio properties and do not follow it.'; - SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(GRP_W), DpiScale(20)); - Font.Size := 9; - Font.Color := CLR_TEXTDIM; - end; + MakePageHeader(FPageTransmit, 'Transmit', + 'Profile and EQ hold the active TX profile; Hardware and Display are ' + + 'radio properties and do not follow it.'); // ---- Полоса профиля (над подвкладками, видна всегда) -------------------- Grp := MakeGroupPanel(FPageTransmit, 'TX Profile', MARGIN, 78, GRP_W, 124); @@ -2209,34 +2262,13 @@ var Spin: TFlatSpinEdit; i, r, c, X, Y: Integer; begin - with TLabel.Create(Self) do - begin - Parent := FPagePA; - Caption := 'Power Amplifier'; - SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Size := 16; - Font.Style := [fsBold]; - Font.Color := CLR_TEXT; - end; - - with TLabel.Create(Self) do - begin - Parent := FPagePA; - Caption := 'Output limit and per-band calibration values.'; - SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(620), DpiScale(20)); - Font.Size := 9; - Font.Color := CLR_TEXTDIM; - end; + MakePageHeader(FPagePA, 'Power Amplifier', + 'Output limit and per-band calibration values.'); Grp := MakeGroupPanel(FPagePA, 'Power && Calibration', MARGIN, 86, SETTINGS_CARD_W, GRP_H); // Max Power - Lbl := TLabel.Create(Self); - Lbl.Parent := Grp; - Lbl.Caption := 'Max power (W)'; - Lbl.SetBounds(DpiScale(GRP_PAD), DpiScale(R1 + 6), DpiScale(132), DpiScale(18)); - Lbl.Font.Color := CLR_TEXTDIM; - Lbl.Font.Size := 9; + Lbl := MakeLbl(Grp, 'Max power (W)', GRP_PAD, R1 + 6, 132); Spin := TFlatSpinEdit.Create(Self); Spin.Parent := Grp; @@ -2252,12 +2284,8 @@ begin FEdMaxPower := Spin; // Pluto TX max attenuation @ drive 100% (потолок под внешний усилитель), дБ<0 - Lbl := TLabel.Create(Self); - Lbl.Parent := Grp; - Lbl.Caption := 'Pluto TX att @100% (dB)'; - Lbl.SetBounds(DpiScale(GRP_PAD + 320), DpiScale(R1 + 6), DpiScale(180), DpiScale(18)); - Lbl.Font.Color := CLR_TEXTDIM; - Lbl.Font.Size := 9; + Lbl := MakeLbl(Grp, 'Pluto TX att @100% (dB)', + GRP_PAD + 320, R1 + 6, 180); FEdPlutoTxMaxAtt := TFlatFloatSpinEdit.Create(Self); FEdPlutoTxMaxAtt.Parent := Grp; @@ -2273,12 +2301,8 @@ begin FEdPlutoTxMaxAtt.OnChange := OnPlutoTxMaxAttChange; // Calibration label - Lbl := TLabel.Create(Self); - Lbl.Parent := Grp; - Lbl.Caption := 'Calibration per band, 38.8 ... 100.0'; - Lbl.SetBounds(DpiScale(GRP_PAD), DpiScale(R1 + ROW_H + 12), DpiScale(320), DpiScale(18)); - Lbl.Font.Color := CLR_TEXTDIM; - Lbl.Font.Size := 9; + Lbl := MakeLbl(Grp, 'Calibration per band, 38.8 ... 100.0', + GRP_PAD, R1 + ROW_H + 12, 320); // Band grid 4 columns × 3 rows for i := 0 to 10 do @@ -2288,12 +2312,7 @@ begin X := GRP_PAD + c * (COL_W + 4); Y := R1 + ROW_H + 34 + r * ROW_H; - Lbl := TLabel.Create(Self); - Lbl.Parent := Grp; - Lbl.Caption := PA_BAND_NAMES[i] + ':'; - Lbl.SetBounds(DpiScale(X), DpiScale(Y + 5), DpiScale(LBL_W), DpiScale(18)); - Lbl.Font.Color := CLR_TEXTDIM; - Lbl.Font.Size := 9; + Lbl := MakeLbl(Grp, PA_BAND_NAMES[i] + ':', X, Y + 5, LBL_W); FEdBandCal[i] := MakeFloatSpin(Grp, X + LBL_W, Y, OnBandCalChange); FEdBandCal[i].Tag := i; @@ -2303,12 +2322,8 @@ begin Grp := MakeGroupPanel(FPagePA, 'VHF Calibration (Transverters)', MARGIN, 86 + GRP_H + 14, SETTINGS_CARD_W, R1 + 4 * ROW_H + 44); - Lbl := TLabel.Create(Self); - Lbl.Parent := Grp; - Lbl.Caption := 'Calibration per transverter slot, 38.8 ... 100.0'; - Lbl.SetBounds(DpiScale(GRP_PAD), DpiScale(R1), DpiScale(420), DpiScale(18)); - Lbl.Font.Color := CLR_TEXTDIM; - Lbl.Font.Size := 9; + Lbl := MakeLbl(Grp, 'Calibration per transverter slot, 38.8 ... 100.0', + GRP_PAD, R1, 420); for i := 0 to CFG_XVTR_COUNT - 1 do begin @@ -2389,24 +2404,8 @@ begin FCalXvtrName[i] := ''; end; - with TLabel.Create(Self) do - begin - Parent := FPageCalib; - Caption := 'Calibration'; - SetBounds(DpiScale(CAL_MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Size := 16; - Font.Style := [fsBold]; - Font.Color := CLR_TEXT; - end; - - with TLabel.Create(Self) do - begin - Parent := FPageCalib; - Caption := 'Power/SWR detector, supply voltage/current and S-meter calibration.'; - SetBounds(DpiScale(CAL_MARGIN), DpiScale(48), DpiScale(620), DpiScale(20)); - Font.Size := 9; - Font.Color := CLR_TEXTDIM; - end; + MakePageHeader(FPageCalib, 'Calibration', + 'Power/SWR detector, supply voltage/current and S-meter calibration.'); // ── Power / SWR detector ──────────────────────────────────────────────── GTop := 86; @@ -2807,15 +2806,8 @@ var Lbl: TLabel; Y, i: Integer; begin - with TLabel.Create(Self) do - begin - Parent := FPageAdvanced; - Caption := 'Advanced'; - SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Size := 16; - Font.Style := [fsBold]; - Font.Color := CLR_TEXT; - end; + MakePageHeader(FPageAdvanced, 'Advanced', + 'ADC processing and built-in web server settings.'); // ── ADC ─────────────────────────────────────────────────────────────────── Grp := MakeGroupPanel(FPageAdvanced, 'ADC', MARGIN, 86, SETTINGS_CARD_W, 128); @@ -2978,12 +2970,24 @@ procedure TSettingsForm.ApplyTheme(const T: TAppTheme); procedure StyleBtn(B: TFlatButton); begin - B.ClrNorm := T.Panel; - B.ClrBorder := T.Panel; - B.ClrHot := T.BG; - B.ClrActive := T.Panel; - B.ClrText := T.Text; - B.ClrTextAct := T.FreqActive; + if B.Parent = FNavPanel then + begin + B.ClrNorm := T.Panel; + B.ClrBorder := T.Panel; + B.ClrHot := T.BtnHot; + B.ClrActive := T.BtnActive; + B.ClrText := T.Text; + B.ClrTextAct := T.BtnTextActive; + end + else + begin + B.ClrNorm := T.BtnNorm; + B.ClrBorder := T.BtnBorderNorm; + B.ClrHot := T.BtnHot; + B.ClrActive := T.BtnActive; + B.ClrText := T.BtnText; + B.ClrTextAct := T.BtnTextActive; + end; B.Invalidate; end; @@ -3777,21 +3781,8 @@ var Cmb: TFlatComboBox; Spin: TFlatSpinEdit; begin - with TLabel.Create(Self) do - begin - Parent := FPageCAT; - Caption := 'CAT'; - SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Size := 16; - Font.Style := [fsBold]; Font.Color := CLR_TEXT; - end; - with TLabel.Create(Self) do - begin - Parent := FPageCAT; - Caption := 'Computer Aided Transceiver — serial port and TCP server settings.'; - SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(680), DpiScale(20)); - Font.Size := 9; Font.Color := CLR_TEXTDIM; - end; + MakePageHeader(FPageCAT, 'CAT', + 'Computer Aided Transceiver — serial port and TCP server settings.'); for i := 0 to 3 do begin @@ -3801,6 +3792,10 @@ begin gy := 86 + row * (GRP_H + GRP_GAP); Grp := MakeGroupPanel(FPageCAT, 'Serial Port ' + IntToStr(i + 1), gx, gy, GRP_W, GRP_H); + if col = 0 then + Grp.Tag := TAG_RESPONSIVE_HALF_LEFT + else + Grp.Tag := TAG_RESPONSIVE_HALF_RIGHT; Chk := TFlatCheckBox.Create(Self); Chk.Parent := Grp; @@ -3869,6 +3864,7 @@ begin // TCP server group below the two rows of serial port panels gy := 86 + 2 * (GRP_H + GRP_GAP); Grp := MakeGroupPanel(FPageCAT, 'TCP CAT Server', MARGIN, gy, GRP_W * 2 + GRP_GAP, 116); + Grp.Tag := TAG_RESPONSIVE_CARD; FCATTcpEn := TFlatCheckBox.Create(Self); FCATTcpEn.Parent := Grp; @@ -4000,30 +3996,10 @@ var Spin: TFlatSpinEdit; Cmb: TFlatComboBox; begin - with TLabel.Create(Self) do - begin - Parent := FPageSlices; - Caption := 'Slices'; - SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(400), DpiScale(28)); - Font.Size := 16; - Font.Style := [fsBold]; Font.Color := CLR_TEXT; - end; - with TLabel.Create(Self) do - begin - Parent := FPageSlices; - Caption := 'A separate CAT TCP port per additional slice — external software ' - + 'controls its own slice as a standalone transceiver.'; - SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(760), DpiScale(20)); - Font.Size := 9; Font.Color := CLR_TEXTDIM; - end; - with TLabel.Create(Self) do - begin - Parent := FPageSlices; - Caption := 'Slice A is the main receiver — it is configured on the CAT tab ' - + '(serial ports and the main TCP port).'; - SetBounds(DpiScale(MARGIN), DpiScale(66), DpiScale(760), DpiScale(20)); - Font.Size := 9; Font.Color := CLR_TEXTDIM; - end; + MakePageHeader(FPageSlices, 'Slices', + 'A separate CAT TCP port per additional slice — external software controls ' + + 'its own slice as a standalone transceiver.' + LineEnding + + 'Slice A is the main receiver and is configured on the CAT page.', 38); for i := 0 to CFG_CAT_SLICE_COUNT - 1 do begin @@ -4033,6 +4009,10 @@ begin gy := 96 + row * (GRP_H + GRP_GAP); Grp := MakeGroupPanel(FPageSlices, 'Slice ' + Chr(Ord('B') + i), gx, gy, GRP_W, GRP_H); + if col = 0 then + Grp.Tag := TAG_RESPONSIVE_HALF_LEFT + else + Grp.Tag := TAG_RESPONSIVE_HALF_RIGHT; Chk := TFlatCheckBox.Create(Self); Chk.Parent := Grp; @@ -4091,39 +4071,13 @@ begin end; gy := 96 + ((CFG_CAT_SLICE_COUNT + 1) div 2) * (GRP_H + GRP_GAP); - with TLabel.Create(Self) do - begin - Parent := FPageSlices; - Caption := 'Audio out/in belong to the slice letter: the setting moves the ' - + 'slice sitting on that slot right away and the next one inherits it.'; - SetBounds(DpiScale(MARGIN), DpiScale(gy), DpiScale(760), DpiScale(20)); - Font.Size := 9; Font.Color := CLR_TEXTDIM; - end; - Inc(gy, 18); - with TLabel.Create(Self) do - begin - Parent := FPageSlices; - Caption := 'Only one slice transmits at a time: while anything is already on ' - + 'the air the request is ignored — no take-over.'; - SetBounds(DpiScale(MARGIN), DpiScale(gy), DpiScale(760), DpiScale(20)); - Font.Size := 9; Font.Color := CLR_TEXTDIM; - end; - with TLabel.Create(Self) do - begin - Parent := FPageSlices; - Caption := 'Without Auto TX a slice keys up over CAT only when it is already ' - + 'the transmit source (TX badge on the slice flag).'; - SetBounds(DpiScale(MARGIN), DpiScale(gy + 18), DpiScale(760), DpiScale(20)); - Font.Size := 9; Font.Color := CLR_TEXTDIM; - end; - with TLabel.Create(Self) do - begin - Parent := FPageSlices; - Caption := 'The port is opened even when the slice does not exist yet — until ' - + 'it is created CAT answers "radio off" (PS0).'; - SetBounds(DpiScale(MARGIN), DpiScale(gy + 36), DpiScale(760), DpiScale(20)); - Font.Size := 9; Font.Color := CLR_TEXTDIM; - end; + MakeTextBlock(FPageSlices, + 'Audio routing belongs to the slice letter and is inherited by the next ' + + 'receiver placed in that slot.' + LineEnding + + 'Only one slice can transmit. Auto TX selects this slice as the TX source; ' + + 'without it, CAT can key only the current TX slice.' + LineEnding + + 'The CAT port stays open before the slice exists and replies with radio-off status.', + MARGIN, gy, SETTINGS_CARD_W, 62); end; procedure TSettingsForm.FireSliceCATChange; @@ -4289,7 +4243,9 @@ var Result := TLabel.Create(Self); Result.Parent := AParent; Result.Caption := ACap; + Result.AutoSize := False; Result.SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(84), DpiScale(18)); + Result.Layout := tlCenter; Result.Font.Size := 9; Result.Font.Color := CLR_TEXT; end; @@ -4300,7 +4256,10 @@ var begin Parent := AParent; Caption := ACap; + AutoSize := False; SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(30), DpiScale(16)); + Alignment := taCenter; + Layout := tlCenter; Font.Size := 9; Font.Color := CLR_TEXTDIM; end; @@ -4309,19 +4268,16 @@ var begin FLoading := True; try - with TLabel.Create(Self) do - begin - Parent := FPageAlex; - Caption := 'Antenna / Alex'; - SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(500), DpiScale(28)); - Font.Size := 16; - Font.Style := [fsBold]; Font.Color := CLR_TEXT; - end; + MakePageHeader(FPageAlex, 'Antenna / Alex', ''); FAlexSubtitle := TLabel.Create(Self); FAlexSubtitle.Parent := FPageAlex; FAlexSubtitle.Caption := 'Per-band RX/TX antenna and secondary path routing for the Alex filter board.'; - FAlexSubtitle.SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(680), DpiScale(20)); + FAlexSubtitle.AutoSize := False; + FAlexSubtitle.SetBounds(DpiScale(MARGIN), DpiScale(48), + DpiScale(SETTINGS_CARD_W), DpiScale(20)); + FAlexSubtitle.Layout := tlTop; + FAlexSubtitle.WordWrap := True; FAlexSubtitle.Font.Size := 9; FAlexSubtitle.Font.Color := CLR_TEXTDIM; @@ -4812,22 +4768,9 @@ var begin FLoading := True; try - // --- Заголовок страницы --- - L := TLabel.Create(Self); - L.Parent := FPageXvtr; - L.Caption := 'Transverters'; - L.SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(500), DpiScale(28)); - L.Font.Size := 16; - L.Font.Style := [fsBold]; L.Font.Color := CLR_TEXT; - - L := TLabel.Create(Self); - L.Parent := FPageXvtr; - L.Caption := 'Configure up to 8 transverter (XVTR) bands. When a band is selected on the main form, ' + - 'the VFO shows the displayed (RF) frequency.' + LineEnding + - 'The radio is internally tuned to the IF: f_IF = f_displayed - LO + LO Error.'; - L.SetBounds(DpiScale(MARGIN), DpiScale(50), DpiScale(GRP_W), DpiScale(34)); - L.Font.Size := 9; L.Font.Color := CLR_TEXTDIM; - L.WordWrap := True; + MakePageHeader(FPageXvtr, 'Transverters', + 'Configure up to 8 transverter (XVTR) bands. The VFO shows RF frequency; ' + + 'the radio tunes to IF: f_IF = f_displayed - LO + LO Error.', 38); // --- Group "Slots" --- (последний слот зарезервирован под QO-100 — не строим) GrpY := 100; @@ -4890,18 +4833,14 @@ begin FChkXvtrTunPwr.OnChange := OnXvtrAnyChange; // --- Подсказка про колонки внизу --- - L := TLabel.Create(Self); - L.Parent := FPageXvtr; - L.Caption := + L := MakeTextBlock(FPageXvtr, 'EN - enable the slot. Label - band button caption. Begin/End - displayed VFO range. ' + 'LO - local oscillator. LO Error - fine correction in Hz.' + LineEnding + 'RX Gain - transverter RX gain offset. RX Only - block TX. TX Pwr - drive limit. ' + 'XVTR RF - use the main HF RF path.' + LineEnding + 'Disable PA - keep the internal PA in RX position during TX. ' + - 'RX Antenna - override the per-band antenna for this slot.'; - L.SetBounds(DpiScale(MARGIN), DpiScale(GrpY + GRP_TITLE_H + 36 + 16), DpiScale(GRP_W), DpiScale(52)); - L.Font.Size := 9; L.Font.Color := CLR_TEXTDIM; - L.WordWrap := True; + 'RX Antenna - override the per-band antenna for this slot.', + MARGIN, GrpY + GRP_TITLE_H + 36 + 16, GRP_W, 52); // Применяем начальное состояние disabled-строк for i := 0 to CFG_XVTR_COUNT - 1 do @@ -5062,19 +5001,16 @@ var begin FLoading := True; try - with TLabel.Create(Self) do - begin - Parent := FPageOC; - Caption := 'OC Control'; - SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(500), DpiScale(28)); - Font.Size := 16; - Font.Style := [fsBold]; Font.Color := CLR_TEXT; - end; + MakePageHeader(FPageOC, 'OC Control', ''); FOCSubtitle := TLabel.Create(Self); FOCSubtitle.Parent := FPageOC; FOCSubtitle.Caption := 'Open Collector outputs (Penny/Alex board), per-band and per-transverter. openHPSDR only.'; - FOCSubtitle.SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(680), DpiScale(20)); + FOCSubtitle.AutoSize := False; + FOCSubtitle.SetBounds(DpiScale(MARGIN), DpiScale(48), + DpiScale(SETTINGS_CARD_W), DpiScale(20)); + FOCSubtitle.Layout := tlTop; + FOCSubtitle.WordWrap := True; FOCSubtitle.Font.Size := 9; FOCSubtitle.Font.Color := CLR_TEXTDIM; @@ -5278,29 +5214,16 @@ var procedure QoLabel(const Cap: string; AY: Integer); begin - L := TLabel.Create(Self); - L.Parent := Grp; L.Caption := Cap; - L.SetBounds(DpiScale(LBL_X), DpiScale(AY + 4), DpiScale(LBL_W), DpiScale(18)); - L.Font.Color := CLR_TEXTDIM; L.Font.Size := 9; + L := MakeLbl(Grp, Cap, LBL_X, AY + 4, LBL_W); end; begin FLoading := True; try - L := TLabel.Create(Self); - L.Parent := FPageQO; L.Caption := 'QO-100 (Es''hail-2 NB transponder)'; - L.SetBounds(DpiScale(MARGIN), DpiScale(18), DpiScale(600), DpiScale(28)); - L.Font.Size := 16; - L.Font.Style := [fsBold]; L.Font.Color := CLR_TEXT; - - L := TLabel.Create(Self); - L.Parent := FPageQO; - L.Caption := 'Geostationary transponder: RX downlink (~10489.5 MHz) via LNB, ' + - 'TX uplink (~2400 MHz) direct. VFO is displayed in downlink terms; ' + - 'RX and TX use separate LOs. Drift is corrected by the beacon lock.'; - L.SetBounds(DpiScale(MARGIN), DpiScale(50), DpiScale(700), DpiScale(34)); - L.Font.Size := 9; L.Font.Color := CLR_TEXTDIM; - L.WordWrap := True; + MakePageHeader(FPageQO, 'QO-100 (Es''hail-2 NB transponder)', + 'Geostationary transponder: RX downlink (~10489.5 MHz) via LNB and direct ' + + 'TX uplink (~2400 MHz). Separate LOs are used; beacon lock corrects drift.', + 38); Grp := MakeGroupPanel(FPageQO, 'Transponder', MARGIN, 100, SETTINGS_CARD_W, GRP_TITLE_H + 9 * 30 + 16); @@ -5349,11 +5272,8 @@ begin FQoPower.Font.Size := 9; FQoPower.OnChange := OnQOAnyChange; - FQoInfo := TLabel.Create(Self); - FQoInfo.Parent := FPageQO; - FQoInfo.SetBounds(DpiScale(MARGIN), DpiScale(100 + GRP_TITLE_H + 9 * 30 + 28), DpiScale(700), DpiScale(52)); - FQoInfo.Font.Color := CLR_TEXTDIM; - FQoInfo.Font.Size := 9; FQoInfo.WordWrap := True; + FQoInfo := MakeTextBlock(FPageQO, '', + MARGIN, 100 + GRP_TITLE_H + 9 * 30 + 28, SETTINGS_CARD_W, 52); finally FLoading := False; end; From adf5c5016e3245d4054d3b4dcb8b27efaf3db8bd Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 20:47:55 +0300 Subject: [PATCH 15/30] ui(settings): refine transmit and hardware layouts --- SettingsForm.pas | 142 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 112 insertions(+), 30 deletions(-) diff --git a/SettingsForm.pas b/SettingsForm.pas index 5995ab2..a1d18db 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -41,6 +41,7 @@ const TAG_PAGE_HEADER = 911; TAG_RESPONSIVE_HALF_LEFT = 912; TAG_RESPONSIVE_HALF_RIGHT = 913; + TAG_RESPONSIVE_INSET = 914; type // PA: MaxPower в Вт, BandCal[0..10] — калибровка 38.8..100.0 на диапазон @@ -1091,6 +1092,8 @@ procedure TSettingsForm.RelayoutResponsiveCards; Ctrl := AParent.Controls[i]; if Ctrl.Tag = TAG_PAGE_HEADER then Ctrl.Width := ACardWidth + else if Ctrl.Tag = TAG_RESPONSIVE_INSET then + Ctrl.Width := ACardWidth - DpiScale(36) else if (Ctrl is TPanel) and (Ctrl.Tag = TAG_RESPONSIVE_CARD) then Ctrl.Width := ACardWidth else if (Ctrl is TPanel) and (Ctrl.Tag = TAG_RESPONSIVE_HALF_LEFT) then @@ -1111,6 +1114,72 @@ procedure TSettingsForm.RelayoutResponsiveCards; end; end; + procedure ResizeTransmit(ACardWidth: Integer); + var + ActionW, ActionGap, ActionLeft, ComboLeft, TabGap, TabW, i: Integer; + begin + ActionW := DpiScale(100); + ActionGap := DpiScale(6); + ActionLeft := ACardWidth - DpiScale(18) - ActionW; + + if FBtnTXProfFactory <> nil then + FBtnTXProfFactory.SetBounds(ActionLeft, FBtnTXProfFactory.Top, + ActionW, FBtnTXProfFactory.Height); + Dec(ActionLeft, ActionW + ActionGap); + if FBtnTXProfDel <> nil then + FBtnTXProfDel.SetBounds(ActionLeft, FBtnTXProfDel.Top, + ActionW, FBtnTXProfDel.Height); + Dec(ActionLeft, ActionW + ActionGap); + if FBtnTXProfRen <> nil then + FBtnTXProfRen.SetBounds(ActionLeft, FBtnTXProfRen.Top, + ActionW, FBtnTXProfRen.Height); + Dec(ActionLeft, ActionW + ActionGap); + if FBtnTXProfNew <> nil then + FBtnTXProfNew.SetBounds(ActionLeft, FBtnTXProfNew.Top, + ActionW, FBtnTXProfNew.Height); + + if FCmbTXProfile <> nil then + begin + ComboLeft := DpiScale(78); + FCmbTXProfile.Width := Max(DpiScale(230), + ActionLeft - DpiScale(12) - ComboLeft); + end; + + TabGap := DpiScale(4); + TabW := (ACardWidth - 3 * TabGap) div TXTAB_COUNT; + for i := 0 to TXTAB_COUNT - 1 do + if FTXSubBtn[i] <> nil then + FTXSubBtn[i].SetBounds(DpiScale(SETTINGS_MARGIN) + + i * (TabW + TabGap), FTXSubBtn[i].Top, + TabW, FTXSubBtn[i].Height); + + if FTXEQControl <> nil then + FTXEQControl.Width := ACardWidth - DpiScale(36); + end; + + procedure ResizeOC(ACardWidth: Integer); + var + AsideWidth, Gap, MainWidth, MainLeft, AsideLeft: Integer; + begin + AsideWidth := DpiScale(260); + Gap := DpiScale(14); + MainLeft := DpiScale(SETTINGS_MARGIN); + MainWidth := ACardWidth - AsideWidth - Gap; + AsideLeft := MainLeft + MainWidth + Gap; + + if FOCHFGrp <> nil then + FOCHFGrp.SetBounds(MainLeft, FOCHFGrp.Top, MainWidth, FOCHFGrp.Height); + if FOCHFActGrp <> nil then + FOCHFActGrp.SetBounds(AsideLeft, FOCHFActGrp.Top, + AsideWidth, FOCHFActGrp.Height); + if FOCXvtrGrp <> nil then + FOCXvtrGrp.SetBounds(MainLeft, FOCXvtrGrp.Top, + MainWidth, FOCXvtrGrp.Height); + if FOCXvtrActGrp <> nil then + FOCXvtrActGrp.SetBounds(AsideLeft, FOCXvtrActGrp.Top, + AsideWidth, FOCXvtrActGrp.Height); + end; + procedure ResizePage(APage: TScrollBox); var CardWidth: Integer; @@ -1121,6 +1190,10 @@ procedure TSettingsForm.RelayoutResponsiveCards; CardWidth := EnsureRange(CardWidth, DpiScale(SETTINGS_CARD_W), DpiScale(SETTINGS_CARD_MAX_W)); ResizeTree(APage, CardWidth); + if APage = FPageTransmit then + ResizeTransmit(CardWidth); + if APage = FPageOC then + ResizeOC(CardWidth); end; begin @@ -1582,6 +1655,16 @@ var Result := MakeCombo(AParent, AX, AY, AW, OnTXAnyChange); end; + procedure TxNote(AParent: TWinControl; const AText: string; + ATop, AHeight: Integer); + var + Note: TLabel; + begin + Note := MakeTextBlock(AParent, AText, PAD, ATop, + GRP_W - 2 * PAD, AHeight); + Note.Tag := TAG_RESPONSIVE_INSET; + end; + begin MakePageHeader(FPageTransmit, 'Transmit', 'Profile and EQ hold the active TX profile; Hardware and Display are ' @@ -1597,12 +1680,11 @@ begin FBtnTXProfDel := MkBtn(Grp, PAD + 514, R1, 100, 'Delete', BtnTXProfDelClick); FBtnTXProfFactory := MkBtn(Grp, PAD + 620, R1, 100, 'Factory', BtnTXProfFactoryClick); - MakeLbl(Grp, 'Edits on the Profile and EQ tabs go into the active profile ' - + 'immediately — there is no separate Save button.', - PAD, R1 + 30, GRP_W - 2 * PAD); - MakeLbl(Grp, 'New... forks the current sound under a new name. Factory rebuilds ' - + 'the stock set; it keeps the microphone wiring and drive, not the sound.', - PAD, R1 + 48, GRP_W - 2 * PAD); + TxNote(Grp, 'Edits on the Profile and EQ tabs go into the active profile ' + + 'immediately — there is no separate Save button.', R1 + 30, 18); + TxNote(Grp, 'New... forks the current sound under a new name. Factory rebuilds ' + + 'the stock set; it keeps microphone wiring and drive, not the sound.', + R1 + 48, 18); // ---- Сегменты подвкладок ------------------------------------------------ Y := 78 + 124 + 12; @@ -1663,9 +1745,9 @@ begin FEdTXFiltLow := MkSpin(Grp, CX, R1, 0, 8000, 200); MakeLbl(Grp, 'High cut (Hz)', PAD + 320, R1 + 5, 90); FEdTXFiltHigh := MkSpin(Grp, PAD + 320 + 100, R1, 100, 12000, 3100); - MakeLbl(Grp, 'Voice bandwidth. ESSB is simply a high cut of 3500..5000 Hz; ' - + 'sideband sign is applied by the engine, these are magnitudes.', - PAD, R1 + 32, GRP_W - 2 * PAD); + TxNote(Grp, 'Voice bandwidth. ESSB is simply a high cut of 3500..5000 Hz; ' + + 'sideband sign is applied by the engine, these are magnitudes.', + R1 + 32, 26); // ---- Динамическая обработка ---- Inc(Y, 108 + 12); @@ -1703,9 +1785,9 @@ begin FEdTXAMCarrier := MkFloat(Grp, PAD + 320 + 120, R1, 0.5, 0.0, 1.0, 0.05); FLblTXProfDrive := MakeLbl(Grp, 'Profile drive: —', PAD, R1 + STEP + 5, 300); - MakeLbl(Grp, 'Drive belongs to the profile, but you set it with the DRV slider ' - + 'in the main window — the active profile follows it.', - PAD, R1 + STEP + 28, GRP_W - 2 * PAD); + TxNote(Grp, 'Drive belongs to the profile, but you set it with the DRV slider ' + + 'in the main window — the active profile follows it.', + R1 + STEP + 28, 26); FTXSubPage[0].Height := DpiScale(Y + 148 + 12); @@ -1726,15 +1808,10 @@ begin // Главное, что надо знать про этот EQ: точки — не «полосы», а узлы кривой, // и за крайними узлами WDSP обрывает АЧХ (см. WDSPEngine.PushTXEQProfile). - MakeLbl(Grp, 'Points are curve nodes: the response is interpolated in dB ' - + 'between them and cut off beyond the outer ones.', - PAD, R1 + 332, GRP_W - 2 * PAD); - MakeLbl(Grp, 'Keep the lowest node at or below the TX filter low cut and the ' - + 'highest at or above the high cut, or the EQ will clip the passband.', - PAD, R1 + 352, GRP_W - 2 * PAD); - MakeLbl(Grp, 'In 3-band mode the frequencies are fixed by WDSP ' - + '(150 / 1500 / 6000 Hz) — only the gains move.', - PAD, R1 + 372, GRP_W - 2 * PAD); + TxNote(Grp, 'Points are curve nodes: response is interpolated between them and ' + + 'cut beyond the outer nodes.' + LineEnding + + 'Keep the outer nodes beyond the TX passband. In 3-band mode WDSP fixes ' + + 'frequencies at 150 / 1500 / 6000 Hz.', R1 + 332, 58); FTXSubPage[1].Height := DpiScale(434 + 12); @@ -1749,9 +1826,8 @@ begin FEdTXAttOnTX := MkSpin(Grp, CX, R1, 0, 31, 31); MakeLbl(Grp, 'Tune tone (Hz)', PAD + 320, R1 + 5, 110); FEdTUNFreq := MkSpin(Grp, PAD + 320 + 120, R1, 100, 5000, 750); - MakeLbl(Grp, 'ATT on TX attenuates the RX ADC while transmitting (input ' - + 'protection) — not to be confused with output power.', - PAD, R1 + 32, GRP_W - 2 * PAD); + TxNote(Grp, 'ATT on TX attenuates the RX ADC while transmitting (input ' + + 'protection) — not to be confused with output power.', R1 + 32, 26); Y := 108 + 12; Grp := MakeGroupPanel(FTXSubPage[2], 'TX Filter (DSP)', MARGIN, Y, GRP_W, 190); @@ -1772,8 +1848,8 @@ begin FCmbTXFiltWindow.Items.Add(WINDOW_NAMES[i]); FCmbTXFiltWindow.ItemIndex := 1; - MakeLbl(Grp, 'Skirt steepness and path latency, not timbre — hence outside ' - + 'the profile.', PAD, R1 + 3 * STEP + 5, GRP_W - 2 * PAD); + TxNote(Grp, 'Skirt steepness and path latency, not timbre — hence outside ' + + 'the profile.', R1 + 3 * STEP - 2, 22); Inc(Y, 190 + 12); Grp := MakeGroupPanel(FTXSubPage[2], 'FM / CTCSS', MARGIN, Y, GRP_W, 250); @@ -1795,8 +1871,8 @@ begin MakeLbl(Grp, 'Freq (Hz)', PAD + 230, R1 + 3 * STEP + 5, 80); FEdTXCTCSSFreq := MkFloat(Grp, PAD + 320, R1 + 3 * STEP, 100.0, 50.0, 300.0, 0.1); - MakeLbl(Grp, 'A property of the channel or repeater — a voice profile does ' - + 'not switch it.', PAD, R1 + 4 * STEP + 5, GRP_W - 2 * PAD); + TxNote(Grp, 'A property of the channel or repeater — a voice profile does ' + + 'not switch it.', R1 + 4 * STEP + 5, 26); FTXSubPage[2].Height := DpiScale(Y + 250 + 12); @@ -1874,6 +1950,7 @@ begin FTXSubIdx := -1; SelectTXSubPage(0); + UpdateMicJackVisibility; end; procedure TSettingsForm.CollectTXFromUI; var i: Integer; @@ -4283,6 +4360,7 @@ begin // ---- Antenna grid group ---- Grp := MakeGroupPanel(FPageAlex, 'Antenna Selection', MARGIN, 86, GRP_W, GRP_H); + Grp.Tag := TAG_RESPONSIVE_CARD; // Header row 1 — group labels MakeLbl(Grp, 'Band', BAND_X, GRP_TITLE_H + 2, 44); @@ -4347,6 +4425,7 @@ begin // ---- TX Routing flags group ---- GrpTX := MakeGroupPanel(FPageAlex, 'TX Routing', MARGIN, 86 + GRP_H + 12, 480, TXGRP_H); + GrpTX.Tag := TAG_RESPONSIVE_CARD; FAlexRxBypassOnTx := MkCh(GrpTX, 'Rx BYPASS on TX', PAD, GRP_TITLE_H + 8, 440); @@ -4367,6 +4446,7 @@ begin FPlutoAntGrp := MakeGroupPanel(FPageAlex, 'Antenna Ports', MARGIN, 86, ANT_GRP_W, GRP_TITLE_H + ANT_HDR_H + (CFG_BAND_COUNT + 1 + CFG_XVTR_COUNT) * ANT_ROW_H + PAD); + FPlutoAntGrp.Tag := TAG_RESPONSIVE_CARD; FPlutoAntGrp.Visible := False; // Шапка колонок: «RX»/«TX» над своей парой, под ними номера разъёмов. @@ -4776,6 +4856,7 @@ begin GrpY := 100; Grp := MakeGroupPanel(FPageXvtr, 'Slots', MARGIN, GrpY, GRP_W, GRP_TITLE_H + HDR_H + QO100_SLOT * ROW_H + 14); + Grp.Tag := TAG_RESPONSIVE_CARD; // Заголовки колонок (под GRP_TITLE_H + небольшой отступ) Y := GRP_TITLE_H + 6; @@ -4823,6 +4904,7 @@ begin GrpY := GrpY + GRP_TITLE_H + HDR_H + QO100_SLOT * ROW_H + 14 + 18; GrpOpts := MakeGroupPanel(FPageXvtr, 'Options', MARGIN, GrpY, GRP_W, GRP_TITLE_H + 36); + GrpOpts.Tag := TAG_RESPONSIVE_CARD; FChkXvtrTunPwr := TFlatCheckBox.Create(Self); FChkXvtrTunPwr.Parent := GrpOpts; FChkXvtrTunPwr.Caption := 'Scale TUN by XVTR power ' @@ -4841,6 +4923,7 @@ begin 'Disable PA - keep the internal PA in RX position during TX. ' + 'RX Antenna - override the per-band antenna for this slot.', MARGIN, GrpY + GRP_TITLE_H + 36 + 16, GRP_W, 52); + L.Tag := TAG_PAGE_HEADER; // Применяем начальное состояние disabled-строк for i := 0 to CFG_XVTR_COUNT - 1 do @@ -5197,7 +5280,6 @@ const ED_X = 250; ED_W = 130; var Grp: TPanel; - L: TLabel; Y: Integer; function QoFloat(AY: Integer; AMin, AMax, AStep: Double; ADec: Integer): TFlatFloatSpinEdit; @@ -5214,7 +5296,7 @@ var procedure QoLabel(const Cap: string; AY: Integer); begin - L := MakeLbl(Grp, Cap, LBL_X, AY + 4, LBL_W); + MakeLbl(Grp, Cap, LBL_X, AY + 4, LBL_W); end; begin From 9047dc586d15c8ea2b68b9ebb32b9b980e32944e Mon Sep 17 00:00:00 2001 From: Vladimir Date: Tue, 4 Aug 2026 21:02:15 +0300 Subject: [PATCH 16/30] ui(settings): align cards and spacing --- SettingsForm.pas | 115 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 86 insertions(+), 29 deletions(-) diff --git a/SettingsForm.pas b/SettingsForm.pas index a1d18db..ba1370c 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -42,6 +42,7 @@ const TAG_RESPONSIVE_HALF_LEFT = 912; TAG_RESPONSIVE_HALF_RIGHT = 913; TAG_RESPONSIVE_INSET = 914; + TAG_BOTTOM_SPACER = 915; type // PA: MaxPower в Вт, BandCal[0..10] — калибровка 38.8..100.0 на диапазон @@ -522,6 +523,8 @@ type function MakeGroupPanel(AParent: TWinControl; const Cap: string; ALeft, ATop, AW, AH: Integer): TPanel; function MakeScrollPage: TScrollBox; + procedure AddPageBottomSpace(APage: TScrollBox); + procedure UpdatePageBottomSpace(APage: TScrollBox); procedure MakeNavSection(const Cap: string; ATop: Integer); function MakeNavButton(const Cap: string; ATop: Integer): TFlatButton; @@ -694,6 +697,7 @@ const SETTINGS_CARD_W = 760; SETTINGS_CARD_MAX_W = 934; SETTINGS_FOOTER_H = 52; + SETTINGS_BOTTOM_SPACE = 24; // Первый пункт комбо аудио слайса = «как у приложения» (пустое имя в конфиге). SLICE_DEV_DEFAULT = 'Default (app output)'; @@ -920,6 +924,42 @@ begin Result.Visible := False; end; +procedure TSettingsForm.AddPageBottomSpace(APage: TScrollBox); +var + Spacer: TPanel; +begin + if APage = nil then Exit; + Spacer := TPanel.Create(Self); + Spacer.Parent := APage; + Spacer.Tag := TAG_BOTTOM_SPACER; + Spacer.BevelOuter := bvNone; + Spacer.Color := CLR_BG; + Spacer.Height := DpiScale(SETTINGS_BOTTOM_SPACE); + UpdatePageBottomSpace(APage); +end; + +procedure TSettingsForm.UpdatePageBottomSpace(APage: TScrollBox); +var + i, ContentBottom: Integer; + Ctrl: TControl; + Spacer: TPanel; +begin + if APage = nil then Exit; + Spacer := nil; + ContentBottom := 0; + for i := 0 to APage.ControlCount - 1 do + begin + Ctrl := APage.Controls[i]; + if Ctrl.Tag = TAG_BOTTOM_SPACER then + Spacer := TPanel(Ctrl) + else if Ctrl.Visible then + ContentBottom := Max(ContentBottom, Ctrl.Top + Ctrl.Height); + end; + if Spacer = nil then Exit; + Spacer.SetBounds(0, ContentBottom, Max(APage.ClientWidth, DpiScale(804)), + DpiScale(SETTINGS_BOTTOM_SPACE)); +end; + procedure TSettingsForm.MakeNavSection(const Cap: string; ATop: Integer); begin with TLabel.Create(Self) do @@ -1056,6 +1096,15 @@ begin BuildOCTab; BuildQOTab; + // Страницы, заканчивающиеся карточкой, получают небольшой участок фона + // после последнего блока. Иначе при прокрутке блок визуально слипается + // с закреплённой нижней панелью Close. + AddPageBottomSpace(FPageSpectrum); + AddPageBottomSpace(FPageWaterfall); + AddPageBottomSpace(FPageCalib); + AddPageBottomSpace(FPageCAT); + AddPageBottomSpace(FPageOC); + FBtnClose := TFlatButton.Create(Self); FBtnClose.Parent := FFooterPanel; FBtnClose.Caption := 'Close'; @@ -1180,38 +1229,42 @@ procedure TSettingsForm.RelayoutResponsiveCards; AsideWidth, FOCXvtrActGrp.Height); end; - procedure ResizePage(APage: TScrollBox); - var - CardWidth: Integer; + procedure ResizePage(APage: TScrollBox; ACardWidth: Integer); begin if (APage = nil) or (APage.ClientWidth <= 0) then Exit; - CardWidth := APage.ClientWidth - - DpiScale(2 * SETTINGS_MARGIN) - DpiScale(20); - CardWidth := EnsureRange(CardWidth, DpiScale(SETTINGS_CARD_W), - DpiScale(SETTINGS_CARD_MAX_W)); - ResizeTree(APage, CardWidth); + ResizeTree(APage, ACardWidth); if APage = FPageTransmit then - ResizeTransmit(CardWidth); + ResizeTransmit(ACardWidth); if APage = FPageOC then - ResizeOC(CardWidth); + ResizeOC(ACardWidth); + UpdatePageBottomSpace(APage); end; +var + CardWidth: Integer; begin if FContentPanel = nil then Exit; - ResizePage(FPageAudio); - ResizePage(FPageDisplay); - ResizePage(FPageSpectrum); - ResizePage(FPageWaterfall); - ResizePage(FPageTransmit); - ResizePage(FPagePA); - ResizePage(FPageCalib); - ResizePage(FPageAdvanced); - ResizePage(FPageCAT); - ResizePage(FPageSlices); - ResizePage(FPageAlex); - ResizePage(FPageXvtr); - ResizePage(FPageOC); - ResizePage(FPageQO); + // Ширина берётся от общего контейнера, а не от отдельной страницы: + // появившийся на длинной вкладке scrollbar не должен сужать её карточки. + CardWidth := FContentPanel.ClientWidth + - DpiScale(2 * SETTINGS_MARGIN) - DpiScale(20); + CardWidth := EnsureRange(CardWidth, DpiScale(SETTINGS_CARD_W), + DpiScale(SETTINGS_CARD_MAX_W)); + + ResizePage(FPageAudio, CardWidth); + ResizePage(FPageDisplay, CardWidth); + ResizePage(FPageSpectrum, CardWidth); + ResizePage(FPageWaterfall, CardWidth); + ResizePage(FPageTransmit, CardWidth); + ResizePage(FPagePA, CardWidth); + ResizePage(FPageCalib, CardWidth); + ResizePage(FPageAdvanced, CardWidth); + ResizePage(FPageCAT, CardWidth); + ResizePage(FPageSlices, CardWidth); + ResizePage(FPageAlex, CardWidth); + ResizePage(FPageXvtr, CardWidth); + ResizePage(FPageOC, CardWidth); + ResizePage(FPageQO, CardWidth); end; procedure TSettingsForm.NavClick(Sender: TObject); @@ -2582,8 +2635,9 @@ begin FBtnDispFromSM := TFlatButton.Create(Self); FBtnDispFromSM.Parent := Grp; - FBtnDispFromSM.Caption := '= S-meter'; - FBtnDispFromSM.SetBounds(DpiScale(CAL_GRP_W - 130), DpiScale(24), DpiScale(112), DpiScale(BTN_H)); + FBtnDispFromSM.Caption := 'Copy all from S-meter'; + FBtnDispFromSM.SetBounds(DpiScale(CAL_PAD), DpiScale(54), + DpiScale(190), DpiScale(BTN_H)); FBtnDispFromSM.OnClick := OnCalDispFromSMClick; FBtnDispFromSM.ClrNorm := CLR_PANEL; FBtnDispFromSM.ClrBorder := TColor($00404040); @@ -2592,7 +2646,8 @@ begin FBtnDispFromSM.ClrText := CLR_TEXT; FBtnDispFromSM.ClrTextAct := CLR_ACCENT; FBtnDispFromSM.Font.Size := 9; - FBtnDispFromSM.Hint := 'Copy the S-meter offsets into this table'; + FBtnDispFromSM.Hint := + 'Copy all band and transverter offsets from the S-meter table above'; FBtnDispFromSM.ShowHint := True; for i := 0 to CFG_BAND_COUNT - 1 do @@ -2686,7 +2741,7 @@ begin HSM := LayoutCalOffsets(FLblSMBand, FEdSMBand, FLblSMXvtrHdr, FLblSMXvtr, FEdSMXvtr, 52); HDisp := LayoutCalOffsets(FLblDispBand, FEdDispBand, FLblDispXvtrHdr, - FLblDispXvtr, FEdDispXvtr, 52); + FLblDispXvtr, FEdDispXvtr, 90); // ---- вертикальная укладка групп ---- GTop := 86; @@ -2704,6 +2759,7 @@ begin FCalGrpDisp.SetBounds(DpiScale(CAL_MARGIN), DpiScale(GTop), DpiScale(CAL_GRP_W), DpiScale(HDisp)); GTop := GTop + HDisp + 14; FCalGrpFreq.SetBounds(DpiScale(CAL_MARGIN), DpiScale(GTop), DpiScale(CAL_GRP_W), DpiScale(CAL_GRP2_H)); + UpdatePageBottomSpace(FPageCalib); end; function TSettingsForm.LayoutCalOffsets(const BandLbl: array of TLabel; @@ -3087,7 +3143,8 @@ procedure TSettingsForm.ApplyTheme(const T: TAppTheme); begin if TPanel(Ctrl).Height = 1 then TPanel(Ctrl).Color := T.Border - else if TPanel(Ctrl).Tag = TAG_PAGE_BG then + else if (TPanel(Ctrl).Tag = TAG_PAGE_BG) or + (TPanel(Ctrl).Tag = TAG_BOTTOM_SPACER) then TPanel(Ctrl).Color := T.BG // подложка подвкладки, не группа else TPanel(Ctrl).Color := T.Panel; From bd057a7849b34c12a9333ed51536f2840ab28cfb Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 5 Aug 2026 12:42:56 +0300 Subject: [PATCH 17/30] fix(settings): prevent duplicate DPI scaling --- SettingsForm.pas | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/SettingsForm.pas b/SettingsForm.pas index ba1370c..44860e1 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -767,6 +767,13 @@ var MaxW, MaxH: Integer; begin inherited CreateNew(AOwner); + // Вся геометрия этой формы строится кодом и уже переводится из 96-DPI + // координат через DpiScale. Если оставить TCustomForm.Scaled=True, LCL + // после конструктора повторно применит monitor DPI ко всей форме: + // при Windows 125% получится 1.25 * 1.25 = 1.5625. Кроме общего раздувания, + // это смешивает два масштаба с RelayoutCalTab, который вызывается позднее, + // и строки Calibration начинают наезжать на поясняющий текст. + Scaled := False; Caption := 'Settings'; if AOwner is TCustomForm then begin From 907c2c05c3de833bae389d4d89cbab5581620fec Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 5 Aug 2026 13:20:03 +0300 Subject: [PATCH 18/30] ui: use system font in flat controls --- EqualizerControl.pas | 1 - FlatButton.pas | 1 - FlatCheckBox.pas | 1 - FlatComboBox.pas | 1 - FlatEdit.pas | 1 - FlatFloatSpinEdit.pas | 1 - FlatListBox.pas | 1 - FlatRadioButton.pas | 1 - FlatSpinEdit.pas | 1 - 9 files changed, 9 deletions(-) diff --git a/EqualizerControl.pas b/EqualizerControl.pas index 428e64d..9f09cc0 100644 --- a/EqualizerControl.pas +++ b/EqualizerControl.pas @@ -529,7 +529,6 @@ begin B.ClrBorder := FButtonTheme.BtnBorderNorm; B.ClrText := FButtonTheme.BtnText; B.ClrTextAct := FButtonTheme.BtnTextActive; - B.Font.Name := 'Courier New'; B.Font.Size := 8; B.Invalidate; end; diff --git a/FlatButton.pas b/FlatButton.pas index d8f94e1..1e6e1fd 100644 --- a/FlatButton.pas +++ b/FlatButton.pas @@ -204,7 +204,6 @@ begin Result.ClrBorder := ClrBorder; Result.ClrText := ClrText; Result.ClrTextAct := ClrTextAct; - Result.Font.Name := 'Courier New'; Result.Font.Size := 8; Result.Font.Color := ClrText; end; diff --git a/FlatCheckBox.pas b/FlatCheckBox.pas index ccf6e53..a6707ac 100644 --- a/FlatCheckBox.pas +++ b/FlatCheckBox.pas @@ -77,7 +77,6 @@ begin TabStop := True; Cursor := crHandPoint; - Font.Name := 'Courier New'; Font.Size := 9; FClrBG := TColor($00181818); diff --git a/FlatComboBox.pas b/FlatComboBox.pas index 688434b..df55b72 100644 --- a/FlatComboBox.pas +++ b/FlatComboBox.pas @@ -272,7 +272,6 @@ begin TabStop := True; Cursor := crDefault; - Font.Name := 'Courier New'; Font.Size := 9; FClrBG := TColor($001A1A1A); diff --git a/FlatEdit.pas b/FlatEdit.pas index 1996c83..12a2856 100644 --- a/FlatEdit.pas +++ b/FlatEdit.pas @@ -112,7 +112,6 @@ begin FEditing := False; FDragging := False; - Font.Name := 'Courier New'; Font.Size := 9; FClrOuterBG := TColor($00181818); diff --git a/FlatFloatSpinEdit.pas b/FlatFloatSpinEdit.pas index 6d8f3d4..e48b6d2 100644 --- a/FlatFloatSpinEdit.pas +++ b/FlatFloatSpinEdit.pas @@ -127,7 +127,6 @@ begin TabStop := True; Cursor := crIBeam; - Font.Name := 'Courier New'; Font.Size := 9; FMinValue := 0.0; diff --git a/FlatListBox.pas b/FlatListBox.pas index 4c0af3a..3a90086 100644 --- a/FlatListBox.pas +++ b/FlatListBox.pas @@ -93,7 +93,6 @@ begin TabStop := True; Cursor := crDefault; - Font.Name := 'Courier New'; Font.Size := 8; FClrOuterBG := TColor($00181818); diff --git a/FlatRadioButton.pas b/FlatRadioButton.pas index 50e6edc..5ed774c 100644 --- a/FlatRadioButton.pas +++ b/FlatRadioButton.pas @@ -78,7 +78,6 @@ begin TabStop := True; Cursor := crHandPoint; - Font.Name := 'Courier New'; Font.Size := 9; FClrBG := TColor($00181818); diff --git a/FlatSpinEdit.pas b/FlatSpinEdit.pas index 13ccb3a..14f71c7 100644 --- a/FlatSpinEdit.pas +++ b/FlatSpinEdit.pas @@ -121,7 +121,6 @@ begin TabStop := True; Cursor := crIBeam; - Font.Name := 'Courier New'; Font.Size := 9; FMinValue := 0; From 94bb15bbaffc83310279e70afe7bb83200eff56f Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 5 Aug 2026 13:24:54 +0300 Subject: [PATCH 19/30] fix(ui): scale Pluto left-panel relayout --- MainForm.pas | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/MainForm.pas b/MainForm.pas index 73dff86..6e941ab 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -8540,9 +8540,17 @@ end; procedure TMainForm.LayoutLeftPanel(Pluto: Boolean); // Показывает/скрывает Pluto RF-AGC блок под AGC и сдвигает панели ниже // (Bands и далее через RelayoutBelowBands). -var baseTop: Integer; +var + baseTop, Gap2, Gap4: Integer; begin if (PanelPlutoAGC = nil) or (PanelAGC = nil) or (PanelBands = nil) then Exit; + // BuildUI создаёт геометрию в 96-DPI координатах, после чего LCL + // масштабирует её вместе с формой. Эта перекладка выполняется позднее, + // уже после подключения устройства, поэтому её литеральные размеры тоже + // нужно перевести в текущий DPI формы. Иначе при 125% RX-панель снова + // получала Height=96, а её уже масштабированный ряд NR/NB/... обрезался. + Gap2 := Scale96ToForm(2); + Gap4 := Scale96ToForm(4); // Шаговый аттенюатор — только у openHPSDR; у AD936x усиление тракта в блоке // RF AGC ниже, поэтому ряд ATT убираем и RX-блок ужимаем. if (TrkAtt <> nil) and (LblAtt <> nil) and (LblAttVal <> nil) then @@ -8550,15 +8558,15 @@ begin TrkAtt.Visible := not Pluto; LblAtt.Visible := not Pluto; LblAttVal.Visible := not Pluto; - if Pluto then PanelRXBlock.Height := RX_BLOCK_H - else PanelRXBlock.Height := RX_BLOCK_H_ATT; - PanelAGC.Top := PanelRXBlock.Top + PanelRXBlock.Height + 4; + if Pluto then PanelRXBlock.Height := Scale96ToForm(RX_BLOCK_H) + else PanelRXBlock.Height := Scale96ToForm(RX_BLOCK_H_ATT); + PanelAGC.Top := PanelRXBlock.Top + PanelRXBlock.Height + Gap4; end; - baseTop := PanelAGC.Top + PanelAGC.Height + 4; + baseTop := PanelAGC.Top + PanelAGC.Height + Gap4; PanelPlutoAGC.Top := baseTop; PanelPlutoAGC.Visible := Pluto; if Pluto then - PanelBands.Top := baseTop + PanelPlutoAGC.Height + 2 + PanelBands.Top := baseTop + PanelPlutoAGC.Height + Gap2 else PanelBands.Top := baseTop; RelayoutBelowBands; From b7b9119339dfba076dec6cab8ec22b8d8e6e935a Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 5 Aug 2026 13:44:12 +0300 Subject: [PATCH 20/30] feat(ui): add hover overlay scroll for left panel --- FlatDropDown.pas | 15 ++- MainForm.pas | 158 ++++++++++++++++++++++++++--- OverlayScrollBar.pas | 237 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 391 insertions(+), 19 deletions(-) create mode 100644 OverlayScrollBar.pas diff --git a/FlatDropDown.pas b/FlatDropDown.pas index 92055b2..009b109 100644 --- a/FlatDropDown.pas +++ b/FlatDropDown.pas @@ -18,7 +18,7 @@ unit FlatDropDown; interface uses - Classes, SysUtils, Controls, Graphics, ExtCtrls, FlatButton; + Classes, SysUtils, Controls, Graphics, ExtCtrls, Math, FlatButton; type TButtonStyleProc = procedure(B: TFlatButton; Active: Boolean) of object; @@ -33,13 +33,14 @@ type FItemIndex: Integer; FColumns: Integer; FItemBtnH: Integer; + FRightInset96: Integer; FIsOpen: Boolean; FOnSelect: TDropDownSelectEvent; procedure ItemClick(Sender: TObject); procedure PositionPopup; public constructor Create(AAnchor: TFlatButton; APopupParent: TWinControl; - ACols, ABtnH: Integer); + ACols, ABtnH: Integer; ARightInset96: Integer = 0); destructor Destroy; override; procedure SetItems(const ANames: array of string); procedure SetItemIndex(Idx: Integer); @@ -54,13 +55,15 @@ type implementation constructor TFlatDropDown.Create(AAnchor: TFlatButton; APopupParent: TWinControl; - ACols, ABtnH: Integer); + ACols, ABtnH: Integer; + ARightInset96: Integer = 0); begin inherited Create; FAnchor := AAnchor; FPopupParent := APopupParent; FColumns := ACols; FItemBtnH := ABtnH; + FRightInset96 := Max(0, ARightInset96); FItemIndex := 0; FIsOpen := False; FPopup := TPanel.Create(nil); @@ -85,7 +88,11 @@ begin FPopup.Controls[0].Free; Count := Length(ANames); SetLength(FItemBtns, Count); - PopW := FPopupParent.ClientWidth; + // Popup может жить в контейнере с постоянно зарезервированным overlay- + // scrollbar gutter. Inset задаётся в 96-DPI координатах и пересчитывается + // при каждом наполнении: SetItems бывает и до, и после DPI-scale формы. + PopW := Max(1, FPopupParent.ClientWidth - + FPopupParent.Scale96ToForm(FRightInset96)); BtnW := (PopW - 4) div FColumns; FPopup.SetBounds(0, 0, PopW, ((Count + FColumns - 1) div FColumns) * FItemBtnH); diff --git a/MainForm.pas b/MainForm.pas index 6e941ab..e8cd3c0 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -24,7 +24,8 @@ interface uses Classes, SysUtils, StrUtils, FreqDisplay, DeviceForm, FilterPopup, VfoOverlay, SampleRateOverlay, BandPlanOverlay, - FlatButton, FlatSlider, FlatDropDown, AppTheme, Forms, Controls, Graphics, Dialogs, + FlatButton, FlatSlider, FlatDropDown, OverlayScrollBar, AppTheme, + Forms, Controls, Graphics, Dialogs, StdCtrls, ExtCtrls, Buttons, Menus, Math, Types, SyncObjs, OpenGLContextEx, LCLIntf, LCLType, GraphType, @@ -104,6 +105,7 @@ const SMETER_MARGIN = 3; TOP_SMETER_GAP = 8; TOP_VFO_FONT = 25; + LEFT_SCROLL_GUTTER_W = 8; LEFT_PANEL_BTN_PAD = 3; LEFT_PANEL_BTN_GAP = 2; @@ -263,6 +265,8 @@ type // ---- Left panel ---- PanelLeft: TPanel; + FLeftScrollBar: TOverlayScrollBar; + FLeftScrollOffset: Integer; PanelVfoA: TPanel; LblVfoALabel: TLabel; @@ -627,6 +631,9 @@ type procedure ApplyXvtrToNetwork; procedure RebuildXvtrButtons; procedure RelayoutBelowBands; + procedure UpdateLeftPanelScroll; + function CursorOverLeftPanel: Boolean; + procedure LeftScrollChanged(Sender: TObject); procedure BtnXvtrBandClick(Sender: TObject); procedure ActivateXvtrBand(Idx: Integer); procedure DeactivateXvtr; @@ -1939,9 +1946,19 @@ begin PanelLeft := TPanel.Create(Self); PanelLeft.Parent := Self; PanelLeft.Align := alLeft; - PanelLeft.Width := LEFT_W; + // Узкий правый gutter зарезервирован постоянно: появление overlay-scrollbar + // не меняет ширину панели и не дёргает область спектра. + PanelLeft.Width := LEFT_W + LEFT_SCROLL_GUTTER_W; PanelLeft.BevelOuter := bvNone; + FLeftScrollOffset := 0; + FLeftScrollBar := TOverlayScrollBar.Create(Self); + FLeftScrollBar.Parent := PanelLeft; + FLeftScrollBar.Align := alRight; + FLeftScrollBar.Width := LEFT_SCROLL_GUTTER_W; + FLeftScrollBar.HoverTarget := PanelLeft; + FLeftScrollBar.OnPositionChange := LeftScrollChanged; + Y := 0; // Compact top VFO group @@ -2134,7 +2151,8 @@ begin BtnTXProfile.OnMouseDown := BtnTXProfileMouseDown; StyleButton(BtnTXProfile, False); - FTXProfileDropDown := TFlatDropDown.Create(BtnTXProfile, PanelLeft, 1, BTN_SM); + FTXProfileDropDown := TFlatDropDown.Create(BtnTXProfile, PanelLeft, 1, BTN_SM, + LEFT_SCROLL_GUTTER_W); FTXProfileDropDown.OnSelect := OnTXProfileDropDownSelect; RenderTXProfile; @@ -2179,7 +2197,8 @@ begin BtnChannels.OnMouseDown := BtnChannelsMouseDown; StyleButton(BtnChannels, False); - FChannelsDropDown := TFlatDropDown.Create(BtnChannels, PanelLeft, 1, BTN_SM); + FChannelsDropDown := TFlatDropDown.Create(BtnChannels, PanelLeft, 1, BTN_SM, + LEFT_SCROLL_GUTTER_W); FChannelsDropDown.OnSelect := OnChannelsDropDownSelect; RefreshChannelsDropDown; @@ -2485,7 +2504,8 @@ begin BTN_H, BtnFMCTCSSToneClick); StyleButton(BtnFMCTCSSTone, False); - FCTCSSDropDown := TFlatDropDown.Create(BtnFMCTCSSTone, PanelLeft, 4, BTN_SM); + FCTCSSDropDown := TFlatDropDown.Create(BtnFMCTCSSTone, PanelLeft, 4, BTN_SM, + LEFT_SCROLL_GUTTER_W); FCTCSSDropDown.SetItems(CTCSS_NAMES); FCTCSSDropDown.SetItemIndex(FController.FFMCTCSSToneIdx); FCTCSSDropDown.OnSelect := OnCTCSSDropDownSelect; @@ -2509,7 +2529,8 @@ begin BTN_H, BtnFMStepSelClick); StyleButton(BtnFMStepSel, False); - FStepDropDown := TFlatDropDown.Create(BtnFMStepSel, PanelLeft, FM_STEP_COUNT, BTN_SM); + FStepDropDown := TFlatDropDown.Create(BtnFMStepSel, PanelLeft, FM_STEP_COUNT, + BTN_SM, LEFT_SCROLL_GUTTER_W); FStepDropDown.SetItems(FM_STEP_NAMES); FStepDropDown.SetItemIndex(FController.FFMStepIdx); FStepDropDown.OnSelect := OnStepDropDownSelect; @@ -2634,6 +2655,7 @@ end; procedure TMainForm.RightPanelResize(Sender: TObject); begin + UpdateLeftPanelScroll; ResizeSpectrumPanels; end; @@ -3149,6 +3171,9 @@ begin Color := T.BG; Font.Color := T.Text; DP(PanelToolbar); DP(PanelLeft); + if FLeftScrollBar <> nil then + FLeftScrollBar.SetColors(T.Panel, T.Border, + T.SliderThumbHot, T.SliderThumbDrag); DP(PanelTopVfoGroup); DP(PanelTopVfoA); DP(PanelTopVfoB); DP(PanelVfoA); DP(PanelVfoB); DP(PanelVfoButtons); DP(PanelBands); DP(PanelMode); DP(PanelFilter); @@ -8092,36 +8117,123 @@ end; // создаёт новые для enabled слотов в 3-м и 4-м ряду PanelBands. procedure TMainForm.RelayoutBelowBands; var - Y: Integer; + Y, Gap: Integer; begin if PanelBands = nil then Exit; - Y := PanelBands.Top + PanelBands.Height + 2; - PanelMode.Top := Y; Y := Y + PanelMode.Height + 2; + Gap := Scale96ToForm(2); + Y := PanelBands.Top + PanelBands.Height + Gap; + PanelMode.Top := Y; Y := Y + PanelMode.Height + Gap; if PanelFilter.Visible then begin PanelFilter.Top := Y; - Y := Y + PanelFilter.Height + 2; + Y := Y + PanelFilter.Height + Gap; end; if (PanelRawDev <> nil) and PanelRawDev.Visible then begin - PanelRawDev.Top := Y; Y := Y + PanelRawDev.Height + 2; + PanelRawDev.Top := Y; Y := Y + PanelRawDev.Height + Gap; end; if (PanelFMStep <> nil) and PanelFMStep.Visible then begin - PanelFMStep.Top := Y; Y := Y + PanelFMStep.Height + 2; + PanelFMStep.Top := Y; Y := Y + PanelFMStep.Height + Gap; end; if (PanelFMSQ <> nil) and PanelFMSQ.Visible then begin - PanelFMSQ.Top := Y; Y := Y + PanelFMSQ.Height + 2; + PanelFMSQ.Top := Y; Y := Y + PanelFMSQ.Height + Gap; end; if (PanelFMCTCSS <> nil) and PanelFMCTCSS.Visible then begin - PanelFMCTCSS.Top := Y; Y := Y + PanelFMCTCSS.Height + 2; + PanelFMCTCSS.Top := Y; Y := Y + PanelFMCTCSS.Height + Gap; end; if (PanelFMRpt <> nil) and PanelFMRpt.Visible then begin - PanelFMRpt.Top := Y; Y := Y + PanelFMRpt.Height + 2; + PanelFMRpt.Top := Y; Y := Y + PanelFMRpt.Height + Gap; end; + UpdateLeftPanelScroll; +end; + +function TMainForm.CursorOverLeftPanel: Boolean; +var + P: TPoint; +begin + Result := False; + if (PanelLeft = nil) or (not PanelLeft.Visible) then Exit; + P := PanelLeft.ScreenToClient(Mouse.CursorPos); + Result := (P.X >= 0) and (P.Y >= 0) and + (P.X < PanelLeft.ClientWidth) and (P.Y < PanelLeft.ClientHeight); +end; + +procedure TMainForm.LeftScrollChanged(Sender: TObject); +var + NewValue, Delta: Integer; + + procedure MovePanel(P: TPanel); + begin + if P <> nil then P.Top := P.Top + Delta; + end; + +begin + if FLeftScrollBar = nil then Exit; + NewValue := FLeftScrollBar.Position; + if NewValue = FLeftScrollOffset then Exit; + // Top хранится уже с учётом текущего scroll offset. При движении вниз + // содержимое уходит вверх, поэтому знак у дельты обратный. + Delta := FLeftScrollOffset - NewValue; + FLeftScrollOffset := NewValue; + + MovePanel(PanelTX); + MovePanel(PanelRXBlock); + MovePanel(PanelAGC); + MovePanel(PanelPlutoAGC); + MovePanel(PanelBands); + MovePanel(PanelMode); + MovePanel(PanelFilter); + MovePanel(PanelRawDev); + MovePanel(PanelFMStep); + MovePanel(PanelFMSQ); + MovePanel(PanelFMCTCSS); + MovePanel(PanelFMRpt); + + if FTXProfileDropDown <> nil then FTXProfileDropDown.ClosePopup; + if FChannelsDropDown <> nil then FChannelsDropDown.ClosePopup; + if FCTCSSDropDown <> nil then FCTCSSDropDown.ClosePopup; + if FStepDropDown <> nil then FStepDropDown.ClosePopup; +end; + +procedure TMainForm.UpdateLeftPanelScroll; +var + ContentBottom, ViewH, NewMax: Integer; + + procedure MeasurePanel(P: TPanel); + var + B: Integer; + begin + if (P = nil) or (not P.Visible) then Exit; + // Возвращаем логическую позицию до сдвига viewport. + B := P.Top + FLeftScrollOffset + P.Height; + if B > ContentBottom then ContentBottom := B; + end; + +begin + if (PanelLeft = nil) or (FLeftScrollBar = nil) then Exit; + ViewH := PanelLeft.ClientHeight; + if ViewH <= 0 then Exit; + + ContentBottom := 0; + MeasurePanel(PanelTX); + MeasurePanel(PanelRXBlock); + MeasurePanel(PanelAGC); + MeasurePanel(PanelPlutoAGC); + MeasurePanel(PanelBands); + MeasurePanel(PanelMode); + MeasurePanel(PanelFilter); + MeasurePanel(PanelRawDev); + MeasurePanel(PanelFMStep); + MeasurePanel(PanelFMSQ); + MeasurePanel(PanelFMCTCSS); + MeasurePanel(PanelFMRpt); + + NewMax := Max(0, ContentBottom + Scale96ToForm(4) - ViewH); + FLeftScrollBar.SetRange(NewMax, ViewH); end; procedure TMainForm.RebuildXvtrButtons; @@ -8362,6 +8474,22 @@ begin // виджет крутил бы B, а этот хендлер — активный A. if OverFreqDisp(FreqDispA) or OverFreqDisp(FreqDispB) then Exit; + // Если левая колонка не помещается, колесо над любой её частью прокручивает + // колонку, а не частоту. Scrollbar остаётся overlay и появляется по hover. + if CursorOverLeftPanel then + begin + UpdateLeftPanelScroll; + if (FLeftScrollBar <> nil) and (FLeftScrollBar.Maximum > 0) then + begin + if WheelDelta > 0 then + FLeftScrollBar.ScrollBy(-Scale96ToForm(64)) + else if WheelDelta < 0 then + FLeftScrollBar.ScrollBy(Scale96ToForm(64)); + Handled := True; + Exit; + end; + end; + // Колесо над раскрытым списком устройств во флаге — прокрутка этого списка. for i := 0 to MAX_PANS - 1 do if (FPans[i] <> nil) and Assigned(FPans[i].PbSpectrum) and diff --git a/OverlayScrollBar.pas b/OverlayScrollBar.pas new file mode 100644 index 0000000..741c96e --- /dev/null +++ b/OverlayScrollBar.pas @@ -0,0 +1,237 @@ +unit OverlayScrollBar; + +{ + Тонкий вертикальный overlay-scrollbar без системного оформления. + + Сам контрол всегда занимает зарезервированный gutter, но трек и бегунок + рисуются только при Max > 0 и наведении мыши на HoverTarget. Содержимое + контрол не двигает: владелец подписывается на OnPositionChange. +} + +{$mode objfpc}{$H+} + +interface + +uses + Classes, Controls, ExtCtrls, Forms, Graphics, Math, Types, LCLType; + +type + TOverlayScrollBar = class(TPaintBox) + private + FHoverTarget: TControl; + FMaximum: Integer; + FPosition: Integer; + FViewportSize: Integer; + FHover: Boolean; + FDragging: Boolean; + FDragY: Integer; + FDragPosition: Integer; + FBGColor: TColor; + FTrackColor: TColor; + FThumbColor: TColor; + FThumbDragColor: TColor; + FOnPositionChange: TNotifyEvent; + procedure AppUserInput(Sender: TObject; Msg: Cardinal); + procedure SetPosition(AValue: Integer); + function CursorOverTarget: Boolean; + function ThumbRect: TRect; + protected + procedure Paint; override; + procedure MouseDown(Button: TMouseButton; Shift: TShiftState; + X, Y: Integer); override; + procedure MouseMove(Shift: TShiftState; X, Y: Integer); override; + procedure MouseUp(Button: TMouseButton; Shift: TShiftState; + X, Y: Integer); override; + public + constructor Create(AOwner: TComponent); override; + destructor Destroy; override; + procedure SetRange(AMaximum, AViewportSize: Integer); + procedure ScrollBy(ADelta: Integer); + procedure SetColors(ABG, ATrack, AThumb, AThumbDrag: TColor); + property HoverTarget: TControl read FHoverTarget write FHoverTarget; + property Maximum: Integer read FMaximum; + property Position: Integer read FPosition write SetPosition; + property OnPositionChange: TNotifyEvent + read FOnPositionChange write FOnPositionChange; + end; + +implementation + +constructor TOverlayScrollBar.Create(AOwner: TComponent); +begin + inherited Create(AOwner); + Width := 8; + FMaximum := 0; + FPosition := 0; + FViewportSize := 0; + FHover := False; + FDragging := False; + FBGColor := TColor($00181818); + FTrackColor := TColor($00303030); + FThumbColor := TColor($00407040); + FThumbDragColor := TColor($00509050); + Application.AddOnUserInputHandler(@AppUserInput); +end; + +destructor TOverlayScrollBar.Destroy; +begin + Application.RemoveOnUserInputHandler(@AppUserInput); + if GetCaptureControl = Self then SetCaptureControl(nil); + inherited Destroy; +end; + +procedure TOverlayScrollBar.SetColors(ABG, ATrack, AThumb, + AThumbDrag: TColor); +begin + FBGColor := ABG; + FTrackColor := ATrack; + FThumbColor := AThumb; + FThumbDragColor := AThumbDrag; + Invalidate; +end; + +procedure TOverlayScrollBar.SetRange(AMaximum, AViewportSize: Integer); +var + OldMaximum: Integer; +begin + OldMaximum := FMaximum; + FMaximum := Max(0, AMaximum); + FViewportSize := Max(0, AViewportSize); + if FPosition > FMaximum then + SetPosition(FMaximum) + else if OldMaximum <> FMaximum then + Invalidate; +end; + +procedure TOverlayScrollBar.SetPosition(AValue: Integer); +var + NewValue: Integer; +begin + NewValue := EnsureRange(AValue, 0, FMaximum); + if NewValue = FPosition then Exit; + FPosition := NewValue; + if Assigned(FOnPositionChange) then FOnPositionChange(Self); + Invalidate; +end; + +procedure TOverlayScrollBar.ScrollBy(ADelta: Integer); +begin + SetPosition(FPosition + ADelta); +end; + +function TOverlayScrollBar.CursorOverTarget: Boolean; +var + P: TPoint; +begin + Result := False; + if (FHoverTarget = nil) or (not FHoverTarget.Visible) then Exit; + P := FHoverTarget.ScreenToClient(Mouse.CursorPos); + Result := (P.X >= 0) and (P.Y >= 0) and + (P.X < FHoverTarget.Width) and (P.Y < FHoverTarget.Height); +end; + +procedure TOverlayScrollBar.AppUserInput(Sender: TObject; Msg: Cardinal); +var + NewHover: Boolean; +begin + NewHover := CursorOverTarget; + if NewHover = FHover then Exit; + FHover := NewHover; + Invalidate; +end; + +function TOverlayScrollBar.ThumbRect: TRect; +var + TrackTop, TrackH, ThumbH, Travel, ContentH: Integer; +begin + Result := Rect(0, 0, 0, 0); + if (FMaximum <= 0) or (FViewportSize <= 0) then Exit; + TrackTop := Scale96ToForm(4); + TrackH := Height - 2 * TrackTop; + if TrackH <= 0 then Exit; + ContentH := FViewportSize + FMaximum; + ThumbH := Max(Scale96ToForm(28), + MulDiv(TrackH, FViewportSize, Max(1, ContentH))); + ThumbH := Min(ThumbH, TrackH); + Travel := TrackH - ThumbH; + Result.Left := Max(1, (Width - Scale96ToForm(4)) div 2); + Result.Right := Width - Result.Left; + Result.Top := TrackTop; + if (Travel > 0) and (FMaximum > 0) then + Inc(Result.Top, MulDiv(Travel, FPosition, FMaximum)); + Result.Bottom := Result.Top + ThumbH; +end; + +procedure TOverlayScrollBar.Paint; +var + R: TRect; + TrackX: Integer; +begin + Canvas.Brush.Style := bsSolid; + Canvas.Brush.Color := FBGColor; + Canvas.FillRect(ClientRect); + if (FMaximum <= 0) or ((not FHover) and (not FDragging)) then Exit; + + TrackX := Width div 2; + Canvas.Pen.Color := FTrackColor; + Canvas.Pen.Width := Max(1, Scale96ToForm(1)); + Canvas.Line(TrackX, Scale96ToForm(4), + TrackX, Height - Scale96ToForm(4)); + + R := ThumbRect; + if IsRectEmpty(R) then Exit; + if FDragging then Canvas.Brush.Color := FThumbDragColor + else Canvas.Brush.Color := FThumbColor; + Canvas.Pen.Style := psClear; + Canvas.RoundRect(R.Left, R.Top, R.Right, R.Bottom, + Scale96ToForm(3), Scale96ToForm(3)); + Canvas.Pen.Style := psSolid; +end; + +procedure TOverlayScrollBar.MouseDown(Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); +var + R: TRect; +begin + inherited MouseDown(Button, Shift, X, Y); + if (Button <> mbLeft) or (FMaximum <= 0) then Exit; + R := ThumbRect; + if PtInRect(R, Point(X, Y)) then + begin + FDragging := True; + FDragY := Y; + FDragPosition := FPosition; + SetCaptureControl(Self); + end + else if Y < R.Top then + ScrollBy(-FViewportSize + Scale96ToForm(40)) + else + ScrollBy(FViewportSize - Scale96ToForm(40)); + Invalidate; +end; + +procedure TOverlayScrollBar.MouseMove(Shift: TShiftState; X, Y: Integer); +var + R: TRect; + TrackH, Travel: Integer; +begin + inherited MouseMove(Shift, X, Y); + if not FDragging then Exit; + R := ThumbRect; + TrackH := Height - 2 * Scale96ToForm(4); + Travel := TrackH - (R.Bottom - R.Top); + if Travel <= 0 then Exit; + SetPosition(FDragPosition + MulDiv(Y - FDragY, FMaximum, Travel)); +end; + +procedure TOverlayScrollBar.MouseUp(Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); +begin + inherited MouseUp(Button, Shift, X, Y); + if Button <> mbLeft then Exit; + FDragging := False; + if GetCaptureControl = Self then SetCaptureControl(nil); + Invalidate; +end; + +end. From 1d04973d6fce73f040a8464e266d0c241ee14ec7 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 5 Aug 2026 13:49:23 +0300 Subject: [PATCH 21/30] fix(discovery): use system fonts and correct DPI scaling --- DeviceForm.pas | 41 ++++++++++++++--------------------------- 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/DeviceForm.pas b/DeviceForm.pas index e6bdcbb..b65fcd6 100644 --- a/DeviceForm.pas +++ b/DeviceForm.pas @@ -7,7 +7,7 @@ interface uses Classes, SysUtils, FlatButton, FlatEdit, FlatListBox, AppTheme, Forms, Controls, Graphics, Dialogs, StdCtrls, ExtCtrls, ComCtrls, LCLType, - BoardUtils, PlatformUtils, DeviceStore, RadioBackend, DpiUtils; + BoardUtils, DeviceStore, RadioBackend, DpiUtils; type // Результат диалога @@ -26,8 +26,6 @@ type FResult: TDeviceDialogResult; // UI - PanelTop: TPanel; - PanelBottom: TPanel; PanelLeft: TPanel; PanelRight: TPanel; @@ -40,6 +38,7 @@ type EdIP: TFlatEdit; LblName: TLabel; LblIP: TLabel; + LblAutoHint: TLabel; LblFound: TLabel; LstFound: TFlatListBox; @@ -52,7 +51,6 @@ type FOnDiscover: TNotifyEvent; // внешний callback для запуска discovery procedure BuildUI; - procedure ApplyTheme; procedure SetStore(AStore: TDeviceStore); procedure RefreshSavedList; @@ -105,7 +103,6 @@ const CLR_PANEL = TColor($001A1A1A); CLR_TEXT = TColor($00E0E0E0); CLR_TEXTDIM = TColor($00888888); - CLR_BORDER = TColor($00303030); CLR_ACCENT = TColor($0040FF80); CLR_AUTO = TColor($0000CCFF); // цвет авто-устройства DLG_W = 760; @@ -123,18 +120,20 @@ const constructor TDeviceDialog.Create(AOwner: TComponent); begin inherited CreateNew(AOwner); + // Форма целиком строится кодом и уже масштабирует геометрию через DpiScale. + // Автоскейл LCL поверх неё дал бы повторный множитель (125% -> 156.25%). + Scaled := False; Caption := 'Device Selection'; Width := DpiScale(DLG_W); Height := DpiScale(DLG_H); Position := poScreenCenter; BorderStyle := bsDialog; Color := CLR_BG; - Font.Name := 'Courier New'; Font.Size := 8; Font.Color := CLR_TEXT; FStore := nil; // назначается владельцем через property Store до показа - FResult.Accepted := False; + ClearResult; BuildUI; SetTheme(DarkTheme); @@ -163,7 +162,6 @@ begin Result.Parent := AParent; Result.Caption := Cap; Result.Left := DpiScale(X); Result.Top := DpiScale(Y); - Result.Font.Name := 'Courier New'; Result.Font.Size := 8; Result.Font.Color := CLR_TEXTDIM; end; @@ -172,7 +170,6 @@ procedure TDeviceDialog.BuildUI; const LabelH = 18; var - LblHint: TLabel; FieldTop, ButtonsTop, BottomTop: Integer; begin // --- Левая панель: сохранённые устройства --- @@ -182,36 +179,33 @@ begin PanelLeft.BevelOuter := bvNone; PanelLeft.Color := CLR_PANEL; - MakeLbl(PanelLeft, 'SAVED DEVICES', PAD, PAD); + LblSaved := MakeLbl(PanelLeft, 'SAVED DEVICES', PAD, PAD); LstSaved := TFlatListBox.Create(Self); LstSaved.Parent := PanelLeft; LstSaved.SetBounds(DpiScale(PAD), DpiScale(PAD + LabelH), DpiScale(LEFT_W - PAD * 2), DpiScale(174)); LstSaved.Color := CLR_BG; LstSaved.Font.Color:= CLR_TEXT; - LstSaved.Font.Name := 'Courier New'; LstSaved.Font.Size := 8; LstSaved.OnClick := @LstSavedClick; LstSaved.OnDblClick := @LstSavedDblClick; FieldTop := 212; - MakeLbl(PanelLeft, 'Name:', PAD, FieldTop + 5); + LblName := MakeLbl(PanelLeft, 'Name:', PAD, FieldTop + 5); EdName := TFlatEdit.Create(Self); EdName.Parent := PanelLeft; EdName.SetBounds(DpiScale(76), DpiScale(FieldTop), DpiScale(LEFT_W - 76 - PAD), DpiScale(EDIT_H)); EdName.Color := CLR_BG; EdName.Font.Color:= CLR_TEXT; - EdName.Font.Name := 'Courier New'; EdName.Font.Size := 8; Inc(FieldTop, EDIT_H + 10); - MakeLbl(PanelLeft, 'IP:', PAD, FieldTop + 5); + LblIP := MakeLbl(PanelLeft, 'IP:', PAD, FieldTop + 5); EdIP := TFlatEdit.Create(Self); EdIP.Parent := PanelLeft; EdIP.SetBounds(DpiScale(76), DpiScale(FieldTop), DpiScale(LEFT_W - 76 - PAD), DpiScale(EDIT_H)); EdIP.Color := CLR_BG; EdIP.Font.Color:= CLR_TEXT; - EdIP.Font.Name := 'Courier New'; EdIP.Font.Size := 8; EdIP.TextHint := '192.168.1.x'; @@ -221,8 +215,9 @@ begin Inc(ButtonsTop, BTN_H + 8); BtnSetAuto := MakeBtn(PanelLeft, 'SET AUTOSTART', PAD, ButtonsTop, 154, BTN_H, @BtnSetAutoClick); - LblHint := MakeLbl(PanelLeft, '* = autostart', PAD + 168, ButtonsTop + 6); - LblHint.Font.Color := CLR_AUTO; + LblAutoHint := MakeLbl(PanelLeft, '* = autostart', + PAD + 168, ButtonsTop + 6); + LblAutoHint.Font.Color := CLR_AUTO; BottomTop := PANEL_H - PAD - BTN_H - 6; BtnConnect := MakeBtn(PanelLeft, 'CONNECT', PAD, BottomTop, LEFT_W - PAD * 2, BTN_H + 6, @BtnConnectClick); @@ -236,14 +231,13 @@ begin PanelRight.BevelOuter := bvNone; PanelRight.Color := CLR_PANEL; - MakeLbl(PanelRight, 'DISCOVERED DEVICES', PAD, PAD); + LblFound := MakeLbl(PanelRight, 'DISCOVERED DEVICES', PAD, PAD); LstFound := TFlatListBox.Create(Self); LstFound.Parent := PanelRight; LstFound.SetBounds(DpiScale(PAD), DpiScale(PAD + LabelH), DpiScale(RIGHT_W - PAD * 2), DpiScale(250)); LstFound.Color := CLR_BG; LstFound.Font.Color:= CLR_TEXT; - LstFound.Font.Name := 'Courier New'; LstFound.Font.Size := 8; LstFound.OnDblClick := @LstFoundDblClick; @@ -254,11 +248,6 @@ begin BtnCancel := MakeBtn(PanelRight, 'CANCEL', RIGHT_W - PAD - 116, BottomTop, 116, BTN_H + 6, @BtnCancelClick); end; -procedure TDeviceDialog.ApplyTheme; -begin - SetTheme(DarkTheme); -end; - procedure TDeviceDialog.SetTheme(const T: TAppTheme); procedure StyleButton(B: TFlatButton; Active: Boolean); @@ -272,7 +261,6 @@ procedure TDeviceDialog.SetTheme(const T: TAppTheme); else B.ClrBorder := T.BtnBorderNorm; B.ClrText := T.BtnText; B.ClrTextAct := T.BtnTextActive; - B.Font.Name := 'Courier New'; B.Font.Size := 8; B.Font.Color := T.Text; B.Invalidate; @@ -288,7 +276,6 @@ procedure TDeviceDialog.SetTheme(const T: TAppTheme); B.ClrBorder := T.TbDiscoverBorder; B.ClrText := T.TbDiscoverText; B.ClrTextAct := T.TbDiscoverText; - B.Font.Name := 'Courier New'; B.Font.Size := 8; B.Font.Color := T.TbDiscoverText; B.Invalidate; @@ -304,7 +291,6 @@ procedure TDeviceDialog.SetTheme(const T: TAppTheme); B.ClrBorder := T.TbStartBorder; B.ClrText := T.TbStartText; B.ClrTextAct := T.TbStartText; - B.Font.Name := 'Courier New'; B.Font.Size := 8; B.Font.Color := T.TbStartText; B.Invalidate; @@ -340,6 +326,7 @@ begin if EdName <> nil then EdName.SetAppTheme(T); if EdIP <> nil then EdIP.SetAppTheme(T); WalkLabels(Self); + if LblAutoHint <> nil then LblAutoHint.Font.Color := T.Amber; Invalidate; end; From 960f221179709c8c7c27ee9c79fc56279727340a Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 5 Aug 2026 14:10:20 +0300 Subject: [PATCH 22/30] ui(settings): replace native vertical scrollbar --- SettingsForm.pas | 88 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/SettingsForm.pas b/SettingsForm.pas index 44860e1..9a6886b 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -29,7 +29,8 @@ uses StdCtrls, ExtCtrls, LCLType, FlatButton, FlatCheckBox, FlatComboBox, FlatEdit, FlatSpinEdit, FlatFloatSpinEdit, FlatRadioButton, - EqualizerControl, AudioOutput, AudioInput, AppTheme, Settings, BoardUtils, DpiUtils; + EqualizerControl, OverlayScrollBar, AudioOutput, AudioInput, AppTheme, Settings, + BoardUtils, DpiUtils; const // Подвкладки страницы Transmit: Profile / EQ / Hardware / Display. @@ -111,6 +112,9 @@ type FNavPanel: TPanel; FContentPanel: TPanel; FFooterPanel: TPanel; + FScrollHost: TPanel; + FScrollBar: TOverlayScrollBar; + FActivePage: TScrollBox; FPageAudio: TScrollBox; FPageDisplay: TScrollBox; FPageSpectrum: TScrollBox; @@ -508,6 +512,10 @@ type procedure BtnTXProfFactoryClick(Sender: TObject); procedure ApplyTheme(const T: TAppTheme); procedure SettingsResize(Sender: TObject); + procedure SettingsScrollChanged(Sender: TObject); + procedure SettingsPageMouseWheel(Sender: TObject; Shift: TShiftState; + WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean); + procedure UpdateSettingsScrollBar; procedure RelayoutResponsiveCards; procedure NavClick(Sender: TObject); procedure SelectPage(APage: TWinControl; ANav: TFlatButton); @@ -799,6 +807,7 @@ begin FRXDevCount := 0; BuildUI; OnResize := SettingsResize; + OnMouseWheel := SettingsPageMouseWheel; RelayoutResponsiveCards; ApplyTheme(DarkTheme); end; @@ -928,6 +937,7 @@ begin Result.BorderStyle := bsNone; Result.Color := CLR_BG; Result.AutoScroll := True; + Result.OnMouseWheel := SettingsPageMouseWheel; Result.Visible := False; end; @@ -1127,12 +1137,78 @@ begin FBtnClose.ClrTextAct:= CLR_ACCENT; FBtnClose.Font.Size := 9; + // Оконный контейнер перекрывает нативный scrollbar TScrollBox. Сам + // TOverlayScrollBar остаётся тонким, а системная прокрутка продолжает + // надёжно двигать всё содержимое страницы. + FScrollHost := TPanel.Create(Self); + FScrollHost.Parent := FContentPanel; + FScrollHost.BevelOuter := bvNone; + FScrollHost.Color := CLR_BG; + FScrollHost.Tag := TAG_PAGE_BG; + + FScrollBar := TOverlayScrollBar.Create(Self); + FScrollBar.Parent := FScrollHost; + FScrollBar.Align := alClient; + FScrollBar.OnPositionChange := SettingsScrollChanged; + SelectPage(FPageAudio, FNavAudio); end; procedure TSettingsForm.SettingsResize(Sender: TObject); begin RelayoutResponsiveCards; + UpdateSettingsScrollBar; +end; + +procedure TSettingsForm.SettingsScrollChanged(Sender: TObject); +begin + if (FActivePage = nil) or (FScrollBar = nil) then Exit; + FActivePage.VertScrollBar.Position := FScrollBar.Position; +end; + +procedure TSettingsForm.SettingsPageMouseWheel(Sender: TObject; + Shift: TShiftState; WheelDelta: Integer; MousePos: TPoint; + var Handled: Boolean); +var + P: TPoint; +begin + if (FActivePage = nil) or (FScrollBar = nil) then Exit; + P := FActivePage.ScreenToClient(Mouse.CursorPos); + if (P.X < 0) or (P.Y < 0) or + (P.X >= FActivePage.Width) or (P.Y >= FActivePage.Height) then Exit; + + UpdateSettingsScrollBar; + if FScrollBar.Maximum <= 0 then Exit; + + if WheelDelta > 0 then + FScrollBar.ScrollBy(-DpiScale(64)) + else if WheelDelta < 0 then + FScrollBar.ScrollBy(DpiScale(64)); + Handled := WheelDelta <> 0; +end; + +procedure TSettingsForm.UpdateSettingsScrollBar; +var + GutterW, ViewH, MaxPos: Integer; +begin + if (FActivePage = nil) or (FScrollHost = nil) or + (FScrollBar = nil) then Exit; + + FActivePage.UpdateScrollbars; + ViewH := FActivePage.ClientHeight; + MaxPos := Max(0, FActivePage.VertScrollBar.Range + - FActivePage.VertScrollBar.Page); + + FScrollBar.HoverTarget := FActivePage; + FScrollBar.SetRange(MaxPos, ViewH); + FScrollBar.Position := FActivePage.VertScrollBar.Position; + + // На Windows gutter должен полностью закрывать нативную полосу. Бегунок + // внутри него всё равно рисуется шириной 4 design px. + GutterW := Max(DpiScale(8), FActivePage.VertScrollBar.Size); + FScrollHost.SetBounds(FContentPanel.ClientWidth - GutterW, 0, GutterW, + Max(0, FContentPanel.ClientHeight - FFooterPanel.Height)); + FScrollHost.BringToFront; end; procedure TSettingsForm.RelayoutResponsiveCards; @@ -1294,6 +1370,9 @@ end; procedure TSettingsForm.SelectPage(APage: TWinControl; ANav: TFlatButton); begin + if APage is TScrollBox then + FActivePage := TScrollBox(APage); + FPageAudio.Visible := APage = FPageAudio; FPageDisplay.Visible := APage = FPageDisplay; FPageSpectrum.Visible := APage = FPageSpectrum; @@ -1327,6 +1406,7 @@ begin // Скрытые alClient-страницы LCL не всегда перекладывает до показа. // После переключения берём уже актуальный ClientWidth выбранной страницы. RelayoutResponsiveCards; + UpdateSettingsScrollBar; end; // --------------------------------------------------------------------------- @@ -2767,6 +2847,7 @@ begin GTop := GTop + HDisp + 14; FCalGrpFreq.SetBounds(DpiScale(CAL_MARGIN), DpiScale(GTop), DpiScale(CAL_GRP_W), DpiScale(CAL_GRP2_H)); UpdatePageBottomSpace(FPageCalib); + if FActivePage = FPageCalib then UpdateSettingsScrollBar; end; function TSettingsForm.LayoutCalOffsets(const BandLbl: array of TLabel; @@ -3191,7 +3272,12 @@ procedure TSettingsForm.ApplyTheme(const T: TAppTheme); begin Color := T.BG; Font.Color := T.Text; + if FScrollHost <> nil then FScrollHost.Color := T.BG; + if FScrollBar <> nil then + FScrollBar.SetColors(T.BG, T.Border, + T.SliderThumbHot, T.SliderThumbDrag); WalkControls(Self); + if FScrollHost <> nil then FScrollHost.BringToFront; Invalidate; end; From 5120d8d21d6d81f525c9bb134610bf7cef2eeb7d Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 5 Aug 2026 14:35:39 +0300 Subject: [PATCH 23/30] ui(beacon): redesign decoder diagnostics form --- BeaconScopeForm.pas | 381 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 309 insertions(+), 72 deletions(-) diff --git a/BeaconScopeForm.pas b/BeaconScopeForm.pas index a5e3bf1..27e2fcd 100644 --- a/BeaconScopeForm.pas +++ b/BeaconScopeForm.pas @@ -17,19 +17,26 @@ interface uses Classes, SysUtils, Math, StrUtils, Forms, Controls, Graphics, ExtCtrls, StdCtrls, - AppTheme, BeaconDecoder, BeaconFEC, RadioController; + AppTheme, BeaconDecoder, BeaconFEC, RadioController, DpiUtils; type TBeaconScopeForm = class(TForm) private FCtl: TRadioController; + FHeader: TPanel; + FTitle: TLabel; + FSubtitle: TLabel; FBox: TPaintBox; + FBulletin: TPanel; + FBulletinTitle: TLabel; + FBulletinHint: TLabel; FMemo: TMemo; // декодированный текст бюллетеня (AO-40 кадры) FTimer: TTimer; FTheme: TAppTheme; FLastFrames: Int64; // счётчик кадров на прошлом тике (детект нового) procedure DoTick(Sender: TObject); procedure DoPaint(Sender: TObject); + procedure FormResize(Sender: TObject); procedure PollFrame; function FrameToText(const Frame: TBeaconFrame): string; public @@ -40,26 +47,105 @@ type implementation constructor TBeaconScopeForm.CreateWith(AOwner: TComponent; ACtl: TRadioController); +var + WorkArea: TRect; + MaxW, MaxH: Integer; begin inherited CreateNew(AOwner); FCtl := ACtl; FTheme := DarkTheme; - Caption := 'QO-100 Beacon — Constellation + Bulletin'; + // Геометрия формы задаётся ниже уже в DPI-aware координатах. + Scaled := False; + Caption := 'QO-100 Beacon'; BorderStyle := bsSizeable; - Width := 600; - Height := 680; - Position := poScreenCenter; + if AOwner is TCustomForm then + begin + WorkArea := TCustomForm(AOwner).BoundsRect; + WorkArea := Screen.MonitorFromRect(WorkArea).WorkareaRect; + Position := poOwnerFormCenter; + end + else + begin + WorkArea := Screen.PrimaryMonitor.WorkareaRect; + Position := poScreenCenter; + end; + MaxW := WorkArea.Right - WorkArea.Left - DpiScale(48); + MaxH := WorkArea.Bottom - WorkArea.Top - DpiScale(48); + Width := Min(DpiScale(760), MaxW); + Height := Min(DpiScale(700), MaxH); + Constraints.MinWidth := Min(DpiScale(520), Width); + Constraints.MinHeight := Min(DpiScale(560), Height); + Color := FTheme.BG; - // Текст бюллетеня (декодированные AO-40 кадры) — снизу, моноширинный, ReadOnly. + FHeader := TPanel.Create(Self); + FHeader.Parent := Self; + FHeader.Align := alTop; + FHeader.Height := DpiScale(72); + FHeader.BevelOuter := bvNone; + FHeader.Color := FTheme.Panel; + + FTitle := TLabel.Create(Self); + FTitle.Parent := FHeader; + FTitle.Caption := 'QO-100 BEACON DECODER'; + FTitle.AutoSize := False; + FTitle.SetBounds(DpiScale(20), DpiScale(12), + DpiScale(520), DpiScale(26)); + FTitle.Font.Size := 15; + FTitle.Font.Style := [fsBold]; + FTitle.Font.Color := FTheme.Text; + + FSubtitle := TLabel.Create(Self); + FSubtitle.Parent := FHeader; + FSubtitle.Caption := + 'BPSK constellation, carrier recovery and AO-40 telemetry'; + FSubtitle.AutoSize := False; + FSubtitle.SetBounds(DpiScale(20), DpiScale(40), + DpiScale(620), DpiScale(20)); + FSubtitle.Font.Size := 9; + FSubtitle.Font.Color := FTheme.TextDim; + + FBulletin := TPanel.Create(Self); + FBulletin.Parent := Self; + FBulletin.Align := alBottom; + FBulletin.Height := DpiScale(236); + FBulletin.BevelOuter := bvNone; + FBulletin.Color := FTheme.Panel; + + FBulletinTitle := TLabel.Create(Self); + FBulletinTitle.Parent := FBulletin; + FBulletinTitle.Caption := 'DECODED BULLETIN'; + FBulletinTitle.AutoSize := False; + FBulletinTitle.SetBounds(DpiScale(16), DpiScale(9), + DpiScale(220), DpiScale(20)); + FBulletinTitle.Font.Size := 9; + FBulletinTitle.Font.Style := [fsBold]; + FBulletinTitle.Font.Color := FTheme.MeterOn; + + FBulletinHint := TLabel.Create(Self); + FBulletinHint.Parent := FBulletin; + FBulletinHint.Caption := 'latest AO-40 frames · read-only'; + FBulletinHint.AutoSize := False; + FBulletinHint.SetBounds(DpiScale(250), DpiScale(9), + DpiScale(300), DpiScale(20)); + FBulletinHint.Anchors := [akTop, akRight]; + FBulletinHint.Left := FBulletin.ClientWidth - DpiScale(316); + FBulletinHint.Alignment := taRightJustify; + FBulletinHint.Font.Size := 8; + FBulletinHint.Font.Color := FTheme.TextDim; + + // Бюллетень остаётся обычным read-only memo: текст можно выделить и скопировать. FMemo := TMemo.Create(Self); - FMemo.Parent := Self; - FMemo.Align := alBottom; - FMemo.Height := 230; + FMemo.Parent := FBulletin; + FMemo.Align := alClient; + FMemo.BorderSpacing.Left := DpiScale(12); + FMemo.BorderSpacing.Top := DpiScale(36); + FMemo.BorderSpacing.Right := DpiScale(12); + FMemo.BorderSpacing.Bottom := DpiScale(12); + FMemo.BorderStyle := bsNone; FMemo.ReadOnly := True; FMemo.ScrollBars := ssVertical; FMemo.WordWrap := False; - FMemo.Font.Name := 'Courier New'; FMemo.Font.Size := 9; FMemo.Color := FTheme.BG; FMemo.Font.Color := FTheme.Text; @@ -68,6 +154,8 @@ begin FBox.Parent := Self; FBox.Align := alClient; FBox.OnPaint := @DoPaint; + OnResize := @FormResize; + FormResize(nil); FTimer := TTimer.Create(Self); FTimer.Interval := 40; // ~25 Гц @@ -78,10 +166,42 @@ end; procedure TBeaconScopeForm.ApplyTheme(const T: TAppTheme); begin FTheme := T; + Color := T.BG; + if FHeader <> nil then FHeader.Color := T.Panel; + if FTitle <> nil then FTitle.Font.Color := T.Text; + if FSubtitle <> nil then FSubtitle.Font.Color := T.TextDim; + if FBulletin <> nil then FBulletin.Color := T.Panel; + if FBulletinTitle <> nil then FBulletinTitle.Font.Color := T.MeterOn; + if FBulletinHint <> nil then FBulletinHint.Font.Color := T.TextDim; if FMemo <> nil then begin - FMemo.Color := FTheme.BG; - FMemo.Font.Color := FTheme.Text; + FMemo.Color := T.BG; + FMemo.Font.Color := T.Text; + end; + if FBox <> nil then FBox.Invalidate; +end; + +procedure TBeaconScopeForm.FormResize(Sender: TObject); +var + BulletinH, MaxBulletinH: Integer; +begin + if (FHeader <> nil) and (FBulletin <> nil) then + begin + MaxBulletinH := ClientHeight - FHeader.Height - DpiScale(270); + BulletinH := Min(DpiScale(236), + Max(DpiScale(150), MaxBulletinH)); + if FBulletin.Height <> BulletinH then + FBulletin.Height := BulletinH; + end; + if FTitle <> nil then + FTitle.Width := Max(0, FHeader.ClientWidth - DpiScale(40)); + if FSubtitle <> nil then + FSubtitle.Width := Max(0, FHeader.ClientWidth - DpiScale(40)); + if FBulletinHint <> nil then + begin + FBulletinHint.Left := DpiScale(250); + FBulletinHint.Width := Max(0, + FBulletin.ClientWidth - DpiScale(266)); end; if FBox <> nil then FBox.Invalidate; end; @@ -134,46 +254,163 @@ end; procedure TBeaconScopeForm.DoPaint(Sender: TObject); var C: TCanvas; - W, H, cx, cy, r, i, px, py: Integer; + W, H, M, Gap, CardGap, GraphSize, cx, cy, r, i, px, py: Integer; + GraphR, MetricsR, PlotR: TRect; S: TBeaconScope; - ok: Boolean; - C1, C2: TColor; - st: string; + ok, Wide: Boolean; + CarrierText, SymbolText, SNRText, BaudText: string; + OffsetText, ResidText, FramesText, FECText: string; + TechText: string; + CarrierColor, SymbolColor: TColor; + + procedure SetUIFont(ASize: Integer; AStyle: TFontStyles = []); + begin + C.Font.Assign(Self.Font); + C.Font.Size := ASize; + C.Font.Style := AStyle; + end; + + procedure DrawCard(const R: TRect; const ATitle: string); + begin + C.Brush.Style := bsSolid; + C.Brush.Color := FTheme.Panel; + C.Pen.Style := psSolid; + C.Pen.Width := 1; + C.Pen.Color := FTheme.Border; + C.RoundRect(R.Left, R.Top, R.Right, R.Bottom, + DpiScale(8), DpiScale(8)); + SetUIFont(8, [fsBold]); + C.Font.Color := FTheme.TextDim; + C.Brush.Style := bsClear; + C.TextOut(R.Left + DpiScale(14), R.Top + DpiScale(10), ATitle); + end; + + procedure DrawMetricTile(const R: TRect; const ALabel, AValue: string; + AValueColor: TColor); + begin + C.Brush.Style := bsSolid; + C.Brush.Color := FTheme.BG; + C.Pen.Style := psSolid; + C.Pen.Color := FTheme.Border; + C.RoundRect(R.Left, R.Top, R.Right, R.Bottom, + DpiScale(5), DpiScale(5)); + + C.Brush.Style := bsClear; + SetUIFont(8); + C.Font.Color := FTheme.TextDim; + C.TextOut(R.Left + DpiScale(9), R.Top + DpiScale(6), ALabel); + SetUIFont(9, [fsBold]); + C.Font.Color := AValueColor; + C.TextOut(R.Left + DpiScale(9), R.Top + DpiScale(21), AValue); + end; + + procedure DrawMetrics; + const + LABELS: array[0..7] of string = + ('CARRIER', 'SYMBOL', 'SNR', 'SYMBOL RATE', + 'TUNE OFFSET', 'RESIDUAL', 'FRAMES', 'FEC'); + var + Values: array[0..7] of string; + Colors: array[0..7] of TColor; + Cols, Rows, Col, Row, TileW, TileH, X, Y, n: Integer; + R: TRect; + begin + Values[0] := CarrierText; Values[1] := SymbolText; + Values[2] := SNRText; Values[3] := BaudText; + Values[4] := OffsetText; Values[5] := ResidText; + Values[6] := FramesText; Values[7] := FECText; + for n := 0 to 7 do Colors[n] := FTheme.Text; + Colors[0] := CarrierColor; + Colors[1] := SymbolColor; + if ok and S.HasFrame then Colors[6] := FTheme.MeterOn; + if ok and S.HasFrame then Colors[7] := FTheme.MeterOn; + + if Wide then begin Cols := 2; Rows := 4; end + else begin Cols := 4; Rows := 2; end; + CardGap := DpiScale(8); + TileW := (MetricsR.Right - MetricsR.Left - DpiScale(28) + - (Cols - 1) * CardGap) div Cols; + TileH := (MetricsR.Bottom - MetricsR.Top - DpiScale(70) + - (Rows - 1) * CardGap) div Rows; + for n := 0 to 7 do + begin + Col := n mod Cols; + Row := n div Cols; + X := MetricsR.Left + DpiScale(14) + Col * (TileW + CardGap); + Y := MetricsR.Top + DpiScale(34) + Row * (TileH + CardGap); + R := Rect(X, Y, X + TileW, Y + TileH); + DrawMetricTile(R, LABELS[n], Values[n], Colors[n]); + end; + SetUIFont(7); + C.Font.Color := FTheme.TextDim; + C.Brush.Style := bsClear; + C.TextOut(MetricsR.Left + DpiScale(14), + MetricsR.Bottom - DpiScale(20), TechText); + end; + begin C := FBox.Canvas; W := FBox.Width; H := FBox.Height; - // фон C.Brush.Style := bsSolid; C.Brush.Color := FTheme.BG; C.FillRect(0, 0, W, H); + if (W <= 0) or (H <= 0) then Exit; - // область констелляции — квадрат сверху, текст снизу - r := (Min(W, H - 70) - 20) div 2; - if r < 20 then r := 20; - if r > 110 then r := 110; // кап: оставляем место метрикам над memo - cx := W div 2; - cy := 10 + r; + M := DpiScale(16); + Gap := DpiScale(14); + Wide := W >= DpiScale(600); + if Wide then + begin + GraphSize := Min(H - 2 * M, (W - 3 * M) div 2); + GraphR := Rect(M, M, M + GraphSize, H - M); + MetricsR := Rect(GraphR.Right + Gap, M, W - M, H - M); + end + else + begin + GraphSize := Max(DpiScale(74), H - DpiScale(154) - 3 * M); + GraphR := Rect(M, M, W - M, M + GraphSize); + MetricsR := Rect(M, GraphR.Bottom + Gap, W - M, H - M); + end; + DrawCard(GraphR, 'CONSTELLATION'); + DrawCard(MetricsR, 'DECODER STATUS'); - // сетка/оси + // Квадрат констелляции центрируется внутри своей карточки. + GraphSize := Min(GraphR.Right - GraphR.Left - DpiScale(28), + GraphR.Bottom - GraphR.Top - DpiScale(48)); + GraphSize := Max(DpiScale(40), GraphSize); + PlotR.Left := GraphR.Left + + (GraphR.Right - GraphR.Left - GraphSize) div 2; + PlotR.Top := GraphR.Top + DpiScale(34) + + (GraphR.Bottom - GraphR.Top - DpiScale(40) - GraphSize) div 2; + PlotR.Right := PlotR.Left + GraphSize; + PlotR.Bottom := PlotR.Top + GraphSize; + cx := (PlotR.Left + PlotR.Right) div 2; + cy := (PlotR.Top + PlotR.Bottom) div 2; + r := GraphSize div 2 - DpiScale(8); + + C.Brush.Style := bsSolid; + C.Brush.Color := FTheme.BG; + C.Pen.Style := psSolid; + C.Pen.Color := FTheme.Border; + C.Rectangle(PlotR.Left, PlotR.Top, PlotR.Right, PlotR.Bottom); C.Pen.Style := psSolid; C.Pen.Color := FTheme.SpecGrid; - C.Line(cx - r, cy, cx + r, cy); // ось I (горизонт) - C.Line(cx, cy - r, cx, cy + r); // ось Q (вертикаль) - C.Pen.Color := FTheme.Border; + C.Line(cx - r, cy, cx + r, cy); + C.Line(cx, cy - r, cx, cy + r); C.Brush.Style := bsClear; - C.Rectangle(cx - r, cy - r, cx + r, cy + r); + C.Ellipse(cx - r, cy - r, cx + r, cy + r); + C.Ellipse(cx - r div 2, cy - r div 2, cx + r div 2, cy + r div 2); - // целевые точки BPSK (±1, 0) — ориентир C.Pen.Color := FTheme.TextDim; - C.Ellipse(cx + r div 2 - 3, cy - 3, cx + r div 2 + 3, cy + 3); - C.Ellipse(cx - r div 2 - 3, cy - 3, cx - r div 2 + 3, cy + 3); + C.Ellipse(cx + r div 2 - DpiScale(3), cy - DpiScale(3), + cx + r div 2 + DpiScale(3), cy + DpiScale(3)); + C.Ellipse(cx - r div 2 - DpiScale(3), cy - DpiScale(3), + cx - r div 2 + DpiScale(3), cy + DpiScale(3)); ok := (FCtl <> nil) and FCtl.GetBeaconScope(S); - if ok and S.Enabled then begin - // точки констелляции (нормированы к ±1; масштаб r/2 → ±1 на половине радиуса) C.Pen.Style := psClear; C.Brush.Style := bsSolid; C.Brush.Color := FTheme.MeterOn; @@ -182,53 +419,53 @@ begin px := cx + Round(S.PtI[i] * (r / 2)); py := cy - Round(S.PtQ[i] * (r / 2)); if (px >= cx - r) and (px <= cx + r) and (py >= cy - r) and (py <= cy + r) then - C.FillRect(px - 1, py - 1, px + 2, py + 2); + C.FillRect(px - DpiScale(1), py - DpiScale(1), + px + DpiScale(2), py + DpiScale(2)); end; end; - // ----- метрики ----- - C.Brush.Style := bsClear; - C.Font.Name := 'Courier New'; - C.Font.Size := 9; - py := cy + r + 12; - if not ok then begin - C.Font.Color := FTheme.TextDim; - C.TextOut(14, py, 'decoder unavailable'); - Exit; + CarrierText := 'UNAVAILABLE'; + SymbolText := 'UNAVAILABLE'; + SNRText := '--'; BaudText := '--'; + OffsetText := '--'; ResidText := '--'; + FramesText := '--'; FECText := '--'; + TechText := 'Decoder data unavailable'; + CarrierColor := FTheme.TextDim; + SymbolColor := FTheme.TextDim; end; - if not S.Enabled then + if ok and (not S.Enabled) then begin - C.Font.Color := FTheme.TextDim; - C.TextOut(14, py, 'decoder OFF (enable via BEACON \ decode)'); - Exit; + CarrierText := 'OFF'; SymbolText := 'OFF'; + SNRText := '--'; BaudText := '--'; + OffsetText := '--'; ResidText := '--'; + FramesText := IntToStr(S.Frames); + FECText := '--'; + TechText := 'Enable BEACON and select the beacon on the spectrum'; + CarrierColor := FTheme.TextDim; + SymbolColor := FTheme.TextDim; end; - - if S.CarrierLock then C1 := FTheme.MeterOn else C1 := FTheme.TextDim; - if S.SymbolLock then C2 := FTheme.MeterOn else C2 := FTheme.TextDim; - - C.Font.Color := C1; - C.TextOut(14, py, 'CARRIER ' + IfThen(S.CarrierLock, 'LOCK', '----')); - C.Font.Color := C2; - C.TextOut(14, py + 18, 'SYMBOL ' + IfThen(S.SymbolLock, 'LOCK', '----')); - - C.Font.Color := FTheme.Text; - if S.SNRdB > -50 then st := Format('%.1f dB', [S.SNRdB]) else st := '--'; - C.TextOut(14, py + 36, 'SNR ' + st); - C.TextOut(14, py + 54, Format('BAUD %.1f (off %.0f Hz)', [S.SymRate, S.OffsetHz])); - C.Font.Color := FTheme.TextDim; - C.TextOut(14, py + 72, Format('CARRIER resid %.0f Hz prom %.0f', [S.ResidHz, S.CarProm])); - if FCtl <> nil then - C.TextOut(14, py + 90, 'INVERT ' + IfThen(FCtl.BeaconDecodeInvert, 'ON', 'OFF')); - C.TextOut(14, py + 108, Format('RATE Fs %.0f Fdec %.0f sps %.1f', - [S.FsHz, S.FdecHz, S.Sps])); - - // ----- FEC (AO-40) ----- - if S.HasFrame then C.Font.Color := FTheme.MeterOn else C.Font.Color := FTheme.TextDim; - C.TextOut(14, py + 132, Format('FRAMES %d (RS err %d)', [S.Frames, S.LastRSErr])); - C.Font.Color := FTheme.TextDim; - C.TextOut(14, py + 150, Format('MANCHESTER phase %d', [S.ManPhase])); + if ok and S.Enabled then + begin + CarrierText := IfThen(S.CarrierLock, 'LOCKED', 'SEARCH'); + SymbolText := IfThen(S.SymbolLock, 'LOCKED', 'SEARCH'); + if S.SNRdB > -50 then SNRText := Format('%.1f dB', [S.SNRdB]) + else SNRText := '--'; + BaudText := Format('%.1f Bd', [S.SymRate]); + OffsetText := Format('%.0f Hz', [S.OffsetHz]); + ResidText := Format('%.0f Hz', [S.ResidHz]); + FramesText := IntToStr(S.Frames); + FECText := Format('RS %d · P%d', [S.LastRSErr, S.ManPhase]); + TechText := Format('INV %s · PROM %.0f · Fs %.0f · Fd %.0f · SPS %.1f', + [IfThen(FCtl.BeaconDecodeInvert, 'ON', 'OFF'), S.CarProm, + S.FsHz, S.FdecHz, S.Sps]); + if S.CarrierLock then CarrierColor := FTheme.MeterOn + else CarrierColor := FTheme.TextDim; + if S.SymbolLock then SymbolColor := FTheme.MeterOn + else SymbolColor := FTheme.TextDim; + end; + DrawMetrics; end; end. From 0396eeed49b9cd525a9f2f63227e4ef48a45f2ab Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 5 Aug 2026 14:50:15 +0300 Subject: [PATCH 24/30] ui(channels): redesign editor and fix DPI scaling --- ChannelsForm.pas | 339 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 247 insertions(+), 92 deletions(-) diff --git a/ChannelsForm.pas b/ChannelsForm.pas index 5d84547..7368c5c 100644 --- a/ChannelsForm.pas +++ b/ChannelsForm.pas @@ -14,7 +14,7 @@ uses Forms, Controls, Graphics, StdCtrls, ExtCtrls, FlatButton, FlatCheckBox, FlatComboBox, FlatEdit, FlatSpinEdit, FlatFloatSpinEdit, FlatListBox, - AppTheme, ChannelStore, RadioModes; + AppTheme, ChannelStore, RadioModes, DpiUtils; const // Копии из MainForm: нужны для выпадающих списков @@ -39,10 +39,19 @@ type FBtnDelete: TFlatButton; FBtnUp: TFlatButton; FBtnDown: TFlatButton; + FBtnClose: TFlatButton; FList: TFlatListBox; // --- Правая панель (поля редактирования) --- FPanelRight: TPanel; + FFooterPanel: TPanel; + FCardIdentity: TPanel; + FCardRadio: TPanel; + FCardRepeater: TPanel; + FLeftTitle: TLabel; + FLeftHint: TLabel; + FRightTitle: TLabel; + FRightHint: TLabel; // Labels (для перекраски темой) FLbls: array of TLabel; @@ -66,11 +75,13 @@ type FUpdating: Boolean; FOnChanged: TNotifyEvent; FCurrentDefaults: TChannel; // значения по умолчанию для новых каналов + FTheme: TAppTheme; procedure BuildUI; procedure StyleBtn(B: TFlatButton; AActive: Boolean = False); function MakeLabel(AParent: TWinControl; const Cap: string; X, Y, W, H: Integer): TLabel; + function MakeGroupPanel(const Cap: string; Y, H: Integer): TPanel; procedure LoadChannelToFields(Idx: Integer); procedure SaveFieldsToChannel; procedure RefreshList(KeepIdx: Integer = -1); @@ -90,6 +101,7 @@ type procedure OnCTCSSChkChange(Sender: TObject); procedure OnCTCSSCmbChange(Sender: TObject); // Handlers — форма + procedure OnCloseClick(Sender: TObject); procedure OnFormClose(Sender: TObject; var CloseAction: TCloseAction); public constructor Create(AOwner: TComponent; AStore: TChannelStore; @@ -103,31 +115,54 @@ implementation const // Геометрия - LPANEL_W = 185; // ширина левой панели - TOOLBAR_H = 32; // высота тулбара с кнопками Add/Del/Up/Down - BTN_H = 24; + LPANEL_W = 240; + TOOLBAR_Y = 66; + TOOLBAR_H = 30; + BTN_H = 26; + FORM_W = 820; + FORM_H = 540; + PAGE_MARGIN = 18; // Правая панель - LBL_X = 8; - LBL_W = 95; - CTRL_X = 106; - ROW_H = 30; // высота строки - CTRL_H = 24; // высота контрола + LBL_X = 14; + LBL_W = 106; + CTRL_X = 126; + ROW_H = 28; + CTRL_H = 24; constructor TChannelsForm.Create(AOwner: TComponent; AStore: TChannelStore; AOnChanged: TNotifyEvent); +var + WorkArea: TRect; + MaxW, MaxH: Integer; begin inherited CreateNew(AOwner); FStore := AStore; FOnChanged := AOnChanged; FUpdating := False; + FTheme := DarkTheme; TChannelStore.DefaultChannel(FCurrentDefaults); + Scaled := False; Caption := 'Channels'; BorderStyle := bsSizeable; - Width := 720; - Height := 480; - Position := poScreenCenter; + if AOwner is TCustomForm then + begin + WorkArea := TCustomForm(AOwner).BoundsRect; + WorkArea := Screen.MonitorFromRect(WorkArea).WorkareaRect; + Position := poOwnerFormCenter; + end + else + begin + WorkArea := Screen.PrimaryMonitor.WorkareaRect; + Position := poScreenCenter; + end; + MaxW := WorkArea.Right - WorkArea.Left - DpiScale(48); + MaxH := WorkArea.Bottom - WorkArea.Top - DpiScale(48); + Width := Min(DpiScale(FORM_W), MaxW); + Height := Min(DpiScale(FORM_H), MaxH); + Constraints.MinWidth := Min(DpiScale(680), Width); + Constraints.MinHeight := Min(DpiScale(530), Height); KeyPreview := True; OnClose := @OnFormClose; @@ -138,46 +173,72 @@ end; procedure TChannelsForm.BuildUI; var - i: Integer; - BtnW: Integer; - RW: Integer; // ширина правой панели - Y: Integer; + i, BtnW, CardW, Y: Integer; + L: TLabel; + + procedure PlaceRightUnit(AParent: TWinControl; const Cap: string; + ATop: Integer); + begin + L := MakeLabel(AParent, Cap, 0, ATop + 1, 42, CTRL_H); + L.SetBounds(AParent.ClientWidth - DpiScale(50), DpiScale(ATop + 1), + DpiScale(42), DpiScale(CTRL_H)); + L.Anchors := [akTop, akRight]; + end; + begin + SetLength(FLbls, 0); + // ----- Левая панель ----- FPanelLeft := TPanel.Create(Self); FPanelLeft.Parent := Self; FPanelLeft.Align := alLeft; - FPanelLeft.Width := LPANEL_W; + FPanelLeft.Width := DpiScale(LPANEL_W); FPanelLeft.BevelOuter := bvNone; - BtnW := (LPANEL_W - 4 - 3 * 2) div 4; // 4 кнопки с зазорами + FLeftTitle := MakeLabel(FPanelLeft, 'CHANNEL MEMORY', + 14, 12, LPANEL_W - 28, 24); + FLeftTitle.Font.Size := 9; + FLeftTitle.Font.Style := [fsBold]; + FLeftTitle.Font.Color := FTheme.Text; + + FLeftHint := MakeLabel(FPanelLeft, 'Saved frequencies and modes', + 14, 37, LPANEL_W - 28, 18); + FLeftHint.Font.Size := 8; + + BtnW := (DpiScale(LPANEL_W - 24) - 3 * DpiScale(6)) div 4; FBtnAdd := TFlatButton.Create(Self); FBtnAdd.Parent := FPanelLeft; - FBtnAdd.SetBounds(2, 4, BtnW, BTN_H); + FBtnAdd.SetBounds(DpiScale(12), DpiScale(TOOLBAR_Y), + BtnW, DpiScale(BTN_H)); FBtnAdd.Caption := 'ADD'; FBtnAdd.OnClick := @OnAddClick; FBtnDelete := TFlatButton.Create(Self); FBtnDelete.Parent := FPanelLeft; - FBtnDelete.SetBounds(2 + (BtnW + 2), 4, BtnW, BTN_H); + FBtnDelete.SetBounds(FBtnAdd.Left + BtnW + DpiScale(6), + FBtnAdd.Top, BtnW, FBtnAdd.Height); FBtnDelete.Caption := 'DEL'; FBtnDelete.OnClick := @OnDeleteClick; FBtnUp := TFlatButton.Create(Self); FBtnUp.Parent := FPanelLeft; - FBtnUp.SetBounds(2 + 2 * (BtnW + 2), 4, BtnW, BTN_H); + FBtnUp.SetBounds(FBtnDelete.Left + BtnW + DpiScale(6), + FBtnAdd.Top, BtnW, FBtnAdd.Height); FBtnUp.Caption := '↑'; FBtnUp.OnClick := @OnUpClick; FBtnDown := TFlatButton.Create(Self); FBtnDown.Parent := FPanelLeft; - FBtnDown.SetBounds(2 + 3 * (BtnW + 2), 4, BtnW, BTN_H); + FBtnDown.SetBounds(FBtnUp.Left + BtnW + DpiScale(6), + FBtnAdd.Top, BtnW, FBtnAdd.Height); FBtnDown.Caption := '↓'; FBtnDown.OnClick := @OnDownClick; FList := TFlatListBox.Create(Self); FList.Parent := FPanelLeft; - FList.SetBounds(0, TOOLBAR_H, LPANEL_W, FPanelLeft.Height - TOOLBAR_H); + FList.SetBounds(DpiScale(12), DpiScale(TOOLBAR_Y + TOOLBAR_H + 10), + FPanelLeft.ClientWidth - DpiScale(24), + FPanelLeft.ClientHeight - DpiScale(TOOLBAR_Y + TOOLBAR_H + 22)); FList.Anchors := [akLeft, akTop, akRight, akBottom]; FList.OnClick := @OnListClick; FList.OnDblClick := @OnListDblClick; @@ -188,34 +249,62 @@ begin FPanelRight.Align := alClient; FPanelRight.BevelOuter := bvNone; - SetLength(FLbls, 0); - RW := Width - LPANEL_W; + FFooterPanel := TPanel.Create(Self); + FFooterPanel.Parent := FPanelRight; + FFooterPanel.Align := alBottom; + FFooterPanel.Height := DpiScale(50); + FFooterPanel.BevelOuter := bvNone; - Y := 8; + FBtnClose := TFlatButton.Create(Self); + FBtnClose.Parent := FFooterPanel; + FBtnClose.Caption := 'Close'; + FBtnClose.SetBounds(FFooterPanel.ClientWidth - DpiScale(110), + DpiScale(10), DpiScale(92), DpiScale(30)); + FBtnClose.Anchors := [akTop, akRight]; + FBtnClose.OnClick := @OnCloseClick; - // Group - MakeLabel(FPanelRight, 'Group', LBL_X, Y + 1, LBL_W, CTRL_H); + FRightTitle := MakeLabel(FPanelRight, 'CHANNEL DETAILS', + PAGE_MARGIN, 10, 420, 22); + FRightTitle.Font.Size := 9; + FRightTitle.Font.Style := [fsBold]; + FRightTitle.Font.Color := FTheme.Text; + FRightTitle.Anchors := [akLeft, akTop, akRight]; + + FRightHint := MakeLabel(FPanelRight, + 'Changes are saved automatically', PAGE_MARGIN, 31, 420, 18); + FRightHint.Font.Size := 8; + FRightHint.Anchors := [akLeft, akTop, akRight]; + + FCardIdentity := MakeGroupPanel('IDENTITY', 56, 90); + CardW := FCardIdentity.ClientWidth; + Y := 34; + + MakeLabel(FCardIdentity, 'Group', LBL_X, Y + 1, LBL_W, CTRL_H); FEdGroup := TFlatEdit.Create(Self); - FEdGroup.Parent := FPanelRight; - FEdGroup.SetBounds(CTRL_X, Y, RW - CTRL_X - 8, CTRL_H); + FEdGroup.Parent := FCardIdentity; + FEdGroup.SetBounds(DpiScale(CTRL_X), DpiScale(Y), + CardW - DpiScale(CTRL_X + 14), DpiScale(CTRL_H)); FEdGroup.Anchors := [akLeft, akTop, akRight]; FEdGroup.OnChange := @OnFieldChange; Inc(Y, ROW_H); - // Name - MakeLabel(FPanelRight, 'Name', LBL_X, Y + 1, LBL_W, CTRL_H); + MakeLabel(FCardIdentity, 'Name', LBL_X, Y + 1, LBL_W, CTRL_H); FEdName := TFlatEdit.Create(Self); - FEdName.Parent := FPanelRight; - FEdName.SetBounds(CTRL_X, Y, RW - CTRL_X - 8, CTRL_H); + FEdName.Parent := FCardIdentity; + FEdName.SetBounds(DpiScale(CTRL_X), DpiScale(Y), + CardW - DpiScale(CTRL_X + 14), DpiScale(CTRL_H)); FEdName.Anchors := [akLeft, akTop, akRight]; FEdName.OnChange := @OnFieldChange; - Inc(Y, ROW_H); - // RX Freq - MakeLabel(FPanelRight, 'RX Freq', LBL_X, Y + 1, LBL_W, CTRL_H); + FCardRadio := MakeGroupPanel('RADIO', 156, 120); + CardW := FCardRadio.ClientWidth; + Y := 34; + + MakeLabel(FCardRadio, 'RX frequency', LBL_X, Y + 1, LBL_W, CTRL_H); FSpinRXFreq := TFlatFloatSpinEdit.Create(Self); - FSpinRXFreq.Parent := FPanelRight; - FSpinRXFreq.SetBounds(CTRL_X, Y, RW - CTRL_X - 50, CTRL_H); + FSpinRXFreq.Parent := FCardRadio; + FSpinRXFreq.SetBounds(DpiScale(CTRL_X), DpiScale(Y), + CardW - DpiScale(CTRL_X + 56), DpiScale(CTRL_H)); FSpinRXFreq.Anchors := [akLeft, akTop, akRight]; FSpinRXFreq.MinValue := 0.001; FSpinRXFreq.MaxValue := 2000.0; @@ -223,14 +312,14 @@ begin FSpinRXFreq.DecimalPlaces := 6; FSpinRXFreq.Value := 145.5; FSpinRXFreq.OnChange := @OnFieldChange; - MakeLabel(FPanelRight, 'MHz', RW - 46, Y + 1, 40, CTRL_H); + PlaceRightUnit(FCardRadio, 'MHz', Y); Inc(Y, ROW_H); - // TX Freq - MakeLabel(FPanelRight, 'TX Freq', LBL_X, Y + 1, LBL_W, CTRL_H); + MakeLabel(FCardRadio, 'TX frequency', LBL_X, Y + 1, LBL_W, CTRL_H); FSpinTXFreq := TFlatFloatSpinEdit.Create(Self); - FSpinTXFreq.Parent := FPanelRight; - FSpinTXFreq.SetBounds(CTRL_X, Y, RW - CTRL_X - 50, CTRL_H); + FSpinTXFreq.Parent := FCardRadio; + FSpinTXFreq.SetBounds(DpiScale(CTRL_X), DpiScale(Y), + CardW - DpiScale(CTRL_X + 56), DpiScale(CTRL_H)); FSpinTXFreq.Anchors := [akLeft, akTop, akRight]; FSpinTXFreq.MinValue := 0.001; FSpinTXFreq.MaxValue := 2000.0; @@ -238,48 +327,53 @@ begin FSpinTXFreq.DecimalPlaces := 6; FSpinTXFreq.Value := 145.5; FSpinTXFreq.OnChange := @OnFieldChange; - MakeLabel(FPanelRight, 'MHz', RW - 46, Y + 1, 40, CTRL_H); + PlaceRightUnit(FCardRadio, 'MHz', Y); Inc(Y, ROW_H); - // Mode - MakeLabel(FPanelRight, 'Mode', LBL_X, Y + 1, LBL_W, CTRL_H); + MakeLabel(FCardRadio, 'Mode', LBL_X, Y + 1, LBL_W, CTRL_H); FCmbMode := TFlatComboBox.Create(Self); - FCmbMode.Parent := FPanelRight; - FCmbMode.SetBounds(CTRL_X, Y, 160, CTRL_H); + FCmbMode.Parent := FCardRadio; + FCmbMode.SetBounds(DpiScale(CTRL_X), DpiScale(Y), + DpiScale(180), DpiScale(CTRL_H)); for i := 0 to CH_MODE_COUNT - 1 do FCmbMode.Items.Add(CH_MODE_NAMES[i]); FCmbMode.ItemIndex := 5; // FM default FCmbMode.OnChange := @OnFieldChange; - Inc(Y, ROW_H); - // RPTR direction - MakeLabel(FPanelRight, 'RPTR', LBL_X, Y + 1, LBL_W, CTRL_H); + FCardRepeater := MakeGroupPanel('REPEATER / TRANSMIT', 286, 148); + CardW := FCardRepeater.ClientWidth; + Y := 34; + + MakeLabel(FCardRepeater, 'Direction', LBL_X, Y + 1, LBL_W, CTRL_H); FBtnRptNone := TFlatButton.Create(Self); - FBtnRptNone.Parent := FPanelRight; - FBtnRptNone.SetBounds(CTRL_X, Y, 55, CTRL_H); + FBtnRptNone.Parent := FCardRepeater; + FBtnRptNone.SetBounds(DpiScale(CTRL_X), DpiScale(Y), + DpiScale(72), DpiScale(CTRL_H)); FBtnRptNone.Caption := 'NONE'; FBtnRptNone.Tag := 0; FBtnRptNone.OnClick := @OnRptBtnClick; FBtnRptMinus := TFlatButton.Create(Self); - FBtnRptMinus.Parent := FPanelRight; - FBtnRptMinus.SetBounds(CTRL_X + 58, Y, 38, CTRL_H); + FBtnRptMinus.Parent := FCardRepeater; + FBtnRptMinus.SetBounds(DpiScale(CTRL_X + 78), DpiScale(Y), + DpiScale(46), DpiScale(CTRL_H)); FBtnRptMinus.Caption := '−'; FBtnRptMinus.Tag := 1; FBtnRptMinus.OnClick := @OnRptBtnClick; FBtnRptPlus := TFlatButton.Create(Self); - FBtnRptPlus.Parent := FPanelRight; - FBtnRptPlus.SetBounds(CTRL_X + 99, Y, 38, CTRL_H); + FBtnRptPlus.Parent := FCardRepeater; + FBtnRptPlus.SetBounds(DpiScale(CTRL_X + 130), DpiScale(Y), + DpiScale(46), DpiScale(CTRL_H)); FBtnRptPlus.Caption := '+'; FBtnRptPlus.Tag := 2; FBtnRptPlus.OnClick := @OnRptBtnClick; Inc(Y, ROW_H); - // RPTR Offset - MakeLabel(FPanelRight, 'RPT Offset', LBL_X, Y + 1, LBL_W, CTRL_H); + MakeLabel(FCardRepeater, 'Offset', LBL_X, Y + 1, LBL_W, CTRL_H); FSpinRptOffset := TFlatFloatSpinEdit.Create(Self); - FSpinRptOffset.Parent := FPanelRight; - FSpinRptOffset.SetBounds(CTRL_X, Y, RW - CTRL_X - 50, CTRL_H); + FSpinRptOffset.Parent := FCardRepeater; + FSpinRptOffset.SetBounds(DpiScale(CTRL_X), DpiScale(Y), + CardW - DpiScale(CTRL_X + 56), DpiScale(CTRL_H)); FSpinRptOffset.Anchors := [akLeft, akTop, akRight]; FSpinRptOffset.MinValue := 0.0; FSpinRptOffset.MaxValue := 100.0; @@ -287,37 +381,38 @@ begin FSpinRptOffset.DecimalPlaces := 3; FSpinRptOffset.Value := 0.6; FSpinRptOffset.OnChange := @OnFieldChange; - MakeLabel(FPanelRight, 'MHz', RW - 46, Y + 1, 40, CTRL_H); + PlaceRightUnit(FCardRepeater, 'MHz', Y); Inc(Y, ROW_H); - // CTCSS - MakeLabel(FPanelRight, 'CTCSS', LBL_X, Y + 1, LBL_W, CTRL_H); + MakeLabel(FCardRepeater, 'CTCSS tone', LBL_X, Y + 1, LBL_W, CTRL_H); FChkCTCSS := TFlatCheckBox.Create(Self); - FChkCTCSS.Parent := FPanelRight; - FChkCTCSS.SetBounds(CTRL_X, Y, 26, CTRL_H); + FChkCTCSS.Parent := FCardRepeater; + FChkCTCSS.SetBounds(DpiScale(CTRL_X), DpiScale(Y), + DpiScale(28), DpiScale(CTRL_H)); FChkCTCSS.Caption := ''; FChkCTCSS.Checked := False; FChkCTCSS.OnChange := @OnCTCSSChkChange; FCmbCTCSS := TFlatComboBox.Create(Self); - FCmbCTCSS.Parent := FPanelRight; - FCmbCTCSS.SetBounds(CTRL_X + 30, Y, RW - CTRL_X - 38, CTRL_H); + FCmbCTCSS.Parent := FCardRepeater; + FCmbCTCSS.SetBounds(DpiScale(CTRL_X + 34), DpiScale(Y), + CardW - DpiScale(CTRL_X + 48), DpiScale(CTRL_H)); FCmbCTCSS.Anchors := [akLeft, akTop, akRight]; for i := 0 to CH_CTCSS_COUNT - 1 do FCmbCTCSS.Items.Add(CH_CTCSS_NAMES[i]); FCmbCTCSS.ItemIndex := 0; FCmbCTCSS.OnChange := @OnCTCSSCmbChange; Inc(Y, ROW_H); - // Power - MakeLabel(FPanelRight, 'Power', LBL_X, Y + 1, LBL_W, CTRL_H); + MakeLabel(FCardRepeater, 'Power', LBL_X, Y + 1, LBL_W, CTRL_H); FSpinPower := TFlatSpinEdit.Create(Self); - FSpinPower.Parent := FPanelRight; - FSpinPower.SetBounds(CTRL_X, Y, 80, CTRL_H); + FSpinPower.Parent := FCardRepeater; + FSpinPower.SetBounds(DpiScale(CTRL_X), DpiScale(Y), + DpiScale(92), DpiScale(CTRL_H)); FSpinPower.MinValue := 0; FSpinPower.MaxValue := 100; FSpinPower.Value := 100; FSpinPower.OnChange := @OnFieldChange; - MakeLabel(FPanelRight, '%', CTRL_X + 84, Y + 1, 20, CTRL_H); + MakeLabel(FCardRepeater, '%', CTRL_X + 98, Y + 1, 24, CTRL_H); end; function TChannelsForm.MakeLabel(AParent: TWinControl; const Cap: string; @@ -327,37 +422,86 @@ begin L := TLabel.Create(Self); L.Parent := AParent; L.Caption := Cap; - L.SetBounds(X, Y, W, H); - L.Font.Name := 'Courier New'; + L.AutoSize := False; + L.SetBounds(DpiScale(X), DpiScale(Y), DpiScale(W), DpiScale(H)); + L.Layout := tlCenter; L.Font.Size := 8; - L.Font.Color := DarkTheme.TextDim; + L.Font.Color := FTheme.TextDim; // Сохраняем для последующей перекраски темой SetLength(FLbls, Length(FLbls) + 1); FLbls[High(FLbls)] := L; Result := L; end; -procedure TChannelsForm.StyleBtn(B: TFlatButton; AActive: Boolean); -var T: TAppTheme; +function TChannelsForm.MakeGroupPanel(const Cap: string; Y, H: Integer): TPanel; +var + Title: TLabel; + Divider: TPanel; begin - T := DarkTheme; + Result := TPanel.Create(Self); + Result.Parent := FPanelRight; + Result.SetBounds(DpiScale(PAGE_MARGIN), DpiScale(Y), + FPanelRight.ClientWidth - DpiScale(2 * PAGE_MARGIN), DpiScale(H)); + Result.Anchors := [akLeft, akTop, akRight]; + Result.BevelOuter := bvNone; + Result.Color := FTheme.Panel; + + Title := MakeLabel(Result, Cap, 12, 2, 300, 24); + Title.Font.Size := 8; + Title.Font.Style := [fsBold]; + Title.Font.Color := FTheme.FreqActive; + + Divider := TPanel.Create(Self); + Divider.Parent := Result; + Divider.SetBounds(0, DpiScale(28), Result.ClientWidth, 1); + Divider.Anchors := [akLeft, akTop, akRight]; + Divider.BevelOuter := bvNone; + Divider.Color := FTheme.Border; +end; + +procedure TChannelsForm.StyleBtn(B: TFlatButton; AActive: Boolean); +begin + // Прямо созданные TFlatButton не проходят через MakeFlatBtn, поэтому + // явно даём им тот же системный шрифт, что и остальному интерфейсу. + B.Font.Assign(Font); + B.Font.Size := 8; + B.Font.Style := []; B.Active := AActive; - B.ClrNorm := T.BtnNorm; - B.ClrActive := T.BtnActive; - B.ClrHot := T.BtnHot; - B.ClrBorder := IfThen(AActive, T.BtnBorderActive, T.BtnBorderNorm); - B.ClrText := T.BtnText; - B.ClrTextAct := T.BtnTextActive; + B.ClrNorm := FTheme.BtnNorm; + B.ClrActive := FTheme.BtnActive; + B.ClrHot := FTheme.BtnHot; + B.ClrBorder := IfThen(AActive, + FTheme.BtnBorderActive, FTheme.BtnBorderNorm); + B.ClrText := FTheme.BtnText; + B.ClrTextAct := FTheme.BtnTextActive; + B.Invalidate; end; procedure TChannelsForm.ApplyTheme(const T: TAppTheme); var i: Integer; Ch: TChannel; + + procedure ThemeCard(P: TPanel); + var + n: Integer; + begin + if P = nil then Exit; + P.Color := T.Panel; + for n := 0 to P.ControlCount - 1 do + if (P.Controls[n] is TPanel) and (P.Controls[n].Height = 1) then + TPanel(P.Controls[n]).Color := T.Border; + end; + begin - Color := T.Panel; + FTheme := T; + Color := T.BG; FPanelLeft.Color := T.Panel; - FPanelRight.Color := T.Panel; + FPanelRight.Color := T.BG; + FFooterPanel.Color := T.BG; + ThemeCard(FCardIdentity); + ThemeCard(FCardRadio); + ThemeCard(FCardRepeater); FList.SetAppTheme(T); @@ -365,6 +509,7 @@ begin StyleBtn(FBtnDelete); StyleBtn(FBtnUp); StyleBtn(FBtnDown); + StyleBtn(FBtnClose); // RPT buttons — пересчитываем Active из текущего состояния if SelectedIdx >= 0 then @@ -393,9 +538,15 @@ begin for i := 0 to High(FLbls) do begin - FLbls[i].Font.Color := T.TextDim; - FLbls[i].Color := T.Panel; - FLbls[i].ParentColor := False; + if FLbls[i] = FLeftTitle then + FLbls[i].Font.Color := T.Text + else if FLbls[i] = FRightTitle then + FLbls[i].Font.Color := T.Text + else if fsBold in FLbls[i].Font.Style then + FLbls[i].Font.Color := T.FreqActive + else + FLbls[i].Font.Color := T.TextDim; + FLbls[i].Transparent := True; end; end; @@ -476,7 +627,6 @@ procedure TChannelsForm.SaveFieldsToChannel; var Idx: Integer; Ch: TChannel; - i: Integer; begin if FUpdating then Exit; Idx := SelectedIdx; @@ -607,6 +757,11 @@ begin SaveFieldsToChannel; end; +procedure TChannelsForm.OnCloseClick(Sender: TObject); +begin + Close; +end; + procedure TChannelsForm.OnFormClose(Sender: TObject; var CloseAction: TCloseAction); begin CloseAction := caHide; // скрываем, не уничтожаем — указатель в MainForm остаётся валидным From 998cb3f0feeddd47cda5051f27ed5821ef574b3a Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 5 Aug 2026 15:06:48 +0300 Subject: [PATCH 25/30] ui(beacon): add flat memo with overlay scroll --- BeaconScopeForm.pas | 16 +-- FlatMemo.pas | 279 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 11 deletions(-) create mode 100644 FlatMemo.pas diff --git a/BeaconScopeForm.pas b/BeaconScopeForm.pas index 27e2fcd..3bda2a9 100644 --- a/BeaconScopeForm.pas +++ b/BeaconScopeForm.pas @@ -17,7 +17,7 @@ interface uses Classes, SysUtils, Math, StrUtils, Forms, Controls, Graphics, ExtCtrls, StdCtrls, - AppTheme, BeaconDecoder, BeaconFEC, RadioController, DpiUtils; + AppTheme, BeaconDecoder, BeaconFEC, RadioController, DpiUtils, FlatMemo; type TBeaconScopeForm = class(TForm) @@ -30,7 +30,7 @@ type FBulletin: TPanel; FBulletinTitle: TLabel; FBulletinHint: TLabel; - FMemo: TMemo; // декодированный текст бюллетеня (AO-40 кадры) + FMemo: TFlatMemo; // декодированный текст бюллетеня (AO-40 кадры) FTimer: TTimer; FTheme: TAppTheme; FLastFrames: Int64; // счётчик кадров на прошлом тике (детект нового) @@ -135,20 +135,17 @@ begin FBulletinHint.Font.Color := FTheme.TextDim; // Бюллетень остаётся обычным read-only memo: текст можно выделить и скопировать. - FMemo := TMemo.Create(Self); + FMemo := TFlatMemo.Create(Self); FMemo.Parent := FBulletin; FMemo.Align := alClient; FMemo.BorderSpacing.Left := DpiScale(12); FMemo.BorderSpacing.Top := DpiScale(36); FMemo.BorderSpacing.Right := DpiScale(12); FMemo.BorderSpacing.Bottom := DpiScale(12); - FMemo.BorderStyle := bsNone; FMemo.ReadOnly := True; - FMemo.ScrollBars := ssVertical; FMemo.WordWrap := False; FMemo.Font.Size := 9; - FMemo.Color := FTheme.BG; - FMemo.Font.Color := FTheme.Text; + FMemo.SetAppTheme(FTheme); FBox := TPaintBox.Create(Self); FBox.Parent := Self; @@ -174,10 +171,7 @@ begin if FBulletinTitle <> nil then FBulletinTitle.Font.Color := T.MeterOn; if FBulletinHint <> nil then FBulletinHint.Font.Color := T.TextDim; if FMemo <> nil then - begin - FMemo.Color := T.BG; - FMemo.Font.Color := T.Text; - end; + FMemo.SetAppTheme(T); if FBox <> nil then FBox.Invalidate; end; diff --git a/FlatMemo.pas b/FlatMemo.pas new file mode 100644 index 0000000..b91b2b6 --- /dev/null +++ b/FlatMemo.pas @@ -0,0 +1,279 @@ +unit FlatMemo; + +{ + Read-only/editable memo с полностью управляемым оформлением и тонким + overlay-scrollbar. Внутри остаётся обычный TMemo, поэтому выделение, + клавиатура и копирование работают штатно на всех LCL widgetset. +} + +{$mode objfpc}{$H+} + +interface + +uses + Classes, SysUtils, Math, Types, + Forms, Controls, Graphics, StdCtrls, ExtCtrls, + AppTheme, DpiUtils, OverlayScrollBar; + +type + TFlatMemo = class(TCustomControl) + private + FMemo: TMemo; + FScrollHost: TPanel; + FScrollBar: TOverlayScrollBar; + FTheme: TAppTheme; + FSyncing: Boolean; + function GetLines: TStrings; + function GetReadOnly: Boolean; + procedure SetReadOnly(AValue: Boolean); + function GetWordWrap: Boolean; + procedure SetWordWrap(AValue: Boolean); + function GetSelStart: Integer; + procedure SetSelStart(AValue: Integer); + function GetText: string; + procedure SetText(const AValue: string); + procedure MemoChange(Sender: TObject); + procedure MemoKeyUp(Sender: TObject; var Key: Word; Shift: TShiftState); + procedure MemoMouseUp(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); + procedure MemoMouseWheel(Sender: TObject; Shift: TShiftState; + WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean); + procedure ScrollChanged(Sender: TObject); + procedure UpdateScrollBar; + procedure LayoutChildren; + protected + procedure Paint; override; + procedure Resize; override; + procedure FontChanged(Sender: TObject); override; + public + constructor Create(AOwner: TComponent); override; + procedure Append(const AValue: string); + procedure SetAppTheme(const T: TAppTheme); + procedure SyncScrollBar; + + property Lines: TStrings read GetLines; + property ReadOnly: Boolean read GetReadOnly write SetReadOnly; + property WordWrap: Boolean read GetWordWrap write SetWordWrap; + property SelStart: Integer read GetSelStart write SetSelStart; + property Text: string read GetText write SetText; + property Align; + property Anchors; + property BorderSpacing; + property Color; + property Font; + property Enabled; + property TabOrder; + property TabStop; + property Visible; + end; + +implementation + +constructor TFlatMemo.Create(AOwner: TComponent); +begin + inherited Create(AOwner); + ControlStyle := ControlStyle + [csOpaque]; + Width := DpiScale(300); + Height := DpiScale(160); + FTheme := DarkTheme; + Color := FTheme.BG; + TabStop := False; + + FMemo := TMemo.Create(Self); + FMemo.Parent := Self; + FMemo.Align := alClient; + FMemo.BorderSpacing.Around := 1; + FMemo.BorderStyle := bsNone; + FMemo.ScrollBars := ssVertical; + FMemo.WordWrap := False; + FMemo.ParentFont := True; + FMemo.Color := FTheme.BG; + FMemo.Font.Color := FTheme.Text; + FMemo.OnChange := @MemoChange; + FMemo.OnKeyUp := @MemoKeyUp; + FMemo.OnMouseUp := @MemoMouseUp; + FMemo.OnMouseWheel := @MemoMouseWheel; + + // TPaintBox сам не может перекрыть оконный TMemo, поэтому scrollbar живёт + // внутри отдельного оконного TPanel, поднятого над нативной полосой. + FScrollHost := TPanel.Create(Self); + FScrollHost.Parent := Self; + FScrollHost.BevelOuter := bvNone; + FScrollHost.Color := FTheme.BG; + FScrollHost.OnMouseWheel := @MemoMouseWheel; + + FScrollBar := TOverlayScrollBar.Create(Self); + FScrollBar.Parent := FScrollHost; + FScrollBar.Align := alClient; + FScrollBar.HoverTarget := Self; + FScrollBar.OnMouseWheel := @MemoMouseWheel; + FScrollBar.OnPositionChange := @ScrollChanged; + + LayoutChildren; + SetAppTheme(FTheme); +end; + +procedure TFlatMemo.Paint; +begin + Canvas.Brush.Style := bsSolid; + Canvas.Brush.Color := FTheme.Border; + Canvas.FillRect(ClientRect); +end; + +procedure TFlatMemo.Resize; +begin + inherited Resize; + LayoutChildren; + UpdateScrollBar; +end; + +procedure TFlatMemo.FontChanged(Sender: TObject); +begin + inherited FontChanged(Sender); + if FMemo <> nil then + begin + FMemo.Font.Assign(Font); + UpdateScrollBar; + end; +end; + +procedure TFlatMemo.LayoutChildren; +var + GutterW: Integer; +begin + if (FMemo = nil) or (FScrollHost = nil) then Exit; + GutterW := Max(DpiScale(8), FMemo.VertScrollBar.Size); + FScrollHost.SetBounds(Max(1, ClientWidth - GutterW - 1), 1, + GutterW, Max(0, ClientHeight - 2)); + FScrollHost.BringToFront; +end; + +procedure TFlatMemo.UpdateScrollBar; +var + MaxPos, Viewport: Integer; +begin + if FSyncing or (FMemo = nil) or (FScrollBar = nil) then Exit; + FSyncing := True; + try + MaxPos := Max(0, FMemo.VertScrollBar.Range - FMemo.VertScrollBar.Page); + Viewport := Max(1, FMemo.VertScrollBar.Page); + FScrollBar.SetRange(MaxPos, Viewport); + FScrollBar.Position := EnsureRange(FMemo.VertScrollBar.Position, 0, MaxPos); + LayoutChildren; + finally + FSyncing := False; + end; +end; + +procedure TFlatMemo.SyncScrollBar; +begin + UpdateScrollBar; +end; + +procedure TFlatMemo.ScrollChanged(Sender: TObject); +begin + if FSyncing or (FMemo = nil) or (FScrollBar = nil) then Exit; + FMemo.VertScrollBar.Position := FScrollBar.Position; +end; + +procedure TFlatMemo.MemoMouseWheel(Sender: TObject; Shift: TShiftState; + WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean); +var + Step: Integer; +begin + UpdateScrollBar; + if (FScrollBar = nil) or (FScrollBar.Maximum <= 0) then Exit; + Step := Max(1, FMemo.VertScrollBar.Increment * 3); + if WheelDelta > 0 then FScrollBar.ScrollBy(-Step) + else if WheelDelta < 0 then FScrollBar.ScrollBy(Step); + Handled := WheelDelta <> 0; +end; + +procedure TFlatMemo.MemoChange(Sender: TObject); +begin + UpdateScrollBar; +end; + +procedure TFlatMemo.MemoKeyUp(Sender: TObject; var Key: Word; + Shift: TShiftState); +begin + UpdateScrollBar; +end; + +procedure TFlatMemo.MemoMouseUp(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); +begin + UpdateScrollBar; +end; + +function TFlatMemo.GetLines: TStrings; +begin + Result := FMemo.Lines; +end; + +function TFlatMemo.GetReadOnly: Boolean; +begin + Result := FMemo.ReadOnly; +end; + +procedure TFlatMemo.SetReadOnly(AValue: Boolean); +begin + FMemo.ReadOnly := AValue; +end; + +function TFlatMemo.GetWordWrap: Boolean; +begin + Result := FMemo.WordWrap; +end; + +procedure TFlatMemo.SetWordWrap(AValue: Boolean); +begin + FMemo.WordWrap := AValue; + UpdateScrollBar; +end; + +function TFlatMemo.GetSelStart: Integer; +begin + Result := FMemo.SelStart; +end; + +procedure TFlatMemo.SetSelStart(AValue: Integer); +begin + FMemo.SelStart := AValue; + UpdateScrollBar; +end; + +function TFlatMemo.GetText: string; +begin + Result := FMemo.Text; +end; + +procedure TFlatMemo.SetText(const AValue: string); +begin + FMemo.Text := AValue; + UpdateScrollBar; +end; + +procedure TFlatMemo.Append(const AValue: string); +begin + FMemo.Append(AValue); + UpdateScrollBar; +end; + +procedure TFlatMemo.SetAppTheme(const T: TAppTheme); +begin + FTheme := T; + Color := T.BG; + if FMemo <> nil then + begin + FMemo.Color := T.BG; + FMemo.Font.Color := T.Text; + end; + if FScrollHost <> nil then FScrollHost.Color := T.BG; + if FScrollBar <> nil then + FScrollBar.SetColors(T.BG, T.Border, + T.SliderThumbHot, T.SliderThumbDrag); + Invalidate; +end; + +end. From b70daa9dbfcf5e32d9d55677a7e66b017b996b7d Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 5 Aug 2026 15:55:03 +0300 Subject: [PATCH 26/30] ui(main): fix DPI layout and theme switching --- FlatDropDown.pas | 36 ++++--- MainForm.lfm | 1 - MainForm.pas | 264 +++++++++++++++++++++++++++++------------------ 3 files changed, 185 insertions(+), 116 deletions(-) diff --git a/FlatDropDown.pas b/FlatDropDown.pas index 009b109..7826406 100644 --- a/FlatDropDown.pas +++ b/FlatDropDown.pas @@ -32,9 +32,11 @@ type FItemBtns: array of TFlatButton; FItemIndex: Integer; FColumns: Integer; - FItemBtnH: Integer; + FItemBtnH96: Integer; FRightInset96: Integer; FIsOpen: Boolean; + FStyleProc: TButtonStyleProc; + FPanelColor: TColor; FOnSelect: TDropDownSelectEvent; procedure ItemClick(Sender: TObject); procedure PositionPopup; @@ -61,15 +63,17 @@ begin inherited Create; FAnchor := AAnchor; FPopupParent := APopupParent; - FColumns := ACols; - FItemBtnH := ABtnH; + FColumns := Max(1, ACols); + FItemBtnH96 := Max(1, ABtnH); FRightInset96 := Max(0, ARightInset96); FItemIndex := 0; FIsOpen := False; + FStyleProc := nil; + FPanelColor := TColor($00181818); FPopup := TPanel.Create(nil); FPopup.Parent := APopupParent; FPopup.BevelOuter := bvNone; - FPopup.Color := TColor($00181818); + FPopup.Color := FPanelColor; FPopup.Visible := False; end; @@ -81,7 +85,7 @@ end; procedure TFlatDropDown.SetItems(const ANames: array of string); var - I, Count, BtnW, PopW: Integer; + I, Count, BtnW, PopW, ItemBtnH, EdgePad, ItemGap: Integer; B: TFlatButton; begin while FPopup.ControlCount > 0 do @@ -93,17 +97,22 @@ begin // при каждом наполнении: SetItems бывает и до, и после DPI-scale формы. PopW := Max(1, FPopupParent.ClientWidth - FPopupParent.Scale96ToForm(FRightInset96)); - BtnW := (PopW - 4) div FColumns; + ItemBtnH := FPopupParent.Scale96ToForm(FItemBtnH96); + EdgePad := FPopupParent.Scale96ToForm(2); + ItemGap := FPopupParent.Scale96ToForm(2); + BtnW := Max(1, (PopW - 2 * EdgePad) div FColumns); FPopup.SetBounds(0, 0, PopW, - ((Count + FColumns - 1) div FColumns) * FItemBtnH); + ((Count + FColumns - 1) div FColumns) * ItemBtnH); for I := 0 to Count - 1 do begin B := MakeFlatBtn(FPopup, ANames[I], - 2 + (I mod FColumns) * BtnW, - (I div FColumns) * FItemBtnH, - BtnW - 2, FItemBtnH, @ItemClick); + EdgePad + (I mod FColumns) * BtnW, + (I div FColumns) * ItemBtnH, + Max(1, BtnW - ItemGap), ItemBtnH, @ItemClick); B.Tag := I; B.Active := (I = FItemIndex); + if Assigned(FStyleProc) then + FStyleProc(B, B.Active); FItemBtns[I] := B; end; end; @@ -141,9 +150,10 @@ var begin P := FAnchor.ClientToScreen(Point(0, FAnchor.Height)); P := FPopupParent.ScreenToClient(P); - Y0 := P.Y + 2; + Y0 := P.Y + FPopupParent.Scale96ToForm(2); if Y0 + FPopup.Height > FPopupParent.ClientHeight then - Y0 := P.Y - FAnchor.Height - FPopup.Height - 2; + Y0 := P.Y - FAnchor.Height - FPopup.Height - + FPopupParent.Scale96ToForm(2); if Y0 < 0 then Y0 := 0; FPopup.Top := Y0; FPopup.Left := 0; @@ -173,6 +183,8 @@ procedure TFlatDropDown.ApplyStyle(AStyleProc: TButtonStyleProc; APanelColor: TC var I: Integer; begin + FStyleProc := AStyleProc; + FPanelColor := APanelColor; FPopup.Color := APanelColor; for I := 0 to High(FItemBtns) do if FItemBtns[I] <> nil then diff --git a/MainForm.lfm b/MainForm.lfm index 5f1454b..2ae9e07 100644 --- a/MainForm.lfm +++ b/MainForm.lfm @@ -7,7 +7,6 @@ object MainForm: TMainForm Color = clBlack Font.Color = clSilver Font.Height = -13 - Font.Name = 'Courier New' Position = poDesigned LCLVersion = '4.6.0.0' OnClose = FormClose diff --git a/MainForm.pas b/MainForm.pas index e8cd3c0..84ae375 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -106,6 +106,7 @@ const TOP_SMETER_GAP = 8; TOP_VFO_FONT = 25; LEFT_SCROLL_GUTTER_W = 8; + LEFT_PANEL_CONTENT_W = 232; LEFT_PANEL_BTN_PAD = 3; LEFT_PANEL_BTN_GAP = 2; @@ -825,17 +826,18 @@ const // Высота RX-блока левой панели: с рядом ATT (openHPSDR) и без него (Pluto — // там усиление тракта в блоке RF AGC). Переключает LayoutLeftPanel, он же // двигает панели ниже. - RX_BLOCK_H = 96; - RX_BLOCK_H_ATT = 120; + RX_BLOCK_H = 100; + RX_BLOCK_H_ATT = 124; function TMainForm.LeftPanelButtonWidth(PanelWidth, ColCount, ColIndex: Integer): Integer; var - InnerW, BaseW, Extra: Integer; + InnerW, BaseW, Extra, Pad, Gap: Integer; begin if ColCount <= 0 then Exit(0); - InnerW := PanelWidth - 2 * LEFT_PANEL_BTN_PAD - - (ColCount - 1) * LEFT_PANEL_BTN_GAP; + Pad := MulDiv(LEFT_PANEL_BTN_PAD, PanelWidth, LEFT_PANEL_CONTENT_W); + Gap := MulDiv(LEFT_PANEL_BTN_GAP, PanelWidth, LEFT_PANEL_CONTENT_W); + InnerW := PanelWidth - 2 * Pad - (ColCount - 1) * Gap; BaseW := InnerW div ColCount; Extra := InnerW mod ColCount; Result := BaseW; @@ -845,12 +847,14 @@ end; function TMainForm.LeftPanelButtonLeft(PanelWidth, ColCount, ColIndex: Integer): Integer; var - i: Integer; + i, Pad, Gap: Integer; begin - Result := LEFT_PANEL_BTN_PAD; + Pad := MulDiv(LEFT_PANEL_BTN_PAD, PanelWidth, LEFT_PANEL_CONTENT_W); + Gap := MulDiv(LEFT_PANEL_BTN_GAP, PanelWidth, LEFT_PANEL_CONTENT_W); + Result := Pad; for i := 0 to ColIndex - 1 do Inc(Result, LeftPanelButtonWidth(PanelWidth, ColCount, i) + - LEFT_PANEL_BTN_GAP); + Gap); end; // =========================================================================== @@ -1864,9 +1868,9 @@ procedure TMainForm.BuildUI; const BTN_H = 26; BTN_SM = 24; - LEFT_W = 232; - // Заголовок (16) + MODE_ROWS рядов с шагом 27 + нижний отступ. - MODE_PANEL_H = 16 + (MODE_ROWS - 1) * 27 + BTN_SM + 7; + LEFT_W = LEFT_PANEL_CONTENT_W; + // Заголовок с отступом (20) + MODE_ROWS рядов с шагом 27 + нижний отступ. + MODE_PANEL_H = 20 + (MODE_ROWS - 1) * 27 + BTN_SM + 3; PLUTO_AGC_H = 64; // высота отдельного Pluto RF-AGC блока (под AGC) TOP_VFO_W = 244; TOP_VFO_FREQ_H = 47; @@ -1899,8 +1903,7 @@ var L.Left := ALeft; L.Top := ATop; L.Font.Color := CLR_TEXTDIM; - L.Font.Name := 'Courier New'; - L.Font.Size := 7; + L.Font.Size := 8; end; begin @@ -1981,8 +1984,7 @@ begin PanelVfoA.BevelOuter := bvNone; BtnTopVfoASelect := MakeBtn(PanelVfoA, 'A', 0, 5, 20, 18, BtnTopVfoASelectClick); - BtnTopVfoASelect.Font.Name := 'Courier New'; - BtnTopVfoASelect.Font.Size := 7; + BtnTopVfoASelect.Font.Size := 8; BtnTopVfoASelect.Font.Bold := True; BtnTopVfoASelect.ClrNorm := TColor($00303030); BtnTopVfoASelect.ClrHot := TColor($00404040); @@ -1992,8 +1994,7 @@ begin BtnTopVfoASelect.ClrTextAct := CLR_TEXT; BtnTopVfoATX := MakeBtn(PanelVfoA, 'TX', 0, 25, 20, 18, BtnTopVfoATXClick); - BtnTopVfoATX.Font.Name := 'Courier New'; - BtnTopVfoATX.Font.Size := 7; + BtnTopVfoATX.Font.Size := 8; BtnTopVfoATX.Font.Bold := True; BtnTopVfoATX.ClrNorm := TColor($00303030); BtnTopVfoATX.ClrHot := TColor($00404040); @@ -2006,7 +2007,7 @@ begin LblVfoALabel.Parent := PanelVfoA; LblVfoALabel.Caption := 'VFO-A'; LblVfoALabel.Left := 0; LblVfoALabel.Top := 0; - LblVfoALabel.Font.Name := 'Courier New'; LblVfoALabel.Font.Size := 7; + LblVfoALabel.Font.Size := 8; LblVfoALabel.Font.Color := CLR_TEXTDIM; LblVfoALabel.Visible := False; @@ -2038,8 +2039,7 @@ begin PanelVfoB.BevelOuter := bvNone; BtnTopVfoBSelect := MakeBtn(PanelVfoB, 'B', 0, 5, 20, 18, BtnTopVfoBSelectClick); - BtnTopVfoBSelect.Font.Name := 'Courier New'; - BtnTopVfoBSelect.Font.Size := 7; + BtnTopVfoBSelect.Font.Size := 8; BtnTopVfoBSelect.Font.Bold := True; BtnTopVfoBSelect.ClrNorm := TColor($00303030); BtnTopVfoBSelect.ClrHot := TColor($00404040); @@ -2049,8 +2049,7 @@ begin BtnTopVfoBSelect.ClrTextAct := CLR_TEXT; BtnTopVfoBTX := MakeBtn(PanelVfoB, 'TX', 0, 25, 20, 18, BtnTopVfoBTXClick); - BtnTopVfoBTX.Font.Name := 'Courier New'; - BtnTopVfoBTX.Font.Size := 7; + BtnTopVfoBTX.Font.Size := 8; BtnTopVfoBTX.Font.Bold := True; BtnTopVfoBTX.ClrNorm := TColor($00303030); BtnTopVfoBTX.ClrHot := TColor($00404040); @@ -2063,7 +2062,7 @@ begin LblVfoBLabel.Parent := PanelVfoB; LblVfoBLabel.Caption := 'VFO-B'; LblVfoBLabel.Left := 0; LblVfoBLabel.Top := 0; - LblVfoBLabel.Font.Name := 'Courier New'; LblVfoBLabel.Font.Size := 7; + LblVfoBLabel.Font.Size := 8; LblVfoBLabel.Font.Color := CLR_TEXTDIM; LblVfoBLabel.Visible := False; @@ -2096,9 +2095,9 @@ begin StyleSpanButton(BtnVfoACopyB, False); StyleSpanButton(BtnVfoSwap, False); StyleSpanButton(BtnVfoBCopyA, False); - BtnVfoACopyB.Font.Size := 7; - BtnVfoSwap.Font.Size := 7; - BtnVfoBCopyA.Font.Size := 7; + BtnVfoACopyB.Font.Size := 8; + BtnVfoSwap.Font.Size := 8; + BtnVfoBCopyA.Font.Size := 8; LayoutTopVfoBlock; @@ -2110,23 +2109,23 @@ begin MakeLbl(PanelTX, 'TX', 4, 2); BtnMOX := MakeBtn(PanelTX, 'MOX', - LeftPanelButtonLeft(LEFT_W, 4, 0), 16, + LeftPanelButtonLeft(LEFT_W, 4, 0), 20, LeftPanelButtonWidth(LEFT_W, 4, 0), BTN_SM, BtnMOXClick); BtnTUN := MakeBtn(PanelTX, 'TUN', - LeftPanelButtonLeft(LEFT_W, 4, 1), 16, + LeftPanelButtonLeft(LEFT_W, 4, 1), 20, LeftPanelButtonWidth(LEFT_W, 4, 1), BTN_SM, BtnTUNClick); // PureSignal: ЛКМ = вкл/выкл, ПКМ = поповер настроек/управления. BtnPS := MakeBtn(PanelTX, 'PS', - LeftPanelButtonLeft(LEFT_W, 4, 2), 16, + LeftPanelButtonLeft(LEFT_W, 4, 2), 20, LeftPanelButtonWidth(LEFT_W, 4, 2), BTN_SM, BtnPSClick); BtnPS.OnMouseDown := BtnPSMouseDown; Btn2Ton := MakeBtn(PanelTX, '2TON', - LeftPanelButtonLeft(LEFT_W, 4, 3), 16, + LeftPanelButtonLeft(LEFT_W, 4, 3), 20, LeftPanelButtonWidth(LEFT_W, 4, 3), BTN_SM, Btn2TonClick); UpdateTXControlAvailability; BtnDUP := MakeBtn(PanelTX, 'DUP', - LeftPanelButtonLeft(LEFT_W, 4, 0), 44, + LeftPanelButtonLeft(LEFT_W, 4, 0), 48, LeftPanelButtonWidth(LEFT_W, 4, 0), BTN_SM, BtnDUPClick); BtnDUP.Tag := 0; StyleButton(BtnDUP, FController.FDisplayDuplex); @@ -2135,7 +2134,7 @@ begin // активна только в DUP (см. UpdateRxMuteButton). ON (подсвечена, умолч.) = // RX-аудио глушится на TX; OFF = слышим свой downlink во время передачи. BtnRxMute := MakeBtn(PanelTX, 'RX MUTE', - LeftPanelButtonLeft(LEFT_W, 4, 1), 44, + LeftPanelButtonLeft(LEFT_W, 4, 1), 48, LeftPanelButtonWidth(LEFT_W, 4, 1), BTN_SM, BtnRxMuteClick); StyleButton(BtnRxMute, FController.FRxMuteOnTx); UpdateRxMuteButton; // видимость/активность по DUP @@ -2143,7 +2142,7 @@ begin // TX-профиль — свободные колонки 2..3 того же ряда (панель не растёт по // высоте). Подпись = имя активного профиля, ЛКМ — список, ПКМ — настройки. BtnTXProfile := MakeBtn(PanelTX, 'Default', - LeftPanelButtonLeft(LEFT_W, 4, 2), 44, + LeftPanelButtonLeft(LEFT_W, 4, 2), 48, LeftPanelButtonLeft(LEFT_W, 4, 3) + LeftPanelButtonWidth(LEFT_W, 4, 3) - LeftPanelButtonLeft(LEFT_W, 4, 2), @@ -2159,10 +2158,10 @@ begin LblDrv := TLabel.Create(Self); LblDrv.Parent := PanelTX; LblDrv.Left := 4; LblDrv.Top := 77; LblDrv.Caption := 'DRV'; LblDrv.Font.Color := CLR_TEXTDIM; - LblDrv.Font.Name := 'Courier New'; LblDrv.Font.Size := 7; + LblDrv.Font.Size := 8; TrkDrive := TFlatSlider.Create(Self); - TrkDrive.Parent := PanelTX; TrkDrive.Left := 34; TrkDrive.Top := 74; + TrkDrive.Parent := PanelTX; TrkDrive.Left := 34; TrkDrive.Top := 78; TrkDrive.Width := 120; TrkDrive.Height := 16; TrkDrive.Min := 0; TrkDrive.Max := 100; TrkDrive.Position := 50; TrkDrive.OnChange := TrkDriveChange; @@ -2170,8 +2169,8 @@ begin LblDrvVal := TLabel.Create(Self); LblDrvVal.Parent := PanelTX; LblDrvVal.Left := 158; LblDrvVal.Top := 77; - LblDrvVal.Font.Color := CLR_TEXT; LblDrvVal.Font.Name := 'Courier New'; - LblDrvVal.Font.Size := 7; + LblDrvVal.Font.Color := CLR_TEXT; + LblDrvVal.Font.Size := 8; UpdateDriveLabel; Inc(Y, 100); @@ -2184,14 +2183,14 @@ begin MakeLbl(PanelRXBlock, 'RX', 4, 2); BtnCTun := MakeBtn(PanelRXBlock, 'CTUN', - LeftPanelButtonLeft(LEFT_W, 3, 0), 16, + LeftPanelButtonLeft(LEFT_W, 3, 0), 20, LeftPanelButtonWidth(LEFT_W, 3, 0), BTN_H, BtnCTunClick); BtnCTun.Tag := 0; StyleButton(BtnCTun, FController.FCTun); BtnChannels := MakeBtn(PanelRXBlock, 'Channel', - LeftPanelButtonLeft(LEFT_W, 3, 1), 16, + LeftPanelButtonLeft(LEFT_W, 3, 1), 20, LeftPanelButtonWidth(LEFT_W, 3, 1), BTN_H, BtnChannelsClick); BtnChannels.OnMouseDown := BtnChannelsMouseDown; @@ -2205,7 +2204,7 @@ begin // QO-100 beacon lock — 3-я колонка ряда. Видима только в XVTR-режиме // (см. рендер rfXvtr ниже). Подпись/подсветка обновляются по rfBeaconLock. BtnBeacon := MakeBtn(PanelRXBlock, 'BEACON', - LeftPanelButtonLeft(LEFT_W, 3, 2), 16, + LeftPanelButtonLeft(LEFT_W, 3, 2), 20, LeftPanelButtonWidth(LEFT_W, 3, 2), BTN_H, BtnBeaconClick); BtnBeacon.Visible := False; @@ -2214,26 +2213,26 @@ begin MakeLbl(PanelRXBlock, 'VOL', 4, 50); TrkVolume := TFlatSlider.Create(Self); - TrkVolume.Parent := PanelRXBlock; TrkVolume.Left := 34; TrkVolume.Top := 46; + TrkVolume.Parent := PanelRXBlock; TrkVolume.Left := 34; TrkVolume.Top := 50; TrkVolume.Width := LEFT_W - 38; TrkVolume.Height := 16; TrkVolume.Min := 0; TrkVolume.Max := 100; TrkVolume.Position := FController.FVolume; TrkVolume.OnChange := TrkVolumeChange; BtnNR := MakeBtn(PanelRXBlock, 'NR', - LeftPanelButtonLeft(LEFT_W, 5, 0), 70, + LeftPanelButtonLeft(LEFT_W, 5, 0), 74, LeftPanelButtonWidth(LEFT_W, 5, 0), BTN_SM, BtnNRClick); BtnNB := MakeBtn(PanelRXBlock, 'NB', - LeftPanelButtonLeft(LEFT_W, 5, 1), 70, + LeftPanelButtonLeft(LEFT_W, 5, 1), 74, LeftPanelButtonWidth(LEFT_W, 5, 1), BTN_SM, BtnNBClick); BtnSNB := MakeBtn(PanelRXBlock, 'SNB', - LeftPanelButtonLeft(LEFT_W, 5, 2), 70, + LeftPanelButtonLeft(LEFT_W, 5, 2), 74, LeftPanelButtonWidth(LEFT_W, 5, 2), BTN_SM, BtnSNBClick); BtnANF := MakeBtn(PanelRXBlock, 'ANF', - LeftPanelButtonLeft(LEFT_W, 5, 3), 70, + LeftPanelButtonLeft(LEFT_W, 5, 3), 74, LeftPanelButtonWidth(LEFT_W, 5, 3), BTN_SM, BtnANFClick); BtnMute := MakeBtn(PanelRXBlock, 'MUTE', - LeftPanelButtonLeft(LEFT_W, 5, 4), 70, + LeftPanelButtonLeft(LEFT_W, 5, 4), 74, LeftPanelButtonWidth(LEFT_W, 5, 4), BTN_SM, BtnMuteClick); // Шаговый аттенюатор ADC — плавный 0..31 дБ (железо openHPSDR так и умеет, @@ -2242,10 +2241,10 @@ begin LblAtt := TLabel.Create(Self); LblAtt.Parent := PanelRXBlock; LblAtt.Left := 4; LblAtt.Top := 106; LblAtt.Caption := 'ATT'; LblAtt.Font.Color := CLR_TEXTDIM; - LblAtt.Font.Name := 'Courier New'; LblAtt.Font.Size := 7; + LblAtt.Font.Size := 8; TrkAtt := TFlatSlider.Create(Self); - TrkAtt.Parent := PanelRXBlock; TrkAtt.Left := 34; TrkAtt.Top := 102; + TrkAtt.Parent := PanelRXBlock; TrkAtt.Left := 34; TrkAtt.Top := 106; TrkAtt.Width := LEFT_W - 76; TrkAtt.Height := 16; TrkAtt.Min := 0; TrkAtt.Max := 31; // ADC step attenuator, дБ TrkAtt.Position := FController.FAtten; @@ -2254,8 +2253,8 @@ begin LblAttVal := TLabel.Create(Self); LblAttVal.Parent := PanelRXBlock; LblAttVal.Left := LEFT_W - 38; LblAttVal.Top := 106; - LblAttVal.Font.Color := CLR_TEXT; LblAttVal.Font.Name := 'Courier New'; - LblAttVal.Font.Size := 7; + LblAttVal.Font.Color := CLR_TEXT; + LblAttVal.Font.Size := 8; UpdateAttLabel; Inc(Y, RX_BLOCK_H_ATT + 4); @@ -2269,27 +2268,27 @@ begin // AGC mode — 5 кнопок в ряд MakeLbl(PanelAGC, 'AGC', 4, 2); BtnAGCMode[0] := MakeBtn(PanelAGC, 'FAST', - LeftPanelButtonLeft(LEFT_W, 5, 0), 16, + LeftPanelButtonLeft(LEFT_W, 5, 0), 20, LeftPanelButtonWidth(LEFT_W, 5, 0), BTN_SM, BtnAGCModeClick); BtnAGCMode[0].Tag := 0; BtnAGCMode[1] := MakeBtn(PanelAGC, 'MED', - LeftPanelButtonLeft(LEFT_W, 5, 1), 16, + LeftPanelButtonLeft(LEFT_W, 5, 1), 20, LeftPanelButtonWidth(LEFT_W, 5, 1), BTN_SM, BtnAGCModeClick); BtnAGCMode[1].Tag := 1; BtnAGCMode[2] := MakeBtn(PanelAGC, 'SLOW', - LeftPanelButtonLeft(LEFT_W, 5, 2), 16, + LeftPanelButtonLeft(LEFT_W, 5, 2), 20, LeftPanelButtonWidth(LEFT_W, 5, 2), BTN_SM, BtnAGCModeClick); BtnAGCMode[2].Tag := 2; BtnAGCMode[3] := MakeBtn(PanelAGC, 'LONG', - LeftPanelButtonLeft(LEFT_W, 5, 3), 16, + LeftPanelButtonLeft(LEFT_W, 5, 3), 20, LeftPanelButtonWidth(LEFT_W, 5, 3), BTN_SM, BtnAGCModeClick); BtnAGCMode[3].Tag := 3; BtnAGCMode[4] := MakeBtn(PanelAGC, 'OFF', - LeftPanelButtonLeft(LEFT_W, 5, 4), 16, + LeftPanelButtonLeft(LEFT_W, 5, 4), 20, LeftPanelButtonWidth(LEFT_W, 5, 4), BTN_SM, BtnAGCModeClick); BtnAGCMode[4].Tag := 4; @@ -2298,7 +2297,7 @@ begin // AGC level slider MakeLbl(PanelAGC, 'THRESH', 4, 44); TrkAGC := TFlatSlider.Create(Self); - TrkAGC.Parent := PanelAGC; TrkAGC.Left := 56; TrkAGC.Top := 40; + TrkAGC.Parent := PanelAGC; TrkAGC.Left := 56; TrkAGC.Top := 44; TrkAGC.Width := LEFT_W - 96; TrkAGC.Height := 16; TrkAGC.Min := 20; TrkAGC.Max := 120; // 20..120 → −20..-120 dBm TrkAGC.Position := FController.FAGCTop; @@ -2309,8 +2308,8 @@ begin LblAGCTop.Parent := PanelAGC; LblAGCTop.Left := LEFT_W - 38; LblAGCTop.Top := 44; LblAGCTop.Caption := Format('-%ddB', [FController.FAGCTop]); - LblAGCTop.Font.Color := CLR_TEXT; LblAGCTop.Font.Name := 'Courier New'; - LblAGCTop.Font.Size := 7; + LblAGCTop.Font.Color := CLR_TEXT; + LblAGCTop.Font.Size := 8; Inc(Y, 74); @@ -2324,25 +2323,25 @@ begin MakeLbl(PanelPlutoAGC, 'RF AGC', 4, 2); // Режимы AD936x: FAST/SLOW/HYB/MAN. Tag = индекс режима (1/2/3/0). BtnPlutoGain[0] := MakeBtn(PanelPlutoAGC, 'FAST', - LeftPanelButtonLeft(LEFT_W, 4, 0), 16, LeftPanelButtonWidth(LEFT_W, 4, 0), + LeftPanelButtonLeft(LEFT_W, 4, 0), 20, LeftPanelButtonWidth(LEFT_W, 4, 0), BTN_SM, BtnPlutoGainClick); BtnPlutoGain[0].Tag := 1; BtnPlutoGain[1] := MakeBtn(PanelPlutoAGC, 'SLOW', - LeftPanelButtonLeft(LEFT_W, 4, 1), 16, LeftPanelButtonWidth(LEFT_W, 4, 1), + LeftPanelButtonLeft(LEFT_W, 4, 1), 20, LeftPanelButtonWidth(LEFT_W, 4, 1), BTN_SM, BtnPlutoGainClick); BtnPlutoGain[1].Tag := 2; BtnPlutoGain[2] := MakeBtn(PanelPlutoAGC, 'HYB', - LeftPanelButtonLeft(LEFT_W, 4, 2), 16, LeftPanelButtonWidth(LEFT_W, 4, 2), + LeftPanelButtonLeft(LEFT_W, 4, 2), 20, LeftPanelButtonWidth(LEFT_W, 4, 2), BTN_SM, BtnPlutoGainClick); BtnPlutoGain[2].Tag := 3; BtnPlutoGain[3] := MakeBtn(PanelPlutoAGC, 'MAN', - LeftPanelButtonLeft(LEFT_W, 4, 3), 16, LeftPanelButtonWidth(LEFT_W, 4, 3), + LeftPanelButtonLeft(LEFT_W, 4, 3), 20, LeftPanelButtonWidth(LEFT_W, 4, 3), BTN_SM, BtnPlutoGainClick); BtnPlutoGain[3].Tag := 0; // Manual gain slider. MakeLbl(PanelPlutoAGC, 'GAIN', 4, 44); TrkRxGain := TFlatSlider.Create(Self); - TrkRxGain.Parent := PanelPlutoAGC; TrkRxGain.Left := 56; TrkRxGain.Top := 40; + TrkRxGain.Parent := PanelPlutoAGC; TrkRxGain.Left := 56; TrkRxGain.Top := 44; TrkRxGain.Width := LEFT_W - 96; TrkRxGain.Height := 16; TrkRxGain.Min := 0; TrkRxGain.Max := 73; // AD936x hardwaregain dB TrkRxGain.Position := FController.FRxGainDb; @@ -2351,8 +2350,8 @@ begin LblRxGain.Parent := PanelPlutoAGC; LblRxGain.Left := LEFT_W - 38; LblRxGain.Top := 44; LblRxGain.Caption := Format('%ddB', [FController.FRxGainDb]); - LblRxGain.Font.Color := CLR_TEXT; LblRxGain.Font.Name := 'Courier New'; - LblRxGain.Font.Size := 7; + LblRxGain.Font.Color := CLR_TEXT; + LblRxGain.Font.Size := 8; // НЕ Inc(Y): панель скрыта и наложена на позицию Bands; LayoutLeftPanel сдвинет. // Band selector. Высота резервирует 2 дополнительных ряда под XVTR-кнопки @@ -2367,7 +2366,7 @@ begin begin B := MakeBtn(PanelBands, BAND_NAMES[i], LeftPanelButtonLeft(LEFT_W, 6, i mod 6), - 16 + (i div 6) * 27, + 20 + (i div 6) * 27, LeftPanelButtonWidth(LEFT_W, 6, i mod 6), BTN_SM, BtnBandClick); B.Tag := i; @@ -2391,7 +2390,7 @@ begin begin B := MakeBtn(PanelMode, MODE_NAMES[i], LeftPanelButtonLeft(LEFT_W, 4, i mod 4), - 16 + (i div 4) * 27, + 20 + (i div 4) * 27, LeftPanelButtonWidth(LEFT_W, 4, i mod 4), BTN_SM, BtnModeClick); B.Tag := i; @@ -2412,7 +2411,7 @@ begin begin B := MakeBtn(PanelFilter, '---', LeftPanelButtonLeft(LEFT_W, FILT_GRID_COLS, i mod FILT_GRID_COLS), - 16 + (i div FILT_GRID_COLS) * 27, + 20 + (i div FILT_GRID_COLS) * 27, LeftPanelButtonWidth(LEFT_W, FILT_GRID_COLS, i mod FILT_GRID_COLS), BTN_SM, BtnFilterClick); B.Tag := i; @@ -2448,7 +2447,6 @@ begin LblRawDev.Alignment := taRightJustify; LblRawDev.Caption := Format('%.2f k', [FController.FRawFMDeviation / 1000.0]); LblRawDev.Font.Color := CLR_TEXT; - LblRawDev.Font.Name := 'Courier New'; LblRawDev.Font.Size := 8; // FM Squelch panel (visible only in FM mode, above CTCSS) @@ -2460,15 +2458,15 @@ begin MakeLbl(PanelFMSQ, 'SQL', 4, 2); W := LeftPanelButtonWidth(LEFT_W, 4, 0); BtnFMSQ := MakeBtn(PanelFMSQ, 'SQL', - LeftPanelButtonLeft(LEFT_W, 4, 0), 16, + LeftPanelButtonLeft(LEFT_W, 4, 0), 20, W, BTN_SM, BtnFMSQClick); StyleButton(BtnFMSQ, False); - // Slider: vertically centred with button text (button top=16, h=24 → center=28) + // Slider vertically centred with the button row. // Reserve 28px on the right for the numeric label TrkFMSQ := TFlatSlider.Create(Self); TrkFMSQ.Parent := PanelFMSQ; TrkFMSQ.Left := BtnFMSQ.Left + BtnFMSQ.Width + 3; - TrkFMSQ.Top := 21; // 28 - 14/2 = 21 + TrkFMSQ.Top := 25; TrkFMSQ.Width := LEFT_W - TrkFMSQ.Left - 30 - 4; // leave 30px for label + margins TrkFMSQ.Height := 14; TrkFMSQ.Min := 0; TrkFMSQ.Max := 100; @@ -2478,11 +2476,10 @@ begin LblFMSQ := TLabel.Create(Self); LblFMSQ.Parent := PanelFMSQ; LblFMSQ.Left := TrkFMSQ.Left + TrkFMSQ.Width + 3; - LblFMSQ.Top := 21; + LblFMSQ.Top := 23; LblFMSQ.Width := 26; LblFMSQ.Caption := IntToStr(FController.FFMSQLevel); LblFMSQ.Font.Color := CLR_TEXT; - LblFMSQ.Font.Name := 'Courier New'; LblFMSQ.Font.Size := 8; // FM CTCSS panel (visible only when FM mode is active) @@ -2493,12 +2490,12 @@ begin PanelFMCTCSS.Visible := False; MakeLbl(PanelFMCTCSS, 'CTCSS', 4, 2); BtnFMCTCSS := MakeBtn(PanelFMCTCSS, 'CTCSS', - LeftPanelButtonLeft(LEFT_W, 3, 0), 16, + LeftPanelButtonLeft(LEFT_W, 3, 0), 20, LeftPanelButtonWidth(LEFT_W, 3, 0), BTN_H, BtnFMCTCSSClick); StyleButton(BtnFMCTCSS, False); BtnFMCTCSSTone := MakeBtn(PanelFMCTCSS, CTCSS_NAMES[0], - LeftPanelButtonLeft(LEFT_W, 3, 1), 16, + LeftPanelButtonLeft(LEFT_W, 3, 1), 20, LEFT_W - LEFT_PANEL_BTN_PAD - LeftPanelButtonLeft(LEFT_W, 3, 1), BTN_H, BtnFMCTCSSToneClick); @@ -2518,12 +2515,12 @@ begin PanelFMStep.Visible := False; MakeLbl(PanelFMStep, 'STEP', 4, 2); BtnFMStep := MakeBtn(PanelFMStep, 'STEP', - LeftPanelButtonLeft(LEFT_W, 3, 0), 16, + LeftPanelButtonLeft(LEFT_W, 3, 0), 20, LeftPanelButtonWidth(LEFT_W, 3, 0), BTN_H, BtnFMStepClick); StyleButton(BtnFMStep, FController.FFMStepOn); BtnFMStepSel := MakeBtn(PanelFMStep, FM_STEP_NAMES[FController.FFMStepIdx], - LeftPanelButtonLeft(LEFT_W, 3, 1), 16, + LeftPanelButtonLeft(LEFT_W, 3, 1), 20, LEFT_W - LEFT_PANEL_BTN_PAD - LeftPanelButtonLeft(LEFT_W, 3, 1), BTN_H, BtnFMStepSelClick); @@ -2543,18 +2540,18 @@ begin PanelFMRpt.Visible := False; MakeLbl(PanelFMRpt, 'RPT', 4, 2); BtnFMRptMinus := MakeBtn(PanelFMRpt, '-', - LeftPanelButtonLeft(LEFT_W, 5, 0), 16, + LeftPanelButtonLeft(LEFT_W, 5, 0), 20, LeftPanelButtonWidth(LEFT_W, 5, 0), BTN_H, BtnFMRptMinusClick); StyleButton(BtnFMRptMinus, False); BtnFMRptPlus := MakeBtn(PanelFMRpt, '+', - LeftPanelButtonLeft(LEFT_W, 5, 4), 16, + LeftPanelButtonLeft(LEFT_W, 5, 4), 20, LeftPanelButtonWidth(LEFT_W, 5, 4), BTN_H, BtnFMRptPlusClick); StyleButton(BtnFMRptPlus, False); W := LeftPanelButtonLeft(LEFT_W, 5, 1); EdFMRptOffset := TFlatEdit.Create(Self); EdFMRptOffset.Parent := PanelFMRpt; EdFMRptOffset.Left := W; - EdFMRptOffset.Top := 18; + EdFMRptOffset.Top := 22; EdFMRptOffset.Width := LeftPanelButtonLeft(LEFT_W, 5, 4) - W - LEFT_PANEL_BTN_GAP; EdFMRptOffset.Height := BTN_H - 4; EdFMRptOffset.Text := RptFormatOffset(FController.FFMRptOffsetHz); @@ -3159,6 +3156,15 @@ procedure TMainForm.ApplyDarkTheme; var T: TAppTheme; i: Integer; procedure DP(P: TPanel); begin P.Color := T.Panel; P.Font.Color := T.Text; end; + procedure DPL(P: TPanel); + var + J: Integer; + begin + if P = nil then Exit; + for J := 0 to P.ControlCount - 1 do + if P.Controls[J] is TLabel then + TLabel(P.Controls[J]).Font.Color := T.TextDim; + end; procedure DL(L: TLabel; Dim: Boolean = True); begin if Dim then L.Font.Color := T.TextDim else L.Font.Color := T.Text; end; procedure DS(S: TFlatSlider); @@ -3177,6 +3183,7 @@ begin DP(PanelTopVfoGroup); DP(PanelTopVfoA); DP(PanelTopVfoB); DP(PanelVfoA); DP(PanelVfoB); DP(PanelVfoButtons); DP(PanelBands); DP(PanelMode); DP(PanelFilter); + if PanelPlutoAGC <> nil then DP(PanelPlutoAGC); if PanelFMSQ <> nil then DP(PanelFMSQ); if PanelRawDev <> nil then DP(PanelRawDev); if PanelFMCTCSS <> nil then DP(PanelFMCTCSS); @@ -3186,17 +3193,30 @@ begin DP(PanelRXBlock); DP(PanelAGC); DP(PanelTX); DP(PanelRight); - if PbSpectrum is TPaintBox then - TPaintBox(PbSpectrum).Color := T.BG; - if PbWaterfall is TPaintBox then - TPaintBox(PbWaterfall).Color := T.BG; if PanelSMeterRight <> nil then PanelSMeterRight.Color := T.Panel; if PbSMeterRight <> nil then PbSMeterRight.Color := T.Panel; ApplyStatusTheme(T); - // Метки + // Заголовки групп и подписи строк. + DPL(PanelTX); + DPL(PanelRXBlock); + DPL(PanelAGC); + DPL(PanelPlutoAGC); + DPL(PanelBands); + DPL(PanelMode); + DPL(PanelFilter); + DPL(PanelRawDev); + DPL(PanelFMStep); + DPL(PanelFMSQ); + DPL(PanelFMCTCSS); + DPL(PanelFMRpt); + // Значения справа от ползунков ярче обычных подписей. DL(LblAGCTop, False); DL(LblDrv); + DL(LblDrvVal, False); + DL(LblAttVal, False); + DL(LblRxGain, False); + DL(LblRawDev, False); if LblFMSQ <> nil then DL(LblFMSQ, False); // Кнопки тулбара @@ -3208,23 +3228,39 @@ begin BtnDiscover.ClrTextAct := T.TbDiscoverText; BtnDiscover.Invalidate; BtnStartStop.ClrNorm := T.TbStartNorm; + BtnStartStop.ClrActive := T.TbStartNorm; + BtnStartStop.ClrHot := T.TbStartNorm; BtnStartStop.ClrBorder := T.TbStartBorder; BtnStartStop.ClrText := T.TbStartText; + BtnStartStop.ClrTextAct:= T.TbStartText; BtnStartStop.Invalidate; + StyleButton(BtnSettings, False); // Обычные кнопки — все массивы for i := 0 to BAND_COUNT-1 do StyleButton(BtnBand[i], BtnBand[i].Active); + for i := 0 to CFG_XVTR_COUNT - 1 do + if BtnXvtrBand[i] <> nil then + StyleButton(BtnXvtrBand[i], BtnXvtrBand[i].Active); for i := 0 to MODE_COUNT-1 do StyleButton(BtnMode[i], BtnMode[i].Active); for i := 0 to FILT_SLOTS-1 do StyleButton(BtnFilter[i], BtnFilter[i].Active); for i := 0 to 4 do StyleButton(BtnAGCMode[i],BtnAGCMode[i].Active); StyleButton(BtnCTun, BtnCTun.Active); StyleButton(BtnDUP, BtnDUP.Active); + StyleButton(BtnPS, BtnPS.Active); + StyleButton(Btn2Ton, Btn2Ton.Active); + StyleButton(BtnRxMute, BtnRxMute.Active); + StyleButton(BtnBeacon, BtnBeacon.Active); if BtnFMSQ <> nil then StyleButton(BtnFMSQ, BtnFMSQ.Active); if TrkFMSQ <> nil then DS(TrkFMSQ); if BtnFMCTCSS <> nil then StyleButton(BtnFMCTCSS, BtnFMCTCSS.Active); if BtnFMCTCSSTone <> nil then StyleButton(BtnFMCTCSSTone, False); + if BtnFMStep <> nil then StyleButton(BtnFMStep, BtnFMStep.Active); + if BtnFMStepSel <> nil then StyleButton(BtnFMStepSel, False); if BtnFMRptMinus <> nil then StyleButton(BtnFMRptMinus, BtnFMRptMinus.Active); if BtnFMRptPlus <> nil then StyleButton(BtnFMRptPlus, BtnFMRptPlus.Active); + for i := 0 to 3 do + if BtnPlutoGain[i] <> nil then + StyleButton(BtnPlutoGain[i], BtnPlutoGain[i].Active); if FCTCSSDropDown <> nil then FCTCSSDropDown.ApplyStyle(StyleButton, T.Panel); if FStepDropDown <> nil then FStepDropDown.ApplyStyle(StyleButton, T.Panel); if BtnZoomIn <> nil then StyleButton(BtnZoomIn, False); @@ -3234,6 +3270,15 @@ begin for i := 0 to MAX_PANS - 1 do if FPans[i] <> nil then begin + FPans[i].View.SetTheme(T); + if FPans[i].PbSpectrum is TPaintBox then + TPaintBox(FPans[i].PbSpectrum).Color := T.BG; + if FPans[i].PbWaterfall is TPaintBox then + TPaintBox(FPans[i].PbWaterfall).Color := T.BG; + if FPans[i].Splitter <> nil then FPans[i].Splitter.Color := T.Border; + if FPans[i].BtnZoomIn <> nil then StyleButton(FPans[i].BtnZoomIn, False); + if FPans[i].BtnZoomDef <> nil then StyleButton(FPans[i].BtnZoomDef, False); + if FPans[i].BtnZoomOut <> nil then StyleButton(FPans[i].BtnZoomOut, False); if FPans[i].BtnAddPan <> nil then StyleButton(FPans[i].BtnAddPan, False); if FPans[i].BtnGrid <> nil then StyleButton(FPans[i].BtnGrid, False); if FPans[i].BtnPopPan <> nil then StyleButton(FPans[i].BtnPopPan, False); @@ -3250,6 +3295,8 @@ begin if FPans[i].ZoomBar <> nil then FPans[i].ZoomBar.SetTheme(T); if FPans[i].PbPanZoom <> nil then FPans[i].PbPanZoom.Color := T.SliderBG; end; + // Тема инвалидирует кэши и GL-текстуры сама; историю водопада сохраняем. + MarkPanDisplayDirty(FPans[i], False); end; if FPanZoomBar <> nil then begin @@ -3275,9 +3322,9 @@ begin // Ползунки DS(TrkAGC); DS(TrkVolume); DS(TrkDrive); + DS(TrkAtt); DS(TrkRxGain); DS(TrkRawDev); - // Спектр - FSpecView.SetTheme(T); + // Wideband не входит в стек обычных панов. if Assigned(FWidebandView) then FWidebandView.SetTheme(T); // Флаги слайсов (оверлеи) — цвета кнопок из темы, как левая панель. @@ -3325,11 +3372,17 @@ begin end; procedure TMainForm.SetLightTheme(V: Boolean); +var + T: TAppTheme; begin if FLightTheme = V then Exit; FLightTheme := V; FController.FLoadedGlobal.LightTheme := V; // зеркало в persist-буфер контроллера ApplyDarkTheme; + T := CurrentAppTheme; + if FDeviceDialog <> nil then FDeviceDialog.SetTheme(T); + if FChannelsForm <> nil then TChannelsForm(FChannelsForm).ApplyTheme(T); + if FBeaconScopeForm <> nil then FBeaconScopeForm.ApplyTheme(T); FController.FSettings.SaveTheme(V); FController.FSettings.Save; if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then @@ -3348,7 +3401,6 @@ begin else B.ClrBorder := T.BtnBorderNorm; B.ClrText := T.BtnText; B.ClrTextAct := T.BtnTextActive; - B.Font.Name := 'Courier New'; B.Font.Size := 8; end; @@ -3363,8 +3415,7 @@ begin B.ClrBorder := T.SpanBorder; B.ClrText := T.SpanText; B.ClrTextAct := T.SpanText; - B.Font.Name := 'Courier New'; - B.Font.Size := 7; + B.Font.Size := 8; end; procedure TMainForm.PositionSampleRateOverlay; @@ -4568,11 +4619,15 @@ end; procedure TMainForm.LayoutFilterButtons(PresetCount: Integer); var i, Total, Cols, Rows, RowOf, ColOf, InRow: Integer; + HeaderH, RowStep, BottomPad: Integer; begin Total := PresetCount + 2; // + VAR1/VAR2 if Total <= FILT_GRID_COLS then Cols := Total // FM/WFM: всё в один ряд else Cols := FILT_GRID_COLS; Rows := (Total + Cols - 1) div Cols; + HeaderH := PanelFilter.Scale96ToForm(20); + RowStep := PanelFilter.Scale96ToForm(27); + BottomPad := PanelFilter.Scale96ToForm(4); for i := 0 to FILT_SLOTS - 1 do begin if i < PresetCount then InRow := i // пресет на своём месте @@ -4583,12 +4638,12 @@ begin ColOf := InRow mod Cols; BtnFilter[i].Left := LeftPanelButtonLeft(PanelFilter.Width, Cols, ColOf); BtnFilter[i].Width := LeftPanelButtonWidth(PanelFilter.Width, Cols, ColOf); - BtnFilter[i].Top := 16 + RowOf * 27; + BtnFilter[i].Top := HeaderH + RowOf * RowStep; BtnFilter[i].Caption := FController.FilterSlot(FController.FMode, i).Name; BtnFilter[i].Visible := True; StyleButton(BtnFilter[i], i = FController.FFilter); end; - PanelFilter.Height := 16 + Rows * 27 + 4; + PanelFilter.Height := HeaderH + Rows * RowStep + BottomPad; end; procedure TMainForm.UpdateFilterButtons; @@ -4634,7 +4689,7 @@ begin // Non-FM: restore full 2-row height and 5-column layout, hide FM panels. // DMR reuses only the tuning STEP panel; SQL/CTCSS/repeater stay FM-only. - PanelFilter.Height := 74; + PanelFilter.Height := PanelFilter.Scale96ToForm(74); if PanelFMSQ <> nil then PanelFMSQ.Visible := False; if PanelFMCTCSS <> nil then PanelFMCTCSS.Visible := False; if PanelFMStep <> nil then @@ -4691,7 +4746,7 @@ begin // Прочие режимы: смена режима возвращает заводской индекс фильтра (как было // до таблицы), кромки и подписи приезжают из таблицы контроллера. - PanelFilter.Height := 74; + PanelFilter.Height := PanelFilter.Scale96ToForm(74); DefIdx := FilterDefIdx(FController.FMode); FController.SelectFilterSlotState(DefIdx); LayoutFilterButtons(FILT_COUNT); @@ -4740,7 +4795,7 @@ begin begin if FBeaconScopeForm = nil then FBeaconScopeForm := TBeaconScopeForm.CreateWith(Self, FController); - FBeaconScopeForm.ApplyTheme(DarkTheme); + FBeaconScopeForm.ApplyTheme(CurrentAppTheme); FBeaconScopeForm.Show; end; UpdateBeaconButton; @@ -4755,7 +4810,7 @@ begin if not FController.BeaconLockEnabled then Exit; if FBeaconScopeForm = nil then FBeaconScopeForm := TBeaconScopeForm.CreateWith(Self, FController); - FBeaconScopeForm.ApplyTheme(DarkTheme); + FBeaconScopeForm.ApplyTheme(CurrentAppTheme); if FBeaconScopeForm.Visible then FBeaconScopeForm.Hide else FBeaconScopeForm.Show; end; @@ -5712,7 +5767,7 @@ begin Def.Power := 100; Def.Name := ''; TChannelsForm(FChannelsForm).SetCurrentDefaults(Def); - TChannelsForm(FChannelsForm).ApplyTheme(DarkTheme); + TChannelsForm(FChannelsForm).ApplyTheme(CurrentAppTheme); TChannelsForm(FChannelsForm).Show; end; @@ -8239,7 +8294,7 @@ end; procedure TMainForm.RebuildXvtrButtons; var i, Pos, XvtrRows: Integer; - BtnH, RowH, MaxBottom: Integer; + BtnH, RowH, MaxBottom, RowGap, TopGap, BottomPad: Integer; B: TFlatButton; Cap: string; begin @@ -8264,7 +8319,10 @@ begin BtnH := BtnBand[i].Height; end; if BtnH = 0 then BtnH := DpiScale(24); - RowH := BtnH + 3; // шаг ряда: высота кнопки + 3px зазор (как 27=24+3 при 1x) + RowGap := PanelBands.Scale96ToForm(3); + TopGap := PanelBands.Scale96ToForm(4); + BottomPad := PanelBands.Scale96ToForm(6); + RowH := BtnH + RowGap; Pos := 0; // позиция среди enabled слотов for i := 0 to CFG_XVTR_COUNT - 1 do @@ -8277,7 +8335,7 @@ begin B.Parent := PanelBands; B.Caption := Cap; B.Left := LeftPanelButtonLeft(PanelBands.Width, 6, Pos mod 6); - B.Top := MaxBottom + 4 + (Pos div 6) * RowH; + B.Top := MaxBottom + TopGap + (Pos div 6) * RowH; B.Width := LeftPanelButtonWidth(PanelBands.Width, 6, Pos mod 6); B.Height := BtnH; B.OnClick := BtnXvtrBandClick; @@ -8291,9 +8349,9 @@ begin else if Pos <= 6 then XvtrRows := 1 else XvtrRows := 2; if XvtrRows = 0 then - PanelBands.Height := MaxBottom + 6 + PanelBands.Height := MaxBottom + BottomPad else - PanelBands.Height := MaxBottom + 4 + XvtrRows * RowH + 6; + PanelBands.Height := MaxBottom + TopGap + XvtrRows * RowH + BottomPad; RelayoutBelowBands; end; From a0f4b044db4f5a327ad1a4843dc2a18e0f50ec12 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 5 Aug 2026 16:07:25 +0300 Subject: [PATCH 27/30] ui(spectrum): theme sample rate overlay --- MainForm.pas | 1 + SampleRateOverlay.pas | 69 ++++++++++++++++++++++++++++--------------- 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/MainForm.pas b/MainForm.pas index 84ae375..4601580 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -3180,6 +3180,7 @@ begin if FLeftScrollBar <> nil then FLeftScrollBar.SetColors(T.Panel, T.Border, T.SliderThumbHot, T.SliderThumbDrag); + if FSampleRateOverlay <> nil then FSampleRateOverlay.SetTheme(T); DP(PanelTopVfoGroup); DP(PanelTopVfoA); DP(PanelTopVfoB); DP(PanelVfoA); DP(PanelVfoB); DP(PanelVfoButtons); DP(PanelBands); DP(PanelMode); DP(PanelFilter); diff --git a/SampleRateOverlay.pas b/SampleRateOverlay.pas index 89a10bf..e9fb73d 100644 --- a/SampleRateOverlay.pas +++ b/SampleRateOverlay.pas @@ -13,7 +13,7 @@ unit SampleRateOverlay; interface uses - Classes, SysUtils, Controls, Graphics, Types, Math; + Classes, SysUtils, Controls, Graphics, Types, Math, AppTheme; type TSpanSelectEvent = procedure(SampleRate: Integer) of object; @@ -35,6 +35,7 @@ type FTop: Integer; FWidth: Integer; FHeight: Integer; + FTheme: TAppTheme; FHitRects: array[0..10] of TRect; // [0]=hide button, [1..]=span buttons FHitValues: array[0..10] of Integer; // 0=hide, else sample rate @@ -76,6 +77,7 @@ type procedure SetRatePresets(const Rates: array of Integer); procedure SetRatePresetsDefault; // HPSDR 48k..1536k procedure SetPanelHidden(Hidden: Boolean); + procedure SetTheme(const T: TAppTheme); property Left: Integer read FLeft; property Top: Integer read FTop; property Width: Integer read FWidth; @@ -97,9 +99,6 @@ const PAD_Y = 4; BTN_GAP = 3; - CLR_TEXT = TColor($00D8E6EE); - CLR_BTN_BDR = TColor($00685624); - procedure BlendRect(Target: TBitmap; const Rct: TRect; R, G, B, Alpha: Byte); var Y, X, InvA: Integer; @@ -141,6 +140,16 @@ begin end; end; +procedure BlendColorRect(Target: TBitmap; const Rct: TRect; + Color: TColor; Alpha: Byte); +var + C: LongInt; +begin + C := ColorToRGB(Color); + BlendRect(Target, Rct, C and $FF, (C shr 8) and $FF, + (C shr 16) and $FF, Alpha); +end; + function SpanBtnWidth(const Name: string; C: TCanvas): Integer; begin C.Font.Name := 'Courier New'; @@ -160,6 +169,7 @@ begin FHotIdx := -1; FWidth := 320; FHeight := SPAN_H + PAD_Y * 2; + FTheme := DarkTheme; FCache := TBitmap.Create; FCache.PixelFormat := pf32bit; FCacheW := 0; @@ -224,30 +234,34 @@ begin if ContentW > W then ContentW := W; FullR := Rect(OriginX, OriginY, OriginX + ContentW, OriginY + H); - BlendRect(Target, FullR, $14, $14, $14, 104); - BlendRect(Target, Rect(FullR.Left, FullR.Top, FullR.Right, FullR.Top + 1), - $80, $80, $80, 56); - BlendRect(Target, Rect(FullR.Left, FullR.Bottom - 1, FullR.Right, FullR.Bottom), - $00, $00, $00, 48); - BlendRect(Target, Rect(FullR.Left, FullR.Top, FullR.Left + 1, FullR.Bottom), - $80, $80, $80, 42); - BlendRect(Target, Rect(FullR.Right - 1, FullR.Top, FullR.Right, FullR.Bottom), - $00, $00, $00, 42); + BlendColorRect(Target, FullR, FTheme.Panel, 210); + BlendColorRect(Target, + Rect(FullR.Left, FullR.Top, FullR.Right, FullR.Top + 1), + FTheme.Border, 150); + BlendColorRect(Target, + Rect(FullR.Left, FullR.Bottom - 1, FullR.Right, FullR.Bottom), + FTheme.Border, 120); + BlendColorRect(Target, + Rect(FullR.Left, FullR.Top, FullR.Left + 1, FullR.Bottom), + FTheme.Border, 120); + BlendColorRect(Target, + Rect(FullR.Right - 1, FullR.Top, FullR.Right, FullR.Bottom), + FTheme.Border, 120); X := OriginX + PAD_X; HR := Rect(X, OriginY + PAD_Y, X + HIDE_W, OriginY + PAD_Y + SPAN_H); IsHot := FHotIdx = 0; - if IsHot then BlendRect(Target, HR, $1D, $4B, $62, 206) - else BlendRect(Target, HR, $18, $3F, $54, 182); + if IsHot then BlendColorRect(Target, HR, FTheme.SpanActive, 230) + else BlendColorRect(Target, HR, FTheme.SpanNorm, 215); C.Pen.Style := psSolid; - C.Pen.Color := CLR_BTN_BDR; + C.Pen.Color := FTheme.SpanBorder; C.Brush.Style := bsClear; C.Rectangle(HR); C.Font.Size := 8; if FPanelHidden then Cap := '>' else Cap := '<'; TW := C.TextWidth(Cap); - C.Font.Color := CLR_TEXT; + C.Font.Color := FTheme.SpanText; C.Brush.Style := bsClear; C.TextOut(HR.Left + (HIDE_W - TW) div 2, HR.Top + (SPAN_H - TH) div 2, Cap); @@ -262,16 +276,16 @@ begin IsAct := FRates[I] = FCurrentRate; IsHot := FHotIdx = I + 1; - if IsAct then BlendRect(Target, HR, $20, $5A, $78, 220) - else if IsHot then BlendRect(Target, HR, $1D, $4B, $62, 206) - else BlendRect(Target, HR, $18, $3F, $54, 182); + if IsAct then BlendColorRect(Target, HR, FTheme.SpanActive, 240) + else if IsHot then BlendColorRect(Target, HR, FTheme.SpanActive, 230) + else BlendColorRect(Target, HR, FTheme.SpanNorm, 215); C.Pen.Style := psSolid; - C.Pen.Color := CLR_BTN_BDR; + C.Pen.Color := FTheme.SpanBorder; C.Brush.Style := bsClear; C.Rectangle(HR); TW := C.TextWidth(FNames[I]); - C.Font.Color := CLR_TEXT; + C.Font.Color := FTheme.SpanText; C.Brush.Style := bsClear; C.TextOut(HR.Left + (BtnW - TW) div 2, HR.Top + (SPAN_H - TH) div 2, FNames[I]); @@ -470,7 +484,7 @@ begin ContentW := Min(ContentW, W); Target.SetSize(ContentW, H); - Target.Canvas.Brush.Color := TColor($00141414); + Target.Canvas.Brush.Color := FTheme.Panel; Target.Canvas.Brush.Style := bsSolid; Target.Canvas.Pen.Style := psClear; Target.Canvas.FillRect(Rect(0, 0, ContentW, H)); @@ -480,7 +494,7 @@ begin try HitTarget.PixelFormat := pf32bit; HitTarget.SetSize(Max(1, FLeft + ContentW), Max(1, FTop + H)); - HitTarget.Canvas.Brush.Color := TColor($00141414); + HitTarget.Canvas.Brush.Color := FTheme.Panel; HitTarget.Canvas.FillRect(Rect(0, 0, HitTarget.Width, HitTarget.Height)); DrawSelf(HitTarget, HitTarget.Canvas, FLeft, FTop, ContentW, H); finally @@ -575,4 +589,11 @@ begin RequestInvalidate; end; +procedure TSampleRateOverlay.SetTheme(const T: TAppTheme); +begin + FTheme := T; + FCacheDirty := True; + RequestInvalidate; +end; + end. From 19babef4d70fd80b7159508dee4209706879309b Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 5 Aug 2026 16:11:57 +0300 Subject: [PATCH 28/30] ui(status): use system font --- StatusBar.pas | 1 - 1 file changed, 1 deletion(-) diff --git a/StatusBar.pas b/StatusBar.pas index adcdd82..835f86e 100644 --- a/StatusBar.pas +++ b/StatusBar.pas @@ -84,7 +84,6 @@ begin FLabels[Index].Alignment := taLeftJustify; FLabels[Index].Layout := tlCenter; FLabels[Index].Caption := Cap; - FLabels[Index].Font.Name := 'Courier New'; FLabels[Index].Font.Size := 8; end; From 7c9064659b4a08adf9a016d9e9dd174d0277c8de Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 5 Aug 2026 16:51:43 +0300 Subject: [PATCH 29/30] ui(main): align left panel sliders --- MainForm.pas | 62 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/MainForm.pas b/MainForm.pas index 4601580..6f78e61 100644 --- a/MainForm.pas +++ b/MainForm.pas @@ -334,6 +334,7 @@ type TrkRxGain: TFlatSlider; // manual gain 0..73 dB LblRxGain: TLabel; // значение усиления TrkVolume: TFlatSlider; + LblVolume: TLabel; BtnNR: TFlatButton; BtnNB: TFlatButton; BtnSNB: TFlatButton; @@ -751,6 +752,7 @@ type procedure TrkDriveChange(Sender: TObject); procedure UpdateDriveLabel; // % (openHPSDR) / дБ (Pluto) рядом с ползунком drive procedure TrkVolumeChange(Sender: TObject); + procedure UpdateVolumeLabel; function CalcDriveByte: Byte; function ActiveTXFreqHz: Double; function DefaultMicSource: TTXMicSource; @@ -1603,7 +1605,10 @@ begin rfAGCMode: for i := 0 to 4 do StyleButton(BtnAGCMode[i], i = FController.FAGCMode); rfVolume: - TrkVolume.Position := FController.ActiveVolume; // RX или TX-монитор + begin + TrkVolume.Position := FController.ActiveVolume; // RX или TX-монитор + UpdateVolumeLabel; + end; rfRxGain: UpdateRxGainUI; rfAtten: @@ -1762,6 +1767,7 @@ begin TrkDrive.Position := FController.FDrivePercent; // слайдер ← drive% TrkVolume.Position := FController.ActiveVolume; // слайдер ← загруженная громкость (был баг: не синхронизировался) UpdateDriveLabel; // переключаем единицу %/дБ под бэкенд + UpdateVolumeLabel; if Assigned(FSampleRateOverlay) then FSampleRateOverlay.SetCurrentRate(FController.FSampleRate); UpdateNRButton; UpdateNBButton; UpdateSNBButton; UpdateANFButton; @@ -1872,6 +1878,9 @@ const // Заголовок с отступом (20) + MODE_ROWS рядов с шагом 27 + нижний отступ. MODE_PANEL_H = 20 + (MODE_ROWS - 1) * 27 + BTN_SM + 3; PLUTO_AGC_H = 64; // высота отдельного Pluto RF-AGC блока (под AGC) + LEFT_SLIDER_LEFT = 56; + LEFT_SLIDER_W = LEFT_W - 96; + LEFT_SLIDER_VALUE_LEFT = LEFT_W - 38; TOP_VFO_W = 244; TOP_VFO_FREQ_H = 47; var @@ -2161,14 +2170,14 @@ begin LblDrv.Font.Size := 8; TrkDrive := TFlatSlider.Create(Self); - TrkDrive.Parent := PanelTX; TrkDrive.Left := 34; TrkDrive.Top := 78; - TrkDrive.Width := 120; TrkDrive.Height := 16; + TrkDrive.Parent := PanelTX; TrkDrive.Left := LEFT_SLIDER_LEFT; TrkDrive.Top := 78; + TrkDrive.Width := LEFT_SLIDER_W; TrkDrive.Height := 16; TrkDrive.Min := 0; TrkDrive.Max := 100; TrkDrive.Position := 50; TrkDrive.OnChange := TrkDriveChange; LblDrvVal := TLabel.Create(Self); LblDrvVal.Parent := PanelTX; - LblDrvVal.Left := 158; LblDrvVal.Top := 77; + LblDrvVal.Left := LEFT_SLIDER_VALUE_LEFT; LblDrvVal.Top := 77; LblDrvVal.Font.Color := CLR_TEXT; LblDrvVal.Font.Size := 8; UpdateDriveLabel; @@ -2213,12 +2222,19 @@ begin MakeLbl(PanelRXBlock, 'VOL', 4, 50); TrkVolume := TFlatSlider.Create(Self); - TrkVolume.Parent := PanelRXBlock; TrkVolume.Left := 34; TrkVolume.Top := 50; - TrkVolume.Width := LEFT_W - 38; TrkVolume.Height := 16; + TrkVolume.Parent := PanelRXBlock; TrkVolume.Left := LEFT_SLIDER_LEFT; TrkVolume.Top := 50; + TrkVolume.Width := LEFT_SLIDER_W; TrkVolume.Height := 16; TrkVolume.Min := 0; TrkVolume.Max := 100; - TrkVolume.Position := FController.FVolume; + TrkVolume.Position := FController.ActiveVolume; TrkVolume.OnChange := TrkVolumeChange; + LblVolume := TLabel.Create(Self); + LblVolume.Parent := PanelRXBlock; + LblVolume.Left := LEFT_SLIDER_VALUE_LEFT; LblVolume.Top := 50; + LblVolume.Font.Color := CLR_TEXT; + LblVolume.Font.Size := 8; + UpdateVolumeLabel; + BtnNR := MakeBtn(PanelRXBlock, 'NR', LeftPanelButtonLeft(LEFT_W, 5, 0), 74, LeftPanelButtonWidth(LEFT_W, 5, 0), BTN_SM, BtnNRClick); @@ -2244,15 +2260,15 @@ begin LblAtt.Font.Size := 8; TrkAtt := TFlatSlider.Create(Self); - TrkAtt.Parent := PanelRXBlock; TrkAtt.Left := 34; TrkAtt.Top := 106; - TrkAtt.Width := LEFT_W - 76; TrkAtt.Height := 16; + TrkAtt.Parent := PanelRXBlock; TrkAtt.Left := LEFT_SLIDER_LEFT; TrkAtt.Top := 106; + TrkAtt.Width := LEFT_SLIDER_W; TrkAtt.Height := 16; TrkAtt.Min := 0; TrkAtt.Max := 31; // ADC step attenuator, дБ TrkAtt.Position := FController.FAtten; TrkAtt.OnChange := TrkAttChange; LblAttVal := TLabel.Create(Self); LblAttVal.Parent := PanelRXBlock; - LblAttVal.Left := LEFT_W - 38; LblAttVal.Top := 106; + LblAttVal.Left := LEFT_SLIDER_VALUE_LEFT; LblAttVal.Top := 106; LblAttVal.Font.Color := CLR_TEXT; LblAttVal.Font.Size := 8; UpdateAttLabel; @@ -2297,8 +2313,8 @@ begin // AGC level slider MakeLbl(PanelAGC, 'THRESH', 4, 44); TrkAGC := TFlatSlider.Create(Self); - TrkAGC.Parent := PanelAGC; TrkAGC.Left := 56; TrkAGC.Top := 44; - TrkAGC.Width := LEFT_W - 96; TrkAGC.Height := 16; + TrkAGC.Parent := PanelAGC; TrkAGC.Left := LEFT_SLIDER_LEFT; TrkAGC.Top := 44; + TrkAGC.Width := LEFT_SLIDER_W; TrkAGC.Height := 16; TrkAGC.Min := 20; TrkAGC.Max := 120; // 20..120 → −20..-120 dBm TrkAGC.Position := FController.FAGCTop; TrkAGC.Reversed := True; @@ -2306,7 +2322,7 @@ begin LblAGCTop := TLabel.Create(Self); LblAGCTop.Parent := PanelAGC; - LblAGCTop.Left := LEFT_W - 38; LblAGCTop.Top := 44; + LblAGCTop.Left := LEFT_SLIDER_VALUE_LEFT; LblAGCTop.Top := 44; LblAGCTop.Caption := Format('-%ddB', [FController.FAGCTop]); LblAGCTop.Font.Color := CLR_TEXT; LblAGCTop.Font.Size := 8; @@ -2341,14 +2357,14 @@ begin // Manual gain slider. MakeLbl(PanelPlutoAGC, 'GAIN', 4, 44); TrkRxGain := TFlatSlider.Create(Self); - TrkRxGain.Parent := PanelPlutoAGC; TrkRxGain.Left := 56; TrkRxGain.Top := 44; - TrkRxGain.Width := LEFT_W - 96; TrkRxGain.Height := 16; + TrkRxGain.Parent := PanelPlutoAGC; TrkRxGain.Left := LEFT_SLIDER_LEFT; TrkRxGain.Top := 44; + TrkRxGain.Width := LEFT_SLIDER_W; TrkRxGain.Height := 16; TrkRxGain.Min := 0; TrkRxGain.Max := 73; // AD936x hardwaregain dB TrkRxGain.Position := FController.FRxGainDb; TrkRxGain.OnChange := TrkRxGainChange; LblRxGain := TLabel.Create(Self); LblRxGain.Parent := PanelPlutoAGC; - LblRxGain.Left := LEFT_W - 38; LblRxGain.Top := 44; + LblRxGain.Left := LEFT_SLIDER_VALUE_LEFT; LblRxGain.Top := 44; LblRxGain.Caption := Format('%ddB', [FController.FRxGainDb]); LblRxGain.Font.Color := CLR_TEXT; LblRxGain.Font.Size := 8; @@ -2431,9 +2447,9 @@ begin MakeLbl(PanelRawDev, 'DEVIATION', 4, 2); TrkRawDev := TFlatSlider.Create(Self); TrkRawDev.Parent := PanelRawDev; - TrkRawDev.Left := 5; + TrkRawDev.Left := 34; TrkRawDev.Top := 22; - TrkRawDev.Width := LEFT_W - 68; + TrkRawDev.Width := LEFT_SLIDER_W; TrkRawDev.Height := 14; TrkRawDev.Min := RAW_FM_DEV_MIN div 50; TrkRawDev.Max := RAW_FM_DEV_MAX div 50; @@ -2467,7 +2483,7 @@ begin TrkFMSQ.Parent := PanelFMSQ; TrkFMSQ.Left := BtnFMSQ.Left + BtnFMSQ.Width + 3; TrkFMSQ.Top := 25; - TrkFMSQ.Width := LEFT_W - TrkFMSQ.Left - 30 - 4; // leave 30px for label + margins + TrkFMSQ.Width := LEFT_SLIDER_W; TrkFMSQ.Height := 14; TrkFMSQ.Min := 0; TrkFMSQ.Max := 100; TrkFMSQ.Position := FController.FFMSQLevel; @@ -3215,6 +3231,7 @@ begin DL(LblAGCTop, False); DL(LblDrv); DL(LblDrvVal, False); + DL(LblVolume, False); DL(LblAttVal, False); DL(LblRxGain, False); DL(LblRawDev, False); @@ -8495,6 +8512,13 @@ procedure TMainForm.TrkVolumeChange(Sender: TObject); begin if FSyncingFromController then Exit; FController.SetVolume(TrkVolume.Position); + UpdateVolumeLabel; +end; + +procedure TMainForm.UpdateVolumeLabel; +begin + if LblVolume <> nil then + LblVolume.Caption := Format('%d%%', [FController.ActiveVolume]); end; // =========================================================================== From 6195c80ce3b9b0528ffb3a69afa042858490ad3f Mon Sep 17 00:00:00 2001 From: Vladimir Date: Wed, 5 Aug 2026 19:09:31 +0300 Subject: [PATCH 30/30] =?UTF-8?q?fix(tx):=20=D0=BF=D0=BE=D0=BB=D0=BE=D1=81?= =?UTF-8?q?=D0=B0=20=D0=A7=D0=9C=20=D0=BD=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B0=D1=87=D0=B5=20=E2=80=94=20=D0=B7=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=BC=D0=B0=D0=B5=D0=BC=D0=B0=D1=8F=20=D0=BF=D0=BE=D0=BB=D0=BE?= =?UTF-8?q?=D1=81=D0=B0,=20=D0=B0=20=D0=BD=D0=B5=20=D0=BA=D1=80=D0=BE?= =?UTF-8?q?=D0=BC=D0=BA=D0=B8=20bp0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TXFilterEdgesHz была тонкой обёрткой над TXSignedEdges, то есть отдавала в отрисовку кромки аудио-полосовика перед модулятором. У SSB/CW/DIGI/AM это то же самое, что полоса вокруг несущей, а у ЧМ — нет: FM RAW рисовался как (20..7000), WFM как (20..15000), то есть полоса уезжала вверх от несущей и визуально «переключалась на USB». Теперь у экранных кромок своя семантика — Карсон ±(девиация + верхний срез аудио) для FM/WFM/FM RAW, остальные режимы как были. TXSignedEdges и SetTXABandpassFreqs не тронуты: bp0 остаётся портом Thetis, в эфире ничего не меняется. Попутно 20.0/7000.0 из трёх мест FM RAW сведены в RAW_AF_LOW/RAW_AF_HIGH. Co-Authored-By: Claude Opus 5 --- WDSPEngine.pas | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/WDSPEngine.pas b/WDSPEngine.pas index 46c3a2b..6607bc1 100644 --- a/WDSPEngine.pas +++ b/WDSPEngine.pas @@ -78,6 +78,10 @@ const WFM_AF_HIGH = 15000.0; WFM_TAU_DE_US = 50.0e-6; // Европа/Россия (Америка — 75 мкс) DMR_DEVIATION = 2500.0; + // FM RAW: плоский baseband для внешних декодеров — одна величина на RX-AF, + // TX-AF и bp0 (раньше 20/7000 стояли числами в трёх местах). + RAW_AF_LOW = 20.0; + RAW_AF_HIGH = 7000.0; // Потолок dsp_rate для WFM. Вещательному каналу (200 кГц) хватает 192 кГц, // а гнать цепь на полном IQ-rate нельзя: при 576 кГц dsp_size стал бы 12288, // и fircore спланировал бы FFT на 2*12288 = 24576 точек. Это не степень @@ -1167,7 +1171,7 @@ procedure TWDSPEngine.ApplyRXRawToChan(Chan: Integer); begin // Decoder-grade discriminator: no broadcast de-emphasis or limiter. SetRXAWFMDeviation (Chan, FRawFMDeviation); - SetRXAWFMAFFilter (Chan, 20.0, 7000.0); + SetRXAWFMAFFilter (Chan, RAW_AF_LOW, RAW_AF_HIGH); SetRXAWFMDeemphRun (Chan, 0); SetRXAWFMLimRun (Chan, 0); end; @@ -1221,7 +1225,7 @@ begin // Плоский baseband для внешних декодеров (совпадает с RX AF в ApplyRXRawToChan // и с явным SetTXABandpassFreqs в ApplyTXModeSettings). РЧ-ширина FILT_RAW_BW // (6..25 кГц) — селективность RX, к аудио-полосовику отношения не имеет. - begin Lo := 20.0; Hi := 7000.0; end; + begin Lo := RAW_AF_LOW; Hi := RAW_AF_HIGH; end; else begin Lo := L; Hi := H; end; // прочее — как есть end; @@ -1236,9 +1240,9 @@ begin // selected peak deviation. Keep only a broad linear-phase AF bandpass; // APRS and other external modems must not see voice EQ, dynamics or emphasis. SetTXAWFMDeviation (TXA_CHAN, FRawFMDeviation); - SetTXAWFMAFFreqs (TXA_CHAN, 20.0, 7000.0); + SetTXAWFMAFFreqs (TXA_CHAN, RAW_AF_LOW, RAW_AF_HIGH); SetTXAWFMPreEmphRun (TXA_CHAN, 0); - SetTXABandpassFreqs (TXA_CHAN, 20.0, 7000.0); + SetTXABandpassFreqs (TXA_CHAN, RAW_AF_LOW, RAW_AF_HIGH); SetTXAPanelGain1 (TXA_CHAN, 1.0); SetTXACompressorRun (TXA_CHAN, 0); SetTXALevelerSt (TXA_CHAN, 0); @@ -3715,8 +3719,27 @@ begin end; procedure TWDSPEngine.TXFilterEdgesHz(out Lo, Hi: Integer); -var L, H: Double; +// ЭКРАННЫЕ кромки — занимаемая полоса вокруг несущей, а НЕ bp0. У SSB/CW/DIGI/ +// AM/DSB это одно и то же, поэтому здесь и стояла обёртка над TXSignedEdges. +// Но у ЧМ bp0 — АУДИО-полосовик ПЕРЕД модулятором: у FM RAW он (20..7000), у +// WFM (20..15000), и полоса на спектре уезжала вверх от несущей — визуально +// «переключалась на USB». Для ЧМ считаем по Карсону: ±(девиация + верхний срез +// аудио), симметрично несущей. У MODE_FM TXSignedEdges отдаёт ровно эту +// величину и без нас (порт Thetis), но пусть формула будет в одном месте. +var L, H, Half: Double; begin + Half := 0.0; + case FTXMode of + MODE_FM: Half := FTXFMDeviation + FTXFMHighCut; + MODE_WFM: Half := WFM_DEVIATION + WFM_AF_HIGH; + MODE_FMRAW: Half := FRawFMDeviation + RAW_AF_HIGH; + end; + if Half > 0.0 then + begin + Lo := -Round(Half); + Hi := Round(Half); + Exit; + end; TXSignedEdges(FTXMode, L, H); Lo := Round(L); Hi := Round(H);