Files
QuickClaude/MainWindow.cs
T

645 lines
29 KiB
C#

using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using WF = System.Windows.Forms;
namespace QuickClaude
{
public class MainWindow : Window
{
readonly Settings settings;
readonly ClaudeRunner runner = new ClaudeRunner();
readonly TextBox transcript = new TextBox();
readonly TextBox input = new TextBox();
readonly TextBlock status = new TextBlock();
readonly TextBlock grip = new TextBlock();
// Holds the transcript. The transcript inside is always sized to a whole number of lines
// and sits at the bottom, so no line is ever shown cut in half (see FitTranscript).
readonly Border transcriptHost = new Border { ClipToBounds = true };
double lineHeight = 13;
WF.NotifyIcon tray;
WF.ToolStripMenuItem fontMenu, sizeMenu, snapItem, topItem, blackItem, whiteItem, lookItem, editItem, autoItem, folderItem;
bool lastWasTool;
bool quitting;
// Nearly invisible fill so the window still catches the mouse (fully clear pixels click through)
static readonly Brush HitFill = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0));
// The current font (picked from the tray's Font menu). Still called Mono since
// ConfirmWindow and DarkMenu read it when they open.
internal static FontFamily Mono = new FontFamily("Consolas, Courier New");
// Picked from the tray's Font size menu, 10 to 18 (same units as wIRC's font size
// slider: 10sp = 10 WPF units). ConfirmWindow and DarkMenu read it when they open.
internal const double MinTextSize = 10, MaxTextSize = 18;
internal static double TextSize = 10;
const double ResizeEdge = 6;
public MainWindow()
{
settings = Settings.Load();
Mono = Fonts.Find(settings.Font).Family;
TextSize = Math.Clamp(Math.Round(settings.FontSize), MinTextSize, MaxTextSize);
Title = "QuickClaude";
WindowStyle = WindowStyle.None;
AllowsTransparency = true;
Background = HitFill;
ShowInTaskbar = false;
ResizeMode = ResizeMode.CanResize;
MinWidth = 240;
MinHeight = 160;
FontFamily = Mono;
FontSize = TextSize;
Width = Math.Max(240, settings.Width);
Height = Math.Max(160, settings.Height);
Topmost = settings.Topmost;
if (double.IsNaN(settings.Left) || double.IsNaN(settings.Top) || !OnScreen(settings.Left, settings.Top))
WindowStartupLocation = WindowStartupLocation.CenterScreen;
else { Left = settings.Left; Top = settings.Top; }
BuildLayout();
ApplyColor();
BuildTray();
runner.TextDelta += t => Dispatcher.InvokeAsync(() => OnText(t));
runner.ToolUsed += t => Dispatcher.InvokeAsync(() => OnTool(t));
runner.SessionStarted += id => Dispatcher.InvokeAsync(() => { settings.SessionId = id; settings.Save(); });
runner.Finished += err => Dispatcher.InvokeAsync(() => OnFinished(err));
try { if (File.Exists(Settings.TranscriptPath)) transcript.Text = File.ReadAllText(Settings.TranscriptPath); } catch { }
if (transcript.Text.Length == 0)
transcript.Text = "QuickClaude. Type below and press Enter. Shift+Enter for a new line, Esc stops Claude, Ctrl+Alt+C hides or shows this note.\n";
Loaded += (s, e) => { MeasureLineHeight(); transcript.ScrollToEnd(); input.Focus(); };
PreviewMouseLeftButtonUp += ClickToInput;
SourceInitialized += (s, e) => RegisterHotkey();
LocationChanged += (s, e) => SavePlace();
Closing += (s, e) =>
{
if (!quitting) { e.Cancel = true; Hide(); return; }
runner.Stop();
SavePlace();
SaveTranscript();
tray.Visible = false;
tray.Dispose();
};
}
void BuildLayout()
{
var rootGrid = new Grid { Background = HitFill, Margin = new Thickness(4) };
rootGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
rootGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
rootGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
// Top row: a solid "clod" label that moves the window, plus a quiet status line
grip.Text = "clod";
grip.FontSize = TextSize;
grip.FontFamily = Mono;
grip.Background = HitFill;
grip.Cursor = Cursors.SizeAll;
grip.ToolTip = "Drag to move";
grip.MouseLeftButtonDown += (s, e) => { try { DragMove(); } catch { } SavePlace(); };
status.FontSize = TextSize;
status.FontFamily = Mono;
status.Margin = new Thickness(8, 0, 0, 0);
var top = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 4) };
top.Children.Add(grip);
top.Children.Add(status);
Grid.SetRow(top, 0);
rootGrid.Children.Add(top);
// Conversation: read-only, selectable text with no border or fill
StyleBox(transcript);
transcript.IsReadOnly = true;
transcript.TextWrapping = TextWrapping.Wrap;
transcript.VerticalScrollBarVisibility = ScrollBarVisibility.Hidden;
transcript.IsUndoEnabled = false;
transcript.VerticalAlignment = VerticalAlignment.Bottom;
transcriptHost.Background = HitFill;
transcriptHost.Child = transcript;
transcriptHost.SizeChanged += (s, e) => FitTranscript();
Grid.SetRow(transcriptHost, 1);
rootGrid.Children.Add(transcriptHost);
// Input line
var bottom = new Grid { Margin = new Thickness(0, 6, 0, 0) };
StyleBox(input);
input.AcceptsReturn = true;
input.TextWrapping = TextWrapping.Wrap;
input.MaxHeight = 120;
input.VerticalScrollBarVisibility = ScrollBarVisibility.Hidden;
input.Padding = new Thickness(0, 4, 0, 0);
input.PreviewKeyDown += InputKeyDown;
bottom.Children.Add(input);
Grid.SetRow(bottom, 2);
rootGrid.Children.Add(bottom);
Content = rootGrid;
BuildContextMenus();
}
// Right-click menus: Copy (and Cut/Paste in the input) plus New conversation; blank areas get just the latter.
// Rebuilt when the font changes so they use it too.
void BuildContextMenus()
{
transcript.ContextMenu = DarkMenu.Make(transcript, NewConversation);
input.ContextMenu = DarkMenu.Make(input, NewConversation);
((Grid)Content).ContextMenu = DarkMenu.Make(null, NewConversation);
}
// Height of one line of transcript text at the current font and size. Measured as the
// difference between two lines and one, so it's exactly what the text box steps by.
void MeasureLineHeight()
{
var probe = new TextBlock { FontFamily = Mono, FontSize = TextSize, Text = "Mg" };
probe.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
double one = probe.DesiredSize.Height;
probe.Text = "Mg\nMg";
probe.Measure(new Size(double.PositiveInfinity, double.PositiveInfinity));
double two = probe.DesiredSize.Height;
lineHeight = two - one > 1 ? two - one : Math.Max(1, one);
FitTranscript();
}
// Make the transcript exactly as tall as the whole lines that fit, bottom-aligned, and keep
// it scrolled to the end so the top line is whole too. Runs on every size change (window
// resize, the input box growing, font changes).
void FitTranscript()
{
double avail = transcriptHost.ActualHeight;
if (avail <= 0 || lineHeight <= 0) return;
int lines = Math.Max(1, (int)Math.Floor((avail + 0.01) / lineHeight));
transcript.Height = lines * lineHeight;
transcript.ScrollToEnd();
}
// Leftover space under the last whole line, in WPF units
double TranscriptGap() => Math.Max(0, transcriptHost.ActualHeight - (double.IsNaN(transcript.Height) ? 0 : transcript.Height));
void SetFontSize(double size)
{
size = Math.Clamp(size, MinTextSize, MaxTextSize);
settings.FontSize = size;
settings.Save();
TextSize = size;
FontSize = size;
transcript.FontSize = size;
input.FontSize = size;
status.FontSize = size;
grip.FontSize = size;
MeasureLineHeight();
BuildContextMenus();
transcript.ScrollToEnd();
if (sizeMenu != null)
foreach (WF.ToolStripMenuItem item in sizeMenu.DropDownItems)
item.Checked = item.Text == size.ToString();
}
void SetFont(string name)
{
var choice = Fonts.Find(name);
settings.Font = choice.Name;
settings.Save();
Mono = choice.Family;
FontFamily = Mono;
transcript.FontFamily = Mono;
input.FontFamily = Mono;
status.FontFamily = Mono;
grip.FontFamily = Mono;
MeasureLineHeight();
BuildContextMenus();
if (fontMenu != null)
foreach (WF.ToolStripMenuItem item in fontMenu.DropDownItems)
item.Checked = item.Text == choice.Name;
}
static void StyleBox(TextBox tb)
{
tb.Background = Brushes.Transparent;
tb.BorderThickness = new Thickness(0);
tb.FontFamily = Mono;
tb.FontSize = TextSize;
tb.Padding = new Thickness(0);
tb.FocusVisualStyle = null;
tb.Template = BareTextBoxTemplate;
}
// Just the text host: no border, so the theme's blue focus/hover border never shows
internal static readonly ControlTemplate BareTextBoxTemplate = MakeBareTextBoxTemplate();
static ControlTemplate MakeBareTextBoxTemplate()
{
var host = new FrameworkElementFactory(typeof(ScrollViewer), "PART_ContentHost");
host.SetValue(ScrollViewer.FocusableProperty, false);
host.SetValue(ScrollViewer.HorizontalScrollBarVisibilityProperty, ScrollBarVisibility.Hidden);
host.SetValue(ScrollViewer.VerticalScrollBarVisibilityProperty, ScrollBarVisibility.Hidden);
var root = new FrameworkElementFactory(typeof(Border));
root.SetValue(Border.BackgroundProperty, new TemplateBindingExtension(Control.BackgroundProperty));
root.SetValue(Border.PaddingProperty, new TemplateBindingExtension(Control.PaddingProperty));
root.AppendChild(host);
var t = new ControlTemplate(typeof(TextBox)) { VisualTree = root };
t.Seal();
return t;
}
void ApplyColor()
{
var c = settings.TextColor == "White" ? Colors.White : Colors.Black;
var solid = new SolidColorBrush(c);
var faint = new SolidColorBrush(Color.FromArgb(110, c.R, c.G, c.B));
transcript.Foreground = solid;
input.Foreground = solid;
input.CaretBrush = solid;
transcript.CaretBrush = solid;
status.Foreground = faint;
grip.Foreground = solid;
if (blackItem != null) { blackItem.Checked = c == Colors.Black; whiteItem.Checked = c == Colors.White; }
}
// A click anywhere puts the cursor in the input. Clicks on the input itself and on "clod"
// keep their own behavior, and a drag that selected conversation text keeps the selection
// (and focus) so it can be copied. Edge resizing never gets here: it's non-client.
void ClickToInput(object sender, MouseButtonEventArgs e)
{
var src = e.OriginalSource as DependencyObject;
if (IsWithin(src, input) || IsWithin(src, grip)) return;
bool inTranscript = IsWithin(src, transcript);
Dispatcher.BeginInvoke(new Action(() =>
{
if (inTranscript && transcript.SelectionLength > 0) return;
input.Focus();
Keyboard.Focus(input);
}), System.Windows.Threading.DispatcherPriority.Input);
}
static bool IsWithin(DependencyObject node, DependencyObject target)
{
while (node != null)
{
if (node == target) return true;
node = node is Visual || node is System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(node)
: LogicalTreeHelper.GetParent(node);
}
return false;
}
void InputKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape)
{
if (runner.Running) runner.Stop(); else Hide();
e.Handled = true;
}
else if (e.Key == Key.Enter && (Keyboard.Modifiers & ModifierKeys.Shift) == 0)
{
e.Handled = true;
Send();
}
}
async void Send()
{
var prompt = input.Text.Trim();
if (prompt.Length == 0 || runner.Running) return;
var claude = ClaudeRunner.FindClaude(settings.ClaudePath);
if (claude == null)
{
Append((transcript.Text.Length > 0 ? "\n" : "") +
"Can't find Claude Code on this computer. Install it, or use the tray menu's \"Find claude.exe...\" to point at it.\n");
return;
}
input.Clear();
var lead = transcript.Text.Length == 0 ? "" : (transcript.Text.EndsWith("\n") ? "\n" : "\n\n");
Append(lead + "> " + prompt.Replace("\n", "\n ") + "\n\n");
lastWasTool = false;
status.Text = "working... (Esc to stop)";
await runner.RunAsync(claude, prompt, settings.WorkDir, settings.SessionId, settings.Mode);
}
void OnText(string t)
{
if (t == "\u0001")
{
if (lastWasTool && !transcript.Text.EndsWith("\n")) Append("\n");
lastWasTool = false;
return;
}
Append(t);
}
void OnTool(string line)
{
if (transcript.Text.Length > 0 && !transcript.Text.EndsWith("\n")) Append("\n");
Append(line + "\n");
lastWasTool = true;
}
void OnFinished(string err)
{
if (err != null)
{
if (!transcript.Text.EndsWith("\n")) Append("\n");
Append("(" + err.Trim() + ")\n");
}
else if (!transcript.Text.EndsWith("\n")) Append("\n");
status.Text = "";
SaveTranscript();
}
void Append(string text)
{
transcript.AppendText(text);
transcript.ScrollToEnd();
}
void SaveTranscript()
{
try
{
Directory.CreateDirectory(Settings.Folder);
var t = transcript.Text;
if (t.Length > 200000) t = t.Substring(t.Length - 200000);
File.WriteAllText(Settings.TranscriptPath, t, new UTF8Encoding(false));
}
catch { }
}
void SavePlace()
{
if (WindowState != WindowState.Normal) return;
settings.Left = Left; settings.Top = Top; settings.Width = Width; settings.Height = Height;
settings.Save();
}
static bool OnScreen(double left, double top)
{
foreach (var s in WF.Screen.AllScreens)
if (s.WorkingArea.Contains((int)left + 20, (int)top + 20)) return true;
return false;
}
public void Toggle()
{
if (IsVisible) Hide();
else
{
Show();
Activate();
input.Focus();
Keyboard.Focus(input);
}
}
void NewConversation()
{
if (runner.Running) runner.Stop();
settings.SessionId = "";
settings.Save();
transcript.Clear();
Append("New conversation.\n");
SaveTranscript();
if (!IsVisible) Toggle();
}
void SetMode(string mode)
{
settings.Mode = mode;
settings.Save();
lookItem.Checked = mode == "default";
editItem.Checked = mode == "acceptEdits";
autoItem.Checked = mode == "auto";
}
void BuildTray()
{
var menu = new WF.ContextMenuStrip();
menu.Items.Add("Show / hide (Ctrl+Alt+C)", null, (s, e) => Toggle());
menu.Items.Add("New conversation", null, (s, e) => NewConversation());
menu.Items.Add(new WF.ToolStripSeparator());
var color = new WF.ToolStripMenuItem("Text color");
blackItem = new WF.ToolStripMenuItem("Black", null, (s, e) => { settings.TextColor = "Black"; settings.Save(); ApplyColor(); });
whiteItem = new WF.ToolStripMenuItem("White", null, (s, e) => { settings.TextColor = "White"; settings.Save(); ApplyColor(); });
color.DropDownItems.Add(blackItem);
color.DropDownItems.Add(whiteItem);
menu.Items.Add(color);
fontMenu = new WF.ToolStripMenuItem("Font");
var current = Fonts.Find(settings.Font).Name;
foreach (var choice in Fonts.All)
{
var name = choice.Name;
fontMenu.DropDownItems.Add(new WF.ToolStripMenuItem(name, null, (s, e) => SetFont(name)) { Checked = name == current });
}
menu.Items.Add(fontMenu);
sizeMenu = new WF.ToolStripMenuItem("Font size");
for (int px = (int)MinTextSize; px <= (int)MaxTextSize; px++)
{
double sz = px;
sizeMenu.DropDownItems.Add(new WF.ToolStripMenuItem(px.ToString(), null, (s, e) => SetFontSize(sz)) { Checked = sz == TextSize });
}
menu.Items.Add(sizeMenu);
snapItem = new WF.ToolStripMenuItem("Snap to desktop grid", null, (s, e) =>
{
settings.SnapToGrid = !settings.SnapToGrid; snapItem.Checked = settings.SnapToGrid; settings.Save();
}) { Checked = settings.SnapToGrid };
menu.Items.Add(snapItem);
topItem = new WF.ToolStripMenuItem("Always on top", null, (s, e) =>
{
settings.Topmost = !settings.Topmost; Topmost = settings.Topmost; topItem.Checked = settings.Topmost; settings.Save();
}) { Checked = settings.Topmost };
menu.Items.Add(topItem);
menu.Items.Add(new WF.ToolStripSeparator());
var can = new WF.ToolStripMenuItem("Claude can");
lookItem = new WF.ToolStripMenuItem("Only look (read files, no changes)", null, (s, e) => SetMode("default"));
editItem = new WF.ToolStripMenuItem("Edit files in the work folder", null, (s, e) => SetMode("acceptEdits"));
autoItem = new WF.ToolStripMenuItem("Auto (Claude Code's auto mode)", null, (s, e) => SetMode("auto"));
can.DropDownItems.Add(lookItem);
can.DropDownItems.Add(editItem);
can.DropDownItems.Add(autoItem);
menu.Items.Add(can);
folderItem = new WF.ToolStripMenuItem("Work folder: " + settings.WorkDir, null, (s, e) =>
{
using var dlg = new WF.FolderBrowserDialog { SelectedPath = settings.WorkDir, Description = "Folder Claude works in" };
if (dlg.ShowDialog() == WF.DialogResult.OK)
{
settings.WorkDir = dlg.SelectedPath;
settings.Save();
folderItem.Text = "Work folder: " + settings.WorkDir;
}
});
menu.Items.Add(folderItem);
menu.Items.Add("Find claude.exe...", null, (s, e) =>
{
using var dlg = new WF.OpenFileDialog { Filter = "Claude Code|claude.exe;claude.cmd|All files|*.*", Title = "Find Claude Code" };
if (dlg.ShowDialog() == WF.DialogResult.OK) { settings.ClaudePath = dlg.FileName; settings.Save(); }
});
menu.Items.Add(new WF.ToolStripSeparator());
menu.Items.Add("Exit", null, (s, e) => { quitting = true; Close(); System.Windows.Application.Current.Shutdown(); });
tray = new WF.NotifyIcon
{
Icon = TrayIcon.Make(),
Text = "QuickClaude",
ContextMenuStrip = menu,
Visible = true
};
tray.MouseClick += (s, e) => { if (e.Button == WF.MouseButtons.Left) Toggle(); };
SetMode(settings.Mode);
ApplyColor();
}
// Global hotkey: Ctrl+Alt+C
[DllImport("user32.dll")] static extern bool RegisterHotKey(IntPtr hWnd, int id, uint mods, uint vk);
const int HotkeyId = 0x5143;
void RegisterHotkey()
{
var helper = new WindowInteropHelper(this);
RegisterHotKey(helper.Handle, HotkeyId, 0x0001 | 0x0002 | 0x4000, 0x43);
HwndSource.FromHwnd(helper.Handle)?.AddHook((IntPtr hwnd, int msg, IntPtr w, IntPtr l, ref bool handled) =>
{
if (msg == 0x0312 && w.ToInt32() == HotkeyId) { Toggle(); handled = true; }
else if (msg == WM_NCHITTEST)
{
var hit = EdgeHit(hwnd, l);
if (hit != 0) { handled = true; return new IntPtr(hit); }
}
else if (msg == WM_ENTERSIZEMOVE) GetWindowRect(hwnd, out moveStart);
else if (msg == WM_EXITSIZEMOVE) { AfterMoveOrResize(hwnd); SavePlace(); }
return IntPtr.Zero;
});
}
// Resize from any edge or corner: report a ~6px border strip as the matching hit code
[DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
[StructLayout(LayoutKind.Sequential)] struct RECT { public int Left, Top, Right, Bottom; }
const int WM_NCHITTEST = 0x0084, WM_ENTERSIZEMOVE = 0x0231, WM_EXITSIZEMOVE = 0x0232;
RECT moveStart;
[DllImport("user32.dll")] static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int cx, int cy, uint flags);
[DllImport("user32.dll")] static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint flags);
[DllImport("user32.dll", CharSet = CharSet.Auto)] static extern bool GetMonitorInfo(IntPtr mon, ref MONITORINFO info);
[DllImport("user32.dll")] static extern bool SystemParametersInfo(uint action, uint param, out int value, uint ini);
[StructLayout(LayoutKind.Sequential)] struct MONITORINFO { public int cbSize; public RECT rcMonitor, rcWork; public uint dwFlags; }
const uint SWP_NOSIZE = 0x1, SWP_NOMOVE = 0x2, SWP_NOZORDER = 0x4, SWP_NOACTIVATE = 0x10;
const uint SPI_ICONHORIZONTALSPACING = 0x000D, SPI_ICONVERTICALSPACING = 0x0018;
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern IntPtr FindWindow(string cls, string name);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern IntPtr FindWindowEx(IntPtr parent, IntPtr after, string cls, string name);
[DllImport("user32.dll")] static extern IntPtr SendMessage(IntPtr h, int msg, IntPtr w, IntPtr l);
const int LVM_GETITEMSPACING = 0x1033;
// The desktop icon grid, asked of the desktop's own icon view so it follows the icon size
// and spacing Windows is really using (wIRC asks the same way, so both line up), falling
// back to the system icon spacing. In physical pixels.
static bool DesktopGridSpacing(out int sx, out int sy)
{
sx = sy = 0;
try
{
var view = FindWindowEx(FindWindow("Progman", null), IntPtr.Zero, "SHELLDLL_DefView", null);
var worker = IntPtr.Zero;
while (view == IntPtr.Zero)
{
worker = FindWindowEx(IntPtr.Zero, worker, "WorkerW", null);
if (worker == IntPtr.Zero) break;
view = FindWindowEx(worker, IntPtr.Zero, "SHELLDLL_DefView", null);
}
var list = view == IntPtr.Zero ? IntPtr.Zero : FindWindowEx(view, IntPtr.Zero, "SysListView32", null);
if (list != IntPtr.Zero)
{
long r = SendMessage(list, LVM_GETITEMSPACING, IntPtr.Zero, IntPtr.Zero).ToInt64();
sx = (int)(r & 0xFFFF); sy = (int)((r >> 16) & 0xFFFF);
if (sx > 8 && sy > 8) return true;
}
}
catch { }
return SystemParametersInfo(SPI_ICONHORIZONTALSPACING, 0, out sx, 0) && sx > 8 &&
SystemParametersInfo(SPI_ICONVERTICALSPACING, 0, out sy, 0) && sy > 8;
}
// After a drag or resize ends: a resize trims the window to whole transcript lines, and a
// plain move snaps the top-left corner to the desktop icon grid (same spacing Windows uses
// for desktop icons, counted from the top-left of the screen's work area).
void AfterMoveOrResize(IntPtr hwnd)
{
if (!GetWindowRect(hwnd, out var r)) return;
bool resized = (r.Right - r.Left) != (moveStart.Right - moveStart.Left) ||
(r.Bottom - r.Top) != (moveStart.Bottom - moveStart.Top);
double scale = PresentationSource.FromVisual(this)?.CompositionTarget?.TransformToDevice.M22 ?? 1;
if (resized)
{
UpdateLayout();
double gap = TranscriptGap();
int gapPx = (int)Math.Floor(gap * scale);
int h = r.Bottom - r.Top;
int minPx = (int)Math.Ceiling(MinHeight * scale);
if (gapPx > 0 && h - gapPx >= minPx)
{
// Keep whichever edge didn't move: resizing from the top keeps the bottom fixed
bool fromTop = r.Top != moveStart.Top && r.Bottom == moveStart.Bottom;
int top = fromTop ? r.Top + gapPx : r.Top;
SetWindowPos(hwnd, IntPtr.Zero, r.Left, top, r.Right - r.Left, h - gapPx, SWP_NOZORDER | SWP_NOACTIVATE);
}
return;
}
if (!settings.SnapToGrid) return;
if (r.Left == moveStart.Left && r.Top == moveStart.Top) return;
if (!DesktopGridSpacing(out int sx, out int sy)) return;
var info = new MONITORINFO { cbSize = Marshal.SizeOf<MONITORINFO>() };
if (!GetMonitorInfo(MonitorFromWindow(hwnd, 2 /* nearest */), ref info)) return;
var w = info.rcWork;
int x = w.Left + (int)Math.Round((r.Left - w.Left) / (double)sx) * sx;
int y = w.Top + (int)Math.Round((r.Top - w.Top) / (double)sy) * sy;
if (x != r.Left || y != r.Top)
SetWindowPos(hwnd, IntPtr.Zero, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
}
const int HTLEFT = 10, HTRIGHT = 11, HTTOP = 12, HTTOPLEFT = 13, HTTOPRIGHT = 14, HTBOTTOM = 15, HTBOTTOMLEFT = 16, HTBOTTOMRIGHT = 17;
int EdgeHit(IntPtr hwnd, IntPtr l)
{
if (!GetWindowRect(hwnd, out var r)) return 0;
long lp = l.ToInt64();
int x = (short)(lp & 0xFFFF), y = (short)((lp >> 16) & 0xFFFF);
double scale = PresentationSource.FromVisual(this)?.CompositionTarget?.TransformToDevice.M11 ?? 1;
int edge = (int)Math.Ceiling(ResizeEdge * scale);
bool left = x < r.Left + edge, right = x >= r.Right - edge;
bool top = y < r.Top + edge, bottom = y >= r.Bottom - edge;
if (top && left) return HTTOPLEFT;
if (top && right) return HTTOPRIGHT;
if (bottom && left) return HTBOTTOMLEFT;
if (bottom && right) return HTBOTTOMRIGHT;
if (left) return HTLEFT;
if (right) return HTRIGHT;
if (top) return HTTOP;
if (bottom) return HTBOTTOM;
return 0;
}
}
}