From 0f4d11e4c4f7a79f954cd7f9d0199fa7ab87d109 Mon Sep 17 00:00:00 2001 From: weishaupt Date: Sat, 10 Oct 2026 11:00:26 -0400 Subject: [PATCH] v1.5: Snap to the desktop grid, whole lines when resizing Co-Authored-By: Claude Opus 5.5 --- MainWindow.cs | 140 +++++++++++++++++++++++++++++++++++++++++++-- QuickClaude.csproj | 2 +- Settings.cs | 1 + 3 files changed, 136 insertions(+), 7 deletions(-) diff --git a/MainWindow.cs b/MainWindow.cs index 2f541a6..91ad6cf 100644 --- a/MainWindow.cs +++ b/MainWindow.cs @@ -20,8 +20,12 @@ namespace QuickClaude 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, topItem, blackItem, whiteItem, lookItem, editItem, autoItem, folderItem; + WF.ToolStripMenuItem fontMenu, sizeMenu, snapItem, topItem, blackItem, whiteItem, lookItem, editItem, autoItem, folderItem; bool lastWasTool; bool quitting; @@ -72,7 +76,7 @@ namespace QuickClaude 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) => { transcript.ScrollToEnd(); input.Focus(); }; + Loaded += (s, e) => { MeasureLineHeight(); transcript.ScrollToEnd(); input.Focus(); }; PreviewMouseLeftButtonUp += ClickToInput; SourceInitialized += (s, e) => RegisterHotkey(); LocationChanged += (s, e) => SavePlace(); @@ -119,8 +123,12 @@ namespace QuickClaude transcript.TextWrapping = TextWrapping.Wrap; transcript.VerticalScrollBarVisibility = ScrollBarVisibility.Hidden; transcript.IsUndoEnabled = false; - Grid.SetRow(transcript, 1); - rootGrid.Children.Add(transcript); + 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) }; @@ -150,6 +158,35 @@ namespace QuickClaude ((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); @@ -161,6 +198,7 @@ namespace QuickClaude input.FontSize = size; status.FontSize = size; grip.FontSize = size; + MeasureLineHeight(); BuildContextMenus(); transcript.ScrollToEnd(); if (sizeMenu != null) @@ -179,6 +217,7 @@ namespace QuickClaude input.FontFamily = Mono; status.FontFamily = Mono; grip.FontFamily = Mono; + MeasureLineHeight(); BuildContextMenus(); if (fontMenu != null) foreach (WF.ToolStripMenuItem item in fontMenu.DropDownItems) @@ -417,6 +456,12 @@ namespace QuickClaude } 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(); @@ -481,7 +526,8 @@ namespace QuickClaude var hit = EdgeHit(hwnd, l); if (hit != 0) { handled = true; return new IntPtr(hit); } } - else if (msg == WM_EXITSIZEMOVE) SavePlace(); + else if (msg == WM_ENTERSIZEMOVE) GetWindowRect(hwnd, out moveStart); + else if (msg == WM_EXITSIZEMOVE) { AfterMoveOrResize(hwnd); SavePlace(); } return IntPtr.Zero; }); } @@ -489,7 +535,89 @@ namespace QuickClaude // 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_EXITSIZEMOVE = 0x0232; + 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() }; + 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) diff --git a/QuickClaude.csproj b/QuickClaude.csproj index 961f2a1..406e8c1 100644 --- a/QuickClaude.csproj +++ b/QuickClaude.csproj @@ -10,7 +10,7 @@ disable QuickClaude QuickClaude - 1.4.0 + 1.5.0 QuickClaude app.ico win-x64 diff --git a/Settings.cs b/Settings.cs index a1893a0..d8512f4 100644 --- a/Settings.cs +++ b/Settings.cs @@ -13,6 +13,7 @@ namespace QuickClaude public string TextColor { get; set; } = "Black"; public string Font { get; set; } = "Consolas"; public double FontSize { get; set; } = 10; + public bool SnapToGrid { get; set; } = true; public bool Topmost { get; set; } = false; public string WorkDir { get; set; } = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); public string Mode { get; set; } = "default";