windowcapture
исходный код / UI/ClipboardForm.cs

ClipboardForm.cs

987 строк · 39,777 байт · модуль UI
  1using System;
  2using System.Collections.Generic;
  3using System.Drawing;
  4using System.Drawing.Drawing2D;
  5using System.Drawing.Text;
  6using System.Runtime.InteropServices;
  7using System.Windows.Forms;
  8using WindowCapture.Helpers;
  9using WindowCapture.Models;
 10using WindowCapture.Native;
 11
 12namespace WindowCapture.UI
 13{
 14    public class ClipboardForm : Form
 15    {
 16        // Don't steal focus from the text field when showing
 17        protected override bool ShowWithoutActivation { get { return true; } }
 18
 19        public enum ClipItemType { Text, Image }
 20
 21        public class ClipItem
 22        {
 23            public ClipItemType Type;
 24            public string Text;
 25            public Bitmap Image;
 26            public DateTime Time;
 27            public string Preview;
 28            public string ImageInfo; // "1920x1080 PNG 2.4MB 16:9"
 29        }
 30
 31        private static string BuildImageInfo(System.Drawing.Image img)
 32        {
 33            int w = img.Width, h = img.Height;
 34            // Format
 35            string fmt = "BMP";
 36            if (img.RawFormat.Equals(System.Drawing.Imaging.ImageFormat.Png)) fmt = "PNG";
 37            else if (img.RawFormat.Equals(System.Drawing.Imaging.ImageFormat.Jpeg)) fmt = "JPEG";
 38            else if (img.RawFormat.Equals(System.Drawing.Imaging.ImageFormat.Gif)) fmt = "GIF";
 39            else if (img.RawFormat.Equals(System.Drawing.Imaging.ImageFormat.Bmp)) fmt = "BMP";
 40            else if (img.RawFormat.Equals(System.Drawing.Imaging.ImageFormat.Tiff)) fmt = "TIFF";
 41            // Size estimate (bitmap in memory)
 42            long bytes = (long)w * h * 4;
 43            string size = bytes < 1024 ? bytes + "B" :
 44                          bytes < 1048576 ? (bytes / 1024) + "KB" :
 45                          (bytes / 1048576f).ToString("0.#") + "MB";
 46            // Aspect ratio
 47            int gcd = GCD(w, h);
 48            string ratio = (w / gcd) + ":" + (h / gcd);
 49            // Simplify large ratios
 50            if (w / gcd > 32) ratio = ((float)w / h).ToString("0.##") + ":1";
 51
 52            return w + "x" + h + "  " + fmt + "  " + size + "  " + ratio;
 53        }
 54
 55        private static int GCD(int a, int b) { while (b != 0) { int t = b; b = a % b; a = t; } return a; }
 56
 57        // Static history
 58        private static List<ClipItem> history = new List<ClipItem>();
 59        private static int maxItems = 50;
 60        private static Form listenerForm;
 61        private static bool listenerInstalled;
 62        private static bool ignoreNextCapture; // skip our own SetClipboard
 63
 64        // Instance
 65        private int selectedIndex = -1; // -1 = nothing selected
 66        private int scrollOffset = 0;
 67        private const int ItemHeight = 48;
 68        private const int HeaderHeight = 0; // no header
 69        private const int FormW = 380;
 70        private const int MaxVisibleItems = 8;
 71        private PreviewForm previewPopup = null; // hover preview window
 72        private float fadeProgress = 0f;
 73        private float fadeTarget = 1f;
 74        private Timer fadeTimer;
 75        private bool closing;
 76
 77        // Drag
 78        private bool dragStarted;
 79        private Point dragStartPoint;
 80        private int dragItemIndex = -1;
 81
 82        // GDI
 83        private Font fontTitle;
 84        private Font fontPreview;
 85        private Font fontTime;
 86        private SolidBrush brushText;
 87        private SolidBrush brushDim;
 88        private SolidBrush brushAccent;
 89        private Pen penBorder;
 90
 91        [DllImport("user32.dll")]
 92        private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
 93        private const int SW_SHOWNOACTIVATE = 4;
 94
 95        public ClipboardForm()
 96        {
 97            FormBorderStyle = FormBorderStyle.None;
 98            ShowInTaskbar = false;
 99            TopMost = true;
100            StartPosition = FormStartPosition.Manual;
101            BackColor = Color.FromArgb(Settings.BlurTintColor.R, Settings.BlurTintColor.G, Settings.BlurTintColor.B);
102            DoubleBuffered = true;
103            SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true);
104
105            int itemCount = Math.Min(history.Count, MaxVisibleItems);
106            if (itemCount < 1) itemCount = 1;
107            int h = itemCount * ItemHeight + HeaderHeight + 4; // 4px padding
108            Size = new Size(FormW, h);
109
110            Point pos = GetCaretScreenPos();
111            var screen = Screen.FromPoint(pos).WorkingArea;
112            int x = pos.X + 8;
113            int y = pos.Y + 20;
114            if (x + FormW > screen.Right) x = screen.Right - FormW - 8;
115            if (y + h > screen.Bottom) y = pos.Y - h - 4;
116            if (x < screen.Left) x = screen.Left + 4;
117            if (y < screen.Top) y = screen.Top + 4;
118            Location = new Point(x, y);
119
120            Opacity = 0;
121            fadeTimer = new Timer { Interval = 16 };
122            fadeTimer.Tick += FadeTimer_Tick;
123
124            Load += (s, e) =>
125            {
126                BlurHelper.Apply(Handle);
127                WinApi.TryEnableRoundedCorners(Handle);
128                fadeTimer.Start();
129            };
130        }
131
132        // Show without stealing focus
133        public void ShowNoActivate()
134        {
135            Show();
136            // Don't take focus away from the text field user is typing in
137        }
138
139        private void InitGdi()
140        {
141            if (fontTitle != null) return;
142            fontTitle = new Font("Segoe UI Semibold", 12f, FontStyle.Regular, GraphicsUnit.Pixel);
143            fontPreview = new Font("Segoe UI", 11f, FontStyle.Regular, GraphicsUnit.Pixel);
144            fontTime = new Font("Segoe UI", 10f, FontStyle.Regular, GraphicsUnit.Pixel);
145            brushText = new SolidBrush(Color.FromArgb(210, 218, 235));
146            brushDim = new SolidBrush(Color.FromArgb(100, 110, 130));
147            brushAccent = new SolidBrush(Color.FromArgb(70, 130, 200));
148            penBorder = new Pen(Color.FromArgb(55, 60, 72));
149        }
150
151        // ===== Clipboard listener =====
152        public static void InstallListener()
153        {
154            if (listenerInstalled) return;
155            listenerInstalled = true;
156            listenerForm = new ClipboardListenerForm();
157            listenerForm.ShowInTaskbar = false;
158            listenerForm.FormBorderStyle = FormBorderStyle.None;
159            listenerForm.Size = new Size(1, 1);
160            listenerForm.StartPosition = FormStartPosition.Manual;
161            listenerForm.Location = new Point(-2000, -2000);
162            listenerForm.Show();
163            listenerForm.Hide();
164        }
165
166        private class ClipboardListenerForm : Form
167        {
168            public ClipboardListenerForm()
169            {
170                Load += (s, e) => WinApi.AddClipboardFormatListener(Handle);
171                FormClosing += (s, e) => WinApi.RemoveClipboardFormatListener(Handle);
172            }
173
174            protected override void WndProc(ref Message m)
175            {
176                if (m.Msg == WinApi.WM_CLIPBOARDUPDATE)
177                {
178                    if (!ignoreNextCapture)
179                        CaptureClipboard();
180                    ignoreNextCapture = false;
181                    m.Result = IntPtr.Zero;
182                    return;
183                }
184                base.WndProc(ref m);
185            }
186        }
187
188        private static void CaptureClipboard()
189        {
190            try
191            {
192                IDataObject data = Clipboard.GetDataObject();
193                if (data == null) return;
194
195                // Check image first (some apps set both text and image)
196                if (data.GetDataPresent(DataFormats.Bitmap))
197                {
198                    // 'using' so the clipboard-provided Image is always freed (it was leaked before,
199                    // exhausting GDI handles under repeated copy/screenshot).
200                    using (var img = data.GetData(DataFormats.Bitmap) as System.Drawing.Image)
201                    if (img != null)
202                    {
203                        for (int di = 0; di < history.Count; di++)
204                            if (history[di].Type == ClipItemType.Image && history[di].Image != null &&
205                                history[di].Image.Width == img.Width && history[di].Image.Height == img.Height)
206                                return;
207
208                        var item = new ClipItem();
209                        item.Type = ClipItemType.Image;
210                        item.Image = new Bitmap(img);
211                        item.Time = DateTime.Now;
212                        item.ImageInfo = BuildImageInfo(img);
213                        item.Preview = item.ImageInfo;
214                        history.Insert(0, item);
215                        TrimHistory();
216                        return;
217                    }
218                }
219
220                // Then check DIB (screenshots often use this format)
221                if (data.GetDataPresent(DataFormats.Dib))
222                {
223                    try
224                    {
225                        using (var img = Clipboard.GetImage())
226                        if (img != null)
227                        {
228                            for (int di = 0; di < history.Count; di++)
229                                if (history[di].Type == ClipItemType.Image && history[di].Image != null &&
230                                    history[di].Image.Width == img.Width && history[di].Image.Height == img.Height)
231                                    return;
232
233                            var item = new ClipItem();
234                            item.Type = ClipItemType.Image;
235                            item.Image = new Bitmap(img);
236                            item.Time = DateTime.Now;
237                            item.ImageInfo = BuildImageInfo(img);
238                            item.Preview = item.ImageInfo;
239                            history.Insert(0, item);
240                            TrimHistory();
241                            return;
242                        }
243                    }
244                    catch { }
245                }
246
247                // Text
248                if (data.GetDataPresent(DataFormats.UnicodeText) || data.GetDataPresent(DataFormats.Text))
249                {
250                    string text = data.GetData(DataFormats.UnicodeText) as string;
251                    if (text == null) text = data.GetData(DataFormats.Text) as string;
252                    if (!string.IsNullOrEmpty(text))
253                    {
254                        // Check for duplicate anywhere in history — skip if found
255                        for (int di = 0; di < history.Count; di++)
256                            if (history[di].Type == ClipItemType.Text && history[di].Text == text)
257                                return;
258
259                        string preview = text.Replace("\r\n", " ").Replace("\n", " ");
260                        if (preview.Length > 80) preview = preview.Substring(0, 80) + "...";
261
262                        var item = new ClipItem();
263                        item.Type = ClipItemType.Text;
264                        item.Text = text;
265                        item.Time = DateTime.Now;
266                        item.Preview = preview;
267                        history.Insert(0, item);
268                        TrimHistory();
269                    }
270                }
271            }
272            catch { }
273        }
274
275        private static void TrimHistory()
276        {
277            while (history.Count > maxItems)
278            {
279                var old = history[history.Count - 1];
280                if (old.Image != null) old.Image.Dispose();
281                history.RemoveAt(history.Count - 1);
282            }
283        }
284
285        private Point GetCaretScreenPos()
286        {
287            try
288            {
289                IntPtr fg = WinApi.GetForegroundWindow();
290                if (fg != IntPtr.Zero)
291                {
292                    uint pid;
293                    uint tid = WinApi.GetWindowThreadProcessId(fg, out pid);
294                    var info = new WinApi.GUITHREADINFO();
295                    info.cbSize = Marshal.SizeOf(info);
296                    if (WinApi.GetGUIThreadInfo(tid, ref info) && info.hwndCaret != IntPtr.Zero)
297                    {
298                        var pt = new WinApi.POINT(info.rcCaret.Left, info.rcCaret.Bottom);
299                        WinApi.ClientToScreen(info.hwndCaret, ref pt);
300                        return new Point(pt.X, pt.Y);
301                    }
302                }
303            }
304            catch { }
305            WinApi.POINT mp;
306            WinApi.GetCursorPos(out mp);
307            return new Point(mp.X, mp.Y);
308        }
309
310        // ===== Fade =====
311        private void FadeTimer_Tick(object s, EventArgs e)
312        {
313            float d = fadeTarget - fadeProgress;
314            if (Math.Abs(d) > 0.01f)
315            {
316                fadeProgress += d * 0.22f;
317                if (Math.Abs(fadeTarget - fadeProgress) < 0.01f)
318                    fadeProgress = fadeTarget;
319                float eased = fadeProgress * fadeProgress * (3f - 2f * fadeProgress);
320                Opacity = eased;
321                Invalidate();
322            }
323            else
324            {
325                fadeProgress = fadeTarget;
326                Opacity = fadeTarget;
327                if (fadeTarget <= 0) { fadeTimer.Stop(); Close(); return; }
328                fadeTimer.Stop();
329                Invalidate();
330            }
331        }
332
333        public void FadeOut()
334        {
335            if (closing) return;
336            if (saveDialogOpen) return; // don't close while save dialog is open
337            closing = true;
338            HidePreview();
339            if (previewDelayTimer != null) previewDelayTimer.Stop();
340            fadeTarget = 0f;
341            fadeTimer.Start();
342        }
343
344        // Re-show form that's in the middle of fading out
345        public void ReviveFromFadeOut()
346        {
347            closing = false;
348            fadeTarget = 1f;
349            if (!fadeTimer.Enabled) fadeTimer.Start();
350        }
351
352        public bool IsFadingOut { get { return closing; } }
353
354        // ===== Keyboard =====
355        protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
356        {
357            if (keyData == Keys.Escape) { FadeOut(); return true; }
358            if (keyData == Keys.Up)
359            {
360                if (selectedIndex > 0) selectedIndex--;
361                EnsureVisible(); Invalidate(); return true;
362            }
363            if (keyData == Keys.Down)
364            {
365                if (selectedIndex < history.Count - 1) selectedIndex++;
366                EnsureVisible(); Invalidate(); return true;
367            }
368            if (keyData == Keys.Enter || keyData == Keys.Space)
369            {
370                SelectItem(selectedIndex); return true;
371            }
372            return base.ProcessCmdKey(ref msg, keyData);
373        }
374
375        private void EnsureVisible()
376        {
377            if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
378            if (selectedIndex >= scrollOffset + MaxVisibleItems) scrollOffset = selectedIndex - MaxVisibleItems + 1;
379        }
380
381        // ===== Mouse =====
382        private bool IsSaveButtonHit(int idx, int mouseX, int mouseY)
383        {
384            if (idx < 0 || idx >= history.Count) return false;
385            if (history[idx].Type != ClipItemType.Image || history[idx].Image == null) return false;
386            int btnW = 40, btnH = 18;
387            int btnX = Width - btnW - 8;
388            int iy = HeaderHeight + 2 + (idx - scrollOffset) * ItemHeight;
389            int btnY = iy + ItemHeight - btnH - 6;
390            return mouseX >= btnX && mouseX <= btnX + btnW && mouseY >= btnY && mouseY <= btnY + btnH;
391        }
392
393        private bool saveDialogOpen = false;
394        private int saveFlashIdx = -1; // which item's Save button is flashing green
395        private long saveFlashTime = 0;
396
397        private void SaveImageQuick(int idx)
398        {
399            if (idx < 0 || idx >= history.Count) return;
400            var item = history[idx];
401            if (item.Type != ClipItemType.Image || item.Image == null) return;
402            try
403            {
404                string desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
405                string name = "clipboard_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".png";
406                string path = System.IO.Path.Combine(desktop, name);
407                item.Image.Save(path, System.Drawing.Imaging.ImageFormat.Png);
408                // Flash button green
409                saveFlashIdx = idx;
410                saveFlashTime = Environment.TickCount;
411                Invalidate();
412                // Reset after 800ms
413                var resetTimer = new Timer { Interval = 800 };
414                resetTimer.Tick += (s, e) => { resetTimer.Stop(); resetTimer.Dispose(); saveFlashIdx = -1; if (!IsDisposed) Invalidate(); };
415                resetTimer.Start();
416            }
417            catch { }
418        }
419
420        private void SaveImageDialog(int idx)
421        {
422            if (idx < 0 || idx >= history.Count) return;
423            var item = history[idx];
424            if (item.Type != ClipItemType.Image || item.Image == null) return;
425            try
426            {
427                saveDialogOpen = true;
428                var dlg = new SaveFileDialog();
429                dlg.Filter = "PNG|*.png|JPEG|*.jpg|BMP|*.bmp";
430                dlg.FileName = "clipboard_" + DateTime.Now.ToString("yyyyMMdd_HHmmss");
431                dlg.InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
432                if (dlg.ShowDialog() == DialogResult.OK)
433                {
434                    var fmt = System.Drawing.Imaging.ImageFormat.Png;
435                    if (dlg.FileName.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase)) fmt = System.Drawing.Imaging.ImageFormat.Jpeg;
436                    else if (dlg.FileName.EndsWith(".bmp", StringComparison.OrdinalIgnoreCase)) fmt = System.Drawing.Imaging.ImageFormat.Bmp;
437                    item.Image.Save(dlg.FileName, fmt);
438                    saveFlashIdx = idx;
439                    saveFlashTime = Environment.TickCount;
440                    Invalidate();
441                    var resetTimer = new Timer { Interval = 800 };
442                    resetTimer.Tick += (s, e) => { resetTimer.Stop(); resetTimer.Dispose(); saveFlashIdx = -1; if (!IsDisposed) Invalidate(); };
443                    resetTimer.Start();
444                }
445                saveDialogOpen = false;
446                // Close clipboard form after save dialog
447                FadeOut();
448            }
449            catch { saveDialogOpen = false; }
450        }
451
452        protected override void OnMouseDown(MouseEventArgs e)
453        {
454            base.OnMouseDown(e);
455            if (e.Button == MouseButtons.Left)
456            {
457                int idx = HitTest(e.Y);
458                // Check Save button click
459                if (IsSaveButtonHit(idx, e.X, e.Y))
460                {
461                    SaveImageQuick(idx);
462                    return;
463                }
464                if (idx >= 0 && idx < history.Count)
465                {
466                    dragStarted = false;
467                    dragStartPoint = e.Location;
468                    dragItemIndex = idx;
469                    selectedIndex = idx;
470                    Invalidate();
471                }
472            }
473            else if (e.Button == MouseButtons.Right)
474            {
475                int idx = HitTest(e.Y);
476                if (IsSaveButtonHit(idx, e.X, e.Y))
477                {
478                    SaveImageDialog(idx);
479                    return;
480                }
481            }
482        }
483
484        protected override void OnMouseUp(MouseEventArgs e)
485        {
486            base.OnMouseUp(e);
487            if (e.Button == MouseButtons.Left && !dragStarted && dragItemIndex >= 0)
488            {
489                SelectItem(dragItemIndex);
490            }
491            dragItemIndex = -1;
492            dragStarted = false;
493        }
494
495        protected override void OnMouseMove(MouseEventArgs e)
496        {
497            base.OnMouseMove(e);
498            if (e.Button == MouseButtons.Left && dragItemIndex >= 0 && !dragStarted)
499            {
500                if (Math.Abs(e.X - dragStartPoint.X) > 6 || Math.Abs(e.Y - dragStartPoint.Y) > 6)
501                {
502                    dragStarted = true;
503                    StartDragDrop(dragItemIndex);
504                    return;
505                }
506            }
507            int idx = HitTest(e.Y);
508            if (idx >= 0 && idx < history.Count)
509            {
510                if (idx != selectedIndex)
511                {
512                    selectedIndex = idx;
513                    ShowPreview(idx);
514                }
515                Invalidate(); // always repaint for Save button hover
516            }
517            else if (selectedIndex >= 0)
518            {
519                selectedIndex = -1;
520                Invalidate();
521                HidePreview();
522            }
523        }
524
525        protected override void OnMouseLeave(EventArgs e)
526        {
527            base.OnMouseLeave(e);
528            // Don't deselect/hide if mouse moved to preview popup
529            if (previewPopup != null && !previewPopup.IsDisposed)
530            {
531                WinApi.POINT mp; WinApi.GetCursorPos(out mp);
532                var r = previewPopup.Bounds;
533                if (mp.X >= r.Left && mp.X <= r.Right && mp.Y >= r.Top && mp.Y <= r.Bottom)
534                    return; // mouse is over preview — keep selection
535            }
536            if (selectedIndex >= 0) { selectedIndex = -1; Invalidate(); }
537            HidePreview();
538        }
539
540        private Timer previewDelayTimer;
541        private int pendingPreviewIdx = -1;
542
543        private void ShowPreview(int idx)
544        {
545            // Cancel any pending preview
546            if (previewDelayTimer != null) previewDelayTimer.Stop();
547            HidePreview();
548            pendingPreviewIdx = idx;
549
550            // Delay 400ms before showing
551            if (previewDelayTimer == null)
552            {
553                previewDelayTimer = new Timer { Interval = 250 };
554                previewDelayTimer.Tick += (s, e) =>
555                {
556                    previewDelayTimer.Stop();
557                    ShowPreviewNow(pendingPreviewIdx);
558                };
559            }
560            previewDelayTimer.Start();
561        }
562
563        private void ShowPreviewNow(int idx)
564        {
565            HidePreview();
566            if (idx < 0 || idx >= history.Count) return;
567            if (closing || IsDisposed) return;
568            var item = history[idx];
569
570            var pop = new PreviewForm();
571            pop.FormBorderStyle = FormBorderStyle.None;
572            pop.ShowInTaskbar = false;
573            pop.TopMost = true;
574            pop.StartPosition = FormStartPosition.Manual;
575            pop.BackColor = Color.FromArgb(30, 30, 35);
576
577            int pw, ph;
578            if (item.Type == ClipItemType.Image && item.Image != null)
579            {
580                float scale = Math.Min(300f / Math.Max(1, item.Image.Width), 300f / Math.Max(1, item.Image.Height));
581                scale = Math.Min(scale, 1f);
582                pw = (int)(item.Image.Width * scale) + 8;
583                ph = (int)(item.Image.Height * scale) + 8;
584            }
585            else
586            {
587                pw = 360;
588                string text = item.Text ?? "";
589                // Count wrapped lines (approx 50 chars per line at 9pt in 350px)
590                int charLines = (text.Length + 49) / 50;
591                int newlineLines = text.Split('\n').Length;
592                int lines = Math.Max(charLines, newlineLines);
593                lines = Math.Min(lines, 15);
594                ph = Math.Max(50, lines * 16 + 20);
595                ph = Math.Min(ph, 320);
596            }
597
598            pop.Size = new Size(pw, ph);
599            int px = this.Right + 4;
600            int py = this.Top + (idx - scrollOffset) * ItemHeight;
601            var scr = Screen.FromControl(this).WorkingArea;
602            if (px + pw > scr.Right) px = this.Left - pw - 4;
603            if (py + ph > scr.Bottom) py = scr.Bottom - ph;
604            if (py < scr.Top) py = scr.Top;
605            pop.Location = new Point(px, py);
606
607            pop.Paint += (s, pe) =>
608            {
609                var g = pe.Graphics;
610                g.SmoothingMode = SmoothingMode.HighSpeed;
611                g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit;
612                g.Clear(Color.FromArgb(30, 30, 35));
613                using (var pen = new Pen(Color.FromArgb(60, 100, 100, 100)))
614                    g.DrawRectangle(pen, 0, 0, pop.Width - 1, pop.Height - 1);
615
616                if (item.Type == ClipItemType.Image && item.Image != null)
617                {
618                    try { g.DrawImage(item.Image, 4, 4, pop.Width - 8, pop.Height - 8); } catch { }
619                }
620                else
621                {
622                    string text = item.Text ?? "";
623                    using (var f = new Font("Segoe UI", 9f))
624                    using (var b = new SolidBrush(Color.FromArgb(220, 220, 220)))
625                    {
626                        var sf = new StringFormat();
627                        sf.Trimming = StringTrimming.EllipsisWord;
628                        g.DrawString(text, f, b, new RectangleF(8, 8, pop.Width - 16, pop.Height - 16), sf);
629                    }
630                }
631            };
632
633            previewPopup = pop;
634            try
635            {
636                if (IsDisposed || closing) { pop.Dispose(); previewPopup = null; return; }
637                pop.Location = new Point(px, py);
638                pop.Size = new Size(pw, ph);
639                pop.Show();
640                pop.StartFadeIn();
641            }
642            catch { try { pop.Dispose(); } catch { } previewPopup = null; }
643        }
644
645        private void HidePreview()
646        {
647            try { if (previewDelayTimer != null) previewDelayTimer.Stop(); } catch { }
648            pendingPreviewIdx = -1;
649            if (previewPopup != null)
650            {
651                try
652                {
653                    if (!previewPopup.IsDisposed)
654                        previewPopup.StartFadeOut(); // smooth fade out, auto-closes
655                }
656                catch { try { previewPopup.Dispose(); } catch { } }
657                previewPopup = null;
658            }
659        }
660
661        protected override void OnMouseWheel(MouseEventArgs e)
662        {
663            base.OnMouseWheel(e);
664            scrollOffset = Math.Max(0, Math.Min(history.Count - MaxVisibleItems, scrollOffset + (e.Delta > 0 ? -1 : 1)));
665            Invalidate();
666        }
667
668        private int HitTest(int y) { return y < HeaderHeight + 2 ? -1 : (y - HeaderHeight - 2) / ItemHeight + scrollOffset; }
669
670        // ===== Select item =====
671        private void SelectItem(int idx)
672        {
673            if (idx < 0 || idx >= history.Count) return;
674            SetClipboardFromItem(idx);
675            FadeOut();
676        }
677
678        private void SetClipboardFromItem(int idx)
679        {
680            if (idx < 0 || idx >= history.Count) return;
681            var item = history[idx];
682            try
683            {
684                ignoreNextCapture = true;
685                if (item.Type == ClipItemType.Text)
686                    Clipboard.SetText(item.Text);
687                else if (item.Type == ClipItemType.Image && item.Image != null)
688                    Clipboard.SetImage(item.Image);
689            }
690            catch { }
691        }
692
693        /// <summary>Set clipboard to whatever item the mouse is currently hovering over.
694        /// Returns true if an item was selected, false if mouse was not over any item.</summary>
695        public bool SelectHoveredItem()
696        {
697            // Use whatever is currently selected (highlighted by hover)
698            if (selectedIndex >= 0 && selectedIndex < history.Count)
699            {
700                SetClipboardFromItem(selectedIndex);
701                return true;
702            }
703            return false;
704        }
705
706        // ===== Drag & drop =====
707        private void StartDragDrop(int idx)
708        {
709            if (idx < 0 || idx >= history.Count) return;
710            var item = history[idx];
711
712            DataObject data = new DataObject();
713            string tempFile = null;
714
715            if (item.Type == ClipItemType.Text && item.Text != null)
716            {
717                data.SetText(item.Text);
718            }
719            else if (item.Type == ClipItemType.Image && item.Image != null)
720            {
721                // Save image to temp file for proper drag & drop
722                try
723                {
724                    tempFile = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "wc_clip_" + DateTime.Now.Ticks + ".png");
725                    item.Image.Save(tempFile, System.Drawing.Imaging.ImageFormat.Png);
726                    var files = new System.Collections.Specialized.StringCollection();
727                    files.Add(tempFile);
728                    data.SetFileDropList(files);
729                    data.SetImage(item.Image); // also set image for apps that support it
730                }
731                catch { }
732            }
733
734            closing = true;
735            Hide();
736
737            DoDragDrop(data, DragDropEffects.Copy | DragDropEffects.Move);
738
739            // Cleanup temp file after a delay
740            if (tempFile != null)
741            {
742                try
743                {
744                    Timer cleanup = new Timer(); cleanup.Interval = 5000;
745                    cleanup.Tick += delegate { cleanup.Stop(); cleanup.Dispose(); try { System.IO.File.Delete(tempFile); } catch { } };
746                    cleanup.Start();
747                }
748                catch { }
749            }
750
751            if (!IsDisposed) Close();
752        }
753
754        // ===== Paint =====
755        protected override void OnPaintBackground(PaintEventArgs e) { }
756
757        protected override void OnPaint(PaintEventArgs e)
758        {
759            InitGdi();
760            var g = e.Graphics;
761            g.SmoothingMode = SmoothingMode.HighSpeed;
762            g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit;
763            int w = Width, h = Height;
764
765            g.Clear(Color.FromArgb(Settings.BlurTintAlpha, Settings.BlurTintColor));
766
767            int ni = Settings.NoiseIntensity;
768            if (ni > 0)
769            {
770                var ns = GetNoise(ni);
771                if (ns != null) using (var tb = new TextureBrush(ns, WrapMode.Tile)) g.FillRectangle(tb, 0, 0, w, h);
772            }
773
774            g.DrawRectangle(penBorder, 0, 0, w - 1, h - 1);
775
776            if (history.Count == 0)
777            {
778                g.DrawString("Empty", fontPreview, brushDim, w / 2 - 20, 16);
779                return;
780            }
781
782            int yOff = HeaderHeight + 2;
783            for (int i = scrollOffset; i < Math.Min(history.Count, scrollOffset + MaxVisibleItems); i++)
784            {
785                var item = history[i];
786                int iy = yOff + (i - scrollOffset) * ItemHeight;
787
788                if (i == selectedIndex)
789                {
790                    using (var sb = new SolidBrush(Color.FromArgb(30, 70, 130, 200))) g.FillRectangle(sb, 1, iy, w - 2, ItemHeight);
791                    using (var lp = new Pen(Color.FromArgb(80, 70, 130, 200), 2)) g.DrawLine(lp, 1, iy, 1, iy + ItemHeight);
792                }
793
794                int tx = 8;
795                string ts = FormatTime(item.Time);
796                var tsz = g.MeasureString(ts, fontTime);
797
798                if (item.Type == ClipItemType.Image && item.Image != null)
799                {
800                    int thumbH = ItemHeight - 6;
801                    int thumbW = Math.Min(56, (int)((float)item.Image.Width / Math.Max(1, item.Image.Height) * thumbH));
802                    try { g.DrawImage(item.Image, tx, iy + 3, thumbW, thumbH); } catch { }
803                    tx += thumbW + 8;
804                    var sf2 = new StringFormat { Trimming = StringTrimming.EllipsisCharacter, FormatFlags = StringFormatFlags.NoWrap };
805                    // Line 1: resolution
806                    string info1 = item.Image.Width + "x" + item.Image.Height;
807                    // Line 2: format + size + ratio
808                    string info2 = item.ImageInfo ?? "";
809                    int sp = info2.IndexOf("  ");
810                    if (sp > 0) info2 = info2.Substring(sp + 2); else info2 = "";
811                    g.DrawString(info1, fontPreview, brushText, new RectangleF(tx, iy + 4, w - tx - 70, 18), sf2);
812                    if (info2.Length > 0)
813                        g.DrawString(info2, fontTime, brushDim, new RectangleF(tx, iy + 22, w - tx - 70, 16), sf2);
814                    g.DrawString(ts, fontTime, brushDim, w - tsz.Width - 8, iy + 4);
815                    // Save button
816                    int btnW = 40, btnH = 18;
817                    int btnX = w - btnW - 8, btnY = iy + ItemHeight - btnH - 6;
818                    bool hoverSave = false;
819                    bool isFlash = (saveFlashIdx == i);
820                    if (i == selectedIndex)
821                    {
822                        WinApi.POINT mpp; WinApi.GetCursorPos(out mpp);
823                        Point cp2 = PointToClient(new Point(mpp.X, mpp.Y));
824                        hoverSave = cp2.X >= btnX && cp2.X <= btnX + btnW && cp2.Y >= btnY && cp2.Y <= btnY + btnH;
825                    }
826                    Color btnColor = isFlash ? Color.FromArgb(100, 40, 180, 60) :
827                                     hoverSave ? Color.FromArgb(60, 100, 180, 255) :
828                                     Color.FromArgb(30, 100, 150, 200);
829                    Color borderColor = isFlash ? Color.FromArgb(120, 60, 220, 80) :
830                                        Color.FromArgb(80, 100, 150, 220);
831                    using (var btnBrush = new SolidBrush(btnColor))
832                        g.FillRectangle(btnBrush, btnX, btnY, btnW, btnH);
833                    using (var btnPen = new Pen(borderColor))
834                        g.DrawRectangle(btnPen, btnX, btnY, btnW, btnH);
835                    string btnText = isFlash ? "OK!" : "Save";
836                    g.DrawString(btnText, fontTime, brushText, btnX + (isFlash ? 8 : 6), btnY + 2);
837                }
838                else
839                {
840                    // Two lines: first line bold/main, second line dimmer continuation
841                    string text = item.Text ?? item.Preview ?? "";
842                    string line1 = text.Length > 60 ? text.Substring(0, 60) : text;
843                    string line2 = text.Length > 60 ? text.Substring(60, Math.Min(60, text.Length - 60)) : "";
844                    line1 = line1.Replace('\n', ' ').Replace('\r', ' ');
845                    line2 = line2.Replace('\n', ' ').Replace('\r', ' ');
846                    var sf = new StringFormat { Trimming = StringTrimming.EllipsisCharacter, FormatFlags = StringFormatFlags.NoWrap };
847                    g.DrawString(line1, fontPreview, brushText, new RectangleF(tx, iy + 4, w - tx - tsz.Width - 14, 18), sf);
848                    if (line2.Length > 0)
849                        g.DrawString(line2, fontTime, brushDim, new RectangleF(tx, iy + 22, w - tx - 14, 16), sf);
850                    g.DrawString(ts, fontTime, brushDim, w - tsz.Width - 8, iy + 4);
851                }
852
853                if (i < scrollOffset + MaxVisibleItems - 1)
854                    g.DrawLine(penBorder, 8, iy + ItemHeight - 1, w - 8, iy + ItemHeight - 1);
855            }
856
857            if (history.Count > MaxVisibleItems)
858            {
859                float ratio = (float)scrollOffset / Math.Max(1, history.Count - MaxVisibleItems);
860                int barH = Math.Max(20, h * MaxVisibleItems / Math.Max(1, history.Count));
861                int barY = 36 + (int)(ratio * (h - 36 - barH));
862                using (var sb = new SolidBrush(Color.FromArgb(40, 255, 255, 255))) g.FillRectangle(sb, w - 5, barY, 3, barH);
863            }
864        }
865
866        private string FormatTime(DateTime t)
867        {
868            var d = DateTime.Now - t;
869            if (d.TotalSeconds < 60) return "now";
870            if (d.TotalMinutes < 60) return (int)d.TotalMinutes + "m";
871            if (d.TotalHours < 24) return (int)d.TotalHours + "h";
872            return t.ToString("dd.MM");
873        }
874
875        private static Bitmap noiseBmp;
876        private static int noiseAlpha;
877        private static Bitmap GetNoise(int alpha)
878        {
879            if (noiseBmp != null && noiseAlpha == alpha) return noiseBmp;
880            noiseAlpha = alpha;
881            if (noiseBmp != null) noiseBmp.Dispose();
882            noiseBmp = new Bitmap(64, 64);
883            var rng = new Random(42);
884            for (int y = 0; y < 64; y++)
885                for (int x = 0; x < 64; x++) { int v = rng.Next(256); noiseBmp.SetPixel(x, y, Color.FromArgb(alpha, v, v, v)); }
886            return noiseBmp;
887        }
888
889        // With WS_EX_NOACTIVATE, form never gets activated/deactivated
890        // Closing is handled by V key release in Controller.cs
891
892        protected override void Dispose(bool disposing)
893        {
894            if (disposing)
895            {
896                if (fadeTimer != null) fadeTimer.Dispose();
897                if (fontTitle != null) fontTitle.Dispose();
898                if (fontPreview != null) fontPreview.Dispose();
899                if (fontTime != null) fontTime.Dispose();
900                if (brushText != null) brushText.Dispose();
901                if (brushDim != null) brushDim.Dispose();
902                if (brushAccent != null) brushAccent.Dispose();
903                if (penBorder != null) penBorder.Dispose();
904            }
905            base.Dispose(disposing);
906        }
907
908        protected override CreateParams CreateParams
909        {
910            get
911            {
912                var cp = base.CreateParams;
913                cp.ExStyle |= 0x00000080 /*WS_EX_TOOLWINDOW*/ | 0x08000000 /*WS_EX_NOACTIVATE*/;
914                return cp;
915            }
916        }
917    }
918
919    // Helper form that doesn't steal focus when shown, with fade animation
920    internal class PreviewForm : Form
921    {
922        protected override bool ShowWithoutActivation { get { return true; } }
923        protected override CreateParams CreateParams
924        {
925            get
926            {
927                var cp = base.CreateParams;
928                cp.ExStyle |= 0x08000000 | 0x00000080 | 0x00000008;
929                return cp;
930            }
931        }
932
933        private Timer fadeTimer;
934        private float fadeProgress = 0f;
935        private float fadeTarget = 1f;
936        private bool fadingOut = false;
937
938        public void StartFadeIn()
939        {
940            Opacity = 0;
941            fadeProgress = 0f;
942            fadeTarget = 1f;
943            fadingOut = false;
944            if (fadeTimer == null)
945            {
946                fadeTimer = new Timer { Interval = 16 };
947                fadeTimer.Tick += FadeTick;
948            }
949            fadeTimer.Start();
950        }
951
952        public void StartFadeOut()
953        {
954            fadeTarget = 0f;
955            fadingOut = true;
956            if (fadeTimer == null)
957            {
958                fadeTimer = new Timer { Interval = 16 };
959                fadeTimer.Tick += FadeTick;
960            }
961            fadeTimer.Start();
962        }
963
964        private void FadeTick(object s, EventArgs e)
965        {
966            float d = fadeTarget - fadeProgress;
967            if (Math.Abs(d) > 0.02f)
968            {
969                fadeProgress += d * 0.45f; // ~75ms fade
970                Opacity = fadeProgress * fadeProgress * (3f - 2f * fadeProgress); // smooth ease
971            }
972            else
973            {
974                fadeProgress = fadeTarget;
975                Opacity = fadeTarget;
976                fadeTimer.Stop();
977                if (fadingOut) { try { Close(); } catch { } }
978            }
979        }
980
981        protected override void Dispose(bool disposing)
982        {
983            if (disposing && fadeTimer != null) { fadeTimer.Stop(); fadeTimer.Dispose(); }
984            base.Dispose(disposing);
985        }
986    }
987}