overlay and calibration
This commit is contained in:
parent
3062993f7c
commit
3456e0d62a
24 changed files with 1193 additions and 439 deletions
|
|
@ -8,6 +8,7 @@ public class StashTabInfo
|
||||||
public int ClickY { get; set; }
|
public int ClickY { get; set; }
|
||||||
public bool IsFolder { get; set; }
|
public bool IsFolder { get; set; }
|
||||||
public int GridCols { get; set; } = 12;
|
public int GridCols { get; set; } = 12;
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
public List<StashTabInfo> SubTabs { get; set; } = [];
|
public List<StashTabInfo> SubTabs { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,14 @@ public class StashCalibrator
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<List<StashTabInfo>> OcrTabBar(Region region)
|
private async Task<List<StashTabInfo>> OcrTabBar(Region region)
|
||||||
{
|
{
|
||||||
|
// Save debug capture of the region
|
||||||
|
Directory.CreateDirectory("debug");
|
||||||
|
var tag = region == TabBarRegion ? "tabbar" : "subtab";
|
||||||
|
await _screen.SaveRegion(region, $"debug/calibrate-{tag}-{DateTime.Now:HHmmss}.png");
|
||||||
|
|
||||||
var ocr = await _screen.Ocr(region);
|
var ocr = await _screen.Ocr(region);
|
||||||
|
Log.Information("StashCalibrator: OCR region ({Tag}) raw text: '{Text}'", tag, ocr.Text);
|
||||||
|
|
||||||
var allWords = ocr.Lines.SelectMany(l => l.Words).ToList();
|
var allWords = ocr.Lines.SelectMany(l => l.Words).ToList();
|
||||||
if (allWords.Count == 0) return [];
|
if (allWords.Count == 0) return [];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,32 @@ using OpenCvSharp.Extensions;
|
||||||
|
|
||||||
static class ImagePreprocessor
|
static class ImagePreprocessor
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// CLAHE (Contrast Limited Adaptive Histogram Equalization) preprocessing.
|
||||||
|
/// Enhances local contrast — works for both light and dark text on varying backgrounds.
|
||||||
|
/// Pipeline: grayscale -> CLAHE -> upscale (keeps grayscale, lets EasyOCR handle binarization)
|
||||||
|
/// </summary>
|
||||||
|
public static Bitmap PreprocessClahe(Bitmap src, double clipLimit = 3.0, int tileSize = 8, int upscale = 2)
|
||||||
|
{
|
||||||
|
using var mat = BitmapConverter.ToMat(src);
|
||||||
|
using var gray = new Mat();
|
||||||
|
Cv2.CvtColor(mat, gray, ColorConversionCodes.BGRA2GRAY);
|
||||||
|
|
||||||
|
using var clahe = Cv2.CreateCLAHE(clipLimit, new OpenCvSharp.Size(tileSize, tileSize));
|
||||||
|
using var enhanced = new Mat();
|
||||||
|
clahe.Apply(gray, enhanced);
|
||||||
|
|
||||||
|
if (upscale > 1)
|
||||||
|
{
|
||||||
|
using var upscaled = new Mat();
|
||||||
|
Cv2.Resize(enhanced, upscaled, new OpenCvSharp.Size(enhanced.Width * upscale, enhanced.Height * upscale),
|
||||||
|
interpolation: InterpolationFlags.Cubic);
|
||||||
|
return BitmapConverter.ToBitmap(upscaled);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BitmapConverter.ToBitmap(enhanced);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Pre-process an image for OCR using morphological white top-hat filtering.
|
/// Pre-process an image for OCR using morphological white top-hat filtering.
|
||||||
/// Isolates bright tooltip text, suppresses dim background text visible through overlay.
|
/// Isolates bright tooltip text, suppresses dim background text visible through overlay.
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,12 @@ public class ScreenReader : IScreenReader
|
||||||
return Task.FromResult(_pythonBridge.OcrFromBitmap(processed));
|
return Task.FromResult(_pythonBridge.OcrFromBitmap(processed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (preprocess == "clahe")
|
||||||
|
{
|
||||||
|
using var processed = ImagePreprocessor.PreprocessClahe(bitmap);
|
||||||
|
return Task.FromResult(_pythonBridge.OcrFromBitmap(processed));
|
||||||
|
}
|
||||||
|
|
||||||
return Task.FromResult(_pythonBridge.OcrFromBitmap(bitmap));
|
return Task.FromResult(_pythonBridge.OcrFromBitmap(bitmap));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,12 +68,12 @@ public partial class App : Application
|
||||||
desktop.MainWindow = window;
|
desktop.MainWindow = window;
|
||||||
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnMainWindowClose;
|
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnMainWindowClose;
|
||||||
|
|
||||||
var overlay = new OverlayWindow(bot);
|
var overlay = new D2dOverlay(bot);
|
||||||
overlay.Show();
|
overlay.Start();
|
||||||
|
|
||||||
desktop.ShutdownRequested += async (_, _) =>
|
desktop.ShutdownRequested += async (_, _) =>
|
||||||
{
|
{
|
||||||
overlay.Close();
|
overlay.Shutdown();
|
||||||
mainVm.Shutdown();
|
mainVm.Shutdown();
|
||||||
await bot.DisposeAsync();
|
await bot.DisposeAsync();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
152
src/Poe2Trade.Ui/Overlay/D2dNativeMethods.cs
Normal file
152
src/Poe2Trade.Ui/Overlay/D2dNativeMethods.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace Poe2Trade.Ui.Overlay;
|
||||||
|
|
||||||
|
/// <summary>Win32 P/Invoke for the D2D overlay window, DWM transparency, and frame timing.</summary>
|
||||||
|
internal static partial class D2dNativeMethods
|
||||||
|
{
|
||||||
|
// --- Window styles ---
|
||||||
|
internal const uint WS_POPUP = 0x80000000;
|
||||||
|
internal const uint WS_VISIBLE = 0x10000000;
|
||||||
|
internal const uint WS_EX_TOPMOST = 0x00000008;
|
||||||
|
internal const uint WS_EX_TRANSPARENT = 0x00000020;
|
||||||
|
internal const uint WS_EX_TOOLWINDOW = 0x00000080;
|
||||||
|
internal const uint WS_EX_LAYERED = 0x00080000;
|
||||||
|
internal const uint WS_EX_NOACTIVATE = 0x08000000;
|
||||||
|
|
||||||
|
// --- Window messages ---
|
||||||
|
internal const uint WM_DESTROY = 0x0002;
|
||||||
|
internal const uint WM_ERASEBKGND = 0x0014;
|
||||||
|
|
||||||
|
// --- ShowWindow / PeekMessage ---
|
||||||
|
internal const int SW_SHOWNOACTIVATE = 4;
|
||||||
|
internal const int SW_HIDE = 0;
|
||||||
|
internal const uint PM_REMOVE = 0x0001;
|
||||||
|
internal const int IDC_ARROW = 32512;
|
||||||
|
internal const uint LWA_ALPHA = 0x02;
|
||||||
|
|
||||||
|
// --- High-resolution waitable timer (Windows 10 1803+) ---
|
||||||
|
internal const uint CREATE_WAITABLE_TIMER_HIGH_RESOLUTION = 0x00000002;
|
||||||
|
internal const uint TIMER_ALL_ACCESS = 0x1F0003;
|
||||||
|
internal const uint INFINITE = 0xFFFFFFFF;
|
||||||
|
|
||||||
|
internal delegate nint WndProcDelegate(nint hWnd, uint msg, nint wParam, nint lParam);
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||||
|
internal struct WNDCLASSEXW
|
||||||
|
{
|
||||||
|
public uint cbSize;
|
||||||
|
public uint style;
|
||||||
|
public nint lpfnWndProc;
|
||||||
|
public int cbClsExtra;
|
||||||
|
public int cbWndExtra;
|
||||||
|
public nint hInstance;
|
||||||
|
public nint hIcon;
|
||||||
|
public nint hCursor;
|
||||||
|
public nint hbrBackground;
|
||||||
|
public string? lpszMenuName;
|
||||||
|
public string lpszClassName;
|
||||||
|
public nint hIconSm;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct MSG
|
||||||
|
{
|
||||||
|
public nint hwnd;
|
||||||
|
public uint message;
|
||||||
|
public nint wParam;
|
||||||
|
public nint lParam;
|
||||||
|
public uint time;
|
||||||
|
public int pt_x;
|
||||||
|
public int pt_y;
|
||||||
|
}
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
internal struct MARGINS
|
||||||
|
{
|
||||||
|
public int Left, Right, Top, Bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- user32.dll ---
|
||||||
|
|
||||||
|
// DllImport required: WNDCLASSEXW has managed string fields unsupported by LibraryImport source generator
|
||||||
|
[DllImport("user32.dll", EntryPoint = "RegisterClassExW")]
|
||||||
|
internal static extern ushort RegisterClassExW(ref WNDCLASSEXW wc);
|
||||||
|
|
||||||
|
[LibraryImport("user32.dll", EntryPoint = "CreateWindowExW", StringMarshalling = StringMarshalling.Utf16)]
|
||||||
|
internal static partial nint CreateWindowExW(
|
||||||
|
uint dwExStyle, string lpClassName, string lpWindowName,
|
||||||
|
uint dwStyle, int x, int y, int nWidth, int nHeight,
|
||||||
|
nint hWndParent, nint hMenu, nint hInstance, nint lpParam);
|
||||||
|
|
||||||
|
[LibraryImport("user32.dll", EntryPoint = "DefWindowProcW")]
|
||||||
|
internal static partial nint DefWindowProcW(nint hWnd, uint msg, nint wParam, nint lParam);
|
||||||
|
|
||||||
|
[LibraryImport("user32.dll")]
|
||||||
|
internal static partial void PostQuitMessage(int nExitCode);
|
||||||
|
|
||||||
|
[LibraryImport("user32.dll", EntryPoint = "PeekMessageW")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
internal static partial bool PeekMessageW(out MSG lpMsg, nint hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
|
||||||
|
|
||||||
|
[LibraryImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
internal static partial bool TranslateMessage(ref MSG lpMsg);
|
||||||
|
|
||||||
|
[LibraryImport("user32.dll", EntryPoint = "DispatchMessageW")]
|
||||||
|
internal static partial nint DispatchMessageW(ref MSG lpMsg);
|
||||||
|
|
||||||
|
[LibraryImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
internal static partial bool DestroyWindow(nint hWnd);
|
||||||
|
|
||||||
|
[LibraryImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
internal static partial bool ShowWindow(nint hWnd, int nCmdShow);
|
||||||
|
|
||||||
|
[LibraryImport("user32.dll", EntryPoint = "LoadCursorW")]
|
||||||
|
internal static partial nint LoadCursorW(nint hInstance, int lpCursorName);
|
||||||
|
|
||||||
|
[LibraryImport("user32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
internal static partial bool SetLayeredWindowAttributes(nint hwnd, uint crKey, byte bAlpha, uint dwFlags);
|
||||||
|
|
||||||
|
// --- kernel32.dll ---
|
||||||
|
|
||||||
|
[LibraryImport("kernel32.dll", EntryPoint = "GetModuleHandleW")]
|
||||||
|
internal static partial nint GetModuleHandleW(nint lpModuleName);
|
||||||
|
|
||||||
|
[LibraryImport("kernel32.dll", EntryPoint = "CreateWaitableTimerExW")]
|
||||||
|
internal static partial nint CreateWaitableTimerExW(
|
||||||
|
nint lpTimerAttributes, nint lpTimerName, uint dwFlags, uint dwDesiredAccess);
|
||||||
|
|
||||||
|
[LibraryImport("kernel32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
internal static partial bool SetWaitableTimer(
|
||||||
|
nint hTimer, ref long lpDueTime, int lPeriod,
|
||||||
|
nint pfnCompletionRoutine, nint lpArgToCompletionRoutine,
|
||||||
|
[MarshalAs(UnmanagedType.Bool)] bool fResume);
|
||||||
|
|
||||||
|
[LibraryImport("kernel32.dll")]
|
||||||
|
internal static partial uint WaitForSingleObject(nint hHandle, uint dwMilliseconds);
|
||||||
|
|
||||||
|
[LibraryImport("kernel32.dll")]
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
internal static partial bool CloseHandle(nint hObject);
|
||||||
|
|
||||||
|
// --- dwmapi.dll / winmm.dll ---
|
||||||
|
|
||||||
|
[LibraryImport("dwmapi.dll")]
|
||||||
|
internal static partial int DwmExtendFrameIntoClientArea(nint hWnd, ref MARGINS pMarInset);
|
||||||
|
|
||||||
|
[LibraryImport("winmm.dll")]
|
||||||
|
internal static partial uint timeBeginPeriod(uint uPeriod);
|
||||||
|
|
||||||
|
[LibraryImport("winmm.dll")]
|
||||||
|
internal static partial uint timeEndPeriod(uint uPeriod);
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
internal static void ShowNoActivate(nint hwnd) => ShowWindow(hwnd, SW_SHOWNOACTIVATE);
|
||||||
|
internal static void HideWindow(nint hwnd) => ShowWindow(hwnd, SW_HIDE);
|
||||||
|
}
|
||||||
270
src/Poe2Trade.Ui/Overlay/D2dOverlay.cs
Normal file
270
src/Poe2Trade.Ui/Overlay/D2dOverlay.cs
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Runtime;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Poe2Trade.Bot;
|
||||||
|
using Poe2Trade.Ui.Overlay.Layers;
|
||||||
|
using Vortice.Mathematics;
|
||||||
|
using static Poe2Trade.Ui.Overlay.D2dNativeMethods;
|
||||||
|
|
||||||
|
namespace Poe2Trade.Ui.Overlay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fullscreen transparent overlay rendered with Direct2D on a dedicated thread.
|
||||||
|
/// Uses a software render target to avoid GPU contention with DXGI capture / YOLO / Avalonia.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class D2dOverlay
|
||||||
|
{
|
||||||
|
private const int Width = 2560;
|
||||||
|
private const int Height = 1440;
|
||||||
|
private const double TargetFrameMs = 16.0; // ~60 fps
|
||||||
|
private const int FocusCheckInterval = 60; // frames between focus checks (~1 Hz)
|
||||||
|
private const string ClassName = "Poe2D2dOverlay";
|
||||||
|
|
||||||
|
private readonly BotOrchestrator _bot;
|
||||||
|
private readonly List<ID2dOverlayLayer> _layers = [];
|
||||||
|
private Thread? _thread;
|
||||||
|
private volatile bool _shutdown;
|
||||||
|
|
||||||
|
// Must be stored as field to prevent GC collection of the delegate
|
||||||
|
private WndProcDelegate? _wndProcDelegate;
|
||||||
|
|
||||||
|
public D2dOverlay(BotOrchestrator bot)
|
||||||
|
{
|
||||||
|
_bot = bot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start()
|
||||||
|
{
|
||||||
|
_thread = new Thread(RenderLoop)
|
||||||
|
{
|
||||||
|
Name = "D2dOverlay",
|
||||||
|
IsBackground = true,
|
||||||
|
Priority = ThreadPriority.AboveNormal,
|
||||||
|
};
|
||||||
|
_thread.SetApartmentState(ApartmentState.STA);
|
||||||
|
_thread.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Shutdown()
|
||||||
|
{
|
||||||
|
_shutdown = true;
|
||||||
|
_thread?.Join(2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RenderLoop()
|
||||||
|
{
|
||||||
|
nint hwnd = 0;
|
||||||
|
nint hTimer = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
hwnd = CreateOverlayWindow();
|
||||||
|
if (hwnd == 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[D2dOverlay] CreateOverlayWindow failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetLayeredWindowAttributes(hwnd, 0, 200, LWA_ALPHA);
|
||||||
|
var margins = new MARGINS { Left = -1, Right = -1, Top = -1, Bottom = -1 };
|
||||||
|
DwmExtendFrameIntoClientArea(hwnd, ref margins);
|
||||||
|
|
||||||
|
using var ctx = new D2dRenderContext(hwnd, Width, Height);
|
||||||
|
|
||||||
|
_layers.Add(new D2dEnemyBoxLayer(ctx));
|
||||||
|
_layers.Add(new D2dHudInfoLayer());
|
||||||
|
_layers.Add(new D2dDebugTextLayer());
|
||||||
|
|
||||||
|
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
|
||||||
|
timeBeginPeriod(1);
|
||||||
|
hTimer = CreateWaitableTimerExW(0, 0, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS);
|
||||||
|
|
||||||
|
// Pre-allocate per-frame arrays
|
||||||
|
var layerCount = _layers.Count;
|
||||||
|
var layerMs = new double[layerCount];
|
||||||
|
var layerNames = new string[layerCount];
|
||||||
|
for (int i = 0; i < layerCount; i++)
|
||||||
|
layerNames[i] = _layers[i].GetType().Name;
|
||||||
|
|
||||||
|
var timing = new RenderTiming();
|
||||||
|
var fpsWatch = Stopwatch.StartNew();
|
||||||
|
int frameCount = 0;
|
||||||
|
double fps = 0;
|
||||||
|
int focusCounter = 0;
|
||||||
|
bool shown = true;
|
||||||
|
long lastFrameTimestamp = Stopwatch.GetTimestamp();
|
||||||
|
|
||||||
|
ShowNoActivate(hwnd);
|
||||||
|
Console.WriteLine($"[D2dOverlay] Started (hwnd={hwnd:X})");
|
||||||
|
|
||||||
|
while (!_shutdown)
|
||||||
|
{
|
||||||
|
var frameStart = Stopwatch.GetTimestamp();
|
||||||
|
var frameInterval = (frameStart - lastFrameTimestamp) * 1000.0 / Stopwatch.Frequency;
|
||||||
|
lastFrameTimestamp = frameStart;
|
||||||
|
|
||||||
|
DrainMessages();
|
||||||
|
UpdateFocusVisibility(ref focusCounter, ref shown, hwnd);
|
||||||
|
UpdateFps(fpsWatch, ref frameCount, ref fps);
|
||||||
|
|
||||||
|
var state = BuildState(fps, timing);
|
||||||
|
var snapMs = ElapsedMs(frameStart);
|
||||||
|
|
||||||
|
Render(ctx, state, layerMs, layerCount);
|
||||||
|
|
||||||
|
var totalRenderMs = ElapsedMs(frameStart);
|
||||||
|
timing.SnapshotMs = snapMs;
|
||||||
|
timing.LayerMs = layerMs;
|
||||||
|
timing.LayerNames = layerNames;
|
||||||
|
timing.TotalRenderMs = totalRenderMs;
|
||||||
|
timing.IntervalMs = frameInterval;
|
||||||
|
|
||||||
|
WaitForNextFrame(frameStart, hTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[D2dOverlay] Exception: {ex}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var layer in _layers)
|
||||||
|
(layer as IDisposable)?.Dispose();
|
||||||
|
if (hTimer != 0) CloseHandle(hTimer);
|
||||||
|
timeEndPeriod(1);
|
||||||
|
if (hwnd != 0) DestroyWindow(hwnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Render loop helpers ---
|
||||||
|
|
||||||
|
private static void DrainMessages()
|
||||||
|
{
|
||||||
|
while (PeekMessageW(out var msg, 0, 0, 0, PM_REMOVE))
|
||||||
|
{
|
||||||
|
TranslateMessage(ref msg);
|
||||||
|
DispatchMessageW(ref msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateFocusVisibility(ref int counter, ref bool shown, nint hwnd)
|
||||||
|
{
|
||||||
|
if (++counter < FocusCheckInterval) return;
|
||||||
|
counter = 0;
|
||||||
|
|
||||||
|
var gameExists = _bot.Game.GetWindowRect() != null;
|
||||||
|
var focused = _bot.Game.IsGameFocused();
|
||||||
|
|
||||||
|
if (!shown && (focused || !gameExists))
|
||||||
|
{
|
||||||
|
ShowNoActivate(hwnd);
|
||||||
|
shown = true;
|
||||||
|
}
|
||||||
|
else if (shown && gameExists && !focused)
|
||||||
|
{
|
||||||
|
HideWindow(hwnd);
|
||||||
|
shown = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void UpdateFps(Stopwatch watch, ref int frameCount, ref double fps)
|
||||||
|
{
|
||||||
|
frameCount++;
|
||||||
|
var elapsed = watch.Elapsed.TotalSeconds;
|
||||||
|
if (elapsed >= 1.0)
|
||||||
|
{
|
||||||
|
fps = frameCount / elapsed;
|
||||||
|
frameCount = 0;
|
||||||
|
watch.Restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OverlayState BuildState(double fps, RenderTiming timing)
|
||||||
|
{
|
||||||
|
var detection = _bot.EnemyDetector.Latest;
|
||||||
|
return new OverlayState(
|
||||||
|
Enemies: detection.Enemies,
|
||||||
|
InferenceMs: detection.InferenceMs,
|
||||||
|
Hud: _bot.HudReader.Current,
|
||||||
|
NavState: _bot.Navigation.State,
|
||||||
|
NavPosition: _bot.Navigation.Position,
|
||||||
|
IsExploring: _bot.Navigation.IsExploring,
|
||||||
|
Fps: fps,
|
||||||
|
Timing: timing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Render(D2dRenderContext ctx, OverlayState state, double[] layerMs, int layerCount)
|
||||||
|
{
|
||||||
|
var rt = ctx.RenderTarget;
|
||||||
|
rt.BeginDraw();
|
||||||
|
rt.Clear(new Color4(0, 0, 0, 0));
|
||||||
|
|
||||||
|
for (int i = 0; i < layerCount; i++)
|
||||||
|
{
|
||||||
|
var start = Stopwatch.GetTimestamp();
|
||||||
|
_layers[i].Draw(ctx, state);
|
||||||
|
layerMs[i] = (Stopwatch.GetTimestamp() - start) * 1000.0 / Stopwatch.Frequency;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hr = rt.EndDraw();
|
||||||
|
if (hr.Failure)
|
||||||
|
ctx.RecreateTarget();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WaitForNextFrame(long frameStart, nint hTimer)
|
||||||
|
{
|
||||||
|
var waitMs = TargetFrameMs - ElapsedMs(frameStart);
|
||||||
|
if (waitMs > 0.5 && hTimer != 0)
|
||||||
|
{
|
||||||
|
var dueTime = -(long)(waitMs * 10_000); // negative = relative, 100ns units
|
||||||
|
SetWaitableTimer(hTimer, ref dueTime, 0, 0, 0, false);
|
||||||
|
WaitForSingleObject(hTimer, INFINITE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double ElapsedMs(long since)
|
||||||
|
=> (Stopwatch.GetTimestamp() - since) * 1000.0 / Stopwatch.Frequency;
|
||||||
|
|
||||||
|
// --- Window creation ---
|
||||||
|
|
||||||
|
private nint CreateOverlayWindow()
|
||||||
|
{
|
||||||
|
var hInstance = GetModuleHandleW(0);
|
||||||
|
|
||||||
|
_wndProcDelegate = WndProc;
|
||||||
|
var fnPtr = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate);
|
||||||
|
|
||||||
|
var wc = new WNDCLASSEXW
|
||||||
|
{
|
||||||
|
cbSize = (uint)Marshal.SizeOf<WNDCLASSEXW>(),
|
||||||
|
lpfnWndProc = fnPtr,
|
||||||
|
hInstance = hInstance,
|
||||||
|
hCursor = LoadCursorW(0, IDC_ARROW),
|
||||||
|
lpszClassName = ClassName,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (RegisterClassExW(ref wc) == 0) return 0;
|
||||||
|
|
||||||
|
const uint exStyle = WS_EX_TOPMOST | WS_EX_TRANSPARENT | WS_EX_LAYERED
|
||||||
|
| WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE;
|
||||||
|
|
||||||
|
return CreateWindowExW(
|
||||||
|
exStyle, ClassName, "Poe2Overlay",
|
||||||
|
WS_POPUP | WS_VISIBLE,
|
||||||
|
0, 0, Width, Height,
|
||||||
|
0, 0, hInstance, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static nint WndProc(nint hWnd, uint msg, nint wParam, nint lParam)
|
||||||
|
{
|
||||||
|
switch (msg)
|
||||||
|
{
|
||||||
|
case WM_DESTROY:
|
||||||
|
PostQuitMessage(0);
|
||||||
|
return 0;
|
||||||
|
case WM_ERASEBKGND:
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
return DefWindowProcW(hWnd, msg, wParam, lParam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/Poe2Trade.Ui/Overlay/D2dRenderContext.cs
Normal file
138
src/Poe2Trade.Ui/Overlay/D2dRenderContext.cs
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
using Vortice.Direct2D1;
|
||||||
|
using Vortice.DirectWrite;
|
||||||
|
using Vortice.DXGI;
|
||||||
|
using Vortice.Mathematics;
|
||||||
|
using DWriteFactory = Vortice.DirectWrite.IDWriteFactory;
|
||||||
|
using D2dFactoryType = Vortice.Direct2D1.FactoryType;
|
||||||
|
using DwFactoryType = Vortice.DirectWrite.FactoryType;
|
||||||
|
|
||||||
|
namespace Poe2Trade.Ui.Overlay;
|
||||||
|
|
||||||
|
public sealed class D2dRenderContext : IDisposable
|
||||||
|
{
|
||||||
|
private readonly nint _hwnd;
|
||||||
|
private readonly int _width;
|
||||||
|
private readonly int _height;
|
||||||
|
|
||||||
|
// Factories
|
||||||
|
public ID2D1Factory1 D2dFactory { get; }
|
||||||
|
public DWriteFactory DWriteFactory { get; }
|
||||||
|
|
||||||
|
// Render target (recreated on device loss)
|
||||||
|
public ID2D1HwndRenderTarget RenderTarget { get; private set; } = null!;
|
||||||
|
|
||||||
|
// Pre-created brushes
|
||||||
|
public ID2D1SolidColorBrush Red { get; private set; } = null!;
|
||||||
|
public ID2D1SolidColorBrush Yellow { get; private set; } = null!;
|
||||||
|
public ID2D1SolidColorBrush Green { get; private set; } = null!;
|
||||||
|
public ID2D1SolidColorBrush White { get; private set; } = null!;
|
||||||
|
public ID2D1SolidColorBrush Gray { get; private set; } = null!;
|
||||||
|
public ID2D1SolidColorBrush LifeBrush { get; private set; } = null!;
|
||||||
|
public ID2D1SolidColorBrush ManaBrush { get; private set; } = null!;
|
||||||
|
public ID2D1SolidColorBrush BarBgBrush { get; private set; } = null!;
|
||||||
|
public ID2D1SolidColorBrush LabelBgBrush { get; private set; } = null!;
|
||||||
|
public ID2D1SolidColorBrush DebugTextBrush { get; private set; } = null!;
|
||||||
|
public ID2D1SolidColorBrush TimingBrush { get; private set; } = null!;
|
||||||
|
public ID2D1SolidColorBrush DebugBgBrush { get; private set; } = null!;
|
||||||
|
|
||||||
|
// Text formats
|
||||||
|
public IDWriteTextFormat LabelFormat { get; } // 12pt — enemy labels
|
||||||
|
public IDWriteTextFormat BarValueFormat { get; } // 11pt — bar values
|
||||||
|
public IDWriteTextFormat DebugFormat { get; } // 13pt — debug overlay
|
||||||
|
|
||||||
|
public D2dRenderContext(nint hwnd, int width, int height)
|
||||||
|
{
|
||||||
|
_hwnd = hwnd;
|
||||||
|
_width = width;
|
||||||
|
_height = height;
|
||||||
|
|
||||||
|
D2dFactory = D2D1.D2D1CreateFactory<ID2D1Factory1>(D2dFactoryType.SingleThreaded);
|
||||||
|
DWriteFactory = Vortice.DirectWrite.DWrite.DWriteCreateFactory<DWriteFactory>(DwFactoryType.Shared);
|
||||||
|
|
||||||
|
CreateRenderTarget();
|
||||||
|
CreateBrushes();
|
||||||
|
|
||||||
|
// Text formats (these survive device loss — they're DWrite, not D2D)
|
||||||
|
LabelFormat = DWriteFactory.CreateTextFormat("Consolas", 12f);
|
||||||
|
BarValueFormat = DWriteFactory.CreateTextFormat("Consolas", 11f);
|
||||||
|
DebugFormat = DWriteFactory.CreateTextFormat("Consolas", 13f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateRenderTarget()
|
||||||
|
{
|
||||||
|
var rtProps = new RenderTargetProperties
|
||||||
|
{
|
||||||
|
Type = RenderTargetType.Software,
|
||||||
|
PixelFormat = new Vortice.DCommon.PixelFormat(Format.B8G8R8A8_UNorm, Vortice.DCommon.AlphaMode.Premultiplied),
|
||||||
|
};
|
||||||
|
var hwndProps = new HwndRenderTargetProperties
|
||||||
|
{
|
||||||
|
Hwnd = _hwnd,
|
||||||
|
PixelSize = new SizeI(_width, _height),
|
||||||
|
PresentOptions = PresentOptions.Immediately,
|
||||||
|
};
|
||||||
|
RenderTarget = D2dFactory.CreateHwndRenderTarget(rtProps, hwndProps);
|
||||||
|
RenderTarget.TextAntialiasMode = Vortice.Direct2D1.TextAntialiasMode.Grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateBrushes()
|
||||||
|
{
|
||||||
|
Red = RenderTarget.CreateSolidColorBrush(new Color4(1f, 0f, 0f, 1f));
|
||||||
|
Yellow = RenderTarget.CreateSolidColorBrush(new Color4(1f, 1f, 0f, 1f));
|
||||||
|
Green = RenderTarget.CreateSolidColorBrush(new Color4(0.31f, 1f, 0.31f, 1f)); // 80,255,80
|
||||||
|
White = RenderTarget.CreateSolidColorBrush(new Color4(1f, 1f, 1f, 1f));
|
||||||
|
Gray = RenderTarget.CreateSolidColorBrush(new Color4(0.5f, 0.5f, 0.5f, 1f));
|
||||||
|
LifeBrush = RenderTarget.CreateSolidColorBrush(new Color4(200 / 255f, 40 / 255f, 40 / 255f, 1f));
|
||||||
|
ManaBrush = RenderTarget.CreateSolidColorBrush(new Color4(40 / 255f, 80 / 255f, 200 / 255f, 1f));
|
||||||
|
BarBgBrush = RenderTarget.CreateSolidColorBrush(new Color4(20 / 255f, 20 / 255f, 20 / 255f, 140 / 255f));
|
||||||
|
LabelBgBrush = RenderTarget.CreateSolidColorBrush(new Color4(0f, 0f, 0f, 160 / 255f));
|
||||||
|
DebugTextBrush = RenderTarget.CreateSolidColorBrush(new Color4(80 / 255f, 1f, 80 / 255f, 1f));
|
||||||
|
TimingBrush = RenderTarget.CreateSolidColorBrush(new Color4(1f, 200 / 255f, 80 / 255f, 1f));
|
||||||
|
DebugBgBrush = RenderTarget.CreateSolidColorBrush(new Color4(0f, 0f, 0f, 160 / 255f));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisposeBrushes()
|
||||||
|
{
|
||||||
|
Red?.Dispose();
|
||||||
|
Yellow?.Dispose();
|
||||||
|
Green?.Dispose();
|
||||||
|
White?.Dispose();
|
||||||
|
Gray?.Dispose();
|
||||||
|
LifeBrush?.Dispose();
|
||||||
|
ManaBrush?.Dispose();
|
||||||
|
BarBgBrush?.Dispose();
|
||||||
|
LabelBgBrush?.Dispose();
|
||||||
|
DebugTextBrush?.Dispose();
|
||||||
|
TimingBrush?.Dispose();
|
||||||
|
DebugBgBrush?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Call after EndDraw returns D2DERR_RECREATE_TARGET.
|
||||||
|
/// Recreates the render target and all device-dependent resources.
|
||||||
|
/// </summary>
|
||||||
|
public void RecreateTarget()
|
||||||
|
{
|
||||||
|
DisposeBrushes();
|
||||||
|
RenderTarget?.Dispose();
|
||||||
|
CreateRenderTarget();
|
||||||
|
CreateBrushes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Create a text layout for measurement + drawing.</summary>
|
||||||
|
public IDWriteTextLayout CreateTextLayout(string text, IDWriteTextFormat format, float maxWidth = 4096f, float maxHeight = 4096f)
|
||||||
|
{
|
||||||
|
return DWriteFactory.CreateTextLayout(text, format, maxWidth, maxHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
DisposeBrushes();
|
||||||
|
RenderTarget?.Dispose();
|
||||||
|
LabelFormat?.Dispose();
|
||||||
|
BarValueFormat?.Dispose();
|
||||||
|
DebugFormat?.Dispose();
|
||||||
|
DWriteFactory?.Dispose();
|
||||||
|
D2dFactory?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
using Avalonia.Media;
|
|
||||||
using Poe2Trade.Navigation;
|
using Poe2Trade.Navigation;
|
||||||
using Poe2Trade.Screen;
|
using Poe2Trade.Screen;
|
||||||
|
|
||||||
|
|
@ -11,9 +10,19 @@ public record OverlayState(
|
||||||
NavigationState NavState,
|
NavigationState NavState,
|
||||||
MapPosition NavPosition,
|
MapPosition NavPosition,
|
||||||
bool IsExploring,
|
bool IsExploring,
|
||||||
double Fps);
|
double Fps,
|
||||||
|
RenderTiming? Timing);
|
||||||
|
|
||||||
public interface IOverlayLayer
|
public class RenderTiming
|
||||||
{
|
{
|
||||||
void Draw(DrawingContext dc, OverlayState state);
|
public double SnapshotMs { get; set; }
|
||||||
|
public double[] LayerMs { get; set; } = [];
|
||||||
|
public string[] LayerNames { get; set; } = [];
|
||||||
|
public double TotalRenderMs { get; set; }
|
||||||
|
public double IntervalMs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface ID2dOverlayLayer
|
||||||
|
{
|
||||||
|
void Draw(D2dRenderContext ctx, OverlayState state);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
109
src/Poe2Trade.Ui/Overlay/Layers/D2dDebugTextLayer.cs
Normal file
109
src/Poe2Trade.Ui/Overlay/Layers/D2dDebugTextLayer.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
using System.Drawing;
|
||||||
|
using Vortice.Direct2D1;
|
||||||
|
using Vortice.DirectWrite;
|
||||||
|
using Vortice.Mathematics;
|
||||||
|
|
||||||
|
namespace Poe2Trade.Ui.Overlay.Layers;
|
||||||
|
|
||||||
|
internal sealed class D2dDebugTextLayer : ID2dOverlayLayer, IDisposable
|
||||||
|
{
|
||||||
|
private const float PadX = 8;
|
||||||
|
private const float PadY = 4;
|
||||||
|
private const float StartX = 10;
|
||||||
|
private const float StartY = 10;
|
||||||
|
private const float ColumnGap = 16;
|
||||||
|
|
||||||
|
private readonly CachedLine[] _left = new CachedLine[8];
|
||||||
|
private readonly CachedLine[] _right = new CachedLine[8];
|
||||||
|
|
||||||
|
public void Draw(D2dRenderContext ctx, OverlayState state)
|
||||||
|
{
|
||||||
|
var rt = ctx.RenderTarget;
|
||||||
|
int lc = 0, rc = 0;
|
||||||
|
|
||||||
|
// Left column: game state
|
||||||
|
UpdateCache(ctx, _left, ref lc, $"FPS: {state.Fps:F0}", ctx.DebugTextBrush);
|
||||||
|
UpdateCache(ctx, _left, ref lc, $"Nav: {state.NavState}{(state.IsExploring ? " [exploring]" : "")}", ctx.DebugTextBrush);
|
||||||
|
UpdateCache(ctx, _left, ref lc, $"Pos: ({state.NavPosition.X:F0}, {state.NavPosition.Y:F0})", ctx.DebugTextBrush);
|
||||||
|
UpdateCache(ctx, _left, ref lc, $"Enemies: {state.Enemies.Count} YOLO: {state.InferenceMs:F1}ms", ctx.DebugTextBrush);
|
||||||
|
if (state.Hud is { Timestamp: > 0 } hud)
|
||||||
|
UpdateCache(ctx, _left, ref lc, $"HP: {hud.LifePct:P0} MP: {hud.ManaPct:P0}", ctx.DebugTextBrush);
|
||||||
|
|
||||||
|
// Right column: timing
|
||||||
|
if (state.Timing != null)
|
||||||
|
{
|
||||||
|
var t = state.Timing;
|
||||||
|
UpdateCache(ctx, _right, ref rc, $"interval: {t.IntervalMs:F1}ms", ctx.TimingBrush);
|
||||||
|
UpdateCache(ctx, _right, ref rc, $"snapshot: {t.SnapshotMs:F2}ms", ctx.TimingBrush);
|
||||||
|
for (int i = 0; i < t.LayerMs.Length; i++)
|
||||||
|
UpdateCache(ctx, _right, ref rc, $"{t.LayerNames[i]}: {t.LayerMs[i]:F2}ms", ctx.TimingBrush);
|
||||||
|
UpdateCache(ctx, _right, ref rc, $"render total: {t.TotalRenderMs:F2}ms", ctx.TimingBrush);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measure columns
|
||||||
|
Measure(_left, lc, out var leftW, out var leftH);
|
||||||
|
Measure(_right, rc, out var rightW, out var rightH);
|
||||||
|
|
||||||
|
var totalW = leftW + (rc > 0 ? ColumnGap + rightW : 0);
|
||||||
|
var totalH = Math.Max(leftH, rightH);
|
||||||
|
|
||||||
|
// Background
|
||||||
|
rt.FillRectangle(
|
||||||
|
new RectangleF(StartX - PadX, StartY - PadY, totalW + PadX * 2, totalH + PadY * 2),
|
||||||
|
ctx.DebugBgBrush);
|
||||||
|
|
||||||
|
// Draw columns
|
||||||
|
DrawColumn(rt, _left, lc, StartX, StartY);
|
||||||
|
if (rc > 0)
|
||||||
|
DrawColumn(rt, _right, rc, StartX + leftW + ColumnGap, StartY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Measure(CachedLine[] col, int count, out float maxW, out float totalH)
|
||||||
|
{
|
||||||
|
maxW = 0;
|
||||||
|
totalH = 0;
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var m = col[i].Layout!.Metrics;
|
||||||
|
if (m.Width > maxW) maxW = m.Width;
|
||||||
|
totalH += m.Height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawColumn(ID2D1RenderTarget rt, CachedLine[] col, int count, float x, float y)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
ref var entry = ref col[i];
|
||||||
|
rt.DrawTextLayout(new System.Numerics.Vector2(x, y), entry.Layout!, entry.Brush!);
|
||||||
|
y += entry.Layout!.Metrics.Height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void UpdateCache(D2dRenderContext ctx, CachedLine[] col, ref int index, string value, ID2D1SolidColorBrush brush)
|
||||||
|
{
|
||||||
|
if (index >= col.Length) return;
|
||||||
|
ref var entry = ref col[index];
|
||||||
|
if (entry.Value != value)
|
||||||
|
{
|
||||||
|
entry.Layout?.Dispose();
|
||||||
|
entry.Value = value;
|
||||||
|
entry.Layout = ctx.CreateTextLayout(value, ctx.DebugFormat);
|
||||||
|
}
|
||||||
|
entry.Brush = brush;
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
for (int i = 0; i < _left.Length; i++) _left[i].Layout?.Dispose();
|
||||||
|
for (int i = 0; i < _right.Length; i++) _right[i].Layout?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CachedLine
|
||||||
|
{
|
||||||
|
public string? Value;
|
||||||
|
public IDWriteTextLayout? Layout;
|
||||||
|
public ID2D1SolidColorBrush? Brush;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/Poe2Trade.Ui/Overlay/Layers/D2dEnemyBoxLayer.cs
Normal file
58
src/Poe2Trade.Ui/Overlay/Layers/D2dEnemyBoxLayer.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
using System.Drawing;
|
||||||
|
using Vortice.Direct2D1;
|
||||||
|
using Vortice.DirectWrite;
|
||||||
|
using Vortice.Mathematics;
|
||||||
|
|
||||||
|
namespace Poe2Trade.Ui.Overlay.Layers;
|
||||||
|
|
||||||
|
internal sealed class D2dEnemyBoxLayer : ID2dOverlayLayer, IDisposable
|
||||||
|
{
|
||||||
|
// Pre-built label layouts for 0%-100% (confirmed = red, unconfirmed = yellow)
|
||||||
|
private readonly IDWriteTextLayout[] _confirmedLabels = new IDWriteTextLayout[101];
|
||||||
|
private readonly IDWriteTextLayout[] _unconfirmedLabels = new IDWriteTextLayout[101];
|
||||||
|
|
||||||
|
public D2dEnemyBoxLayer(D2dRenderContext ctx)
|
||||||
|
{
|
||||||
|
for (int i = 0; i <= 100; i++)
|
||||||
|
{
|
||||||
|
var text = $"{i}%";
|
||||||
|
_confirmedLabels[i] = ctx.CreateTextLayout(text, ctx.LabelFormat);
|
||||||
|
_unconfirmedLabels[i] = ctx.CreateTextLayout(text, ctx.LabelFormat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Draw(D2dRenderContext ctx, OverlayState state)
|
||||||
|
{
|
||||||
|
var rt = ctx.RenderTarget;
|
||||||
|
|
||||||
|
foreach (var enemy in state.Enemies)
|
||||||
|
{
|
||||||
|
var confirmed = enemy.HealthBarConfirmed;
|
||||||
|
var boxBrush = confirmed ? ctx.Red : ctx.Yellow;
|
||||||
|
var rect = new RectangleF(enemy.X, enemy.Y, enemy.Width, enemy.Height);
|
||||||
|
rt.DrawRectangle(rect, boxBrush, 2f);
|
||||||
|
|
||||||
|
// Confidence label above the box
|
||||||
|
var pctIndex = Math.Clamp((int)(enemy.Confidence * 100), 0, 100);
|
||||||
|
var layout = confirmed ? _confirmedLabels[pctIndex] : _unconfirmedLabels[pctIndex];
|
||||||
|
var textBrush = confirmed ? ctx.Red : ctx.Yellow;
|
||||||
|
|
||||||
|
var m = layout.Metrics;
|
||||||
|
var labelX = enemy.X;
|
||||||
|
var labelY = enemy.Y - m.Height - 2;
|
||||||
|
|
||||||
|
// Background behind label
|
||||||
|
rt.FillRectangle(
|
||||||
|
new RectangleF(labelX - 1, labelY - 1, m.Width + 2, m.Height + 2),
|
||||||
|
ctx.LabelBgBrush);
|
||||||
|
|
||||||
|
rt.DrawTextLayout(new System.Numerics.Vector2(labelX, labelY), layout, textBrush);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var l in _confirmedLabels) l?.Dispose();
|
||||||
|
foreach (var l in _unconfirmedLabels) l?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/Poe2Trade.Ui/Overlay/Layers/D2dHudInfoLayer.cs
Normal file
72
src/Poe2Trade.Ui/Overlay/Layers/D2dHudInfoLayer.cs
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
using System.Drawing;
|
||||||
|
using Vortice.Direct2D1;
|
||||||
|
using Vortice.DirectWrite;
|
||||||
|
using Vortice.Mathematics;
|
||||||
|
|
||||||
|
namespace Poe2Trade.Ui.Overlay.Layers;
|
||||||
|
|
||||||
|
internal sealed class D2dHudInfoLayer : ID2dOverlayLayer, IDisposable
|
||||||
|
{
|
||||||
|
private const float BarWidth = 200;
|
||||||
|
private const float BarHeight = 16;
|
||||||
|
private const float BarY = 1300;
|
||||||
|
private const float LifeBarX = 1130;
|
||||||
|
private const float ManaBarX = 1230;
|
||||||
|
|
||||||
|
// Cached bar value layouts
|
||||||
|
private string? _lifeLabel;
|
||||||
|
private IDWriteTextLayout? _lifeLayout;
|
||||||
|
private string? _manaLabel;
|
||||||
|
private IDWriteTextLayout? _manaLayout;
|
||||||
|
|
||||||
|
public void Draw(D2dRenderContext ctx, OverlayState state)
|
||||||
|
{
|
||||||
|
if (state.Hud == null || state.Hud.Timestamp == 0) return;
|
||||||
|
|
||||||
|
DrawBar(ctx, LifeBarX, BarY, state.Hud.LifePct, ctx.LifeBrush, state.Hud.Life,
|
||||||
|
ref _lifeLabel, ref _lifeLayout);
|
||||||
|
DrawBar(ctx, ManaBarX, BarY, state.Hud.ManaPct, ctx.ManaBrush, state.Hud.Mana,
|
||||||
|
ref _manaLabel, ref _manaLayout);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DrawBar(D2dRenderContext ctx, float x, float y, float pct,
|
||||||
|
ID2D1SolidColorBrush fillBrush, Screen.HudValues? values,
|
||||||
|
ref string? cachedLabel, ref IDWriteTextLayout? cachedLayout)
|
||||||
|
{
|
||||||
|
var rt = ctx.RenderTarget;
|
||||||
|
var outer = new RectangleF(x, y, BarWidth, BarHeight);
|
||||||
|
|
||||||
|
// Background
|
||||||
|
rt.FillRectangle(outer, ctx.BarBgBrush);
|
||||||
|
// Border
|
||||||
|
rt.DrawRectangle(outer, ctx.Gray, 1f);
|
||||||
|
|
||||||
|
// Fill
|
||||||
|
var fillWidth = BarWidth * Math.Clamp(pct, 0, 1);
|
||||||
|
if (fillWidth > 0)
|
||||||
|
rt.FillRectangle(new RectangleF(x, y, fillWidth, BarHeight), fillBrush);
|
||||||
|
|
||||||
|
// Value text
|
||||||
|
if (values != null)
|
||||||
|
{
|
||||||
|
var label = $"{values.Current}/{values.Max}";
|
||||||
|
if (label != cachedLabel)
|
||||||
|
{
|
||||||
|
cachedLayout?.Dispose();
|
||||||
|
cachedLabel = label;
|
||||||
|
cachedLayout = ctx.CreateTextLayout(label, ctx.BarValueFormat);
|
||||||
|
}
|
||||||
|
|
||||||
|
var m = cachedLayout!.Metrics;
|
||||||
|
var textX = x + (BarWidth - m.Width) / 2;
|
||||||
|
var textY = y + (BarHeight - m.Height) / 2;
|
||||||
|
rt.DrawTextLayout(new System.Numerics.Vector2(textX, textY), cachedLayout, ctx.White);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_lifeLayout?.Dispose();
|
||||||
|
_manaLayout?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Media;
|
|
||||||
|
|
||||||
namespace Poe2Trade.Ui.Overlay.Layers;
|
|
||||||
|
|
||||||
public class DebugTextLayer : IOverlayLayer
|
|
||||||
{
|
|
||||||
private static readonly Typeface MonoTypeface = new("Consolas");
|
|
||||||
private static readonly IBrush TextBrush = new SolidColorBrush(Color.FromRgb(80, 255, 80));
|
|
||||||
private static readonly IBrush Background = new SolidColorBrush(Color.FromArgb(160, 0, 0, 0));
|
|
||||||
|
|
||||||
private const double PadX = 8;
|
|
||||||
private const double PadY = 4;
|
|
||||||
private const double StartX = 10;
|
|
||||||
private const double StartY = 10;
|
|
||||||
private const double FontSize = 13;
|
|
||||||
|
|
||||||
public void Draw(DrawingContext dc, OverlayState state)
|
|
||||||
{
|
|
||||||
var lines = new List<string>(8)
|
|
||||||
{
|
|
||||||
$"FPS: {state.Fps:F0}",
|
|
||||||
$"Nav: {state.NavState}{(state.IsExploring ? " [exploring]" : "")}",
|
|
||||||
$"Pos: ({state.NavPosition.X:F0}, {state.NavPosition.Y:F0})",
|
|
||||||
$"Enemies: {state.Enemies.Count} YOLO: {state.InferenceMs:F1}ms"
|
|
||||||
};
|
|
||||||
|
|
||||||
if (state.Hud is { Timestamp: > 0 } hud)
|
|
||||||
{
|
|
||||||
lines.Add($"HP: {hud.LifePct:P0} MP: {hud.ManaPct:P0}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Measure max width for background
|
|
||||||
double maxWidth = 0;
|
|
||||||
double totalHeight = 0;
|
|
||||||
var formatted = new List<FormattedText>(lines.Count);
|
|
||||||
|
|
||||||
foreach (var line in lines)
|
|
||||||
{
|
|
||||||
var ft = new FormattedText(line, System.Globalization.CultureInfo.InvariantCulture,
|
|
||||||
FlowDirection.LeftToRight, MonoTypeface, FontSize, TextBrush);
|
|
||||||
formatted.Add(ft);
|
|
||||||
if (ft.Width > maxWidth) maxWidth = ft.Width;
|
|
||||||
totalHeight += ft.Height;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw background
|
|
||||||
dc.DrawRectangle(Background, null,
|
|
||||||
new Rect(StartX - PadX, StartY - PadY,
|
|
||||||
maxWidth + PadX * 2, totalHeight + PadY * 2));
|
|
||||||
|
|
||||||
// Draw text lines
|
|
||||||
var y = StartY;
|
|
||||||
foreach (var ft in formatted)
|
|
||||||
{
|
|
||||||
dc.DrawText(ft, new Point(StartX, y));
|
|
||||||
y += ft.Height;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Media;
|
|
||||||
|
|
||||||
namespace Poe2Trade.Ui.Overlay.Layers;
|
|
||||||
|
|
||||||
public class EnemyBoxLayer : IOverlayLayer
|
|
||||||
{
|
|
||||||
// Pre-allocated pens — zero allocation per frame
|
|
||||||
private static readonly IPen ConfirmedPen = new Pen(Brushes.Red, 2);
|
|
||||||
private static readonly IPen UnconfirmedPen = new Pen(Brushes.Yellow, 2);
|
|
||||||
private static readonly Typeface LabelTypeface = new("Consolas");
|
|
||||||
private static readonly IBrush LabelBackground = new SolidColorBrush(Color.FromArgb(160, 0, 0, 0));
|
|
||||||
|
|
||||||
public void Draw(DrawingContext dc, OverlayState state)
|
|
||||||
{
|
|
||||||
foreach (var enemy in state.Enemies)
|
|
||||||
{
|
|
||||||
var pen = enemy.HealthBarConfirmed ? ConfirmedPen : UnconfirmedPen;
|
|
||||||
var rect = new Rect(enemy.X, enemy.Y, enemy.Width, enemy.Height);
|
|
||||||
dc.DrawRectangle(null, pen, rect);
|
|
||||||
|
|
||||||
// Confidence label above the box
|
|
||||||
var label = $"{enemy.Confidence:P0}";
|
|
||||||
var text = new FormattedText(label, System.Globalization.CultureInfo.InvariantCulture,
|
|
||||||
FlowDirection.LeftToRight, LabelTypeface, 12, pen.Brush);
|
|
||||||
|
|
||||||
var labelX = enemy.X;
|
|
||||||
var labelY = enemy.Y - text.Height - 2;
|
|
||||||
|
|
||||||
// Background for readability
|
|
||||||
dc.DrawRectangle(LabelBackground, null,
|
|
||||||
new Rect(labelX - 1, labelY - 1, text.Width + 2, text.Height + 2));
|
|
||||||
dc.DrawText(text, new Point(labelX, labelY));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
using Avalonia;
|
|
||||||
using Avalonia.Media;
|
|
||||||
|
|
||||||
namespace Poe2Trade.Ui.Overlay.Layers;
|
|
||||||
|
|
||||||
public class HudInfoLayer : IOverlayLayer
|
|
||||||
{
|
|
||||||
private static readonly IBrush LifeBrush = new SolidColorBrush(Color.FromRgb(200, 40, 40));
|
|
||||||
private static readonly IBrush ManaBrush = new SolidColorBrush(Color.FromRgb(40, 80, 200));
|
|
||||||
private static readonly IBrush BarBackground = new SolidColorBrush(Color.FromArgb(140, 20, 20, 20));
|
|
||||||
private static readonly IPen BarBorder = new Pen(Brushes.Gray, 1);
|
|
||||||
private static readonly Typeface ValueTypeface = new("Consolas");
|
|
||||||
|
|
||||||
// Bar dimensions — positioned bottom-center above globe area
|
|
||||||
private const double BarWidth = 200;
|
|
||||||
private const double BarHeight = 16;
|
|
||||||
private const double BarY = 1300; // above the globe at 2560x1440
|
|
||||||
private const double LifeBarX = 1130; // left of center
|
|
||||||
private const double ManaBarX = 1230; // right of center
|
|
||||||
|
|
||||||
public void Draw(DrawingContext dc, OverlayState state)
|
|
||||||
{
|
|
||||||
if (state.Hud == null || state.Hud.Timestamp == 0) return;
|
|
||||||
|
|
||||||
DrawBar(dc, LifeBarX, BarY, state.Hud.LifePct, LifeBrush, state.Hud.Life);
|
|
||||||
DrawBar(dc, ManaBarX, BarY, state.Hud.ManaPct, ManaBrush, state.Hud.Mana);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void DrawBar(DrawingContext dc, double x, double y, float pct,
|
|
||||||
IBrush fillBrush, Screen.HudValues? values)
|
|
||||||
{
|
|
||||||
var outer = new Rect(x, y, BarWidth, BarHeight);
|
|
||||||
dc.DrawRectangle(BarBackground, BarBorder, outer);
|
|
||||||
|
|
||||||
var fillWidth = BarWidth * Math.Clamp(pct, 0, 1);
|
|
||||||
if (fillWidth > 0)
|
|
||||||
dc.DrawRectangle(fillBrush, null, new Rect(x, y, fillWidth, BarHeight));
|
|
||||||
|
|
||||||
if (values != null)
|
|
||||||
{
|
|
||||||
var label = $"{values.Current}/{values.Max}";
|
|
||||||
var text = new FormattedText(label, System.Globalization.CultureInfo.InvariantCulture,
|
|
||||||
FlowDirection.LeftToRight, ValueTypeface, 11, Brushes.White);
|
|
||||||
dc.DrawText(text, new Point(x + (BarWidth - text.Width) / 2, y + (BarHeight - text.Height) / 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
using System.Diagnostics;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using Poe2Trade.Bot;
|
|
||||||
using Poe2Trade.Navigation;
|
|
||||||
using Poe2Trade.Ui.Overlay.Layers;
|
|
||||||
|
|
||||||
namespace Poe2Trade.Ui.Overlay;
|
|
||||||
|
|
||||||
public class OverlayCanvas : Control
|
|
||||||
{
|
|
||||||
private readonly List<IOverlayLayer> _layers = [];
|
|
||||||
private BotOrchestrator? _bot;
|
|
||||||
private DispatcherTimer? _timer;
|
|
||||||
private nint _hwnd;
|
|
||||||
private bool _shown;
|
|
||||||
|
|
||||||
// FPS tracking
|
|
||||||
private readonly Stopwatch _fpsWatch = new();
|
|
||||||
private int _frameCount;
|
|
||||||
private double _fps;
|
|
||||||
|
|
||||||
public void Initialize(BotOrchestrator bot)
|
|
||||||
{
|
|
||||||
_bot = bot;
|
|
||||||
|
|
||||||
_layers.Add(new EnemyBoxLayer());
|
|
||||||
_layers.Add(new HudInfoLayer());
|
|
||||||
_layers.Add(new DebugTextLayer());
|
|
||||||
|
|
||||||
_fpsWatch.Start();
|
|
||||||
|
|
||||||
_timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(33) }; // ~30fps
|
|
||||||
_timer.Tick += OnTick;
|
|
||||||
_timer.Start();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnTick(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
if (_bot == null) return;
|
|
||||||
|
|
||||||
// Lazily grab the HWND once the window is realized
|
|
||||||
if (_hwnd == 0)
|
|
||||||
{
|
|
||||||
var handle = ((Window?)VisualRoot)?.TryGetPlatformHandle();
|
|
||||||
if (handle != null) _hwnd = handle.Handle;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show/hide overlay based on game focus — use native Win32 calls
|
|
||||||
// to avoid Avalonia's Show() which activates the window and steals focus
|
|
||||||
if (_hwnd != 0)
|
|
||||||
{
|
|
||||||
var focused = _bot.Game.IsGameFocused();
|
|
||||||
if (focused && !_shown)
|
|
||||||
{
|
|
||||||
OverlayNativeMethods.ShowNoActivate(_hwnd);
|
|
||||||
_shown = true;
|
|
||||||
}
|
|
||||||
else if (!focused && _shown)
|
|
||||||
{
|
|
||||||
OverlayNativeMethods.HideWindow(_hwnd);
|
|
||||||
_shown = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
InvalidateVisual();
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Render(DrawingContext dc)
|
|
||||||
{
|
|
||||||
if (_bot == null) return;
|
|
||||||
|
|
||||||
// Update FPS
|
|
||||||
_frameCount++;
|
|
||||||
var elapsed = _fpsWatch.Elapsed.TotalSeconds;
|
|
||||||
if (elapsed >= 1.0)
|
|
||||||
{
|
|
||||||
_fps = _frameCount / elapsed;
|
|
||||||
_frameCount = 0;
|
|
||||||
_fpsWatch.Restart();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build state snapshot from volatile sources
|
|
||||||
var detection = _bot.EnemyDetector.Latest;
|
|
||||||
var state = new OverlayState(
|
|
||||||
Enemies: detection.Enemies,
|
|
||||||
InferenceMs: detection.InferenceMs,
|
|
||||||
Hud: _bot.HudReader.Current,
|
|
||||||
NavState: _bot.Navigation.State,
|
|
||||||
NavPosition: _bot.Navigation.Position,
|
|
||||||
IsExploring: _bot.Navigation.IsExploring,
|
|
||||||
Fps: _fps);
|
|
||||||
|
|
||||||
foreach (var layer in _layers)
|
|
||||||
layer.Draw(dc, state);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Shutdown()
|
|
||||||
{
|
|
||||||
_timer?.Stop();
|
|
||||||
_fpsWatch.Stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
namespace Poe2Trade.Ui.Overlay;
|
|
||||||
|
|
||||||
internal static partial class OverlayNativeMethods
|
|
||||||
{
|
|
||||||
private const int GWL_EXSTYLE = -20;
|
|
||||||
|
|
||||||
internal const int WS_EX_TRANSPARENT = 0x00000020;
|
|
||||||
internal const int WS_EX_LAYERED = 0x00080000;
|
|
||||||
internal const int WS_EX_TOOLWINDOW = 0x00000080;
|
|
||||||
internal const int WS_EX_NOACTIVATE = 0x08000000;
|
|
||||||
|
|
||||||
private const int SW_SHOWNOACTIVATE = 4;
|
|
||||||
private const int SW_HIDE = 0;
|
|
||||||
|
|
||||||
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
|
|
||||||
private static partial nint GetWindowLongPtr(nint hWnd, int nIndex);
|
|
||||||
|
|
||||||
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
|
|
||||||
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
|
|
||||||
|
|
||||||
[LibraryImport("user32.dll")]
|
|
||||||
[return: MarshalAs(UnmanagedType.Bool)]
|
|
||||||
private static partial bool ShowWindow(nint hWnd, int nCmdShow);
|
|
||||||
|
|
||||||
internal static void MakeClickThrough(nint hwnd)
|
|
||||||
{
|
|
||||||
var style = GetWindowLongPtr(hwnd, GWL_EXSTYLE);
|
|
||||||
SetWindowLongPtr(hwnd, GWL_EXSTYLE,
|
|
||||||
style | WS_EX_TRANSPARENT | WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static void ShowNoActivate(nint hwnd) => ShowWindow(hwnd, SW_SHOWNOACTIVATE);
|
|
||||||
internal static void HideWindow(nint hwnd) => ShowWindow(hwnd, SW_HIDE);
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
<Window xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:overlay="using:Poe2Trade.Ui.Overlay"
|
|
||||||
x:Class="Poe2Trade.Ui.Overlay.OverlayWindow"
|
|
||||||
SystemDecorations="None"
|
|
||||||
Background="Transparent"
|
|
||||||
TransparencyLevelHint="Transparent"
|
|
||||||
Topmost="True"
|
|
||||||
ShowInTaskbar="False"
|
|
||||||
Width="2560" Height="1440"
|
|
||||||
CanResize="False">
|
|
||||||
<overlay:OverlayCanvas x:Name="Canvas" />
|
|
||||||
</Window>
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
using Avalonia.Controls;
|
|
||||||
using Poe2Trade.Bot;
|
|
||||||
|
|
||||||
namespace Poe2Trade.Ui.Overlay;
|
|
||||||
|
|
||||||
public partial class OverlayWindow : Window
|
|
||||||
{
|
|
||||||
private readonly BotOrchestrator _bot = null!;
|
|
||||||
|
|
||||||
// Designer/XAML loader requires parameterless constructor
|
|
||||||
public OverlayWindow() => InitializeComponent();
|
|
||||||
|
|
||||||
public OverlayWindow(BotOrchestrator bot)
|
|
||||||
{
|
|
||||||
_bot = bot;
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnOpened(EventArgs e)
|
|
||||||
{
|
|
||||||
base.OnOpened(e);
|
|
||||||
|
|
||||||
// Position at top-left corner
|
|
||||||
Position = new Avalonia.PixelPoint(0, 0);
|
|
||||||
|
|
||||||
// Apply Win32 click-through extended styles
|
|
||||||
if (TryGetPlatformHandle() is { } handle)
|
|
||||||
OverlayNativeMethods.MakeClickThrough(handle.Handle);
|
|
||||||
|
|
||||||
Canvas.Initialize(_bot);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnClosing(WindowClosingEventArgs e)
|
|
||||||
{
|
|
||||||
Canvas.Shutdown();
|
|
||||||
base.OnClosing(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Vortice.Direct2D1" Version="3.8.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
|
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
using System.Text;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Poe2Trade.Bot;
|
using Poe2Trade.Bot;
|
||||||
using Poe2Trade.Core;
|
|
||||||
using Poe2Trade.Inventory;
|
|
||||||
using Poe2Trade.Screen;
|
using Poe2Trade.Screen;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
|
|
@ -202,98 +199,4 @@ public partial class DebugViewModel : ObservableObject
|
||||||
catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; }
|
catch (Exception ex) { DebugResult = $"Failed: {ex.Message}"; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task CalibrateStash()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var calibrator = new StashCalibrator(_bot.Screen, _bot.Game);
|
|
||||||
DebugResult = "Calibrating stash tabs...";
|
|
||||||
|
|
||||||
// Focus game and open stash
|
|
||||||
await _bot.Game.FocusGame();
|
|
||||||
await Helpers.RandomDelay(150, 300);
|
|
||||||
|
|
||||||
var stashPos = await _bot.Inventory.FindAndClickNameplate("STASH");
|
|
||||||
if (!stashPos.HasValue)
|
|
||||||
{
|
|
||||||
DebugResult = "STASH nameplate not found. Stand near your stash.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await Helpers.RandomDelay(300, 500);
|
|
||||||
|
|
||||||
// Calibrate stash
|
|
||||||
var stashCal = await calibrator.CalibrateOpenPanel();
|
|
||||||
|
|
||||||
// Close stash, try shop
|
|
||||||
await _bot.Game.PressEscape();
|
|
||||||
await Helpers.RandomDelay(200, 400);
|
|
||||||
|
|
||||||
StashCalibration? shopCal = null;
|
|
||||||
var angePos = await _bot.Inventory.FindAndClickNameplate("ANGE");
|
|
||||||
if (angePos.HasValue)
|
|
||||||
{
|
|
||||||
await Helpers.RandomDelay(300, 500);
|
|
||||||
// ANGE opens a dialog — click "Manage Shop" to open shop tabs
|
|
||||||
var managePos = await _bot.Screen.FindTextOnScreen("Manage Shop", fuzzy: true);
|
|
||||||
if (managePos.HasValue)
|
|
||||||
{
|
|
||||||
await _bot.Game.LeftClickAt(managePos.Value.X, managePos.Value.Y);
|
|
||||||
await Helpers.RandomDelay(300, 500);
|
|
||||||
}
|
|
||||||
shopCal = await calibrator.CalibrateOpenPanel(firstFolderOnly: true);
|
|
||||||
await _bot.Game.PressEscape();
|
|
||||||
await Helpers.RandomDelay(200, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save
|
|
||||||
_bot.Store.UpdateSettings(s =>
|
|
||||||
{
|
|
||||||
s.StashCalibration = stashCal;
|
|
||||||
s.ShopCalibration = shopCal;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Format results
|
|
||||||
DebugResult = FormatCalibration(stashCal, shopCal);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
DebugResult = $"Calibration failed: {ex.Message}";
|
|
||||||
Log.Error(ex, "Stash calibration failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatCalibration(StashCalibration stash, StashCalibration? shop)
|
|
||||||
{
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine("=== STASH CALIBRATION ===");
|
|
||||||
FormatTabs(sb, stash.Tabs, indent: "");
|
|
||||||
|
|
||||||
if (shop != null)
|
|
||||||
{
|
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine("=== SHOP CALIBRATION ===");
|
|
||||||
FormatTabs(sb, shop.Tabs, indent: "");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine("(Shop: ANGE not found, skipped)");
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void FormatTabs(StringBuilder sb, List<StashTabInfo> tabs, string indent)
|
|
||||||
{
|
|
||||||
foreach (var tab in tabs)
|
|
||||||
{
|
|
||||||
var folder = tab.IsFolder ? " [FOLDER]" : "";
|
|
||||||
sb.AppendLine($"{indent}#{tab.Index} \"{tab.Name}\" @ ({tab.ClickX},{tab.ClickY}) grid={tab.GridCols}col{folder}");
|
|
||||||
if (tab.IsFolder)
|
|
||||||
{
|
|
||||||
FormatTabs(sb, tab.SubTabs, indent + " ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using Poe2Trade.Bot;
|
using Poe2Trade.Bot;
|
||||||
|
using Poe2Trade.Core;
|
||||||
|
using Poe2Trade.Inventory;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
namespace Poe2Trade.Ui.ViewModels;
|
namespace Poe2Trade.Ui.ViewModels;
|
||||||
|
|
||||||
|
|
@ -16,11 +20,18 @@ public partial class SettingsViewModel : ObservableObject
|
||||||
[ObservableProperty] private decimal? _betweenTradesDelayMs = 5000;
|
[ObservableProperty] private decimal? _betweenTradesDelayMs = 5000;
|
||||||
[ObservableProperty] private bool _headless = true;
|
[ObservableProperty] private bool _headless = true;
|
||||||
[ObservableProperty] private bool _isSaved;
|
[ObservableProperty] private bool _isSaved;
|
||||||
|
[ObservableProperty] private string _calibrationStatus = "";
|
||||||
|
[ObservableProperty] private string _stashCalibratedAt = "";
|
||||||
|
[ObservableProperty] private string _shopCalibratedAt = "";
|
||||||
|
|
||||||
|
public ObservableCollection<StashTabViewModel> StashTabs { get; } = [];
|
||||||
|
public ObservableCollection<StashTabViewModel> ShopTabs { get; } = [];
|
||||||
|
|
||||||
public SettingsViewModel(BotOrchestrator bot)
|
public SettingsViewModel(BotOrchestrator bot)
|
||||||
{
|
{
|
||||||
_bot = bot;
|
_bot = bot;
|
||||||
LoadFromConfig();
|
LoadFromConfig();
|
||||||
|
LoadTabs();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadFromConfig()
|
private void LoadFromConfig()
|
||||||
|
|
@ -35,6 +46,42 @@ public partial class SettingsViewModel : ObservableObject
|
||||||
Headless = s.Headless;
|
Headless = s.Headless;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void LoadTabs()
|
||||||
|
{
|
||||||
|
var s = _bot.Store.Settings;
|
||||||
|
|
||||||
|
StashTabs.Clear();
|
||||||
|
if (s.StashCalibration != null)
|
||||||
|
{
|
||||||
|
foreach (var tab in s.StashCalibration.Tabs)
|
||||||
|
StashTabs.Add(new StashTabViewModel(tab));
|
||||||
|
StashCalibratedAt = FormatTimestamp(s.StashCalibration.CalibratedAt);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
StashCalibratedAt = "Not calibrated";
|
||||||
|
}
|
||||||
|
|
||||||
|
ShopTabs.Clear();
|
||||||
|
if (s.ShopCalibration != null)
|
||||||
|
{
|
||||||
|
foreach (var tab in s.ShopCalibration.Tabs)
|
||||||
|
ShopTabs.Add(new StashTabViewModel(tab));
|
||||||
|
ShopCalibratedAt = FormatTimestamp(s.ShopCalibration.CalibratedAt);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ShopCalibratedAt = "Not calibrated";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatTimestamp(long unixMs)
|
||||||
|
{
|
||||||
|
if (unixMs == 0) return "Not calibrated";
|
||||||
|
var dt = DateTimeOffset.FromUnixTimeMilliseconds(unixMs).LocalDateTime;
|
||||||
|
return dt.ToString("yyyy-MM-dd HH:mm");
|
||||||
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void SaveSettings()
|
private void SaveSettings()
|
||||||
{
|
{
|
||||||
|
|
@ -52,6 +99,106 @@ public partial class SettingsViewModel : ObservableObject
|
||||||
IsSaved = true;
|
IsSaved = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void SaveTabs()
|
||||||
|
{
|
||||||
|
_bot.Store.UpdateSettings(s =>
|
||||||
|
{
|
||||||
|
// Models are already updated via write-through in StashTabViewModel
|
||||||
|
// Just trigger a save
|
||||||
|
if (s.StashCalibration != null)
|
||||||
|
s.StashCalibration = s.StashCalibration;
|
||||||
|
if (s.ShopCalibration != null)
|
||||||
|
s.ShopCalibration = s.ShopCalibration;
|
||||||
|
});
|
||||||
|
|
||||||
|
CalibrationStatus = "Tabs saved!";
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CalibrateStash()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var calibrator = new StashCalibrator(_bot.Screen, _bot.Game);
|
||||||
|
CalibrationStatus = "Calibrating stash...";
|
||||||
|
|
||||||
|
await _bot.Game.FocusGame();
|
||||||
|
await Helpers.RandomDelay(150, 300);
|
||||||
|
|
||||||
|
var pos = await _bot.Inventory.FindAndClickNameplate("STASH");
|
||||||
|
if (!pos.HasValue)
|
||||||
|
{
|
||||||
|
CalibrationStatus = "STASH not found. Stand near your stash.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Helpers.RandomDelay(300, 500);
|
||||||
|
|
||||||
|
var cal = await calibrator.CalibrateOpenPanel();
|
||||||
|
|
||||||
|
await _bot.Game.PressEscape();
|
||||||
|
await Helpers.RandomDelay(200, 400);
|
||||||
|
|
||||||
|
_bot.Store.UpdateSettings(s => s.StashCalibration = cal);
|
||||||
|
LoadTabs();
|
||||||
|
CalibrationStatus = $"Stash calibrated — {cal.Tabs.Count} tabs found.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
CalibrationStatus = $"Stash calibration failed: {ex.Message}";
|
||||||
|
Log.Error(ex, "Stash calibration failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task CalibrateShop()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var calibrator = new StashCalibrator(_bot.Screen, _bot.Game);
|
||||||
|
CalibrationStatus = "Calibrating shop...";
|
||||||
|
|
||||||
|
await _bot.Game.FocusGame();
|
||||||
|
await Helpers.RandomDelay(150, 300);
|
||||||
|
|
||||||
|
var pos = await _bot.Inventory.FindAndClickNameplate("ANGE");
|
||||||
|
if (!pos.HasValue)
|
||||||
|
{
|
||||||
|
CalibrationStatus = "ANGE not found. Stand near the vendor.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Helpers.RandomDelay(800, 1200);
|
||||||
|
|
||||||
|
// ANGE opens a dialog — click "Manage Shop"
|
||||||
|
var dialogRegion = new Region(1080, 600, 400, 300);
|
||||||
|
var managePos = await _bot.Screen.FindTextInRegion(dialogRegion, "Manage");
|
||||||
|
if (managePos.HasValue)
|
||||||
|
{
|
||||||
|
await _bot.Game.LeftClickAt(managePos.Value.X, managePos.Value.Y);
|
||||||
|
await Helpers.RandomDelay(300, 500);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log.Warning("'Manage Shop' not found in dialog region, saving debug capture");
|
||||||
|
await _bot.Screen.SaveRegion(dialogRegion, "debug/calibrate-dialog.png");
|
||||||
|
}
|
||||||
|
|
||||||
|
var cal = await calibrator.CalibrateOpenPanel(firstFolderOnly: true);
|
||||||
|
|
||||||
|
await _bot.Game.PressEscape();
|
||||||
|
await Helpers.RandomDelay(200, 400);
|
||||||
|
|
||||||
|
_bot.Store.UpdateSettings(s => s.ShopCalibration = cal);
|
||||||
|
LoadTabs();
|
||||||
|
CalibrationStatus = $"Shop calibrated — {cal.Tabs.Count} tabs found.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
CalibrationStatus = $"Shop calibration failed: {ex.Message}";
|
||||||
|
Log.Error(ex, "Shop calibration failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
partial void OnPoe2LogPathChanged(string value) => IsSaved = false;
|
partial void OnPoe2LogPathChanged(string value) => IsSaved = false;
|
||||||
partial void OnWindowTitleChanged(string value) => IsSaved = false;
|
partial void OnWindowTitleChanged(string value) => IsSaved = false;
|
||||||
partial void OnTravelTimeoutMsChanged(decimal? value) => IsSaved = false;
|
partial void OnTravelTimeoutMsChanged(decimal? value) => IsSaved = false;
|
||||||
|
|
|
||||||
42
src/Poe2Trade.Ui/ViewModels/StashTabViewModel.cs
Normal file
42
src/Poe2Trade.Ui/ViewModels/StashTabViewModel.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using Poe2Trade.Core;
|
||||||
|
|
||||||
|
namespace Poe2Trade.Ui.ViewModels;
|
||||||
|
|
||||||
|
public partial class StashTabViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly StashTabInfo _model;
|
||||||
|
|
||||||
|
public StashTabViewModel(StashTabInfo model)
|
||||||
|
{
|
||||||
|
_model = model;
|
||||||
|
_name = model.Name;
|
||||||
|
_gridCols = model.GridCols;
|
||||||
|
_enabled = model.Enabled;
|
||||||
|
|
||||||
|
if (model.IsFolder)
|
||||||
|
{
|
||||||
|
foreach (var sub in model.SubTabs)
|
||||||
|
SubTabs.Add(new StashTabViewModel(sub));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public StashTabInfo Model => _model;
|
||||||
|
public int Index => _model.Index;
|
||||||
|
public int ClickX => _model.ClickX;
|
||||||
|
public int ClickY => _model.ClickY;
|
||||||
|
public bool IsFolder => _model.IsFolder;
|
||||||
|
|
||||||
|
[ObservableProperty] private string _name;
|
||||||
|
[ObservableProperty] private int _gridCols;
|
||||||
|
[ObservableProperty] private bool _enabled;
|
||||||
|
|
||||||
|
public ObservableCollection<StashTabViewModel> SubTabs { get; } = [];
|
||||||
|
|
||||||
|
public static int[] GridColOptions { get; } = [12, 24];
|
||||||
|
|
||||||
|
partial void OnNameChanged(string value) => _model.Name = value;
|
||||||
|
partial void OnGridColsChanged(int value) => _model.GridCols = value;
|
||||||
|
partial void OnEnabledChanged(bool value) => _model.Enabled = value;
|
||||||
|
}
|
||||||
|
|
@ -298,7 +298,6 @@
|
||||||
<Button Content="ANGE" Command="{Binding ClickAngeCommand}" />
|
<Button Content="ANGE" Command="{Binding ClickAngeCommand}" />
|
||||||
<Button Content="STASH" Command="{Binding ClickStashCommand}" />
|
<Button Content="STASH" Command="{Binding ClickStashCommand}" />
|
||||||
<Button Content="SALVAGE" Command="{Binding ClickSalvageCommand}" />
|
<Button Content="SALVAGE" Command="{Binding ClickSalvageCommand}" />
|
||||||
<Button Content="Calibrate Stash" Command="{Binding CalibrateStashCommand}" />
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
@ -417,6 +416,154 @@
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
<!-- Status -->
|
||||||
|
<TextBlock Text="{Binding CalibrationStatus}" FontSize="11"
|
||||||
|
Foreground="#3fb950" Margin="0,0,0,4"
|
||||||
|
IsVisible="{Binding CalibrationStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
|
||||||
|
<!-- STASH TABS -->
|
||||||
|
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
||||||
|
CornerRadius="8" Padding="10">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<DockPanel>
|
||||||
|
<TextBlock Text="STASH TABS" FontSize="11" FontWeight="SemiBold"
|
||||||
|
Foreground="#8b949e" VerticalAlignment="Center" />
|
||||||
|
<StackPanel Orientation="Horizontal" DockPanel.Dock="Right"
|
||||||
|
Spacing="6" HorizontalAlignment="Right">
|
||||||
|
<Button Content="Calibrate" Command="{Binding CalibrateStashCommand}" />
|
||||||
|
<Button Content="Save" Command="{Binding SaveTabsCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DockPanel>
|
||||||
|
<TextBlock Text="{Binding StashCalibratedAt, StringFormat='Last calibrated: {0}'}"
|
||||||
|
FontSize="11" Foreground="#484f58" />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding StashTabs}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:StashTabViewModel">
|
||||||
|
<StackPanel>
|
||||||
|
<Border Margin="0,2" Padding="6" Background="#21262d" CornerRadius="4"
|
||||||
|
Opacity="{Binding Enabled, Converter={StaticResource ActiveOpacity}}">
|
||||||
|
<DockPanel>
|
||||||
|
<CheckBox DockPanel.Dock="Left" IsChecked="{Binding Enabled}"
|
||||||
|
Margin="0,0,6,0" VerticalAlignment="Center" />
|
||||||
|
<TextBlock DockPanel.Dock="Left"
|
||||||
|
Text="{Binding Index, StringFormat='#{0}'}"
|
||||||
|
Width="28" VerticalAlignment="Center"
|
||||||
|
FontSize="11" Foreground="#8b949e" />
|
||||||
|
<TextBlock DockPanel.Dock="Right" Text="FOLDER"
|
||||||
|
IsVisible="{Binding IsFolder}"
|
||||||
|
FontSize="10" Foreground="#58a6ff"
|
||||||
|
VerticalAlignment="Center" Margin="6,0,0,0" />
|
||||||
|
<ComboBox DockPanel.Dock="Right"
|
||||||
|
ItemsSource="{x:Static vm:StashTabViewModel.GridColOptions}"
|
||||||
|
SelectedItem="{Binding GridCols}"
|
||||||
|
Width="70" Margin="6,0,0,0"
|
||||||
|
IsVisible="{Binding !IsFolder}" />
|
||||||
|
<TextBox Text="{Binding Name}" Margin="0,0,6,0" />
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
<!-- Nested sub-tabs for folders -->
|
||||||
|
<ItemsControl ItemsSource="{Binding SubTabs}" Margin="24,0,0,0"
|
||||||
|
IsVisible="{Binding IsFolder}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:StashTabViewModel">
|
||||||
|
<Border Margin="0,2" Padding="6" Background="#21262d" CornerRadius="4"
|
||||||
|
Opacity="{Binding Enabled, Converter={StaticResource ActiveOpacity}}">
|
||||||
|
<DockPanel>
|
||||||
|
<CheckBox DockPanel.Dock="Left" IsChecked="{Binding Enabled}"
|
||||||
|
Margin="0,0,6,0" VerticalAlignment="Center" />
|
||||||
|
<TextBlock DockPanel.Dock="Left"
|
||||||
|
Text="{Binding Index, StringFormat='#{0}'}"
|
||||||
|
Width="28" VerticalAlignment="Center"
|
||||||
|
FontSize="11" Foreground="#8b949e" />
|
||||||
|
<ComboBox DockPanel.Dock="Right"
|
||||||
|
ItemsSource="{x:Static vm:StashTabViewModel.GridColOptions}"
|
||||||
|
SelectedItem="{Binding GridCols}"
|
||||||
|
Width="70" Margin="6,0,0,0" />
|
||||||
|
<TextBox Text="{Binding Name}" Margin="0,0,6,0" />
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- SHOP TABS -->
|
||||||
|
<Border Background="#161b22" BorderBrush="#30363d" BorderThickness="1"
|
||||||
|
CornerRadius="8" Padding="10">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<DockPanel>
|
||||||
|
<TextBlock Text="SHOP TABS" FontSize="11" FontWeight="SemiBold"
|
||||||
|
Foreground="#8b949e" VerticalAlignment="Center" />
|
||||||
|
<StackPanel Orientation="Horizontal" DockPanel.Dock="Right"
|
||||||
|
Spacing="6" HorizontalAlignment="Right">
|
||||||
|
<Button Content="Calibrate" Command="{Binding CalibrateShopCommand}" />
|
||||||
|
<Button Content="Save" Command="{Binding SaveTabsCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</DockPanel>
|
||||||
|
<TextBlock Text="{Binding ShopCalibratedAt, StringFormat='Last calibrated: {0}'}"
|
||||||
|
FontSize="11" Foreground="#484f58" />
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding ShopTabs}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:StashTabViewModel">
|
||||||
|
<StackPanel>
|
||||||
|
<Border Margin="0,2" Padding="6" Background="#21262d" CornerRadius="4"
|
||||||
|
Opacity="{Binding Enabled, Converter={StaticResource ActiveOpacity}}">
|
||||||
|
<DockPanel>
|
||||||
|
<CheckBox DockPanel.Dock="Left" IsChecked="{Binding Enabled}"
|
||||||
|
Margin="0,0,6,0" VerticalAlignment="Center" />
|
||||||
|
<TextBlock DockPanel.Dock="Left"
|
||||||
|
Text="{Binding Index, StringFormat='#{0}'}"
|
||||||
|
Width="28" VerticalAlignment="Center"
|
||||||
|
FontSize="11" Foreground="#8b949e" />
|
||||||
|
<TextBlock DockPanel.Dock="Right" Text="FOLDER"
|
||||||
|
IsVisible="{Binding IsFolder}"
|
||||||
|
FontSize="10" Foreground="#58a6ff"
|
||||||
|
VerticalAlignment="Center" Margin="6,0,0,0" />
|
||||||
|
<ComboBox DockPanel.Dock="Right"
|
||||||
|
ItemsSource="{x:Static vm:StashTabViewModel.GridColOptions}"
|
||||||
|
SelectedItem="{Binding GridCols}"
|
||||||
|
Width="70" Margin="6,0,0,0"
|
||||||
|
IsVisible="{Binding !IsFolder}" />
|
||||||
|
<TextBox Text="{Binding Name}" Margin="0,0,6,0" />
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
<!-- Nested sub-tabs for folders -->
|
||||||
|
<ItemsControl ItemsSource="{Binding SubTabs}" Margin="24,0,0,0"
|
||||||
|
IsVisible="{Binding IsFolder}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:StashTabViewModel">
|
||||||
|
<Border Margin="0,2" Padding="6" Background="#21262d" CornerRadius="4"
|
||||||
|
Opacity="{Binding Enabled, Converter={StaticResource ActiveOpacity}}">
|
||||||
|
<DockPanel>
|
||||||
|
<CheckBox DockPanel.Dock="Left" IsChecked="{Binding Enabled}"
|
||||||
|
Margin="0,0,6,0" VerticalAlignment="Center" />
|
||||||
|
<TextBlock DockPanel.Dock="Left"
|
||||||
|
Text="{Binding Index, StringFormat='#{0}'}"
|
||||||
|
Width="28" VerticalAlignment="Center"
|
||||||
|
FontSize="11" Foreground="#8b949e" />
|
||||||
|
<ComboBox DockPanel.Dock="Right"
|
||||||
|
ItemsSource="{x:Static vm:StashTabViewModel.GridColOptions}"
|
||||||
|
SelectedItem="{Binding GridCols}"
|
||||||
|
Width="70" Margin="6,0,0,0" />
|
||||||
|
<TextBox Text="{Binding Name}" Margin="0,0,6,0" />
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue