{
  Copyright (C)
  2026 - Uladzimir Karpenka, EW8BAK

  This program is free software; you can redistribute it and/or
  modify it under the terms of the GNU General Public License
  as published by the Free Software Foundation; either version 2
  of the License, or (at your option) any later version.

  This program is distributed in the hope that it will be useful,
  but WITHOUT ANY WARRANTY; without even the implied warranty of
  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  GNU General Public License for more details.

  You should have received a copy of the GNU General Public License
  along with this program; if not, write to the Free Software
  Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
}

unit FilterPopup;

{
  TFilterPopup — поповер редактора фильтров приёмника, открывается ПКМ по
  любой кнопке фильтра на левой панели. Слева список слотов режима (10
  пресетов + VAR1/VAR2), справа Name/Low/High/Width, внизу форма полосы в
  масштабе на ЗНАКОВОЙ оси частот.

  Знак и есть главный смысл нижней картинки: у LSB полоса лежит в минусе, и
  это видно глазами — ровно то место, где в проекте дважды заводились баги с
  боковой. Скат нарисован схематично: реальная крутизна у WDSP определяется
  длиной FIR (fircore) и никаким пользовательским параметром не управляется.

  Содержимое включает 12 слотов и поля Name/Low/High/Width, а график наглядно
  показывает положение и форму полосы.

  Реализован как ДОЧЕРНИЙ контрол верхнеуровневой формы (не отдельное окно) —
  как TPureSignalPopup/TPanDisplayPopup: Wayland не даёт клиенту
  позиционировать top-level окна.
}

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

interface

uses
  Classes, SysUtils, Math, Types, Controls, ExtCtrls, StdCtrls, Graphics, Forms,
  RadioController, RadioModes, Settings,
  FlatButton, FlatEdit, FlatSpinEdit, FlatListBox;

type
  TFilterSlotEvent  = procedure(Mode, Idx: Integer; const S: TFilterSlot) of object;
  TFilterResetEvent = procedure(Mode, Idx: Integer) of object;

  TFilterPopup = class(TCustomControl)
  private
    FController: TRadioController;   // не владеем
    FMode:      Integer;
    FSlotIdx:   Integer;
    FSet:       TFilterSet;
    FPresets:   Integer;             // пресетов у режима (VAR идут сверх)
    FLoading:   Boolean;             // подавляет Apply при программной заливке
    FBg:        TPanel;
    FTitle:     TLabel;
    FBtnClose:  TFlatButton;
    FList:      TFlatListBox;
    FEdName:    TFlatEdit;
    FEdLow:     TFlatSpinEdit;
    FEdHigh:    TFlatSpinEdit;
    FEdWidth:   TFlatSpinEdit;
    FBtnReset:    TFlatButton;
    FBtnResetAll: TFlatButton;
    FShape:     TPaintBox;
    FOnSlotEdited: TFilterSlotEvent;
    FOnSlotReset:  TFilterResetEvent;
    function  MakeLbl(const Cap: string; ALeft, ATop, AW: Integer): TLabel;
    procedure FillList;
    procedure ShowSlot;
    procedure Commit;
    procedure ListClick(Sender: TObject);
    procedure EdgeChange(Sender: TObject);
    procedure WidthChange(Sender: TObject);
    procedure NameChange(Sender: TObject);
    procedure ResetClick(Sender: TObject);
    procedure ResetAllClick(Sender: TObject);
    procedure CloseClick(Sender: TObject);
    procedure ShapePaint(Sender: TObject);
    function  SlotLabel(Idx: Integer): string;
  protected
    procedure Paint; override;
  public
    constructor Create(AOwner: TComponent); override;
    // Перечитывает таблицу контроллера и показывает слот SlotIdx режима Mode.
    procedure LoadFrom(C: TRadioController; Mode, SlotIdx: Integer);
    // Подсветить выбор, когда фильтр переключили снаружи (кнопка/CAT/drag).
    procedure SyncSelection(Mode, Idx: Integer);
    // Показать под контролом-якорем (кнопкой фильтра) как дочерний контрол формы.
    procedure PopupBelow(C: TRadioController; Anchor: TControl;
                         Mode, SlotIdx: Integer);
    property SlotIdx: Integer read FSlotIdx;
    property OnSlotEdited: TFilterSlotEvent read FOnSlotEdited write FOnSlotEdited;
    property OnSlotReset:  TFilterResetEvent read FOnSlotReset write FOnSlotReset;
  end;

implementation

const
{$IFDEF WINDOWS}
  UI_FONT = 'Segoe UI';
{$ELSE}
  UI_FONT = 'Sans';
{$ENDIF}
  CLR_BG      = TColor($00303030);   // рамка (1px кайма поповера)
  CLR_PANEL   = TColor($001A1A1A);
  CLR_TEXT    = TColor($00E0E0E0);
  CLR_TEXTDIM = TColor($00888888);
  CLR_ACCENT  = TColor($0040FF80);
  CLR_INPUT      = TColor($00FFFFFF);
  CLR_INPUT_TEXT = TColor($00202020);
  CLR_GRID    = TColor($00303030);
  CLR_BAND    = TColor($0040FF80);

  POP_W   = 300;
  PAD     = 10;
  LISTW   = 78;
  LBLW    = 40;
  ROWH    = 24;
  ITEMH   = 22;             // = BASE_ITEM_H в FlatListBox
  SHAPE_H = 96;
  RULER_H = 13;
  EDGE_LIM = 20000;

  FE_MODE_NAMES: array[0..MODE_MAX] of string = (
    'LSB','USB','DSB','CWL','CWU','FM','AM','SAM','DIGU','DIGL','WFM','DMR','FMRAW');

constructor TFilterPopup.Create(AOwner: TComponent);
var
  Y, CtlX, CtlW, BW: Integer;

  function MakeSpin(ATop: Integer; H: TNotifyEvent): TFlatSpinEdit;
  begin
    Result := TFlatSpinEdit.Create(Self);
    Result.Parent    := FBg;
    Result.SetBounds(CtlX, ATop, CtlW, ROWH - 2);
    Result.MinValue  := -EDGE_LIM;
    Result.MaxValue  :=  EDGE_LIM;
    Result.Increment := 10;
    Result.Color     := CLR_INPUT;
    Result.Font.Color := CLR_INPUT_TEXT;
    Result.Font.Name := UI_FONT;
    Result.Font.Size := 9;
    Result.OnChange  := H;
  end;

begin
  inherited Create(AOwner);
  ControlStyle := ControlStyle + [csOpaque];
  Visible    := False;
  Color      := CLR_BG;
  Font.Name  := UI_FONT;
  Font.Size  := 9;
  Font.Color := CLR_TEXT;
  FMode      := MODE_USB;
  FSlotIdx   := 0;
  FPresets   := FILT_COUNT;
  // ★Гард на ВСЁ время постройки: сеттеры Flat*-контролов (напр.
  // TFlatSpinEdit.SetMinValue → SetValue → DoChange) стреляют OnChange прямо
  // из конструктора, а обработчики трогают контролы, созданные ниже.
  FLoading   := True;

  FBg := TPanel.Create(Self);
  FBg.Parent     := Self;
  FBg.BevelOuter := bvNone;
  FBg.Color      := CLR_PANEL;
  FBg.SetBounds(1, 1, POP_W - 2, 10);

  Y := PAD;
  FTitle := MakeLbl('Filters', PAD, Y + 2, POP_W - 2 * PAD - 18);
  FTitle.Font.Color := CLR_ACCENT;
  FTitle.Font.Style := [fsBold];
  FBtnClose := MakeFlatBtn(FBg, '×', POP_W - PAD - 16, Y, 16, 16, CloseClick);
  FBtnClose.ClrText    := CLR_TEXTDIM;
  FBtnClose.ClrTextAct := CLR_TEXT;
  Inc(Y, ROWH);

  // ---- Слева список слотов, справа поля ----
  FList := TFlatListBox.Create(Self);
  FList.Parent := FBg;
  FList.SetBounds(PAD, Y, LISTW, FILT_SLOTS * ITEMH + 4);
  FList.Font.Name := UI_FONT;
  FList.Font.Size := 9;
  FList.OnClick   := ListClick;   // выбор строки = клик (у TFlatListBox нет OnSelect)

  CtlX := PAD + LISTW + 10 + LBLW + 6;
  CtlW := POP_W - PAD - CtlX;

  MakeLbl('Name', PAD + LISTW + 10, Y + 3, LBLW);
  FEdName := TFlatEdit.Create(Self);
  FEdName.Parent := FBg;
  FEdName.SetBounds(CtlX, Y, CtlW, ROWH - 2);
  FEdName.MaxLength  := 6;
  FEdName.Color      := CLR_INPUT;
  FEdName.Font.Color := CLR_INPUT_TEXT;
  FEdName.Font.Name  := UI_FONT;
  FEdName.Font.Size  := 9;
  FEdName.OnChange   := NameChange;
  Inc(Y, ROWH + 4);

  MakeLbl('Low', PAD + LISTW + 10, Y + 3, LBLW);
  FEdLow := MakeSpin(Y, EdgeChange);
  Inc(Y, ROWH + 4);

  MakeLbl('High', PAD + LISTW + 10, Y + 3, LBLW);
  FEdHigh := MakeSpin(Y, EdgeChange);
  Inc(Y, ROWH + 4);

  MakeLbl('Width', PAD + LISTW + 10, Y + 3, LBLW);
  FEdWidth := MakeSpin(Y, WidthChange);
  FEdWidth.MinValue := FILT_MIN_WIDTH;
  Inc(Y, ROWH + 8);

  BW := (POP_W - PAD - (PAD + LISTW + 10) - 6) div 2;
  FBtnReset    := MakeFlatBtn(FBg, 'Reset', PAD + LISTW + 10, Y, BW, ROWH - 2,
                              ResetClick);
  FBtnResetAll := MakeFlatBtn(FBg, 'Reset all', PAD + LISTW + 10 + BW + 6, Y,
                              BW, ROWH - 2, ResetAllClick);

  // Список выше полей — берём максимум по высоте.
  Y := Max(Y + ROWH - 2, FList.Top + FList.Height) + 8;

  MakeLbl('— Passband —', PAD, Y, POP_W - 2 * PAD).Font.Color := CLR_TEXTDIM;
  Inc(Y, 18);
  FShape := TPaintBox.Create(Self);
  FShape.Parent := FBg;
  FShape.SetBounds(PAD, Y, POP_W - 2 * PAD, SHAPE_H);
  FShape.OnPaint := ShapePaint;
  Inc(Y, SHAPE_H);

  FBg.Height := Y - 1 + PAD;
  Width  := POP_W;
  Height := Y + 1 + PAD;
  FLoading := False;
end;

procedure TFilterPopup.Paint;
begin
  Canvas.Brush.Style := bsSolid;
  Canvas.Brush.Color := CLR_BG;
  Canvas.Pen.Style   := psClear;
  Canvas.FillRect(0, 0, Width, Height);
end;

function TFilterPopup.MakeLbl(const Cap: string; ALeft, ATop, AW: Integer): TLabel;
begin
  Result := TLabel.Create(Self);
  Result.Parent  := FBg;
  Result.Caption := Cap;
  Result.Left := ALeft;
  Result.Top  := ATop;
  if AW > 0 then Result.Width := AW;
  Result.Font.Color := CLR_TEXT;
  Result.Font.Name  := UI_FONT;
  Result.Font.Size  := 9;
end;

function TFilterPopup.SlotLabel(Idx: Integer): string;
begin
  Result := FSet[Idx].Name;
  if Result = '' then Result := '—';
end;

procedure TFilterPopup.FillList;
var i: Integer;
begin
  FList.Items.BeginUpdate;
  try
    FList.Items.Clear;
    for i := 0 to FPresets - 1 do FList.Items.Add(SlotLabel(i));
    FList.Items.Add(SlotLabel(FILT_VAR1));
    FList.Items.Add(SlotLabel(FILT_VAR2));
  finally
    FList.Items.EndUpdate;
  end;
end;

procedure TFilterPopup.LoadFrom(C: TRadioController; Mode, SlotIdx: Integer);
var i: Integer;
begin
  if C = nil then Exit;
  FController := C;
  FMode    := EnsureRange(Mode, 0, CFG_MODE_MAX);
  FPresets := FilterPresetCount(FMode);
  for i := 0 to FILT_SLOTS - 1 do FSet[i] := C.FilterSlot(FMode, i);
  FSlotIdx := EnsureRange(SlotIdx, 0, FILT_SLOTS - 1);
  // Пресет вне таблицы режима (FM/WFM короче) — показываем VAR1.
  if (FSlotIdx >= FPresets) and (FSlotIdx < FILT_VAR1) then FSlotIdx := FILT_VAR1;
  FTitle.Caption := 'Filters — ' + FE_MODE_NAMES[EnsureRange(FMode, 0, MODE_MAX)];
  FLoading := True;
  try
    FillList;
    ShowSlot;
  finally
    FLoading := False;
  end;
end;

procedure TFilterPopup.SyncSelection(Mode, Idx: Integer);
begin
  if (Mode <> FMode) or (Idx = FSlotIdx) then Exit;
  FSlotIdx := EnsureRange(Idx, 0, FILT_SLOTS - 1);
  FLoading := True;
  try ShowSlot; finally FLoading := False; end;
end;

procedure TFilterPopup.ShowSlot;
var Row: Integer;
begin
  if FSlotIdx >= FILT_VAR1 then Row := FPresets + (FSlotIdx - FILT_VAR1)
  else                          Row := FSlotIdx;
  FList.ItemIndex := Row;
  FEdName.Text   := FSet[FSlotIdx].Name;
  FEdLow.Value   := FSet[FSlotIdx].Lo;
  FEdHigh.Value  := FSet[FSlotIdx].Hi;
  FEdWidth.Value := FSet[FSlotIdx].Hi - FSet[FSlotIdx].Lo;
  FShape.Invalidate;
end;

procedure TFilterPopup.Commit;
begin
  if FLoading or (FShape = nil) then Exit;
  FShape.Invalidate;
  if Assigned(FOnSlotEdited) then FOnSlotEdited(FMode, FSlotIdx, FSet[FSlotIdx]);
end;

procedure TFilterPopup.ListClick(Sender: TObject);
var Row: Integer;
begin
  if FLoading then Exit;
  Row := FList.ItemIndex;
  if Row < 0 then Exit;
  if Row >= FPresets then FSlotIdx := FILT_VAR1 + (Row - FPresets)
  else                    FSlotIdx := Row;
  FLoading := True;
  try ShowSlot; finally FLoading := False; end;
  // Клик по строке = выбрать этот фильтр в приёмнике (как кнопка на панели).
  if Assigned(FController) then FController.SetFilterIdx(FSlotIdx);
end;

procedure TFilterPopup.EdgeChange(Sender: TObject);
var Lo, Hi: Integer;
begin
  if FLoading then Exit;
  Lo := FEdLow.Value;
  Hi := FEdHigh.Value;
  if Hi - Lo < FILT_MIN_WIDTH then
  begin
    // Края не пускаем друг через друга: двигаем тот, который НЕ трогали.
    if Sender = FEdLow then Hi := Lo + FILT_MIN_WIDTH
    else                    Lo := Hi - FILT_MIN_WIDTH;
    FLoading := True;
    try
      FEdLow.Value  := Lo;
      FEdHigh.Value := Hi;
    finally
      FLoading := False;
    end;
  end;
  FSet[FSlotIdx].Lo := Lo;
  FSet[FSlotIdx].Hi := Hi;
  FLoading := True;
  try FEdWidth.Value := Hi - Lo; finally FLoading := False; end;
  Commit;
end;

procedure TFilterPopup.WidthChange(Sender: TObject);
// Width — производная: центр полосы сохраняется, изменяются оба края.
var W, C, Lo, Hi: Integer;
begin
  if FLoading then Exit;
  W  := Max(FILT_MIN_WIDTH, FEdWidth.Value);
  C  := (FSet[FSlotIdx].Lo + FSet[FSlotIdx].Hi) div 2;
  Lo := C - W div 2;
  Hi := Lo + W;
  FSet[FSlotIdx].Lo := Lo;
  FSet[FSlotIdx].Hi := Hi;
  FLoading := True;
  try
    FEdLow.Value  := Lo;
    FEdHigh.Value := Hi;
  finally
    FLoading := False;
  end;
  Commit;
end;

procedure TFilterPopup.NameChange(Sender: TObject);
var Row: Integer;
begin
  if FLoading then Exit;
  FSet[FSlotIdx].Name := FEdName.Text;
  Row := FList.ItemIndex;
  FLoading := True;
  try
    FillList;
    FList.ItemIndex := Row;
  finally
    FLoading := False;
  end;
  Commit;
end;

procedure TFilterPopup.ResetClick(Sender: TObject);
begin
  if Assigned(FOnSlotReset) then FOnSlotReset(FMode, FSlotIdx);
end;

procedure TFilterPopup.ResetAllClick(Sender: TObject);
begin
  if Assigned(FOnSlotReset) then FOnSlotReset(FMode, -1);
end;

procedure TFilterPopup.CloseClick(Sender: TObject);
begin
  Hide;
end;

procedure TFilterPopup.ShapePaint(Sender: TObject);
// Трапеция полосы в масштабе на знаковой оси. Окно оси симметрично нулю и
// шире полосы, поэтому сразу видно, по какую сторону от несущей она лежит.
var
  W, H, GH, ZeroX, i, X, Skirt, Lo, Hi, X1, X2: Integer;
  Span, Step: Double;
  S: string;

  function FreqToX(F: Double): Integer;
  begin
    Result := Round((F + Span) / (2 * Span) * W);
  end;

begin
  W := FShape.Width;
  H := FShape.Height;
  if (W <= 0) or (H <= 0) then Exit;
  GH := H - RULER_H;
  Lo := FSet[FSlotIdx].Lo;
  Hi := FSet[FSlotIdx].Hi;

  with FShape.Canvas do
  begin
    Brush.Style := bsSolid;
    Brush.Color := CLR_PANEL;
    FillRect(0, 0, W, H);

    Span := Max(Abs(Lo), Abs(Hi)) * 1.6;
    if Span < 500 then Span := 500;

    // Сетка: шаг кратен 1 кГц, подстраивается под окно.
    Step := 1000;
    while Span / Step > 12 do Step := Step * 2;
    Pen.Style := psSolid;
    Pen.Color := CLR_GRID;
    i := -Trunc(Span / Step);
    while i <= Trunc(Span / Step) do
    begin
      X := FreqToX(i * Step);
      Line(X, 0, X, GH);
      Inc(i);
    end;

    // Ноль = несущая, ярче сетки.
    ZeroX := FreqToX(0);
    Pen.Color := CLR_TEXTDIM;
    Line(ZeroX, 0, ZeroX, GH);

    // Полоса: плоская вершина + схематичные скаты.
    X1 := FreqToX(Lo);
    X2 := FreqToX(Hi);
    Skirt := Max(2, Round(0.06 * (X2 - X1)));
    Brush.Color := CLR_BAND;
    Pen.Color   := CLR_BAND;
    Polygon([Point(X1, GH - 2),
             Point(X1 + Skirt, Round(GH * 0.2)),
             Point(X2 - Skirt, Round(GH * 0.2)),
             Point(X2, GH - 2)]);

    // Линейка: края полосы + ноль.
    Brush.Style := bsClear;
    Font.Name   := UI_FONT;
    Font.Size   := 7;
    Font.Color  := CLR_TEXTDIM;
    S := '0';
    TextOut(ZeroX - TextWidth(S) div 2, GH, S);
    Font.Color := CLR_BAND;
    S := IntToStr(Lo);
    TextOut(Max(0, Min(W - TextWidth(S), X1 - TextWidth(S) div 2)), GH, S);
    S := IntToStr(Hi);
    TextOut(Max(0, Min(W - TextWidth(S), X2 - TextWidth(S) div 2)), GH, S);
  end;
end;

procedure TFilterPopup.PopupBelow(C: TRadioController; Anchor: TControl;
                                   Mode, SlotIdx: Integer);
var
  Root: TCustomForm;
  L, T: Integer;
  Cl: TPoint;
begin
  if Anchor = nil then Exit;
  Root := GetParentForm(Anchor);
  if Root = nil then Exit;
  // ★Родителя назначаем ДО заливки данных. TFlatListBox при заполнении и при
  // установке ItemIndex считает ItemHeight, а тот трогает Canvas
  // (Canvas.Font.Assign / TextHeight) — у контрола без родительского окна это
  // AV. У TPureSignalPopup списка нет, поэтому там порядок был обратный и
  // проблемы не возникало.
  Parent := Root;
  LoadFrom(C, Mode, SlotIdx);
  // Позиция под якорем в КЛИЕНТСКИХ координатах формы (не top-level окно!).
  Cl := Root.ScreenToClient(Anchor.ClientToScreen(Point(0, Anchor.Height + 2)));
  L := Cl.X;
  T := Cl.Y;
  if L + Width > Root.ClientWidth then L := Root.ClientWidth - Width;
  if L < 0 then L := 0;
  if T + Height > Root.ClientHeight then
    T := Root.ScreenToClient(Anchor.ClientToScreen(Point(0, 0))).Y - Height;
  if T < 0 then T := 0;
  SetBounds(L, T, Width, Height);
  Visible := True;
  BringToFront;
end;

end.
