Files
ewsdr/FlatPopupMenu.pas
ew8bakandClaude Sonnet 5 c95620fb23 fix(ui): DPI-масштабирование SettingsForm/DeviceForm + общий DpiScale
Вся раскладка окон, построенных кодом (SettingsForm, DeviceForm), была
захардкожена в пикселях под неявные 96 DPI — на Windows 125-200% или
HiDPI-десктопах контролы физически не росли вместе с укрупнившимся
шрифтом. Добавлено масштабирование SetBounds через MulDiv(V,
Screen.PixelsPerInch, 96) на каждой листовой точке потребления
(const-блоки раскладки не трогались).

Общий DpiScale вынесен в новый юнит DpiUtils.pas — убрана дублированная
копия одноимённого приватного метода в 9 классах (FlatCheckBox,
FlatComboBox, FlatListBox, FlatSpinEdit, FlatFloatSpinEdit, FlatEdit,
FlatRadioButton, FlatPopupMenu, SettingsForm, DeviceForm) и инлайн-MulDiv
без обёртки в MainForm.pas/PanafallPanel.pas.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 16:05:39 +03:00

308 lines
8.8 KiB
ObjectPascal

unit FlatPopupMenu;
{
TFlatPopupMenu — кастомное popup-меню в стиле приложения (не нативное
TPopupMenu, чтобы одинаково выглядело на всех платформах и жило в цветовой
теме). Реализовано как ДОЧЕРНИЙ контрол верхнеуровневой формы (а не отдельное
окно): Wayland не позволяет клиенту позиционировать свои top-level окна —
они центрируются компоновщиком. Дочерний контрол позиционируется в клиентских
координатах формы и работает на всех платформах (тот же приём, что у
выпадающего списка TFlatComboBox).
Owner-draw список: hover-подсветка, галка текущего пункта, клавиатура
(↑↓/Enter/Esc), закрытие по клику вне (MouseCapture) и по потере фокуса.
Использование:
Menu.SetTheme(T);
Menu.Clear;
Menu.AddItem('20m', BandIdx, Checked);
Menu.OnSelect := Handler; // Handler(Tag)
Menu.PopupBelow(AnchorControl); // выпадает под якорем
Селекторы диапазона/samplerate панадаптеров (этап 3.6).
}
{$IFDEF FPC}
{$MODE Delphi}
{$ENDIF}
interface
uses
Classes, SysUtils, Math, Types, Controls, Graphics, Forms, LCLType, LCLIntf,
AppTheme, DpiUtils;
type
TFlatMenuSelect = procedure(Tag: Integer) of object;
TFlatPopupMenu = class(TCustomControl)
private
FCaptions: array of string;
FTags: array of Integer;
FChecked: array of Boolean;
FEnabled: array of Boolean;
FHot: Integer;
FItemH: Integer;
FTheme: TAppTheme;
FOnSelect: TFlatMenuSelect;
function ItemAt(Y: Integer): Integer;
procedure Commit(Idx: Integer);
protected
procedure Paint; override;
procedure MouseMove(Shift: TShiftState; X, Y: Integer); override;
procedure MouseDown(Button: TMouseButton; Shift: TShiftState;
X, Y: Integer); override;
procedure MouseLeave; override;
procedure DoExit; override;
procedure KeyDown(var Key: Word; Shift: TShiftState); override;
public
constructor Create(AOwner: TComponent); override;
procedure Clear;
procedure AddItem(const ACaption: string; ATag: Integer;
AChecked: Boolean = False; AEnabled: Boolean = True);
procedure SetTheme(const T: TAppTheme);
procedure PopupBelow(Anchor: TControl); // выпадает под контролом-якорем
procedure ClosePopup;
property OnSelect: TFlatMenuSelect read FOnSelect write FOnSelect;
end;
implementation
const
{$IFDEF WINDOWS}
UI_FONT = 'Segoe UI';
{$ELSE}
UI_FONT = 'Sans';
{$ENDIF}
BASE_ITEM_H = 22;
BASE_PAD = 10; // левый отступ текста
BASE_CHECKW = 16; // колонка галки слева
BASE_MINW = 96;
constructor TFlatPopupMenu.Create(AOwner: TComponent);
begin
inherited Create(AOwner);
ControlStyle := ControlStyle + [csOpaque];
TabStop := True;
Visible := False;
Font.Name := UI_FONT;
Font.Size := 9;
FHot := -1;
FTheme := DarkTheme;
end;
procedure TFlatPopupMenu.SetTheme(const T: TAppTheme);
begin
FTheme := T;
Font.Color := T.Text;
Invalidate;
end;
procedure TFlatPopupMenu.Clear;
begin
SetLength(FCaptions, 0);
SetLength(FTags, 0);
SetLength(FChecked, 0);
SetLength(FEnabled, 0);
FHot := -1;
end;
procedure TFlatPopupMenu.AddItem(const ACaption: string; ATag: Integer;
AChecked: Boolean; AEnabled: Boolean);
var n: Integer;
begin
n := Length(FCaptions);
SetLength(FCaptions, n + 1);
SetLength(FTags, n + 1);
SetLength(FChecked, n + 1);
SetLength(FEnabled, n + 1);
FCaptions[n] := ACaption;
FTags[n] := ATag;
FChecked[n] := AChecked;
FEnabled[n] := AEnabled;
end;
function TFlatPopupMenu.ItemAt(Y: Integer): Integer;
begin
if FItemH <= 0 then Exit(-1);
Result := (Y - 1) div FItemH; // -1: верхняя 1px-рамка
if (Result < 0) or (Result >= Length(FCaptions)) then Result := -1;
end;
procedure TFlatPopupMenu.Paint;
var
i, Y, TextY, TextX, W, H: Integer;
R: TRect;
begin
W := ClientWidth;
H := ClientHeight;
Canvas.Brush.Style := bsSolid;
Canvas.Brush.Color := FTheme.Panel;
Canvas.Pen.Style := psClear;
Canvas.FillRect(0, 0, W, H);
Canvas.Font.Assign(Font);
for i := 0 to High(FCaptions) do
begin
Y := 1 + i * FItemH;
R := Rect(1, Y, W - 1, Y + FItemH);
if i = FHot then
begin
Canvas.Brush.Style := bsSolid;
Canvas.Brush.Color := FTheme.BtnActive;
Canvas.Pen.Style := psClear;
Canvas.FillRect(R);
end;
// Галка текущего пункта.
Canvas.Brush.Style := bsClear;
if FChecked[i] then
begin
Canvas.Font.Color := FTheme.BtnTextActive;
Canvas.TextOut(DpiScale(BASE_PAD) - DpiScale(2),
Y + (FItemH - Canvas.TextHeight('Ag')) div 2, '✓');
end;
// Текст пункта.
if FEnabled[i] then Canvas.Font.Color := FTheme.Text
else Canvas.Font.Color := FTheme.TextDim;
TextX := DpiScale(BASE_PAD) + DpiScale(BASE_CHECKW);
TextY := Y + (FItemH - Canvas.TextHeight('Ag')) div 2;
Canvas.TextOut(TextX, TextY, FCaptions[i]);
end;
// Рамка.
Canvas.Brush.Style := bsClear;
Canvas.Pen.Style := psSolid;
Canvas.Pen.Color := FTheme.Border;
Canvas.Rectangle(0, 0, W, H);
end;
procedure TFlatPopupMenu.MouseMove(Shift: TShiftState; X, Y: Integer);
var Idx: Integer;
begin
inherited MouseMove(Shift, X, Y);
if PtInRect(ClientRect, Point(X, Y)) then Idx := ItemAt(Y) else Idx := -1;
if Idx = FHot then Exit;
FHot := Idx;
Invalidate;
end;
procedure TFlatPopupMenu.MouseLeave;
begin
inherited MouseLeave;
if FHot <> -1 then begin FHot := -1; Invalidate; end;
end;
procedure TFlatPopupMenu.Commit(Idx: Integer);
var T: Integer;
begin
if (Idx < 0) or (Idx > High(FCaptions)) or not FEnabled[Idx] then
begin
ClosePopup;
Exit;
end;
T := FTags[Idx];
ClosePopup;
if Assigned(FOnSelect) then FOnSelect(T);
end;
procedure TFlatPopupMenu.MouseDown(Button: TMouseButton; Shift: TShiftState;
X, Y: Integer);
begin
inherited MouseDown(Button, Shift, X, Y);
// Клик вне списка (MouseCapture доставляет сюда координаты вне ClientRect) —
// просто закрыть.
if (Button <> mbLeft) or not PtInRect(ClientRect, Point(X, Y)) then
begin
ClosePopup;
Exit;
end;
Commit(ItemAt(Y));
end;
procedure TFlatPopupMenu.DoExit;
begin
inherited DoExit;
ClosePopup;
end;
procedure TFlatPopupMenu.KeyDown(var Key: Word; Shift: TShiftState);
var n: Integer;
begin
inherited KeyDown(Key, Shift);
n := Length(FCaptions);
case Key of
VK_ESCAPE: begin ClosePopup; Key := 0; end;
VK_RETURN: begin Commit(FHot); Key := 0; end;
VK_UP:
begin
if n > 0 then FHot := EnsureRange(FHot - 1, 0, n - 1);
Invalidate; Key := 0;
end;
VK_DOWN:
begin
if n > 0 then
begin
if FHot < 0 then FHot := 0
else FHot := EnsureRange(FHot + 1, 0, n - 1);
end;
Invalidate; Key := 0;
end;
end;
end;
procedure TFlatPopupMenu.ClosePopup;
begin
if not Visible then Exit;
MouseCapture := False;
Visible := False;
end;
procedure TFlatPopupMenu.PopupBelow(Anchor: TControl);
var
Root: TCustomForm;
bmp: TBitmap;
i, maxTW, W, H, L, T: Integer;
ScreenPt, Cl: TPoint;
begin
if (Anchor = nil) or (Length(FCaptions) = 0) then Exit;
Root := GetParentForm(Anchor);
if Root = nil then Exit;
// Размеры по временному холсту (у контрола Canvas может быть ещё невалиден).
bmp := TBitmap.Create;
try
bmp.Canvas.Font.Assign(Font);
FItemH := Max(DpiScale(BASE_ITEM_H),
bmp.Canvas.TextHeight('Ag') + DpiScale(8));
maxTW := 0;
for i := 0 to High(FCaptions) do
maxTW := Max(maxTW, bmp.Canvas.TextWidth(FCaptions[i]));
finally
bmp.Free;
end;
W := DpiScale(BASE_PAD) + DpiScale(BASE_CHECKW) + maxTW + DpiScale(BASE_PAD);
W := Max(W, DpiScale(BASE_MINW));
H := Length(FCaptions) * FItemH + 2;
// Позиция под якорем в КЛИЕНТСКИХ координатах формы.
ScreenPt := Anchor.ClientToScreen(Point(0, Anchor.Height + 1));
Cl := Root.ScreenToClient(ScreenPt);
L := Cl.X;
T := Cl.Y;
if L + W > Root.ClientWidth then L := Root.ClientWidth - W;
if L < 0 then L := 0;
if T + H > Root.ClientHeight then
T := Root.ScreenToClient(Anchor.ClientToScreen(Point(0, 0))).Y - H; // вверх
if T < 0 then T := 0;
FHot := -1;
Parent := Root;
SetBounds(L, T, W, H);
Visible := True;
BringToFront;
SetFocus;
MouseCapture := True;
end;
end.