Files
ewsdr/PanZoomBar.pas
T
ew8bakandClaude Opus 4.8 388687d684 fix(macos): масштаб канвы берём у окна контрола, а не у главного экрана
backingScaleFactor главного экрана врёт, когда окно лежит на другом
мониторе. Спрашиваем масштаб у NSWindow того NSView, которому принадлежит
паинтбокс: это ровно то число, в котором Cocoa рисует эту канву, и оно
меняется само при перетаскивании окна между мониторами.

Objective-C по-прежнему живёт только в MacScale; наружу торчит
кроссплатформенная GetControlScale, которая на Windows/Linux сворачивается
в Result := 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 21:20:53 +03:00

447 lines
16 KiB
ObjectPascal

unit PanZoomBar;
{
PanZoomBar.pas — нижняя панель пана/зума спектра и водопада.
Полоса = мини-линейка ПОЛНОГО диапазона (sample rate): цифры частот + деления.
Поверх — окно-селектор «стекло» (полупрозрачное, с объёмной фаской), равное
видимой полосе. Взаимодействие:
• тянешь СЕРЕДИНУ окна → пан (сдвиг видимой полосы);
• тянешь ЛЕВЫЙ/ПРАВЫЙ край → зум (меняешь ширину окна, противоположный край
зафиксирован), курсор у края ⇔;
• клик мимо окна → окно центрируется на курсоре (пан);
• двойной клик → сброс к полному диапазону (зум 0).
Сквозь стекло видны цифры/деления. Callback OnZoomPan(zoom, pan) отдаёт обе
величины (зум 0..1, пан 0..1) — контроллер применяет их напрямую.
Рисует в чужой TPaintBox (как RulerView) через offscreen-битмап. Полупрозрачность
стекла — реальный per-pixel альфа-бленд (TLazIntfImage). Кнопки +/умолч/- — в MainForm.
}
{$IFDEF FPC}
{$MODE Delphi}
{$ENDIF}
interface
uses
Classes, SysUtils, Graphics, ExtCtrls, Controls, Math,
LCLIntf, LCLType, IntfGraphics, fpImage, AppTheme, PlatformUtils;
type
TPanZoomEvent = procedure(AZoom, APan: Double) of object;
TDragMode = (dmNone, dmBody, dmLeft, dmRight);
TPanZoomBar = class
private
FPb: TPaintBox;
FBmp: TBitmap;
FTheme: TAppTheme;
FZoom: Double; // 0..1 (0 = без зума, весь span виден)
FPan: Double; // 0..1 (положение окна по span)
FCenterFreq: Double; // центр ПОЛНОГО диапазона (DDC), Гц
FSpanHz: Double; // полный span (= sample rate), Гц
FDragMode: TDragMode;
FDragGrab: Double; // body: (курсор_доля − левый_край_доля) в момент захвата
FDragLo: Double; // зафиксированный левый край окна (для edge-зума), доля
FDragHi: Double; // зафиксированный правый край окна, доля
FOnZoomPan: TPanZoomEvent;
// Масштаб канвы: 2 на Retina, 1 на Windows/Linux. Только для отрисовки —
// мышь работает в логических координатах паинтбокса.
FScale: Integer;
function VisFraction: Double; // доля span, видимая при текущем зуме
// AScale: 1 для хит-теста (логические px), FScale для отрисовки (физические).
function ThumbGeom(W: Integer; out L, Wd: Integer;
AScale: Integer = 1): Boolean;
procedure CurWindow(out Lo, Hi: Double); // доли краёв окна
procedure EmitWindow(NewLo, NewHi: Double); // окно → zoom/pan + callback
function EdgePx(Wd: Integer): Integer;
procedure DrawRulerInto(W, H: Integer);
procedure BlendThumb(L, Wd, W, H: Integer);
public
constructor Create;
destructor Destroy; override;
property PaintBox: TPaintBox write FPb;
property OnZoomPan: TPanZoomEvent read FOnZoomPan write FOnZoomPan;
procedure SetState(AZoom, APan: Double);
procedure SetFreq(ACenterFreq, ASpanHz: Double);
procedure SetTheme(const T: TAppTheme);
procedure Paint(Sender: TObject);
procedure HandleMouseDown(Button: TMouseButton; X, Y: Integer);
procedure HandleMouseMove(X, Y: Integer);
procedure HandleMouseUp;
procedure HandleDblClick;
end;
implementation
const
MIN_THUMB_PX = 12; // окно не уже этого на экране — чтобы было за что схватить
MIN_VIS = 0.01; // минимальная видимая доля (= максимальный зум ~100x)
// Линейная интерполяция байт-канала.
function MixB(Bg, Fg: Integer; A: Double): Integer; inline;
begin
Result := Round(Bg * (1.0 - A) + Fg * A);
if Result < 0 then Result := 0 else if Result > 255 then Result := 255;
end;
function FreqLabel(Hz: Double): string;
begin
Result := Format('%.3f', [Hz / 1e6]);
end;
// Видимая доля → коэффициент зума z (инверсия формулы анализатора).
function VisToZoom(Vis: Double): Double;
var V: Double;
begin
V := EnsureRange(Vis, MIN_VIS, 1.0);
Result := EnsureRange((Power(10.0, (1.0 - V) / 0.99) - 1.0) / 9.0, 0.0, 1.0);
end;
constructor TPanZoomBar.Create;
begin
inherited Create;
FBmp := TBitmap.Create;
FBmp.PixelFormat := pf32bit;
FTheme := DarkTheme;
FZoom := 0.0;
FPan := 0.5;
FCenterFreq := 0.0;
FSpanHz := 192000.0;
FDragMode := dmNone;
FScale := 1;
end;
destructor TPanZoomBar.Destroy;
begin
FBmp.Free;
inherited;
end;
procedure TPanZoomBar.SetTheme(const T: TAppTheme);
begin
FTheme := T;
if Assigned(FPb) then FPb.Invalidate;
end;
procedure TPanZoomBar.SetState(AZoom, APan: Double);
var NewZoom, NewPan: Double;
begin
NewZoom := EnsureRange(AZoom, 0.0, 1.0);
NewPan := EnsureRange(APan, 0.0, 1.0);
if (Abs(NewZoom - FZoom) < 1e-4) and (Abs(NewPan - FPan) < 1e-4) then Exit;
FZoom := NewZoom;
FPan := NewPan;
if Assigned(FPb) then FPb.Invalidate;
end;
procedure TPanZoomBar.SetFreq(ACenterFreq, ASpanHz: Double);
begin
if (Abs(ACenterFreq - FCenterFreq) < 0.5) and (Abs(ASpanHz - FSpanHz) < 1.0) then Exit;
FCenterFreq := ACenterFreq;
FSpanHz := ASpanHz;
if Assigned(FPb) then FPb.Invalidate;
end;
function TPanZoomBar.VisFraction: Double;
// Та же формула, что и span-clip в WDSPEngine: видимая доля = width/bins.
begin
Result := EnsureRange(1.0 - 0.99 * Log10(9.0 * FZoom + 1.0), MIN_VIS, 1.0);
end;
procedure TPanZoomBar.CurWindow(out Lo, Hi: Double);
var Vis: Double;
begin
Vis := VisFraction;
Lo := EnsureRange(FPan, 0.0, 1.0) * (1.0 - Vis);
Hi := Lo + Vis;
end;
function TPanZoomBar.EdgePx(Wd: Integer): Integer;
// Зона захвата края — узкая, но не съедает всё узкое окно (оставляем тело).
begin
Result := Max(3, Min(8, Wd div 3));
end;
function TPanZoomBar.ThumbGeom(W: Integer; out L, Wd: Integer;
AScale: Integer = 1): Boolean;
var Lo, Hi: Double;
begin
Result := W > 0;
if not Result then begin L := 0; Wd := 0; Exit; end;
CurWindow(Lo, Hi);
Wd := Max(MIN_THUMB_PX * AScale, Round((Hi - Lo) * W));
if Wd > W then Wd := W;
L := Round(Lo * W);
if L < 0 then L := 0;
if L + Wd > W then L := W - Wd;
end;
procedure TPanZoomBar.EmitWindow(NewLo, NewHi: Double);
var Vis, Z, P: Double;
begin
if NewLo < 0.0 then NewLo := 0.0;
if NewHi > 1.0 then NewHi := 1.0;
if NewHi - NewLo < MIN_VIS then NewHi := NewLo + MIN_VIS;
if NewHi > 1.0 then begin NewHi := 1.0; NewLo := NewHi - MIN_VIS; end;
Vis := NewHi - NewLo;
Z := VisToZoom(Vis);
if (1.0 - Vis) > 1e-6 then P := NewLo / (1.0 - Vis) else P := 0.5;
P := EnsureRange(P, 0.0, 1.0);
// Локально обновляем сразу — драг плавный даже до round-trip контроллера.
FZoom := Z; FPan := P;
if Assigned(FPb) then FPb.Invalidate;
if Assigned(FOnZoomPan) then FOnZoomPan(Z, P);
end;
procedure TPanZoomBar.DrawRulerInto(W, H: Integer);
// Цифры частот + деления на всю ширину (= полный диапазон).
var
C: TCanvas;
i, X, TickMaj, TickMin, LabelY, NDiv, LabelEvery, TW: Integer;
FreqStart, FreqHz, PixPerDiv: Double;
Lbl: string;
Sc: Integer; // масштаб канвы, см. FScale
begin
Sc := FScale;
C := FBmp.Canvas;
C.Brush.Style := bsSolid;
C.Brush.Color := FTheme.SliderBG;
C.FillRect(Rect(0, 0, W, H));
C.Font.Name := 'Courier New';
C.Font.Style := [];
C.Font.Height := -Max(7 * Sc, Round(H * 0.40));
C.Brush.Style := bsClear;
C.Pen.Width := Sc;
TickMaj := Max(3 * Sc, Round(H * 0.28));
TickMin := Max(2 * Sc, Round(H * 0.16));
LabelY := TickMaj + ((H - TickMaj) - C.TextHeight('0')) div 2;
if LabelY < TickMaj then LabelY := TickMaj;
NDiv := 8;
if FSpanHz <= 0 then Exit;
FreqStart := FCenterFreq - FSpanHz / 2;
PixPerDiv := W / NDiv;
if PixPerDiv >= (C.TextWidth('000.000') + 8 * Sc) then LabelEvery := 1
else if PixPerDiv * 2 >= (C.TextWidth('000.000') + 8 * Sc) then LabelEvery := 2
else LabelEvery := 4;
for i := 0 to NDiv do
begin
X := Round(i * PixPerDiv);
if X >= W then X := W - Sc;
C.Pen.Color := FTheme.RulerBorder;
if (i mod LabelEvery) = 0 then
begin
C.MoveTo(X, 0); C.LineTo(X, TickMaj);
FreqHz := FreqStart + i * FSpanHz / NDiv;
Lbl := FreqLabel(FreqHz);
TW := C.TextWidth(Lbl);
C.Font.Color := FTheme.RulerText;
C.TextOut(EnsureRange(X - TW div 2, 0, W - TW), LabelY, Lbl);
end
else
begin
C.MoveTo(X, 0); C.LineTo(X, TickMin);
end;
end;
C.Brush.Style := bsSolid;
end;
procedure TPanZoomBar.BlendThumb(L, Wd, W, H: Integer);
// Полупрозрачное «стекло» с объёмной фаской поверх линейки.
var
Intf: TLazIntfImage;
x, y, top, bot: Integer;
c: TFPColor;
tR, tG, tB, bgR, bgG, bgB, nR, nG, nB: Integer;
A, RowT, Gloss: Double;
Active: Boolean;
Sc: Integer; // масштаб канвы, см. FScale
begin
Sc := FScale;
if (Wd <= 0) or (W <= 0) or (H <= 2 * Sc) then Exit;
Active := FDragMode <> dmNone;
if Active then
begin
tR := GetRValue(ColorToRGB(FTheme.SliderThumbDrag));
tG := GetGValue(ColorToRGB(FTheme.SliderThumbDrag));
tB := GetBValue(ColorToRGB(FTheme.SliderThumbDrag));
end
else
begin
tR := GetRValue(ColorToRGB(FTheme.SliderThumbNorm));
tG := GetGValue(ColorToRGB(FTheme.SliderThumbNorm));
tB := GetBValue(ColorToRGB(FTheme.SliderThumbNorm));
end;
top := Sc;
bot := H - 2 * Sc;
Intf := FBmp.CreateIntfImage;
try
for y := top to bot do
begin
if (bot - top) > 0 then RowT := (y - top) / (bot - top) else RowT := 0.0;
Gloss := 1.18 - 0.42 * RowT; // объём: ярче вверху, темнее внизу
A := 0.42 + 0.10 * (1.0 - RowT);
for x := L to L + Wd - 1 do
begin
if (x < 0) or (x >= W) then Continue;
c := Intf.Colors[x, y];
bgR := c.red shr 8; bgG := c.green shr 8; bgB := c.blue shr 8;
nR := MixB(bgR, EnsureRange(Round(tR * Gloss), 0, 255), A);
nG := MixB(bgG, EnsureRange(Round(tG * Gloss), 0, 255), A);
nB := MixB(bgB, EnsureRange(Round(tB * Gloss), 0, 255), A);
c.red := nR * 257; c.green := nG * 257; c.blue := nB * 257; c.alpha := $FFFF;
Intf.Colors[x, y] := c;
end;
end;
FBmp.LoadFromIntfImage(Intf);
finally
Intf.Free;
end;
// Объёмная фаска + маркеры краёв (две вертикальные риски — «ручки» ресайза).
with FBmp.Canvas do
begin
Pen.Width := Sc; Brush.Style := bsClear;
Pen.Color := FTheme.SliderThumbBdr;
Rectangle(L, top, L + Wd, bot + Sc);
Pen.Color := RGBToColor(EnsureRange(tR + 70, 0, 255),
EnsureRange(tG + 70, 0, 255),
EnsureRange(tB + 70, 0, 255));
MoveTo(L + Sc, top + Sc); LineTo(L + Wd - Sc, top + Sc);
MoveTo(L + Sc, top + Sc); LineTo(L + Sc, bot);
Pen.Color := RGBToColor(EnsureRange(tR - 60, 0, 255),
EnsureRange(tG - 60, 0, 255),
EnsureRange(tB - 60, 0, 255));
MoveTo(L + Sc, bot); LineTo(L + Wd - Sc, bot);
MoveTo(L + Wd - Sc, top + Sc); LineTo(L + Wd - Sc, bot + Sc);
// «ручки» захвата по краям (видны, когда окно достаточно широкое)
if Wd >= 3 * MIN_THUMB_PX * Sc then
begin
Pen.Color := FTheme.SliderThumbBdr;
MoveTo(L + 3 * Sc, top + 2 * Sc); LineTo(L + 3 * Sc, bot - Sc);
MoveTo(L + Wd - 4 * Sc, top + 2 * Sc); LineTo(L + Wd - 4 * Sc, bot - Sc);
end;
Brush.Style := bsSolid;
end;
end;
procedure TPanZoomBar.Paint(Sender: TObject);
var W, H, BmpW, BmpH, L, Wd: Integer;
begin
if FPb = nil then Exit;
W := FPb.Width; H := FPb.Height;
if (W <= 0) or (H <= 0) then Exit;
// Битмап в физических пикселях канвы, иначе Cocoa растянет его и замылит.
FScale := GetControlScale(FPb);
BmpW := W * FScale; BmpH := H * FScale;
if (FBmp.Width <> BmpW) or (FBmp.Height <> BmpH) then FBmp.SetSize(BmpW, BmpH);
DrawRulerInto(BmpW, BmpH);
// Геометрия бегунка — в физических px; хит-тест в мыши зовёт ThumbGeom без масштаба.
if ThumbGeom(BmpW, L, Wd, FScale) then BlendThumb(L, Wd, BmpW, BmpH);
FBmp.Canvas.Brush.Style := bsClear;
FBmp.Canvas.Pen.Width := FScale;
FBmp.Canvas.Pen.Color := FTheme.RulerBorder;
FBmp.Canvas.Rectangle(0, 0, BmpW, BmpH);
FBmp.Canvas.Brush.Style := bsSolid;
if FScale = 1 then
FPb.Canvas.Draw(0, 0, FBmp)
else
FPb.Canvas.StretchDraw(Rect(0, 0, W, H), FBmp);
end;
procedure TPanZoomBar.HandleMouseDown(Button: TMouseButton; X, Y: Integer);
var W, L, Wd, Edge: Integer; Lo, Hi, MouseFrac: Double;
begin
if (FPb = nil) or (Button <> mbLeft) then Exit;
W := FPb.Width;
if not ThumbGeom(W, L, Wd) then Exit;
CurWindow(Lo, Hi);
FDragLo := Lo; FDragHi := Hi;
Edge := EdgePx(Wd);
MouseFrac := X / W;
if (X >= L) and (X < L + Edge) then
FDragMode := dmLeft
else if (X > L + Wd - Edge) and (X <= L + Wd) then
FDragMode := dmRight
else if (X >= L) and (X <= L + Wd) then
begin
FDragMode := dmBody;
FDragGrab := MouseFrac - Lo; // тянем тело за точку захвата
end
else
begin
// Клик мимо окна → центрируем окно на курсоре, дальше тянем телом.
FDragMode := dmBody;
FDragGrab := (Hi - Lo) / 2.0;
EmitWindow(MouseFrac - FDragGrab, MouseFrac - FDragGrab + (Hi - Lo));
end;
if Assigned(FPb) then FPb.Invalidate;
end;
procedure TPanZoomBar.HandleMouseMove(X, Y: Integer);
var W, L, Wd, Edge: Integer; MouseFrac, Width: Double;
begin
if FPb = nil then Exit;
W := FPb.Width;
if W <= 0 then Exit;
MouseFrac := X / W;
if FDragMode = dmNone then
begin
// Hover: курсор-подсказка над краями (зум) / телом (пан).
if ThumbGeom(W, L, Wd) then
begin
Edge := EdgePx(Wd);
if ((X >= L) and (X < L + Edge)) or ((X > L + Wd - Edge) and (X <= L + Wd)) then
FPb.Cursor := crSizeWE
else if (X >= L) and (X <= L + Wd) then
FPb.Cursor := crSizeAll
else
FPb.Cursor := crDefault;
end;
Exit;
end;
case FDragMode of
dmLeft: EmitWindow(Min(MouseFrac, FDragHi - MIN_VIS), FDragHi);
dmRight: EmitWindow(FDragLo, Max(MouseFrac, FDragLo + MIN_VIS));
dmBody:
begin
Width := FDragHi - FDragLo; // ширина фиксирована → зум не меняется
EmitWindow(EnsureRange(MouseFrac - FDragGrab, 0.0, 1.0 - Width),
EnsureRange(MouseFrac - FDragGrab, 0.0, 1.0 - Width) + Width);
end;
end;
end;
procedure TPanZoomBar.HandleMouseUp;
begin
if FDragMode = dmNone then Exit;
FDragMode := dmNone;
if Assigned(FPb) then FPb.Invalidate;
end;
procedure TPanZoomBar.HandleDblClick;
begin
// Сброс к полному диапазону.
FDragMode := dmNone;
EmitWindow(0.0, 1.0);
end;
end.