diff --git a/BeaconScopeForm.pas b/BeaconScopeForm.pas index a5e3bf1..3bda2a9 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, FlatMemo; type TBeaconScopeForm = class(TForm) private FCtl: TRadioController; + FHeader: TPanel; + FTitle: TLabel; + FSubtitle: TLabel; FBox: TPaintBox; - FMemo: TMemo; // декодированный текст бюллетеня (AO-40 кадры) + FBulletin: TPanel; + FBulletinTitle: TLabel; + FBulletinHint: TLabel; + FMemo: TFlatMemo; // декодированный текст бюллетеня (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,34 +47,112 @@ 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. - FMemo := TMemo.Create(Self); - FMemo.Parent := Self; - FMemo.Align := alBottom; - FMemo.Height := 230; + 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 := 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.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; + FMemo.SetAppTheme(FTheme); FBox := TPaintBox.Create(Self); FBox.Parent := Self; FBox.Align := alClient; FBox.OnPaint := @DoPaint; + OnResize := @FormResize; + FormResize(nil); FTimer := TTimer.Create(Self); FTimer.Interval := 40; // ~25 Гц @@ -78,10 +163,39 @@ 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 + FMemo.SetAppTheme(T); + 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 - FMemo.Color := FTheme.BG; - FMemo.Font.Color := FTheme.Text; + 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 +248,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 +413,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. 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 остаётся валидным 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; diff --git a/EqualizerControl.pas b/EqualizerControl.pas index 1102e89..9f09cc0 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])); @@ -419,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; @@ -527,6 +636,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 +647,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 +659,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 +733,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 +749,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/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/FlatDropDown.pas b/FlatDropDown.pas index 92055b2..7826406 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; @@ -32,14 +32,17 @@ 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; 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,19 +57,23 @@ 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; + 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; @@ -78,25 +85,34 @@ 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 FPopup.Controls[0].Free; Count := Length(ANames); SetLength(FItemBtns, Count); - PopW := FPopupParent.ClientWidth; - BtnW := (PopW - 4) div FColumns; + // Popup может жить в контейнере с постоянно зарезервированным overlay- + // scrollbar gutter. Inset задаётся в 96-DPI координатах и пересчитывается + // при каждом наполнении: SetItems бывает и до, и после DPI-scale формы. + PopW := Max(1, FPopupParent.ClientWidth - + FPopupParent.Scale96ToForm(FRightInset96)); + 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; @@ -134,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; @@ -166,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/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/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. 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; 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 de6fb79..6f78e61 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,8 @@ const SMETER_MARGIN = 3; TOP_SMETER_GAP = 8; TOP_VFO_FONT = 25; + LEFT_SCROLL_GUTTER_W = 8; + LEFT_PANEL_CONTENT_W = 232; LEFT_PANEL_BTN_PAD = 3; LEFT_PANEL_BTN_GAP = 2; @@ -263,6 +266,8 @@ type // ---- Left panel ---- PanelLeft: TPanel; + FLeftScrollBar: TOverlayScrollBar; + FLeftScrollOffset: Integer; PanelVfoA: TPanel; LblVfoALabel: TLabel; @@ -329,6 +334,7 @@ type TrkRxGain: TFlatSlider; // manual gain 0..73 dB LblRxGain: TLabel; // значение усиления TrkVolume: TFlatSlider; + LblVolume: TLabel; BtnNR: TFlatButton; BtnNB: TFlatButton; BtnSNB: TFlatButton; @@ -356,6 +362,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 +527,19 @@ 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 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; @@ -610,6 +633,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; @@ -726,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; @@ -801,17 +828,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; @@ -821,12 +849,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; // =========================================================================== @@ -1245,6 +1275,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); @@ -1550,6 +1583,10 @@ begin SyncSpecViewFreq; // иначе полоса фильтра рисуется под старый режим UpdateTXControlAvailability; UpdateDMRStatus; + // Пилюля профиля во флаге A: привязка ключуется режимом, значит на + // смене модуляции она показывает уже другой профиль. Флаг главного + // приёмника живёт только на пане 0 — как в RefreshTxIndicators. + FPan.PushMainFlagExtState; end; rfFilter: begin @@ -1568,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: @@ -1582,7 +1622,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; @@ -1722,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; @@ -1739,6 +1785,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)); @@ -1827,10 +1874,13 @@ 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) + 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 @@ -1862,8 +1912,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 @@ -1909,9 +1958,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 @@ -1934,8 +1993,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); @@ -1945,8 +2003,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); @@ -1959,7 +2016,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; @@ -1991,8 +2048,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); @@ -2002,8 +2058,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); @@ -2016,7 +2071,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; @@ -2049,9 +2104,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; @@ -2063,23 +2118,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); @@ -2088,27 +2143,43 @@ 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 + // TX-профиль — свободные колонки 2..3 того же ряда (панель не растёт по + // высоте). Подпись = имя активного профиля, ЛКМ — список, ПКМ — настройки. + BtnTXProfile := MakeBtn(PanelTX, 'Default', + LeftPanelButtonLeft(LEFT_W, 4, 2), 48, + LeftPanelButtonLeft(LEFT_W, 4, 3) + + LeftPanelButtonWidth(LEFT_W, 4, 3) + - LeftPanelButtonLeft(LEFT_W, 4, 2), + BTN_SM, BtnTXProfileClick); + BtnTXProfile.OnMouseDown := BtnTXProfileMouseDown; + StyleButton(BtnTXProfile, False); + + FTXProfileDropDown := TFlatDropDown.Create(BtnTXProfile, PanelLeft, 1, BTN_SM, + LEFT_SCROLL_GUTTER_W); + FTXProfileDropDown.OnSelect := OnTXProfileDropDownSelect; + RenderTXProfile; + LblDrv := TLabel.Create(Self); LblDrv.Parent := PanelTX; LblDrv.Left := 4; LblDrv.Top := 77; LblDrv.Caption := 'DRV'; LblDrv.Font.Color := CLR_TEXTDIM; - LblDrv.Font.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.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.Font.Color := CLR_TEXT; LblDrvVal.Font.Name := 'Courier New'; - LblDrvVal.Font.Size := 7; + LblDrvVal.Left := LEFT_SLIDER_VALUE_LEFT; LblDrvVal.Top := 77; + LblDrvVal.Font.Color := CLR_TEXT; + LblDrvVal.Font.Size := 8; UpdateDriveLabel; Inc(Y, 100); @@ -2121,27 +2192,28 @@ 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; 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; // 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; @@ -2150,26 +2222,33 @@ begin MakeLbl(PanelRXBlock, 'VOL', 4, 50); TrkVolume := TFlatSlider.Create(Self); - TrkVolume.Parent := PanelRXBlock; TrkVolume.Left := 34; TrkVolume.Top := 46; - 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), 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 так и умеет, @@ -2178,20 +2257,20 @@ 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.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.Font.Color := CLR_TEXT; LblAttVal.Font.Name := 'Courier New'; - LblAttVal.Font.Size := 7; + LblAttVal.Left := LEFT_SLIDER_VALUE_LEFT; LblAttVal.Top := 106; + LblAttVal.Font.Color := CLR_TEXT; + LblAttVal.Font.Size := 8; UpdateAttLabel; Inc(Y, RX_BLOCK_H_ATT + 4); @@ -2205,27 +2284,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; @@ -2234,8 +2313,8 @@ begin // AGC level slider MakeLbl(PanelAGC, 'THRESH', 4, 44); TrkAGC := TFlatSlider.Create(Self); - TrkAGC.Parent := PanelAGC; TrkAGC.Left := 56; TrkAGC.Top := 40; - 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; @@ -2243,10 +2322,10 @@ 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.Name := 'Courier New'; - LblAGCTop.Font.Size := 7; + LblAGCTop.Font.Color := CLR_TEXT; + LblAGCTop.Font.Size := 8; Inc(Y, 74); @@ -2260,35 +2339,35 @@ 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.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.Name := 'Courier New'; - LblRxGain.Font.Size := 7; + LblRxGain.Font.Color := CLR_TEXT; + LblRxGain.Font.Size := 8; // НЕ Inc(Y): панель скрыта и наложена на позицию Bands; LayoutLeftPanel сдвинет. // Band selector. Высота резервирует 2 дополнительных ряда под XVTR-кнопки @@ -2303,7 +2382,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; @@ -2327,7 +2406,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; @@ -2348,7 +2427,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; @@ -2368,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; @@ -2384,7 +2463,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) @@ -2396,16 +2474,16 @@ 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.Width := LEFT_W - TrkFMSQ.Left - 30 - 4; // leave 30px for label + margins + TrkFMSQ.Top := 25; + TrkFMSQ.Width := LEFT_SLIDER_W; TrkFMSQ.Height := 14; TrkFMSQ.Min := 0; TrkFMSQ.Max := 100; TrkFMSQ.Position := FController.FFMSQLevel; @@ -2414,11 +2492,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) @@ -2429,18 +2506,19 @@ 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); 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; @@ -2453,18 +2531,19 @@ 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); 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; @@ -2477,18 +2556,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); @@ -2589,6 +2668,7 @@ end; procedure TMainForm.RightPanelResize(Sender: TObject); begin + UpdateLeftPanelScroll; ResizeSpectrumPanels; end; @@ -3092,6 +3172,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); @@ -3104,9 +3193,14 @@ 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); + if FSampleRateOverlay <> nil then FSampleRateOverlay.SetTheme(T); DP(PanelTopVfoGroup); DP(PanelTopVfoA); DP(PanelTopVfoB); DP(PanelVfoA); DP(PanelVfoB); DP(PanelVfoButtons); DP(PanelBands); DP(PanelMode); DP(PanelFilter); + if PanelPlutoAGC <> nil then DP(PanelPlutoAGC); if PanelFMSQ <> nil then DP(PanelFMSQ); if PanelRawDev <> nil then DP(PanelRawDev); if PanelFMCTCSS <> nil then DP(PanelFMCTCSS); @@ -3116,17 +3210,31 @@ 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(LblVolume, False); + DL(LblAttVal, False); + DL(LblRxGain, False); + DL(LblRawDev, False); if LblFMSQ <> nil then DL(LblFMSQ, False); // Кнопки тулбара @@ -3138,23 +3246,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); @@ -3164,6 +3288,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); @@ -3180,6 +3313,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 @@ -3188,6 +3323,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); @@ -3203,9 +3340,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); // Флаги слайсов (оверлеи) — цвета кнопок из темы, как левая панель. @@ -3253,11 +3390,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 @@ -3276,7 +3419,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; @@ -3291,8 +3433,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; @@ -3793,6 +3934,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; @@ -4484,11 +4637,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 // пресет на своём месте @@ -4499,12 +4656,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; @@ -4550,7 +4707,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 @@ -4607,7 +4764,7 @@ begin // Прочие режимы: смена режима возвращает заводской индекс фильтра (как было // до таблицы), кромки и подписи приезжают из таблицы контроллера. - PanelFilter.Height := 74; + PanelFilter.Height := PanelFilter.Scale96ToForm(74); DefIdx := FilterDefIdx(FController.FMode); FController.SelectFilterSlotState(DefIdx); LayoutFilterButtons(FILT_COUNT); @@ -4656,7 +4813,7 @@ begin begin if FBeaconScopeForm = nil then FBeaconScopeForm := TBeaconScopeForm.CreateWith(Self, FController); - FBeaconScopeForm.ApplyTheme(DarkTheme); + FBeaconScopeForm.ApplyTheme(CurrentAppTheme); FBeaconScopeForm.Show; end; UpdateBeaconButton; @@ -4671,7 +4828,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; @@ -5390,12 +5547,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; @@ -5486,7 +5653,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 +5760,7 @@ begin if FController.FChannelStore.Count = 0 then Exit; FCTCSSDropDown.ClosePopup; FStepDropDown.ClosePopup; + if FTXProfileDropDown <> nil then FTXProfileDropDown.ClosePopup; FChannelsDropDown.Toggle; end; @@ -5616,7 +5785,7 @@ begin Def.Power := 100; Def.Name := ''; TChannelsForm(FChannelsForm).SetCurrentDefaults(Def); - TChannelsForm(FChannelsForm).ApplyTheme(DarkTheme); + TChannelsForm(FChannelsForm).ApplyTheme(CurrentAppTheme); TChannelsForm(FChannelsForm).Show; end; @@ -5627,6 +5796,114 @@ 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; + // Профиль несёт кромки TX-фильтра: на передаче красная полоска обязана + // переехать сразу, а не ждать следующего rfMOX/rfFilter (их на TX не будет). + PushFilterEdgesToView; + FSpectrumDirty := True; + if FTXProfileDropDown <> nil then + begin + SetLength(Names, Cnt); + for i := 0 to Cnt - 1 do Names[i] := FController.TXProfileName(i); + FTXProfileDropDown.SetItems(Names); + if (Act >= 0) and (Act < Cnt) then FTXProfileDropDown.SetItemIndex(Act); + end; + if (FSettingsForm <> nil) and TSettingsForm(FSettingsForm).Visible then + begin + TSettingsForm(FSettingsForm).LoadTXSettings(FController.FTXSettings, + IfThen(FController.FNetwork.Connected, + FController.FNetwork.Device.BoardType, FPendingBoardType)); + PushTXProfilesToSettings; + end; + // Пилюли профиля на флагах: список общий, привязки могли съехать после + // удаления/сброса — обновляем на всех панах. + for i := 0 to MAX_PANS - 1 do + if FPans[i] <> nil then FPans[i].PushAllFlagTXProfiles; +end; + +procedure TMainForm.PushTXProfilesToSettings; +var + Names: array of string; + i: Integer; +begin + if FSettingsForm = nil then Exit; + SetLength(Names, FController.TXProfileCount); + for i := 0 to FController.TXProfileCount - 1 do + Names[i] := FController.TXProfileName(i); + TSettingsForm(FSettingsForm).LoadTXProfiles(Names, FController.ActiveTXProfile, + FController.FDrivePercent); +end; + +procedure TMainForm.BtnTXProfileClick(Sender: TObject); +begin + if FController.TXProfileCount = 0 then Exit; + // Чужие списки гасим поимённо: общий CloseCTCSSPopup закрыл бы и наш, и + // повторный клик по кнопке никогда не сворачивал бы список. + if FCTCSSDropDown <> nil then FCTCSSDropDown.ClosePopup; + CloseFMStepPopup; + FTXProfileDropDown.Toggle; +end; + +procedure TMainForm.BtnTXProfileMouseDown(Sender: TObject; Button: TMouseButton; + Shift: TShiftState; X, Y: Integer); +begin + if Button <> mbRight then Exit; + // ПКМ — прямо в редактор профиля (вкладка Transmit), как ПКМ по кнопке фильтра. + if FTXProfileDropDown <> nil then FTXProfileDropDown.ClosePopup; + BtnSettingsClick(nil); + if FSettingsForm <> nil then TSettingsForm(FSettingsForm).ShowTransmitPage; +end; + +procedure TMainForm.OnTXProfileDropDownSelect(Sender: TObject; Idx: Integer); +begin + // Применение (WDSP + mic-байт + drive) и персист — в контроллере; + // рендер придёт назад событием rfTXProfile. + FController.SelectTXProfile(Idx); +end; + +procedure TMainForm.OnTXProfileSelectFromSettings(Idx: Integer); +begin + FController.SelectTXProfile(Idx); +end; + +procedure TMainForm.OnTXProfileAddFromSettings(const AName: string); +begin + FController.AddTXProfile(AName); +end; + +procedure TMainForm.OnTXProfileRenameFromSettings(Idx: Integer; const AName: string); +begin + FController.RenameTXProfile(Idx, AName); +end; + +procedure TMainForm.OnTXProfileDeleteFromSettings(Idx: Integer); +begin + FController.DeleteTXProfile(Idx); +end; + +procedure TMainForm.OnTXProfileFactoryFromSettings(Sender: TObject); +begin + FController.ResetTXProfilesToFactory; +end; + procedure TMainForm.OnChannelActiveChanged(Idx: Integer; const Name: string; Active: Boolean); begin // Рендер кнопки Channel по событию адаптера каналов (Active=True → подсветка+имя). @@ -5859,6 +6136,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 @@ -5950,6 +6229,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-слайса / состояния передачи. @@ -6050,6 +6339,7 @@ begin O.OnSplit := OnVfoOverlaySplit; O.OnTxSelect := OnVfoOverlayTxSelect; O.OnRxMuteTx := OnVfoOverlayRxMuteTx; + O.OnTXProfile := OnVfoOverlayTXProfile; O.OnSliceSelect := OnVfoOverlaySliceSelect; O.OnClose := OnVfoOverlayClose; O.OnInvalidate := OnVfoOverlayInvalidate; @@ -7012,6 +7302,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].TXProfByMode := FController.FSlices[s].TXProfByMode; Inc(n); end; end; @@ -7038,6 +7329,9 @@ begin FController.FAudioIn.FindDeviceByName(SC.InDevName), SC.InDevName); if SC.Muted then FController.SetSliceMute(Id, True); FController.SetSliceRxMuteOnTx(Id, SC.RxMuteOnTx); + // Таблица «режим → профиль» этого слайса целиком (не через SetSliceTXProfile: + // тот пишет только ячейку текущего режима). + FController.SetSliceTXProfileTable(Id, SC.TXProfByMode); if SC.FMSQOn then FController.SetSliceFMSquelch(Id, True, SC.FMSQLevel); P.CreateSliceFlag(Id); FActiveSliceId := Id; @@ -7792,6 +8086,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, иначе просто запомнить. @@ -7812,10 +8110,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; @@ -7888,42 +8190,129 @@ 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; var i, Pos, XvtrRows: Integer; - BtnH, RowH, MaxBottom: Integer; + BtnH, RowH, MaxBottom, RowGap, TopGap, BottomPad: Integer; B: TFlatButton; Cap: string; begin @@ -7948,7 +8337,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 @@ -7961,7 +8353,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; @@ -7975,9 +8367,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; @@ -8120,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; // =========================================================================== @@ -8158,6 +8557,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 @@ -8336,9 +8751,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 @@ -8346,15 +8769,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; @@ -8624,6 +9047,11 @@ begin SF.OnThemeChange := SetLightTheme; SF.OnFreqMhzDigitsChange := ApplyFreqMhzDigits; SF.OnTXChange := OnTXSettingsChange; + SF.OnTXProfileSelect := OnTXProfileSelectFromSettings; + SF.OnTXProfileAdd := OnTXProfileAddFromSettings; + SF.OnTXProfileRename := OnTXProfileRenameFromSettings; + SF.OnTXProfileDelete := OnTXProfileDeleteFromSettings; + SF.OnTXProfileFactory := OnTXProfileFactoryFromSettings; SF.OnAlexChange := OnAlexSettingsChange; SF.OnXvtrChange := OnXvtrSettingsChange; SF.OnOCChange := OnOCSettingsChange; @@ -8632,6 +9060,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/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. 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 356ffcf..7ed9f52 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: статус/коррекция @@ -177,6 +178,10 @@ type InDeviceIndex: Integer; // PortAudio-индекс ввода (-1 = общий) InDevName: string; AudioIn: TAudioInput; // НЕ владеет: ссылка на поток из mic-пула + // TX-профиль слайса ПО РЕЖИМУ: индекс в FTXProfiles или -1 = «не + // переключать». Применяется, когда слайс становится TX-источником + // (SetTxSlice) и когда он меняет режим, будучи источником. + TXProfByMode: TTXProfModeTable; DMR: TDMRSliceDecoder; // владеет; nil для не-DMR слайса end; @@ -424,6 +429,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 +586,36 @@ 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; + // 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); + procedure DeleteTXProfile(Idx: Integer); + procedure ResetTXProfilesToFactory; // заводской набор заново + // Профиль на TX-источник, ключ — режим этого флага (-1 = не переключать, + // звучит активный). Таблица у каждого флага своя: главный в 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; + procedure SetSliceTXProfileTable(SliceId: Integer; const T: TTXProfModeTable); + procedure RemapTXProfileBindings(Deleted: Integer); // после удаления/замены списка + procedure ApplyBoundTXProfile; // профиль текущего TX-источника → в эфир + procedure SaveTXProfilesToConfig; + // ---- Управление диапазонами (состояние + DSP; рендер через события) ---- // MakeBandSettings — снимок текущего состояния в запись диапазона. // SaveCurrentBand — сохраняет текущее состояние в кэш+конфиг (или в XVTR-слот). @@ -2127,6 +2166,7 @@ begin FSlices[slot].RxMuteOnTx := True; // себя на передаче по умолчанию не слышим FSlices[slot].FMSQOn := False; // squelch по умолчанию выключен FSlices[slot].FMSQLevel := 40; + ClearTXProfModeTable(FSlices[slot].TXProfByMode); // звучит активный профиль FSlices[slot].NRMode := 0; // DSP слайса стартует выключенным FSlices[slot].NBMode := 0; FSlices[slot].SNB := False; @@ -2201,6 +2241,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 @@ -2919,6 +2961,9 @@ begin else if FTransmitting then SetMOX(False); end; FTxSliceId := SliceId; + // Профиль, привязанный к новому источнику, — ДО пуша режима и цепи: иначе + // ApplyTXSettingsToDSP внутри SelectTXProfile лёг бы на старый режим. + ApplyBoundTXProfile; // Передающий тракт под режим нового источника (idle-канал, применится к PTT). if FWDSPReady and Assigned(FDSPEngine) then begin @@ -3340,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 @@ -3590,6 +3640,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); @@ -3963,6 +4023,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); @@ -4222,6 +4286,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 +4972,293 @@ 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; Auto: Boolean); +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); + // ★Ручной выбор ЗАПОМИНАЕТСЯ за парой «источник передачи + его модуляция» — + // это и есть обещанное «профиль помнится по виду модуляции». Без этого в + // таблице оказывалось только то, что назначено пилюлей во флаге, а профиль, + // выбранный дропдауном в блоке TX, нигде не оставался следа. + if not Auto then BindTXProfileToCurrentMode(Idx); + 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); + // Привязки флагов — это ИНДЕКСЫ: после сдвига списка их надо переписать, + // иначе слайс молча начнёт включать соседний профиль. + RemapTXProfileBindings(Idx); + 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.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, m: Integer; +begin + for m := 0 to CFG_MODE_MAX do Fix(FTXProfiles.MainByMode[m]); + for i := 0 to High(FSlices) do + if FSlices[i].Active then + 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; +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 = не переключать. Ключ — режим самого флага, поэтому смена модуляции +// сама достаёт профиль, с которым в этом виде работали в прошлый раз. +var idx: Integer; +begin + Result := -1; + if SliceId <= 0 then + Result := FTXProfiles.MainByMode[FlagMode(SliceId)] + else + begin + idx := FindSliceIndex(SliceId); + 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, m: Integer; +begin + if (ProfIdx < -1) or (ProfIdx >= FTXProfiles.Count) then ProfIdx := -1; + m := FlagMode(SliceId); + if SliceId <= 0 then + begin + if FTXProfiles.MainByMode[m] = ProfIdx then Exit; + FTXProfiles.MainByMode[m] := ProfIdx; + SaveTXProfilesToConfig; + end + else + begin + idx := FindSliceIndex(SliceId); + if (idx < 0) or (FSlices[idx].TXProfByMode[m] = ProfIdx) then Exit; + FSlices[idx].TXProfByMode[m] := ProfIdx; + // Персист слайсов делает MainForm на общем сохранении панов — здесь только + // состояние. Привязку главного пишем сразу: она живёт в секции профилей. + end; + // Привязали источник, который передаёт прямо сейчас, — применяем немедленно, + // иначе профиль подхватился бы только при следующей смене TX-источника. + if SliceId = FTxSliceId then ApplyBoundTXProfile; + 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) → базовый профиль (первый в списке). +// Фолбэк обязателен: без него профиль прошлого режима оставался висеть в новом +// (симптом «перешёл из 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 then P := 0; + if P <> FTXProfiles.ActiveIdx then SelectTXProfile(P, True); +end; + +procedure TRadioController.ResetTXProfilesToFactory; +// Пересобирает заводской набор поверх текущего списка. База — ЖИВЫЕ настройки, +// поэтому «Default» = микрофонный вход, полоса, динамика и мощность как сейчас, +// но с заводским (ровным) EQ. Единственный способ доехать до обновлённых +// заводских профилей с уже записанной секцией tx_profiles: она читается, а не +// пересоздаётся. +begin + DefaultTXProfiles(FTXSettings, FDrivePercent, FTXProfiles); + RemapTXProfileBindings(-1); // список другой — старые привязки бессмысленны + // ActiveIdx гасим перед Select: иначе StoreActiveTXProfile внутри него + // впишет в свежий «Default» живой EQ и сброс тембра тут же отменится. + FTXProfiles.ActiveIdx := -1; + // Применяем «Default» в эфир — иначе живой EQ остался бы кривым, а профиль + // говорил бы «ровный», и первая же правка TX-настроек вернула бы кривую. + SelectTXProfile(0); // сам сохранит и отрендерит +end; + procedure TRadioController.ApplyTXSettingsToDSP; begin if not FWDSPReady then Exit; @@ -5847,6 +6202,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 +6360,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/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. diff --git a/Settings.pas b/Settings.pas index ae1f861..9ffbd2d 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-таблице) @@ -163,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; @@ -176,6 +181,9 @@ type FMSQLevel: Integer; DevName: string; // имя PortAudio-устройства вывода ('' = default) InDevName: string; // имя устройства ВВОДА слайса ('' = общий вход) + // TX-профиль слайса ПО РЕЖИМУ: -1 = какой активен, тот и звучит. Таблица, + // а не одно значение: слайс на FT8 и слайс голосом помнят каждый своё. + TXProfByMode: TTXProfModeTable; end; TPanCfg = record @@ -298,6 +306,65 @@ 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; + // Привязка профиля к ГЛАВНОМУ приёмнику (слайс A) ПО РЕЖИМУ; -1 = ничего + // не переключать. У слайсов B..G своя такая же в TSliceCfg.TXProfByMode. + // Таблица главного одна на устройство — общая для КВ и трансвертера + // (решение пользователя); у слайсов она сама разъезжается по контекстам, + // потому что их записи хранятся отдельно для КВ и трансвертера. + MainByMode: TTXProfModeTable; + Items: array[0..TXPROF_MAX-1] of TTXProfile; + end; + // Web-сервер — глобальные настройки (не привязаны к устройству, секция "web"). TWebSettings = record Enabled: Boolean; @@ -625,8 +692,30 @@ 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); +// Таблица «режим → профиль» ↔ 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; // Заводской индекс фильтра режима. @@ -1465,6 +1554,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) // --------------------------------------------------------------------------- @@ -1472,12 +1569,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; @@ -1519,7 +1610,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; @@ -1715,6 +1806,372 @@ 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 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); +var + // Заводской звук + ЖИВАЯ обвязка микрофона. Всё, что определяет тембр + // (полоса, динамика, EQ, AM carrier, TUN level), берётся из DefaultTX: + // «сброс к заводским» обязан сбрасывать именно это, иначе Default наследует + // ту полосу, на которой оператора застали (например 5000 от ESSB 5k). + // Не сбрасываем то, что описывает ЖЕЛЕЗО и безопасность: разъём и режим + // микрофона с его усилением (иначе слетит Line In у микшера) и мощность + // (внезапный скачок drive пошёл бы в усилитель). + Stock: TTXSettings; +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; + // 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 нельзя — + // иначе «сброс к заводским» не сбрасывал бы то, ради чего его жмут. + 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); + begin + if L.Count >= TXPROF_MAX then Exit; + 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; + 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; + ClearTXProfModeTable(L.MainByMode); // главный ничего не переключает + // База всех заводских профилей: заводской звук, живая обвязка микрофона. + 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; + // SSB-профили отличаются от Default только полосой и компрессором; + // микрофонный вход и мощность наследуются, EQ у всех троих заводской-ровный. + // ESSB несут ещё и свою кривую: без неё «широкий» профиль звучит тем же + // телефонным тембром, а с чужой сеткой (верхний узел 3500) EQ обрезал бы + // верх ESSB 5k. + Add('SSB DX', 300, 2700, True, 8.0); + 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; + 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; + ClearTXProfModeTable(L.MainByMode); + 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; + 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; + +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('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); + 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 @@ -2362,6 +2819,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_prof_modes', + ModeTableToJSON(P.Pans[i].Slices[j].TXProfByMode)); end; end; end; @@ -2442,6 +2901,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', ''); + JSONToModeTable(SliceObj.Find('tx_prof_modes'), + P.Pans[id].Slices[n].TXProfByMode); end; end; end; diff --git a/SettingsForm.pas b/SettingsForm.pas index c5308c4..9a6886b 100644 --- a/SettingsForm.pas +++ b/SettingsForm.pas @@ -24,12 +24,26 @@ unit SettingsForm; interface uses - Classes, SysUtils, Math, + Classes, SysUtils, Math, Types, Forms, Controls, Graphics, Dialogs, 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. + TXTAB_COUNT = 4; + // Tag контейнера подвкладки: ApplyTheme красит такие панели фоном страницы, + // а не цветом группы (иначе подложка выглядела бы как одна гигантская группа). + TAG_PAGE_BG = 909; + TAG_RESPONSIVE_CARD = 910; + TAG_PAGE_HEADER = 911; + TAG_RESPONSIVE_HALF_LEFT = 912; + TAG_RESPONSIVE_HALF_RIGHT = 913; + TAG_RESPONSIVE_INSET = 914; + TAG_BOTTOM_SPACER = 915; type // PA: MaxPower в Вт, BandCal[0..10] — калибровка 38.8..100.0 на диапазон @@ -45,6 +59,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, @@ -93,6 +111,10 @@ type // ---- Layout ---- FNavPanel: TPanel; FContentPanel: TPanel; + FFooterPanel: TPanel; + FScrollHost: TPanel; + FScrollBar: TOverlayScrollBar; + FActivePage: TScrollBox; FPageAudio: TScrollBox; FPageDisplay: TScrollBox; FPageSpectrum: TScrollBox; @@ -204,6 +226,19 @@ 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; + FBtnTXProfFactory: TFlatButton; + FLblTXProfDrive: TLabel; // «Drive профиля: N %» (ползунок — в главном окне) FCmbTXMicJack: TFlatComboBox; // "Mic In" / "Line In" FChkTXMicBoost: TFlatCheckBox; // +20 dB (только Mic In) FLblLineInGain: TLabel; // метка "Line In gain" (только Line In) @@ -411,6 +446,11 @@ type FOnWfAGCNFChange: TOnWfAGCNFChange; FOnFreqMhzDigitsChange: TOnFreqMhzDigitsChange; FOnTXChange: TOnTXSettingsChange; + FOnTXProfileSelect: TOnTXProfileIdx; + FOnTXProfileAdd: TOnTXProfileName; + FOnTXProfileRename: TOnTXProfileIdxName; + FOnTXProfileDelete: TOnTXProfileIdx; + FOnTXProfileFactory: TNotifyEvent; FLoading: Boolean; @@ -462,17 +502,38 @@ 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 BtnTXProfFactoryClick(Sender: TObject); procedure ApplyTheme(const T: TAppTheme); + procedure SettingsResize(Sender: TObject); + procedure SettingsScrollChanged(Sender: TObject); + procedure SettingsPageMouseWheel(Sender: TObject; Shift: TShiftState; + WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean); + procedure UpdateSettingsScrollBar; + procedure RelayoutResponsiveCards; procedure NavClick(Sender: TObject); procedure SelectPage(APage: TWinControl; ANav: TFlatButton); function MakeLbl(AParent: TWinControl; const Cap: string; ALeft, ATop, AW: Integer): TLabel; + function MakeTextBlock(AParent: TWinControl; const Cap: string; + ALeft, ATop, AW, AH: Integer): TLabel; + procedure MakePageHeader(AParent: TWinControl; const ATitle, ASubtitle: string; + ASubtitleHeight: Integer = 20); function MakeCombo(AParent: TWinControl; ALeft, ATop, AW: Integer; OnChange: TNotifyEvent): TFlatComboBox; function MakeGroupPanel(AParent: TWinControl; const Cap: string; ALeft, ATop, AW, AH: Integer): TPanel; 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; procedure OnFFTSizeChange(Sender: TObject); @@ -571,6 +632,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 +678,11 @@ 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 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; @@ -624,13 +699,13 @@ 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_MARGIN = 22; + SETTINGS_CARD_W = 760; + SETTINGS_CARD_MAX_W = 934; + SETTINGS_FOOTER_H = 52; + SETTINGS_BOTTOM_SPACE = 24; // Первый пункт комбо аудио слайса = «как у приложения» (пустое имя в конфиге). SLICE_DEV_DEFAULT = 'Default (app output)'; @@ -687,7 +762,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; @@ -695,20 +770,45 @@ const { TSettingsForm } constructor TSettingsForm.Create(AOwner: TComponent); +var + WorkArea: TRect; + MaxW, MaxH: Integer; begin inherited CreateNew(AOwner); + // Вся геометрия этой формы строится кодом и уже переводится из 96-DPI + // координат через DpiScale. Если оставить TCustomForm.Scaled=True, LCL + // после конструктора повторно применит monitor DPI ко всей форме: + // при Windows 125% получится 1.25 * 1.25 = 1.5625. Кроме общего раздувания, + // это смешивает два масштаба с RelayoutCalTab, который вызывается позднее, + // и строки Calibration начинают наезжать на поясняющий текст. + Scaled := False; Caption := 'Settings'; - 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; BuildUI; + OnResize := SettingsResize; + OnMouseWheel := SettingsPageMouseWheel; + RelayoutResponsiveCards; ApplyTheme(DarkTheme); end; @@ -722,14 +822,60 @@ 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; +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 @@ -741,7 +887,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; @@ -761,19 +906,25 @@ 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; 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.Anchors := [akLeft, akTop, akRight]; + 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.Anchors := [akLeft, akTop, akRight]; Divider.BevelOuter := bvNone; Divider.Color := CLR_BORDER; end; @@ -786,15 +937,67 @@ begin Result.BorderStyle := bsNone; Result.Color := CLR_BG; Result.AutoScroll := True; + Result.OnMouseWheel := SettingsPageMouseWheel; Result.Visible := False; end; +procedure TSettingsForm.AddPageBottomSpace(APage: TScrollBox); +var + Spacer: TPanel; +begin + if APage = nil then Exit; + Spacer := TPanel.Create(Self); + Spacer.Parent := APage; + Spacer.Tag := TAG_BOTTOM_SPACER; + Spacer.BevelOuter := bvNone; + Spacer.Color := CLR_BG; + Spacer.Height := DpiScale(SETTINGS_BOTTOM_SPACE); + UpdatePageBottomSpace(APage); +end; + +procedure TSettingsForm.UpdatePageBottomSpace(APage: TScrollBox); +var + i, ContentBottom: Integer; + Ctrl: TControl; + Spacer: TPanel; +begin + if APage = nil then Exit; + Spacer := nil; + ContentBottom := 0; + for i := 0 to APage.ControlCount - 1 do + begin + Ctrl := APage.Controls[i]; + if Ctrl.Tag = TAG_BOTTOM_SPACER then + Spacer := TPanel(Ctrl) + else if Ctrl.Visible then + ContentBottom := Max(ContentBottom, Ctrl.Top + Ctrl.Height); + end; + if Spacer = nil then Exit; + Spacer.SetBounds(0, ContentBottom, Max(APage.ClientWidth, DpiScale(804)), + DpiScale(SETTINGS_BOTTOM_SPACE)); +end; + +procedure TSettingsForm.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; @@ -803,7 +1006,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; @@ -825,8 +1027,9 @@ begin begin Parent := FNavPanel; Caption := 'Settings'; + AutoSize := False; SetBounds(DpiScale(14), DpiScale(14), DpiScale(160), DpiScale(24)); - Font.Name := UI_FONT; + Layout := tlCenter; Font.Size := 14; Font.Style := [fsBold]; Font.Color := CLR_TEXT; @@ -836,26 +1039,32 @@ begin begin Parent := FNavPanel; Caption := 'EWSDR'; + AutoSize := False; SetBounds(DpiScale(14), DpiScale(39), DpiScale(160), DpiScale(18)); - Font.Name := UI_FONT; + Layout := tlCenter; 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; @@ -863,6 +1072,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; @@ -897,10 +1113,20 @@ begin BuildOCTab; BuildQOTab; + // Страницы, заканчивающиеся карточкой, получают небольшой участок фона + // после последнего блока. Иначе при прокрутке блок визуально слипается + // с закреплённой нижней панелью Close. + AddPageBottomSpace(FPageSpectrum); + AddPageBottomSpace(FPageWaterfall); + AddPageBottomSpace(FPageCalib); + AddPageBottomSpace(FPageCAT); + AddPageBottomSpace(FPageOC); + 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; @@ -909,12 +1135,221 @@ begin FBtnClose.ClrActive := TColor($00003300); FBtnClose.ClrText := CLR_TEXT; FBtnClose.ClrTextAct:= CLR_ACCENT; - FBtnClose.Font.Name := UI_FONT; FBtnClose.Font.Size := 9; + // Оконный контейнер перекрывает нативный scrollbar TScrollBox. Сам + // TOverlayScrollBar остаётся тонким, а системная прокрутка продолжает + // надёжно двигать всё содержимое страницы. + FScrollHost := TPanel.Create(Self); + FScrollHost.Parent := FContentPanel; + FScrollHost.BevelOuter := bvNone; + FScrollHost.Color := CLR_BG; + FScrollHost.Tag := TAG_PAGE_BG; + + FScrollBar := TOverlayScrollBar.Create(Self); + FScrollBar.Parent := FScrollHost; + FScrollBar.Align := alClient; + FScrollBar.OnPositionChange := SettingsScrollChanged; + SelectPage(FPageAudio, FNavAudio); end; +procedure TSettingsForm.SettingsResize(Sender: TObject); +begin + RelayoutResponsiveCards; + UpdateSettingsScrollBar; +end; + +procedure TSettingsForm.SettingsScrollChanged(Sender: TObject); +begin + if (FActivePage = nil) or (FScrollBar = nil) then Exit; + FActivePage.VertScrollBar.Position := FScrollBar.Position; +end; + +procedure TSettingsForm.SettingsPageMouseWheel(Sender: TObject; + Shift: TShiftState; WheelDelta: Integer; MousePos: TPoint; + var Handled: Boolean); +var + P: TPoint; +begin + if (FActivePage = nil) or (FScrollBar = nil) then Exit; + P := FActivePage.ScreenToClient(Mouse.CursorPos); + if (P.X < 0) or (P.Y < 0) or + (P.X >= FActivePage.Width) or (P.Y >= FActivePage.Height) then Exit; + + UpdateSettingsScrollBar; + if FScrollBar.Maximum <= 0 then Exit; + + if WheelDelta > 0 then + FScrollBar.ScrollBy(-DpiScale(64)) + else if WheelDelta < 0 then + FScrollBar.ScrollBy(DpiScale(64)); + Handled := WheelDelta <> 0; +end; + +procedure TSettingsForm.UpdateSettingsScrollBar; +var + GutterW, ViewH, MaxPos: Integer; +begin + if (FActivePage = nil) or (FScrollHost = nil) or + (FScrollBar = nil) then Exit; + + FActivePage.UpdateScrollbars; + ViewH := FActivePage.ClientHeight; + MaxPos := Max(0, FActivePage.VertScrollBar.Range + - FActivePage.VertScrollBar.Page); + + FScrollBar.HoverTarget := FActivePage; + FScrollBar.SetRange(MaxPos, ViewH); + FScrollBar.Position := FActivePage.VertScrollBar.Position; + + // На Windows gutter должен полностью закрывать нативную полосу. Бегунок + // внутри него всё равно рисуется шириной 4 design px. + GutterW := Max(DpiScale(8), FActivePage.VertScrollBar.Size); + FScrollHost.SetBounds(FContentPanel.ClientWidth - GutterW, 0, GutterW, + Max(0, FContentPanel.ClientHeight - FFooterPanel.Height)); + FScrollHost.BringToFront; +end; + +procedure TSettingsForm.RelayoutResponsiveCards; + + procedure ResizeTree(AParent: TWinControl; ACardWidth: Integer); + var + i, HalfWidth: Integer; + Ctrl: TControl; + begin + HalfWidth := (ACardWidth - DpiScale(16)) div 2; + for i := 0 to AParent.ControlCount - 1 do + begin + Ctrl := AParent.Controls[i]; + if Ctrl.Tag = TAG_PAGE_HEADER then + Ctrl.Width := ACardWidth + else if Ctrl.Tag = TAG_RESPONSIVE_INSET then + Ctrl.Width := ACardWidth - DpiScale(36) + else if (Ctrl is TPanel) and (Ctrl.Tag = TAG_RESPONSIVE_CARD) then + Ctrl.Width := ACardWidth + else if (Ctrl is TPanel) and (Ctrl.Tag = TAG_RESPONSIVE_HALF_LEFT) then + begin + Ctrl.Left := DpiScale(SETTINGS_MARGIN); + Ctrl.Width := HalfWidth; + end + else if (Ctrl is TPanel) and (Ctrl.Tag = TAG_RESPONSIVE_HALF_RIGHT) then + begin + Ctrl.Left := DpiScale(SETTINGS_MARGIN) + HalfWidth + DpiScale(16); + Ctrl.Width := HalfWidth; + end + else if (Ctrl is TPanel) and (Ctrl.Tag = TAG_PAGE_BG) then + Ctrl.Width := ACardWidth + DpiScale(2 * SETTINGS_MARGIN); + + if Ctrl is TWinControl then + ResizeTree(TWinControl(Ctrl), ACardWidth); + end; + end; + + procedure ResizeTransmit(ACardWidth: Integer); + var + ActionW, ActionGap, ActionLeft, ComboLeft, TabGap, TabW, i: Integer; + begin + ActionW := DpiScale(100); + ActionGap := DpiScale(6); + ActionLeft := ACardWidth - DpiScale(18) - ActionW; + + if FBtnTXProfFactory <> nil then + FBtnTXProfFactory.SetBounds(ActionLeft, FBtnTXProfFactory.Top, + ActionW, FBtnTXProfFactory.Height); + Dec(ActionLeft, ActionW + ActionGap); + if FBtnTXProfDel <> nil then + FBtnTXProfDel.SetBounds(ActionLeft, FBtnTXProfDel.Top, + ActionW, FBtnTXProfDel.Height); + Dec(ActionLeft, ActionW + ActionGap); + if FBtnTXProfRen <> nil then + FBtnTXProfRen.SetBounds(ActionLeft, FBtnTXProfRen.Top, + ActionW, FBtnTXProfRen.Height); + Dec(ActionLeft, ActionW + ActionGap); + if FBtnTXProfNew <> nil then + FBtnTXProfNew.SetBounds(ActionLeft, FBtnTXProfNew.Top, + ActionW, FBtnTXProfNew.Height); + + if FCmbTXProfile <> nil then + begin + ComboLeft := DpiScale(78); + FCmbTXProfile.Width := Max(DpiScale(230), + ActionLeft - DpiScale(12) - ComboLeft); + end; + + TabGap := DpiScale(4); + TabW := (ACardWidth - 3 * TabGap) div TXTAB_COUNT; + for i := 0 to TXTAB_COUNT - 1 do + if FTXSubBtn[i] <> nil then + FTXSubBtn[i].SetBounds(DpiScale(SETTINGS_MARGIN) + + i * (TabW + TabGap), FTXSubBtn[i].Top, + TabW, FTXSubBtn[i].Height); + + if FTXEQControl <> nil then + FTXEQControl.Width := ACardWidth - DpiScale(36); + end; + + procedure ResizeOC(ACardWidth: Integer); + var + AsideWidth, Gap, MainWidth, MainLeft, AsideLeft: Integer; + begin + AsideWidth := DpiScale(260); + Gap := DpiScale(14); + MainLeft := DpiScale(SETTINGS_MARGIN); + MainWidth := ACardWidth - AsideWidth - Gap; + AsideLeft := MainLeft + MainWidth + Gap; + + if FOCHFGrp <> nil then + FOCHFGrp.SetBounds(MainLeft, FOCHFGrp.Top, MainWidth, FOCHFGrp.Height); + if FOCHFActGrp <> nil then + FOCHFActGrp.SetBounds(AsideLeft, FOCHFActGrp.Top, + AsideWidth, FOCHFActGrp.Height); + if FOCXvtrGrp <> nil then + FOCXvtrGrp.SetBounds(MainLeft, FOCXvtrGrp.Top, + MainWidth, FOCXvtrGrp.Height); + if FOCXvtrActGrp <> nil then + FOCXvtrActGrp.SetBounds(AsideLeft, FOCXvtrActGrp.Top, + AsideWidth, FOCXvtrActGrp.Height); + end; + + procedure ResizePage(APage: TScrollBox; ACardWidth: Integer); + begin + if (APage = nil) or (APage.ClientWidth <= 0) then Exit; + ResizeTree(APage, ACardWidth); + if APage = FPageTransmit then + ResizeTransmit(ACardWidth); + if APage = FPageOC then + ResizeOC(ACardWidth); + UpdatePageBottomSpace(APage); + end; + +var + CardWidth: Integer; +begin + if FContentPanel = nil then Exit; + // Ширина берётся от общего контейнера, а не от отдельной страницы: + // появившийся на длинной вкладке scrollbar не должен сужать её карточки. + CardWidth := FContentPanel.ClientWidth + - DpiScale(2 * SETTINGS_MARGIN) - DpiScale(20); + CardWidth := EnsureRange(CardWidth, DpiScale(SETTINGS_CARD_W), + DpiScale(SETTINGS_CARD_MAX_W)); + + ResizePage(FPageAudio, CardWidth); + ResizePage(FPageDisplay, CardWidth); + ResizePage(FPageSpectrum, CardWidth); + ResizePage(FPageWaterfall, CardWidth); + ResizePage(FPageTransmit, CardWidth); + ResizePage(FPagePA, CardWidth); + ResizePage(FPageCalib, CardWidth); + ResizePage(FPageAdvanced, CardWidth); + ResizePage(FPageCAT, CardWidth); + ResizePage(FPageSlices, CardWidth); + ResizePage(FPageAlex, CardWidth); + ResizePage(FPageXvtr, CardWidth); + ResizePage(FPageOC, CardWidth); + ResizePage(FPageQO, CardWidth); +end; + procedure TSettingsForm.NavClick(Sender: TObject); begin if Sender = FNavAudio then SelectPage(FPageAudio, FNavAudio) @@ -935,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; @@ -964,6 +1402,11 @@ begin FNavXvtr.Active := ANav = FNavXvtr; FNavOC.Active := ANav = FNavOC; FNavQO.Active := ANav = FNavQO; + + // Скрытые alClient-страницы LCL не всегда перекладывает до показа. + // После переключения берём уже актуальный ClientWidth выбранной страницы. + RelayoutResponsiveCards; + UpdateSettingsScrollBar; end; // --------------------------------------------------------------------------- @@ -983,28 +1426,10 @@ 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.Name := UI_FONT; - Font.Size := 16; - Font.Style := [fsBold]; - Font.Color := CLR_TEXT; - end; + MakePageHeader(FPageAudio, 'Audio', + 'Input and output devices used by the receiver and transmitter.'); - 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.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); @@ -1021,7 +1446,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; @@ -1029,7 +1454,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; @@ -1051,28 +1475,10 @@ 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.Name := UI_FONT; - Font.Size := 16; - Font.Style := [fsBold]; - Font.Color := CLR_TEXT; - end; + MakePageHeader(FPageDisplay, 'Display', + 'General visual options. Spectrum and waterfall processing are split into their own pages.'); - 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.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; @@ -1080,7 +1486,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; @@ -1090,7 +1495,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; @@ -1100,7 +1504,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; @@ -1110,7 +1513,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; @@ -1120,11 +1522,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); @@ -1132,7 +1533,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; @@ -1140,11 +1541,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); @@ -1185,7 +1585,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; @@ -1196,33 +1596,15 @@ 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; begin - with TLabel.Create(Self) do - 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; - end; + MakePageHeader(FPageSpectrum, 'Spectrum', + 'Analyzer, detector, averaging and grid parameters for RX1.'); - 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.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]); @@ -1230,7 +1612,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]); @@ -1240,14 +1622,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); @@ -1257,28 +1639,10 @@ 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.Name := UI_FONT; - Font.Size := 16; - Font.Style := [fsBold]; - Font.Color := CLR_TEXT; - end; + MakePageHeader(FPageWaterfall, 'Waterfall', + 'Waterfall detector, smoothing and color scale thresholds.'); - 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.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]); @@ -1288,7 +1652,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); @@ -1296,14 +1660,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); @@ -1312,11 +1675,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]); @@ -1352,6 +1714,39 @@ 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.Size := 9; + Result.OnClick := AClick; + end; function MkSpin(AParent: TWinControl; AX, AY: Integer; AMin, AMax, AVal: Integer): TFlatSpinEdit; begin @@ -1363,7 +1758,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; @@ -1381,7 +1775,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; @@ -1393,7 +1786,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; @@ -1403,99 +1795,103 @@ 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 - with TLabel.Create(Self) do - 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; - end; - with TLabel.Create(Self) do - begin - Parent := FPageTransmit; - Caption := 'TX-цепь WDSP: микрофон, фильтр, динамическая обработка, модуляция.'; - SetBounds(DpiScale(MARGIN), DpiScale(48), DpiScale(700), DpiScale(20)); - Font.Name := UI_FONT; - 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.'); - // ---- 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); - // 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); + MakeLbl(Grp, 'Active', PAD, R1 + 5, 60); + FCmbTXProfile := MakeCombo(Grp, PAD + 60, R1, 230, OnTXProfileComboChange); + FBtnTXProfNew := MkBtn(Grp, PAD + 302, R1, 100, 'New...', BtnTXProfNewClick); + FBtnTXProfRen := MkBtn(Grp, PAD + 408, R1, 100, 'Rename...', BtnTXProfRenClick); + FBtnTXProfDel := MkBtn(Grp, PAD + 514, R1, 100, 'Delete', BtnTXProfDelClick); + FBtnTXProfFactory := MkBtn(Grp, PAD + 620, R1, 100, 'Factory', BtnTXProfFactoryClick); + + TxNote(Grp, 'Edits on the Profile and EQ tabs go into the active profile ' + + 'immediately — there is no separate Save button.', R1 + 30, 18); + TxNote(Grp, 'New... forks the current sound under a new name. Factory rebuilds ' + + 'the stock set; it keeps microphone wiring and drive, not the sound.', + R1 + 48, 18); + + // ---- Сегменты подвкладок ------------------------------------------------ + Y := 78 + 124 + 12; + FTXSubBtn[0] := MkBtn(FPageTransmit, MARGIN, Y, 132, 'Profile', TXSubTabClick); + FTXSubBtn[1] := MkBtn(FPageTransmit, MARGIN + 136, Y, 132, 'EQ', TXSubTabClick); + FTXSubBtn[2] := MkBtn(FPageTransmit, MARGIN + 272, Y, 132, 'Hardware', TXSubTabClick); + FTXSubBtn[3] := MkBtn(FPageTransmit, MARGIN + 408, Y, 132, 'Display', TXSubTabClick); + for i := 0 to TXTAB_COUNT - 1 do FTXSubBtn[i].Tag := i; + + Inc(Y, BTN_H + 14); + SubTop := Y; // ★ немасштабированный: FTXSubPage[].Top уже в пикселях + + // ========================================================================= + // Подвкладка 0 — Profile: всё, что определяет звук в эфире + // ========================================================================= + SubPage(0, SubTop); + + // ---- Микрофонный вход (часть профиля: гарнитура vs микшер-ESSB) ---- + // Две строки, обе по общей сетке страницы: подпись в колонке PAD (ширина LW), + // поле в колонке CX, вторая пара — от PAD+320. Тумблеры справа с равным + // шагом. Connector скрыт на не-Orion и потому делит строку с Mic gain — + // спрятанная пара не оставляет дыры. + Grp := MakeGroupPanel(FTXSubPage[0], 'Microphone', MARGIN, 0, GRP_W, 134); + + // 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: 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 | 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); + + FLblTipRing := MakeLbl(Grp, 'Connector', PAD + 302, R1 + STEP + 5, 76); + FCmbTXMicTR := MkCmb(Grp, PAD + 382, R1 + STEP, 96); FCmbTXMicTR.Items.Add('Tip'); // MicTipRing=False, bit3=0 FCmbTXMicTR.Items.Add('Ring'); // MicTipRing=True, bit3=1 FCmbTXMicTR.ItemIndex := 0; FLblTipRing.Visible := False; FCmbTXMicTR.Visible := False; - // 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 := 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); + 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); - 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,46 +1915,113 @@ 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, 'Profile drive: —', PAD, R1 + STEP + 5, 300); + TxNote(Grp, 'Drive belongs to the profile, but you set it with the DRV slider ' + + 'in the main window — the active profile follows it.', + R1 + STEP + 28, 26); + + FTXSubPage[0].Height := DpiScale(Y + 148 + 12); + + // ========================================================================= + // Подвкладка 1 — EQ (тоже профильный, но занимает целый экран) + // ========================================================================= + SubPage(1, SubTop); + + Grp := MakeGroupPanel(FTXSubPage[1], 'TX Equalizer', MARGIN, 0, GRP_W, 434); FTXEQControl := TEqualizerControl.Create(Self); FTXEQControl.Parent := Grp; FTXEQControl.SetBounds(DpiScale(PAD), DpiScale(R1), DpiScale(GRP_W - 2 * PAD), DpiScale(326)); - FTXEQControl.Font.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)); FTXEQControl.OnChange := OnTXAnyChange; - // ---- AM / FM / CTCSS ---- - Inc(Y, 390 + 12); - Grp := MakeGroupPanel(FPageTransmit, 'AM / FM / CTCSS', MARGIN, Y, GRP_W, 260); + // Главное, что надо знать про этот EQ: точки — не «полосы», а узлы кривой, + // и за крайними узлами WDSP обрывает АЧХ (см. WDSPEngine.PushTXEQProfile). + TxNote(Grp, 'Points are curve nodes: response is interpolated between them and ' + + 'cut beyond the outer nodes.' + LineEnding + + 'Keep the outer nodes beyond the TX passband. In 3-band mode WDSP fixes ' + + 'frequencies at 150 / 1500 / 6000 Hz.', R1 + 332, 58); - MakeLbl(Grp, 'AM carrier level', PAD, R1 + 5, LW); - FEdTXAMCarrier := MkFloat(Grp, CX, R1, 0.5, 0.0, 1.0, 0.05); + FTXSubPage[1].Height := DpiScale(434 + 12); - MakeLbl(Grp, 'FM deviation (Hz)', PAD, R1 + STEP + 5, LW); - FEdTXFMDev := MkFloat(Grp, CX, R1 + STEP, 5000.0, 1000.0, 25000.0, 100.0); + // ========================================================================= + // Подвкладка 2 — Hardware: свойства устройства, профилем не переключаются + // ========================================================================= + SubPage(2, SubTop); - 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); + Grp := MakeGroupPanel(FTXSubPage[2], 'Radio', MARGIN, 0, GRP_W, 108); - MakeLbl(Grp, 'FM emphasis', PAD, R1 + 3 * STEP + 5, LW); - FCmbTXFMEmph := MkCmb(Grp, CX, R1 + 3 * STEP, 180); + MakeLbl(Grp, 'ATT on TX (dB)', PAD, R1 + 5, LW); + FEdTXAttOnTX := MkSpin(Grp, CX, R1, 0, 31, 31); + MakeLbl(Grp, 'Tune tone (Hz)', PAD + 320, R1 + 5, 110); + FEdTUNFreq := MkSpin(Grp, PAD + 320 + 120, R1, 100, 5000, 750); + TxNote(Grp, 'ATT on TX attenuates the RX ADC while transmitting (input ' + + 'protection) — not to be confused with output power.', R1 + 32, 26); + + Y := 108 + 12; + Grp := MakeGroupPanel(FTXSubPage[2], 'TX Filter (DSP)', MARGIN, Y, GRP_W, 190); + + MakeLbl(Grp, 'FIR length', PAD, R1 + 5, LW); + FCmbTXFiltNC := MkCmb(Grp, CX, R1, 140); + FCmbTXFiltNC.Items.AddStrings(['256','512','1024','2048','4096']); + FCmbTXFiltNC.ItemIndex := 2; + + MakeLbl(Grp, 'Phase mode', PAD, R1 + STEP + 5, LW); + FCmbTXFiltMP := MkCmb(Grp, CX, R1 + STEP, 200); + FCmbTXFiltMP.Items.AddStrings(['Linear Phase','Low Latency']); + FCmbTXFiltMP.ItemIndex := 1; + + MakeLbl(Grp, 'Window', PAD, R1 + 2 * STEP + 5, LW); + FCmbTXFiltWindow := MkCmb(Grp, CX, R1 + 2 * STEP, 240); + for i := 0 to High(WINDOW_NAMES) do + FCmbTXFiltWindow.Items.Add(WINDOW_NAMES[i]); + FCmbTXFiltWindow.ItemIndex := 1; + + TxNote(Grp, 'Skirt steepness and path latency, not timbre — hence outside ' + + 'the profile.', R1 + 3 * STEP - 2, 22); + + 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); + TxNote(Grp, 'A property of the channel or repeater — a voice profile does ' + + 'not switch it.', R1 + 4 * STEP + 5, 26); + + FTXSubPage[2].Height := DpiScale(Y + 250 + 12); + + // ========================================================================= + // Подвкладка 3 — Display: анализатор и сетка TX-спектра + // ========================================================================= + SubPage(3, SubTop); + + Grp := MakeGroupPanel(FTXSubPage[3], 'TX Display (Analyzer)', MARGIN, 0, GRP_W, 318); MakeLbl(Grp, 'FFT size', PAD, R1 + 5, LW); FCmbTXFFT := MkCmb(Grp, CX, R1, 140); @@ -1595,8 +2058,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); @@ -1604,7 +2067,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; @@ -1615,7 +2077,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; @@ -1624,8 +2085,13 @@ 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); + UpdateMicJackVisibility; +end; procedure TSettingsForm.CollectTXFromUI; var i: Integer; begin @@ -1729,6 +2195,131 @@ 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('No more than %d profiles per radio.', [TXPROF_MAX]), + mtInformation, [mbOK], 0); + Exit; + end; + S := 'Profile ' + IntToStr(FCmbTXProfile.Items.Count + 1); + if not InputQuery('TX Profile', 'Name for the new profile (copy of the current sound):', S) then Exit; + if Trim(S) = '' then Exit; + if Assigned(FOnTXProfileAdd) then FOnTXProfileAdd(Trim(S)); +end; + +procedure TSettingsForm.BtnTXProfRenClick(Sender: TObject); +var S: string; Idx: Integer; +begin + if FCmbTXProfile = nil then Exit; + Idx := FCmbTXProfile.ItemIndex; + if (Idx < 0) or (Idx >= FCmbTXProfile.Items.Count) then Exit; + S := FCmbTXProfile.Items[Idx]; + if not InputQuery('TX Profile', 'New profile name:', S) then Exit; + if Trim(S) = '' then Exit; + if Assigned(FOnTXProfileRename) then FOnTXProfileRename(Idx, Trim(S)); +end; + +procedure TSettingsForm.BtnTXProfDelClick(Sender: TObject); +var Idx: Integer; +begin + if FCmbTXProfile = nil then Exit; + Idx := FCmbTXProfile.ItemIndex; + // Последний профиль не удаляем — активному профилю всегда есть куда писать. + if (Idx < 0) or (FCmbTXProfile.Items.Count <= 1) then Exit; + if MessageDlg('TX Profile', + 'Delete profile "' + FCmbTXProfile.Items[Idx] + '"?', + mtConfirmation, [mbYes, mbNo], 0) <> mrYes then Exit; + if Assigned(FOnTXProfileDelete) then FOnTXProfileDelete(Idx); +end; + +procedure TSettingsForm.BtnTXProfFactoryClick(Sender: TObject); +// Пересоздать заводской набор. Нужен не только «на случай чего»: заводские +// профили пишутся в конфиг один раз при первом подключении устройства, и без +// этой кнопки обновлённые заводские настройки (например кривые EQ у ESSB) +// не доехали бы до тех, у кого секция tx_profiles уже записана. +begin + if MessageDlg('TX Profile', + 'Replace the whole profile list with the factory set?'#13#10 + + 'Your own profiles and edits to the stock ones will be lost.'#13#10 + + '"Default" becomes active and restores the stock sound: ' + + 'bandwidth, dynamics and a flat equalizer. Microphone wiring, ' + + 'its gain and drive are kept as they are.', + mtConfirmation, [mbYes, mbNo], 0) <> mrYes then Exit; + if Assigned(FOnTXProfileFactory) then FOnTXProfileFactory(Self); +end; + +procedure TSettingsForm.LoadTXProfiles(const Names: array of string; + ActiveIdx, DrivePct: Integer); +var + i: Integer; + WasLoading: Boolean; +begin + if FCmbTXProfile = nil then Exit; + WasLoading := FLoading; + FLoading := True; // заполнение списка не должно шибать OnChange + try + FCmbTXProfile.Items.Clear; + for i := 0 to High(Names) do FCmbTXProfile.Items.Add(Names[i]); + if (ActiveIdx >= 0) and (ActiveIdx < FCmbTXProfile.Items.Count) then + FCmbTXProfile.ItemIndex := ActiveIdx; + if FBtnTXProfDel <> nil then + FBtnTXProfDel.Enabled := FCmbTXProfile.Items.Count > 1; + if FLblTXProfDrive <> nil then + FLblTXProfDrive.Caption := Format('Profile drive: %d %%', [DrivePct]); + finally + FLoading := WasLoading; + end; +end; + +procedure TSettingsForm.SetTXProfileDrive(DrivePct: Integer); +begin + if FLblTXProfDrive <> nil then + FLblTXProfDrive.Caption := Format('Profile drive: %d %%', [DrivePct]); +end; + +procedure TSettingsForm.ShowTransmitPage(SubIdx: Integer = 0); +begin + SelectPage(FPageTransmit, FNavTransmit); + SelectTXSubPage(SubIdx); + Show; + BringToFront; +end; + procedure TSettingsForm.LoadTXSettings(const T: TTXSettings; BoardType: Integer = 0); var i: Integer; IsOrion: Boolean; begin @@ -1872,7 +2463,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; @@ -1889,44 +2479,19 @@ 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.Name := UI_FONT; - Font.Size := 16; - Font.Style := [fsBold]; - Font.Color := CLR_TEXT; - end; + MakePageHeader(FPagePA, 'Power Amplifier', + 'Output limit and per-band calibration values.'); - 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.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); - 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.Name := UI_FONT; - Lbl.Font.Size := 9; + Lbl := MakeLbl(Grp, 'Max power (W)', GRP_PAD, R1 + 6, 132); Spin := TFlatSpinEdit.Create(Self); Spin.Parent := Grp; Spin.SetBounds(DpiScale(GRP_PAD + 150), DpiScale(R1), DpiScale(ED_W), DpiScale(BTN_H + 2)); Spin.Color := CLR_INPUT; Spin.Font.Color := CLR_INPUT_TEXT; - Spin.Font.Name := UI_FONT; Spin.Font.Size := 9; Spin.MinValue := 5; Spin.MaxValue := 200; @@ -1936,20 +2501,14 @@ 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.Name := UI_FONT; - 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; 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; @@ -1959,13 +2518,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.Name := UI_FONT; - 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 @@ -1975,13 +2529,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.Name := UI_FONT; - 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; @@ -1989,15 +2537,10 @@ 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; + 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 @@ -2012,7 +2555,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; @@ -2053,7 +2595,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; @@ -2080,26 +2621,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.Name := UI_FONT; - 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.Name := UI_FONT; - 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; @@ -2199,8 +2722,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); @@ -2208,9 +2732,9 @@ 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.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 @@ -2304,7 +2828,7 @@ begin HSM := LayoutCalOffsets(FLblSMBand, FEdSMBand, FLblSMXvtrHdr, FLblSMXvtr, FEdSMXvtr, 52); HDisp := LayoutCalOffsets(FLblDispBand, FEdDispBand, FLblDispXvtrHdr, - FLblDispXvtr, FEdDispXvtr, 52); + FLblDispXvtr, FEdDispXvtr, 90); // ---- вертикальная укладка групп ---- GTop := 86; @@ -2322,6 +2846,8 @@ 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); + if FActivePage = FPageCalib then UpdateSettingsScrollBar; end; function TSettingsForm.LayoutCalOffsets(const BandLbl: array of TLabel; @@ -2501,25 +3027,16 @@ 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.Name := UI_FONT; - 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, 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; @@ -2530,7 +3047,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; @@ -2538,14 +3054,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; @@ -2559,7 +3074,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; @@ -2574,7 +3088,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; @@ -2587,7 +3100,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; @@ -2600,7 +3112,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'; @@ -2680,12 +3191,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; @@ -2708,6 +3231,9 @@ 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) or + (TPanel(Ctrl).Tag = TAG_BOTTOM_SPACER) then + TPanel(Ctrl).Color := T.BG // подложка подвкладки, не группа else TPanel(Ctrl).Color := T.Panel; WalkControls(TPanel(Ctrl)); @@ -2746,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; @@ -3462,9 +3993,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; @@ -3477,21 +4008,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.Name := UI_FONT; 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.Name := UI_FONT; 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 @@ -3501,13 +4019,17 @@ 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; 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; @@ -3517,7 +4039,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} @@ -3560,7 +4082,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; @@ -3569,13 +4091,14 @@ 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; 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); @@ -3583,7 +4106,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; @@ -3700,30 +4223,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.Name := UI_FONT; 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.Name := UI_FONT; 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.Name := UI_FONT; 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 @@ -3733,13 +4236,17 @@ 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; 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; @@ -3749,7 +4256,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; @@ -3765,7 +4272,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; @@ -3791,39 +4298,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.Name := UI_FONT; 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.Name := UI_FONT; 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.Name := UI_FONT; 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.Name := UI_FONT; 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; @@ -3955,7 +4436,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; @@ -3968,7 +4448,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; @@ -3981,7 +4460,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; @@ -3992,8 +4470,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.Font.Name := UI_FONT; + Result.Layout := tlCenter; Result.Font.Size := 9; Result.Font.Color := CLR_TEXT; end; @@ -4004,8 +4483,10 @@ var begin Parent := AParent; Caption := ACap; + AutoSize := False; SetBounds(DpiScale(AX), DpiScale(AY), DpiScale(30), DpiScale(16)); - Font.Name := UI_FONT; + Alignment := taCenter; + Layout := tlCenter; Font.Size := 9; Font.Color := CLR_TEXTDIM; end; @@ -4014,25 +4495,22 @@ 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.Name := UI_FONT; 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.Font.Name := UI_FONT; + FAlexSubtitle.AutoSize := False; + FAlexSubtitle.SetBounds(DpiScale(MARGIN), DpiScale(48), + DpiScale(SETTINGS_CARD_W), DpiScale(20)); + FAlexSubtitle.Layout := tlTop; + FAlexSubtitle.WordWrap := True; FAlexSubtitle.Font.Size := 9; FAlexSubtitle.Font.Color := CLR_TEXTDIM; // ---- Antenna grid group ---- Grp := MakeGroupPanel(FPageAlex, 'Antenna Selection', MARGIN, 86, GRP_W, GRP_H); + Grp.Tag := TAG_RESPONSIVE_CARD; // Header row 1 — group labels MakeLbl(Grp, 'Band', BAND_X, GRP_TITLE_H + 2, 44); @@ -4097,6 +4575,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); @@ -4117,6 +4596,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» над своей парой, под ними номера разъёмов. @@ -4446,7 +4926,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; @@ -4461,7 +4940,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; @@ -4474,7 +4952,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; @@ -4486,7 +4963,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; @@ -4499,7 +4975,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; @@ -4517,34 +4992,21 @@ var Result.Layout := tlCenter; Result.WordWrap := True; Result.Font.Color := CLR_TEXTDIM; - Result.Font.Name := UI_FONT; Result.Font.Size := 9; end; 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.Name := UI_FONT; 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.Name := UI_FONT; 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; 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; @@ -4592,29 +5054,26 @@ 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 := '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; FChkXvtrTunPwr.Font.Size := 9; 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.Name := UI_FONT; 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); + L.Tag := TAG_PAGE_HEADER; // Применяем начальное состояние disabled-строк for i := 0 to CFG_XVTR_COUNT - 1 do @@ -4768,7 +5227,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; @@ -4776,20 +5234,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.Name := UI_FONT; 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.Font.Name := UI_FONT; + 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; @@ -4798,7 +5252,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; @@ -4977,7 +5430,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; @@ -4988,43 +5440,31 @@ 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; 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.Name := UI_FONT; L.Font.Size := 9; + 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.Name := UI_FONT; L.Font.Size := 16; - L.Font.Style := [fsBold]; L.Font.Color := CLR_TEXT; + 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); - 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.Name := UI_FONT; 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); @@ -5061,14 +5501,11 @@ 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.Size := 9; FQoInfo.WordWrap := True; + FQoInfo := MakeTextBlock(FPageQO, '', + MARGIN, 100 + GRP_TITLE_H + 9 * 30 + 28, SETTINGS_CARD_W, 52); finally FLoading := False; end; 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; diff --git a/VfoOverlay.pas b/VfoOverlay.pas index 7c2dec5..804a649 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, TxtY, TriY: 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,50 @@ 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); + 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, 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); + end; Inc(CurY, HDR_H); { --- Метр: индикаторы + сигнал-бар --- } @@ -1259,6 +1437,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 +1518,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 +1545,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 +1730,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 +1845,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; diff --git a/WDSPEngine.pas b/WDSPEngine.pas index f717232..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 точек. Это не степень @@ -554,6 +558,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 @@ -1166,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; @@ -1220,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; @@ -1235,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); @@ -3542,8 +3547,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 +3587,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); @@ -3723,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); @@ -3791,11 +3806,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 +3856,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);