Files
ewsdr/FlatListBox.pas
ew8bakandClaude Opus 5 a2fdc3a741 feat(dxcluster): споты DX-кластера на панадаптере
Telnet-клиент DX-кластера, база спотов и подписи позывных прямо на спектре
на своих частотах. Разложено на три слоя, как бэндплан и лупа маяка.

DXClusterClient.pas — один рабочий поток: резолв, неблокирующий connect с
select квантами по 200 мс (Stop не ждёт таймаут соединения), логин позывным,
чтение строк, реконнект с backoff. Приглашения логина И пароля ловятся в
незавершённом хвосте буфера — типичный telnet-prompt приходит без CR/LF.
Ошибки recv отличаются от таймаута кванта (EAGAIN/EINTR/WSAETIMEDOUT), иначе
на ECONNRESET поток крутился бы в пустом цикле вместо реконнекта. Отправка
дописывает частичный send. LCL-free.

DXSpotStore.pas — потокобезопасная база: дедуп по позывному, TTL, потолок
записей, монотонный Version. Единственная точка обмена потока с UI: никакого
Synchronize, UI сам замечает правки по Version, как оверлеи — по ключам кэша.

DXSpotOverlay.pas — рендер по модели BandPlanOverlay/VfoOverlay: кэшируется
только полоса подписей (W × BandH) в key-color битмап, пересборка строго по
dirty-ключу, на кадр — один keyed-композит. Штрихи от полосы до низа спектра
рисует вызывающая сторона теми же примитивами, что и прочие маркеры: CPU —
RawVLine внутри RawBegin/RawEnd, GL — DrawLine по готовому списку X/цвет.
GL берёт тот же битмап текстурой и заливает её только при смене RenderVersion.
Пересекающиеся подписи раскладываются лесенкой, цвет гаснет с возрастом,
свой позывной выделен; палитра парная под тёмную и светлую тему.

DXClusterForm.pas — окно списка: споты, лог соединения, строка команды
кластеру (диалект set/filter у всех свой — не угадываем). Двойной клик или
Enter = QSY. Данные тянутся поллингом по Version/LogVersion.

Интеграция: кнопка DX в тулбаре (ЛКМ — подписи на спектре, ПКМ — окно),
клик по подписи спота = QSY с автовыбором моды по комментарию кластера,
страница SETUP → DX Cluster с персистом в секции "dxcluster". Правки SETUP
прилетают посимвольно, поэтому запись конфига, TTL стора и переподключение
откладываются до паузы в наборе — иначе набор позывного стоил бы шесть
реконнектов, а промежуточный TTL «3» необратимо выбросил бы споты.

Частота спота кладётся как есть и сравнивается с GetViewWindow: на QO-100
кластеры постят downlink 10489.xxx, что совпадает со шкалой пана само собой.

Побочно в общих юнитах: WebUtils.SockSetRcvTimeout, FlatMemo.OnChange,
FlatListBox.OnKeyDown, BlendBitmapKey вынесен в interface VfoOverlay (одна
копия дворд-блендера на проект).

Проверено на локальном фейковом кластере: логин и пароль по prompt без CR/LF,
разбор спотов (включая QO-100), уход в RETRY по RST, Stop за 200 мс на
висящем connect. На железе рендер не проверялся.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:47:26 +03:00

378 lines
9.6 KiB
ObjectPascal
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
unit FlatListBox;
{ Custom list box with canvas rendering.
Use instead of TListBox where native widgetset styling is undesirable. }
{$mode objfpc}{$H+}
interface
uses
Classes, SysUtils, Controls, Graphics, Forms, LCLType, LMessages, Types,
Math, AppTheme, DpiUtils;
type
TFlatListBox = class(TCustomControl)
private
FItems: TStringList;
FItemIndex: Integer;
FHotIndex: Integer;
FTopIndex: Integer;
FMouseDownIndex: Integer;
FClrOuterBG: TColor;
FClrBG: TColor;
FClrBGHot: TColor;
FClrBorder: TColor;
FClrText: TColor;
FClrTextDim: TColor;
FClrSelBG: TColor;
FClrSelText: TColor;
FClrScroll: TColor;
function ItemHeight: Integer;
function VisibleCount: Integer;
function ItemAt(Y: Integer): Integer;
procedure ItemsChanged(Sender: TObject);
procedure SetItemIndex(V: Integer);
procedure SetTopIndex(V: Integer);
procedure EnsureItemVisible(Idx: Integer);
procedure CMTextChanged(var Msg: TLMessage); message CM_TEXTCHANGED;
protected
procedure Paint; override;
procedure MouseEnter; override;
procedure MouseLeave; 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;
procedure KeyDown(var Key: Word; Shift: TShiftState); override;
function DoMouseWheelUp(Shift: TShiftState; MousePos: TPoint): Boolean; override;
function DoMouseWheelDown(Shift: TShiftState; MousePos: TPoint): Boolean; override;
public
constructor Create(AOwner: TComponent); override;
destructor Destroy; override;
procedure SetAppTheme(const T: TAppTheme);
property Items: TStringList read FItems;
property ItemIndex: Integer read FItemIndex write SetItemIndex;
property TopIndex: Integer read FTopIndex write SetTopIndex;
property Align;
property Anchors;
property Enabled;
property Font;
property ParentShowHint;
property PopupMenu;
property ShowHint;
property TabOrder;
property TabStop;
property Tag;
property Visible;
property OnClick;
property OnDblClick;
// Клавиатура: собственный KeyDown обрабатывает стрелки/PgUp/Home, а
// необработанные клавиши (Enter и прочее) достаются владельцу через
// inherited — поэтому событие имеет смысл публиковать.
property OnKeyDown;
end;
implementation
const
BASE_ITEM_H = 22;
BASE_PAD_X = 8;
BASE_SCROLL_W = 3;
constructor TFlatListBox.Create(AOwner: TComponent);
begin
inherited Create(AOwner);
ControlStyle := ControlStyle + [csOpaque, csCaptureMouse, csClickEvents, csDoubleClicks];
FItems := TStringList.Create;
FItems.OnChange := @ItemsChanged;
FItemIndex := -1;
FHotIndex := -1;
FTopIndex := 0;
FMouseDownIndex := -1;
Width := 180;
Height := 140;
TabStop := True;
Cursor := crDefault;
Font.Size := 8;
FClrOuterBG := TColor($00181818);
FClrBG := TColor($00101010);
FClrBGHot := TColor($000C3010);
FClrBorder := TColor($00303030);
FClrText := TColor($00CCCCCC);
FClrTextDim := TColor($00666666);
FClrSelBG := TColor($00183618);
FClrSelText := TColor($0000FF88);
FClrScroll := TColor($00387838);
end;
destructor TFlatListBox.Destroy;
begin
FItems.Free;
inherited Destroy;
end;
function TFlatListBox.ItemHeight: Integer;
begin
Canvas.Font.Assign(Font);
Result := DpiScale(BASE_ITEM_H);
if Result < Canvas.TextHeight('Ag') + DpiScale(7) then
Result := Canvas.TextHeight('Ag') + DpiScale(7);
end;
function TFlatListBox.VisibleCount: Integer;
begin
Result := Max(1, (Height - DpiScale(2)) div ItemHeight);
end;
function TFlatListBox.ItemAt(Y: Integer): Integer;
begin
Result := FTopIndex + (Y - DpiScale(1)) div ItemHeight;
if (Result < 0) or (Result >= FItems.Count) then
Result := -1;
end;
procedure TFlatListBox.SetAppTheme(const T: TAppTheme);
begin
FClrOuterBG := T.Panel;
FClrBG := T.BG;
FClrBGHot := T.BtnActive;
FClrBorder := T.Border;
FClrText := T.Text;
FClrTextDim := T.TextDim;
FClrSelBG := T.SliderTrackFill;
FClrSelText := T.BtnTextActive;
FClrScroll := T.BtnBorderActive;
Font.Color := T.Text;
Invalidate;
end;
procedure TFlatListBox.ItemsChanged(Sender: TObject);
begin
if FItems.Count = 0 then
FItemIndex := -1
else if FItemIndex >= FItems.Count then
FItemIndex := FItems.Count - 1;
if FHotIndex >= FItems.Count then
FHotIndex := -1;
SetTopIndex(FTopIndex);
Invalidate;
end;
procedure TFlatListBox.SetItemIndex(V: Integer);
begin
V := EnsureRange(V, -1, FItems.Count - 1);
if FItemIndex = V then
begin
EnsureItemVisible(FItemIndex);
Exit;
end;
FItemIndex := V;
EnsureItemVisible(FItemIndex);
Invalidate;
end;
procedure TFlatListBox.SetTopIndex(V: Integer);
begin
V := EnsureRange(V, 0, Max(0, FItems.Count - VisibleCount));
if FTopIndex = V then Exit;
FTopIndex := V;
Invalidate;
end;
procedure TFlatListBox.EnsureItemVisible(Idx: Integer);
var
VC: Integer;
begin
if Idx < 0 then Exit;
VC := VisibleCount;
if Idx < FTopIndex then
SetTopIndex(Idx)
else if Idx >= FTopIndex + VC then
SetTopIndex(Idx - VC + 1);
end;
procedure TFlatListBox.CMTextChanged(var Msg: TLMessage);
begin
Invalidate;
end;
procedure TFlatListBox.Paint;
var
i, ItemIdx, IH, Y, TextY, Pad, ScrollW, ThumbTop, ThumbH: Integer;
R, TextR: TRect;
begin
Canvas.Brush.Color := FClrOuterBG;
Canvas.Brush.Style := bsSolid;
Canvas.Pen.Style := psClear;
Canvas.FillRect(ClientRect);
Canvas.Brush.Color := FClrBG;
Canvas.Brush.Style := bsSolid;
Canvas.Pen.Color := FClrBorder;
Canvas.Pen.Style := psSolid;
Canvas.Pen.Width := 1;
Canvas.Rectangle(0, 0, Width, Height);
Canvas.Font.Assign(Font);
IH := ItemHeight;
Pad := DpiScale(BASE_PAD_X);
ScrollW := DpiScale(BASE_SCROLL_W);
for i := 0 to VisibleCount - 1 do
begin
ItemIdx := FTopIndex + i;
if ItemIdx >= FItems.Count then Break;
Y := DpiScale(1) + i * IH;
R := Rect(DpiScale(1), Y, Width - DpiScale(1), Y + IH);
if ItemIdx = FItemIndex then
begin
Canvas.Brush.Color := FClrSelBG;
Canvas.Font.Color := FClrSelText;
end
else if (ItemIdx = FHotIndex) and Enabled then
begin
Canvas.Brush.Color := FClrBGHot;
Canvas.Font.Color := FClrText;
end
else
begin
Canvas.Brush.Color := FClrBG;
if Enabled then
Canvas.Font.Color := FClrText
else
Canvas.Font.Color := FClrTextDim;
end;
Canvas.Pen.Style := psClear;
Canvas.Brush.Style := bsSolid;
Canvas.FillRect(R);
Canvas.Brush.Style := bsClear;
TextY := Y + (IH - Canvas.TextHeight('Ag')) div 2;
TextR := Rect(Pad, Y, Width - Pad - ScrollW, Y + IH);
Canvas.TextRect(TextR, TextR.Left, TextY, FItems[ItemIdx]);
end;
if FItems.Count > VisibleCount then
begin
ThumbH := Max(DpiScale(16), MulDiv(Height - DpiScale(4), VisibleCount, FItems.Count));
ThumbTop := DpiScale(2) + MulDiv(Height - DpiScale(4) - ThumbH,
FTopIndex, Max(1, FItems.Count - VisibleCount));
Canvas.Brush.Color := FClrScroll;
Canvas.Brush.Style := bsSolid;
Canvas.Pen.Style := psClear;
Canvas.FillRect(Rect(Width - DpiScale(5), ThumbTop, Width - DpiScale(2), ThumbTop + ThumbH));
end;
end;
procedure TFlatListBox.MouseEnter;
begin
inherited;
Invalidate;
end;
procedure TFlatListBox.MouseLeave;
begin
inherited;
FHotIndex := -1;
Invalidate;
end;
procedure TFlatListBox.MouseDown(Button: TMouseButton; Shift: TShiftState;
X, Y: Integer);
begin
inherited;
if (Button <> mbLeft) or not Enabled then Exit;
SetFocus;
FMouseDownIndex := ItemAt(Y);
if FMouseDownIndex >= 0 then
SetItemIndex(FMouseDownIndex);
end;
procedure TFlatListBox.MouseMove(Shift: TShiftState; X, Y: Integer);
var
Idx: Integer;
begin
inherited;
if PtInRect(ClientRect, Point(X, Y)) then
Idx := ItemAt(Y)
else
Idx := -1;
if FHotIndex = Idx then Exit;
FHotIndex := Idx;
Invalidate;
end;
procedure TFlatListBox.MouseUp(Button: TMouseButton; Shift: TShiftState;
X, Y: Integer);
begin
inherited;
if Button <> mbLeft then Exit;
FMouseDownIndex := -1;
end;
procedure TFlatListBox.KeyDown(var Key: Word; Shift: TShiftState);
var
OldIndex: Integer;
begin
inherited;
if not Enabled then Exit;
OldIndex := FItemIndex;
case Key of
VK_UP:
begin
if FItems.Count > 0 then
SetItemIndex(EnsureRange(FItemIndex - 1, 0, FItems.Count - 1));
Key := 0;
end;
VK_DOWN:
begin
if FItems.Count > 0 then
SetItemIndex(EnsureRange(FItemIndex + 1, 0, FItems.Count - 1));
Key := 0;
end;
VK_PRIOR:
begin
if FItems.Count > 0 then
SetItemIndex(EnsureRange(FItemIndex - VisibleCount, 0, FItems.Count - 1));
Key := 0;
end;
VK_NEXT:
begin
if FItems.Count > 0 then
SetItemIndex(EnsureRange(FItemIndex + VisibleCount, 0, FItems.Count - 1));
Key := 0;
end;
VK_HOME:
begin
if FItems.Count > 0 then
SetItemIndex(0);
Key := 0;
end;
VK_END:
begin
if FItems.Count > 0 then
SetItemIndex(FItems.Count - 1);
Key := 0;
end;
end;
if (Key = 0) and (FItemIndex <> OldIndex) then
Click;
end;
function TFlatListBox.DoMouseWheelUp(Shift: TShiftState; MousePos: TPoint): Boolean;
begin
SetTopIndex(FTopIndex - 3);
Result := True;
end;
function TFlatListBox.DoMouseWheelDown(Shift: TShiftState; MousePos: TPoint): Boolean;
begin
SetTopIndex(FTopIndex + 3);
Result := True;
end;
end.