From fb12f0a7575f0a11218ffa1896338166faa38872 Mon Sep 17 00:00:00 2001 From: qpd-v Date: Sat, 10 Oct 2026 22:35:10 -0500 Subject: [PATCH] Add clipboard history carousel Add opt-in keyboard or mouse activation, lightweight text/image previews, wrapping history navigation, selected-text copy/paste, and optional paste into a clicked destination. Reuse Ditto clipboard and target-tracking machinery. Include desktop image export and the reviewed popup styling. --- CP_Main.rc | 20 ++ CP_Main.vcxproj | 11 +- CP_Main.vcxproj.filters | 29 +- resource.h | 18 +- src/ClipboardCarousel.cpp | 589 +++++++++++++++++++++++++++++++ src/ClipboardCarousel.h | 76 ++++ src/ClipboardCarouselBinding.h | 31 ++ src/ClipboardCarouselInput.cpp | 355 +++++++++++++++++++ src/ClipboardCarouselInput.h | 66 ++++ src/ClipboardCarouselWnd.cpp | 413 ++++++++++++++++++++++ src/ClipboardCarouselWnd.h | 58 +++ src/ExternalWindowTracker.cpp | 12 + src/ExternalWindowTracker.h | 1 + src/MainFrm.cpp | 60 +++- src/MainFrm.h | 5 + src/Options.cpp | 27 +- src/Options.h | 9 +- src/OptionsClipboardCarousel.cpp | 126 +++++++ src/OptionsClipboardCarousel.h | 27 ++ src/OptionsSheet.cpp | 5 + src/OptionsSheet.h | 1 + src/SendKeys.cpp | 8 +- 22 files changed, 1936 insertions(+), 11 deletions(-) create mode 100644 src/ClipboardCarousel.cpp create mode 100644 src/ClipboardCarousel.h create mode 100644 src/ClipboardCarouselBinding.h create mode 100644 src/ClipboardCarouselInput.cpp create mode 100644 src/ClipboardCarouselInput.h create mode 100644 src/ClipboardCarouselWnd.cpp create mode 100644 src/ClipboardCarouselWnd.h create mode 100644 src/OptionsClipboardCarousel.cpp create mode 100644 src/OptionsClipboardCarousel.h diff --git a/CP_Main.rc b/CP_Main.rc index 5a41e2b4..3d5e84a8 100644 --- a/CP_Main.rc +++ b/CP_Main.rc @@ -1247,6 +1247,26 @@ BEGIN PUSHBUTTON "Reset",IDC_BUTTON_RESET,318,7,50,14 END +IDD_OPTIONS_CLIPBOARD_CAROUSEL DIALOGEX 0, 0, 370, 286 +STYLE DS_SETFONT | DS_FIXEDSYS | WS_CHILD | WS_CAPTION +CAPTION "Clipboard Carousel" +FONT 8, "MS Shell Dlg", 400, 0, 0x1 +BEGIN + CONTROL "Enable Clipboard Carousel",IDC_CC_ENABLE,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,8,8,225,10 + GROUPBOX "Activate carousel",IDC_CC_EDITOR_GROUP,8,29,351,93 + CONTROL "Keyboard shortcut",IDC_CC_KEYBOARD,"Button",BS_AUTORADIOBUTTON | WS_GROUP | WS_TABSTOP,17,43,93,10 + CONTROL "Mouse button + modifiers",IDC_CC_MOUSE,"Button",BS_AUTORADIOBUTTON | WS_TABSTOP,120,43,218,10 + CONTROL "",IDC_CC_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,17,63,246,14 + COMBOBOX IDC_CC_MOUSE_TRIGGER,17,63,246,70,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP + LTEXT "Mouse modifiers (optional):",IDC_CC_MODIFIERS_LABEL,17,84,326,8 + CONTROL "Shift",IDC_CC_SHIFT,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,17,99,60,10 + CONTROL "Ctrl",IDC_CC_CONTROL,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,86,99,60,10 + CONTROL "Alt",IDC_CC_ALT,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,155,99,60,10 + PUSHBUTTON "Clear",IDC_CC_CLEAR,287,63,60,14 + CONTROL "Paste when clicking outside the carousel",IDC_CC_PASTE_CLICK,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,8,132,345,10 + LTEXT "",IDC_CC_HELP,8,156,345,91 +END + IDD_DIALOG_FREIND_PROMPT DIALOGEX 0, 0, 237, 91 STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | WS_POPUP | WS_CAPTION | WS_SYSMENU CAPTION "Send to Friend" diff --git a/CP_Main.vcxproj b/CP_Main.vcxproj index cd39b313..940b33fa 100644 --- a/CP_Main.vcxproj +++ b/CP_Main.vcxproj @@ -541,6 +541,9 @@ + + + @@ -618,6 +621,7 @@ + @@ -809,6 +813,10 @@ + + + + @@ -886,6 +894,7 @@ + @@ -1267,4 +1276,4 @@ - \ No newline at end of file + diff --git a/CP_Main.vcxproj.filters b/CP_Main.vcxproj.filters index ba9944ba..9240224c 100644 --- a/CP_Main.vcxproj.filters +++ b/CP_Main.vcxproj.filters @@ -46,6 +46,15 @@ src + + src + + + src + + + src + src @@ -244,6 +253,9 @@ src + + src + src @@ -541,6 +553,18 @@ src + + src + + + src + + + src + + + src + src @@ -760,6 +784,9 @@ src + + src + src @@ -1684,4 +1711,4 @@ - \ No newline at end of file + diff --git a/resource.h b/resource.h index 6ecab036..c9492a2b 100644 --- a/resource.h +++ b/resource.h @@ -255,6 +255,20 @@ #define IDR_MENU2 390 #define IDR_MENU_DELETE_CLIP_DATA 390 #define IDR_MENU3 391 +#define IDD_OPTIONS_CLIPBOARD_CAROUSEL 394 +#define IDC_CC_ENABLE 2200 +#define IDC_CC_EDITOR_GROUP 2201 +#define IDC_CC_KEYBOARD 2202 +#define IDC_CC_MOUSE 2203 +#define IDC_CC_HOTKEY 2204 +#define IDC_CC_MOUSE_TRIGGER 2205 +#define IDC_CC_SHIFT 2206 +#define IDC_CC_CONTROL 2207 +#define IDC_CC_ALT 2208 +#define IDC_CC_CLEAR 2209 +#define IDC_CC_PASTE_CLICK 2210 +#define IDC_CC_HELP 2211 +#define IDC_CC_MODIFIERS_LABEL 2212 #define IDC_PATH 1000 #define IDC_GET_PATH 1001 #define IDC_SELECT_SOUND 1002 @@ -882,9 +896,9 @@ #ifdef APSTUDIO_INVOKED #ifndef APSTUDIO_READONLY_SYMBOLS #define _APS_3D_CONTROLS 1 -#define _APS_NEXT_RESOURCE_VALUE 394 +#define _APS_NEXT_RESOURCE_VALUE 395 #define _APS_NEXT_COMMAND_VALUE 33002 -#define _APS_NEXT_CONTROL_VALUE 2174 +#define _APS_NEXT_CONTROL_VALUE 2213 #define _APS_NEXT_SYMED_VALUE 104 #endif #endif diff --git a/src/ClipboardCarousel.cpp b/src/ClipboardCarousel.cpp new file mode 100644 index 00000000..acfc6566 --- /dev/null +++ b/src/ClipboardCarousel.cpp @@ -0,0 +1,589 @@ +#include "stdafx.h" +#include "CP_Main.h" +#include "ClipboardCarousel.h" +#include "Options.h" +#include "ProcessPaste.h" +#include "Clip.h" +#include "Misc.h" +#include "ImageHelper.h" +#include +#include + +namespace +{ + const int BATCH_SIZE = 100; + const size_t CACHE_SIZE = 300; + + bool IsDesktopTarget(HWND window) + { + TCHAR className[128] = {}; + GetClassName(window,className,_countof(className)); + CString name(className); + return window == GetDesktopWindow() || name == _T("Progman") || name == _T("WorkerW"); + } + + bool IsShellWindow(HWND window) + { + if(IsDesktopTarget(window)) return true; + TCHAR className[128] = {}; + GetClassName(window,className,_countof(className)); + CString name(className); + return name == _T("Shell_TrayWnd") || name == _T("Shell_SecondaryTrayWnd"); + } +} + +CClipboardCarousel::CClipboardCarousel() + : m_owner(NULL), m_notifyWnd(NULL), m_active(false), m_shutdown(false), m_clickPending(false), + m_target(NULL), m_focus(NULL), m_targetProcess(0), m_clickFocus(NULL), m_clickDeadline(0), + m_emptyUntil(0), m_previewGeneration(0), m_snapshotOrder(0), m_snapshotId(0), m_current(0), + m_stop(false), m_requestPending(false) +{ +} +CClipboardCarousel::~CClipboardCarousel() { Shutdown(); } +bool CClipboardCarousel::Initialize(CWnd* owner) +{ + m_owner = owner; + m_notifyWnd = owner->GetSafeHwnd(); + if(!m_window.Create(owner)) return false; + return m_input.Initialize(m_notifyWnd,m_window.GetSafeHwnd()); +} +void CClipboardCarousel::Shutdown() +{ + if(m_shutdown) return; + m_shutdown = true; + Cancel(false); + m_input.Shutdown(); + StopWorker(); + MSG message; + while(PeekMessage(&message,m_notifyWnd,WM_CLIPBOARD_CAROUSEL_PREVIEW_READY, + WM_CLIPBOARD_CAROUSEL_PREVIEW_READY,PM_REMOVE)) + HandlePreviewResult((ClipboardCarouselPreviewResult*)message.wParam); + if(m_window.GetSafeHwnd()) m_window.DestroyWindow(); + m_notifyWnd = NULL; + m_owner = NULL; +} +void CClipboardCarousel::SuspendInput() +{ + Cancel(); + m_input.Suspend(); + StopWorker(); +} +void CClipboardCarousel::ResumeInput() +{ + CString error; + if(!m_input.Resume(error) && !error.IsEmpty()) + m_owner->MessageBox(error,theApp.m_Language.GetString("ClipboardCarouselTitle","Clipboard Carousel"),MB_OK | MB_ICONWARNING); + if(!m_input.IsEnabled()) StopWorker(); +} +void CClipboardCarousel::HandleCommand(ClipboardCarouselCommand command, UINT_PTR generation) +{ + if(command == ClipboardCarouselCommand::Activate) + { + if(!m_active) Start(); + return; + } + if(!m_active || generation != m_input.Generation()) return; + switch(command) + { + case ClipboardCarouselCommand::Older: if(!m_clickPending) Move(true); break; + case ClipboardCarouselCommand::Newer: if(!m_clickPending) Move(false); break; + case ClipboardCarouselCommand::Confirm: if(!m_clickPending) Confirm(); break; + case ClipboardCarouselCommand::Cancel: Cancel(); break; + case ClipboardCarouselCommand::ClickCancel: Cancel(false); break; + case ClipboardCarouselCommand::ClickPaste: + if(!CGetSetOptions::GetCarouselPasteAtClick()) Cancel(false); + else if(!m_clickPending) + { + m_clickPending = true; + m_clickFocus = NULL; + m_clickDeadline = GetTickCount64() + 1000; + } + break; + default: break; + } +} +void CClipboardCarousel::Start() +{ + if(m_shutdown || !m_input.IsEnabled() || (theApp.QPasteWnd() && IsWindowVisible(theApp.QPasteWnd()->GetSafeHwnd()))) + return; + if(!theApp.m_activeWnd.TrackActiveWnd(true)) return; + m_target = theApp.m_activeWnd.ActiveWnd(); + m_focus = theApp.m_activeWnd.FocusWnd(); + GetWindowThreadProcessId(m_target,&m_targetProcess); + m_window.RefreshColors(); + m_items.clear(); + m_current = 0; + if(!LoadBatch(true,true)) + { + m_window.SetPreview(theApp.m_Language.GetString("ClipboardCarouselEmptyHistory","Clipboard history is empty.")); + m_window.ShowPreview(); + m_emptyUntil = GetTickCount64() + 1000; + return; + } + CString error; + if(!m_input.BeginSession(error)) + { + m_owner->MessageBox(error,theApp.m_Language.GetString("ClipboardCarouselTitle","Clipboard Carousel"),MB_OK | MB_ICONWARNING); + m_items.clear(); + return; + } + m_active = true; + m_emptyUntil = 0; + RefreshPreview(); +} +bool CClipboardCarousel::LoadBatch(bool older, bool initial, bool restart) +{ + try + { + if(initial) + { + CppSQLite3Query top = theApp.m_db.execQuery(_T("SELECT clipOrder FROM Main INDEXED BY Main_ClipOrder WHERE bIsGroup = 0 ORDER BY clipOrder DESC, lID DESC LIMIT 1")); + if(top.eof()) return false; + m_snapshotOrder = top.getFloatField(0); + m_snapshotId = theApp.m_db.execScalar(_T("SELECT MAX(lID) FROM Main")); + } + CString boundary; + if(restart) + { + m_items.clear(); + m_current = 0; + } + if(!initial && !restart && !m_items.empty()) + { + const Item& edge = older ? m_items.back() : m_items.front(); + boundary = StrF(older ? _T("AND clipOrder <= %.17g AND (clipOrder < %.17g OR (clipOrder = %.17g AND lID < %d))") : + _T("AND clipOrder >= %.17g AND (clipOrder > %.17g OR (clipOrder = %.17g AND lID > %d))"),edge.order,edge.order,edge.order,edge.id); + } + CppSQLite3Query query = theApp.m_db.execQueryEx( + _T("SELECT lID, clipOrder, substr(mText,1,4096) FROM Main INDEXED BY Main_ClipOrder WHERE bIsGroup = 0 AND clipOrder <= %.17g AND lID <= %d %s ORDER BY clipOrder %s, lID %s LIMIT %d"), + m_snapshotOrder,m_snapshotId,(LPCTSTR)boundary,older ? _T("DESC") : _T("ASC"),older ? _T("DESC") : _T("ASC"),BATCH_SIZE); + size_t added = 0; + while(!query.eof()) + { + Item item = { query.getIntField(0),query.getFloatField(1),query.getStringField(2) }; + if(older) m_items.push_back(item); + else m_items.push_front(item); + added++; + query.nextRow(); + } + if(restart && added > 0) + m_current = older ? 0 : m_items.size()-1; + else if(!older) + m_current += added; + while(m_items.size() > CACHE_SIZE) + { + if(older) { m_items.pop_front(); m_current--; } + else m_items.pop_back(); + } + return added > 0; + } + CATCH_SQLITE_EXCEPTION + return false; +} +bool CClipboardCarousel::ClipExists(int id) +{ + try { return theApp.m_db.execScalar(StrF(_T("SELECT EXISTS(SELECT 1 FROM Main WHERE lID = %d AND bIsGroup = 0)"),id)) != 0; } + CATCH_SQLITE_EXCEPTION + return false; +} +void CClipboardCarousel::Move(bool older) +{ + bool wrapped = false; + if(older) + { + if(m_current + 1 >= m_items.size() && !LoadBatch(true)) + { + if(!LoadBatch(true,false,true)) { Cancel(); return; } + wrapped = true; + } + if(!wrapped) m_current++; + } + else + { + if(m_current == 0 && !LoadBatch(false)) + { + if(!LoadBatch(false,false,true)) { Cancel(); return; } + wrapped = true; + } + if(!wrapped) m_current--; + } + while(!m_items.empty() && !ClipExists(m_items[m_current].id)) + { + m_items.erase(m_items.begin() + m_current); + if(m_items.empty()) { Cancel(); return; } + if(!older && m_current > 0) m_current--; + if(m_current >= m_items.size()) + { + if(!LoadBatch(true)) m_current = m_items.size()-1; + } + } + RefreshPreview(); +} +void CClipboardCarousel::RefreshPreview() +{ + if(m_items.empty()) return; + const Item& item = m_items[m_current]; + CString text = item.description; + bool image = false, files = false; + try + { + CppSQLite3Query types = theApp.m_db.execQueryEx(_T("SELECT strClipboardFormat FROM Data WHERE lParentID = %d"),item.id); + while(!types.eof()) + { + CString type = types.getStringField(0); + image |= type == GetFormatName(CF_DIB) || type.CompareNoCase(_T("PNG")) == 0; + files |= type == GetFormatName(CF_HDROP); + types.nextRow(); + } + // Only a bounded text prefix is needed for selection and preview. + CppSQLite3Query plain = theApp.m_db.execQueryEx( + _T("SELECT strClipboardFormat, substr(ooData,1,8192) FROM Data WHERE lParentID = %d AND strClipboardFormat IN ('CF_UNICODETEXT','CF_TEXT') ORDER BY CASE strClipboardFormat WHEN 'CF_UNICODETEXT' THEN 0 ELSE 1 END LIMIT 1"),item.id); + if(!plain.eof()) + { + int length = 0; + const unsigned char* data = plain.getBlobField(1,length); + if(data && length > 0) + { + if(CString(plain.getStringField(0)) == GetFormatName(CF_UNICODETEXT)) + text = CString((LPCWSTR)data,length / sizeof(WCHAR)); + else + text = CString(CStringA((LPCSTR)data,length)); + int end = text.Find(_T('\0')); + if(end >= 0) text = text.Left(end); + } + } + } + CATCH_SQLITE_EXCEPTION + if(files) + { + image = false; + HGLOBAL data = CClip::LoadFormat(item.id,CF_HDROP); + if(data) + { + CClipFormat format(CF_HDROP,data); + text.Empty(); + UINT count = DragQueryFile((HDROP)data,0xFFFFFFFF,NULL,0); + for(UINT i = 0; i < count && text.GetLength() < 4096; i++) + { + CString path; + UINT length = DragQueryFile((HDROP)data,i,NULL,0); + DragQueryFile((HDROP)data,i,path.GetBuffer(min(length,4096U)+1),min(length,4096U)+1); + path.ReleaseBuffer(); + if(i) text += _T("\r\n"); + text += path; + } + } + } + if(text.IsEmpty()) text = theApp.m_Language.GetString("ClipboardCarouselNoPreview","No preview available."); + m_previewGeneration++; + m_window.SetPreview(text,image); + m_window.ShowPreview(); + if(image) + { + StartWorker(); + { + std::lock_guard lock(m_mutex); + m_request = { item.id,m_previewGeneration,m_window.BackgroundColor() }; + m_requestPending = true; + } + m_condition.notify_one(); + } +} +void CClipboardCarousel::Confirm(bool clicked) +{ + if(!m_active || m_items.empty()) return; + // Desktop activation is useful for browsing, but Enter must not paste files + // into Explorer's desktop. A destination click establishes an application target. + if(IsShellWindow(m_target)) + { + Cancel(); + return; + } + int id = m_items[m_current].id; + if(!ClipExists(id)) { Move(true); return; } + DWORD process = 0; + GetWindowThreadProcessId(m_target,&process); + if(!IsWindow(m_target) || process != m_targetProcess || + !theApp.m_activeWnd.RestoreTrackedWnd(m_target,m_focus)) + { + Cancel(false); + return; + } + CString selected = m_window.SelectedText(); + if(!selected.IsEmpty()) + { + HGLOBAL data = NewGlobalP((LPVOID)(LPCTSTR)selected,(selected.GetLength()+1)*sizeof(TCHAR)); + HGLOBAL ignore = NewGlobalP("Ignore",sizeof("Ignore")); + if(!data || !ignore) + { + if(data) GlobalFree(data); + if(ignore) GlobalFree(ignore); + return; + } + EndSession(); + COleDataSource* source = new COleDataSource; + source->CacheGlobalData(CF_UNICODETEXT,data); + source->CacheGlobalData(theApp.m_cfIgnoreClipboard,ignore); + source->SetClipboard(); + if(clicked || CGetSetOptions::m_bSendPasteMessageAfterSelection) + theApp.m_activeWnd.SendPaste(!clicked); + else + theApp.m_activeWnd.ActivateTarget(); + return; + } + EndSession(); + CProcessPaste paste; + paste.GetClipIDs().Add(id); + paste.m_bSendPaste = clicked || CGetSetOptions::m_bSendPasteMessageAfterSelection; + paste.m_bActivateTarget = !clicked; + if(paste.DoPaste()) theApp.OnPasteCompleted(); + else Log(_T("Clipboard Carousel paste failed: ") + paste.m_lastErrorMessage); +} +void CClipboardCarousel::EndSession() +{ + m_active = false; + m_previewGeneration++; + m_input.EndSession(); + m_window.HidePreview(); + m_items.clear(); + m_clickPending = false; + m_emptyUntil = 0; + std::lock_guard lock(m_mutex); + m_requestPending = false; +} +void CClipboardCarousel::Cancel(bool restoreFocus) +{ + bool restore = restoreFocus && m_active && m_window.HasFocus(); + EndSession(); + if(restore && theApp.m_activeWnd.RestoreTrackedWnd(m_target,m_focus)) + theApp.m_activeWnd.ActivateTarget(); +} +void CClipboardCarousel::PasteAtClick() +{ + HWND clicked = m_input.ClickedWindow(); + HWND root = GetAncestor(clicked,GA_ROOT); + if(!IsWindow(clicked) || GetTickCount64() >= m_clickDeadline) + { + Cancel(false); + return; + } + bool desktopClick = IsDesktopTarget(root); + HWND foreground = GetForegroundWindow(); + // The desktop can have different Progman/WorkerW roots across monitors. + if(foreground != root && !(desktopClick && IsDesktopTarget(foreground))) return; + POINT point = m_input.ClickedPoint(); + DWORD_PTR hit = 0; + if(!SendMessageTimeout(root,WM_NCHITTEST,0,MAKELPARAM(point.x,point.y), + SMTO_ABORTIFHUNG,50,&hit) || hit != HTCLIENT) + { + Cancel(false); + return; + } + if(GetAsyncKeyState(VK_LBUTTON) & 0x8000 || GetAsyncKeyState(VK_CONTROL) & 0x8000 || + GetAsyncKeyState(VK_SHIFT) & 0x8000 || GetAsyncKeyState(VK_MENU) & 0x8000 || + (GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000) + return; + if(IsShellWindow(root)) + { + if(desktopClick && IsDesktopTarget(m_target) && m_window.IsImagePreview()) + SaveDesktopImage(); + else + Cancel(false); + return; + } + if(!theApp.m_activeWnd.TrackActiveWnd(true) || theApp.m_activeWnd.DesktopHasFocus() || + theApp.m_activeWnd.ActiveWnd() != root) + { + Cancel(false); + return; + } + HWND focus = theApp.m_activeWnd.FocusWnd(); + if(m_clickFocus != focus) { m_clickFocus = focus; return; } + m_target = root; + m_focus = focus; + GetWindowThreadProcessId(root,&m_targetProcess); + Confirm(true); +} + +void CClipboardCarousel::SaveDesktopImage() +{ + m_clickPending = false; + if(m_items.empty() || !ClipExists(m_items[m_current].id)) + { + Cancel(false); + return; + } + bool saved = false; + CString savedPath; + do + { + PWSTR desktop = NULL; + HRESULT result = SHGetKnownFolderPath(FOLDERID_Desktop,KF_FLAG_DEFAULT,NULL,&desktop); + CString folder = desktop ? desktop : _T(""); + CoTaskMemFree(desktop); + if(FAILED(result) || folder.IsEmpty()) break; + + // Decode the original saved format, never the downscaled preview bitmap. + CClipFormat format(theApp.m_PNG_Format); + if(!theApp.GetClipData(m_items[m_current].id,format)) + { + format.Free(); format.Clear(); + format.m_cfType = CF_DIB; + theApp.GetClipData(m_items[m_current].id,format); + } + if(!format.m_hgData) break; + std::shared_ptr image = format.m_cfType == theApp.m_PNG_Format ? + PNGImageHelper::CImageFromHGLOBAL(format.m_hgData) : + DIBImageHelper::CImageFromHGLOBAL(format.m_hgData); + if(!image || image->IsNull()) break; + CComPtr encoded; + if(FAILED(CreateStreamOnHGlobal(NULL,TRUE,&encoded)) || + FAILED(image->Save(encoded,Gdiplus::ImageFormatPNG))) break; + STATSTG info = {}; + HGLOBAL buffer = NULL; + if(FAILED(encoded->Stat(&info,STATFLAG_NONAME)) || info.cbSize.HighPart != 0 || + info.cbSize.LowPart == 0 || FAILED(GetHGlobalFromStream(encoded,&buffer))) break; + BYTE* bytes = (BYTE*)GlobalLock(buffer); + if(!bytes) break; + + SYSTEMTIME time = {}; + GetLocalTime(&time); + CString stem = StrF(_T("Ditto image %04d-%02d-%02d %02d-%02d-%02d"), + time.wYear,time.wMonth,time.wDay,time.wHour,time.wMinute,time.wSecond); + for(int suffix = 0; suffix < 1000; suffix++) + { + CString name = stem + (suffix ? StrF(_T(" (%d)"),suffix+1) : CString()) + _T(".png"); + savedPath = folder + _T("\\") + name; + HANDLE handle = CreateFile(savedPath,GENERIC_WRITE | DELETE,0,NULL, + CREATE_NEW,FILE_ATTRIBUTE_NORMAL,NULL); + if(handle == INVALID_HANDLE_VALUE) + { + DWORD error = GetLastError(); + if(error == ERROR_FILE_EXISTS || error == ERROR_ALREADY_EXISTS) continue; + break; + } + CHandle file(handle); + DWORD offset = 0; + bool written = true; + while(offset < info.cbSize.LowPart) + { + DWORD count = min(info.cbSize.LowPart-offset,1024UL*1024); + DWORD actual = 0; + if(!WriteFile(file,bytes+offset,count,&actual,NULL) || actual == 0) + { + written = false; + break; + } + offset += actual; + } + saved = written && FlushFileBuffers(file) != FALSE; + if(!saved) + { + // Remove only the failed new file, through its exclusive handle. + FILE_DISPOSITION_INFO discard = { TRUE }; + SetFileInformationByHandle(file,FileDispositionInfo,&discard,sizeof(discard)); + } + break; + } + GlobalUnlock(buffer); + } while(false); + // Export does not load the clipboard or mark/reorder the original history clip. + EndSession(); + if(saved) + SHChangeNotify(SHCNE_CREATE,SHCNF_PATH,(LPCTSTR)savedPath,NULL); + else + m_owner->MessageBox(theApp.m_Language.GetString("ClipboardCarouselDesktopSaveFailed", + "Unable to save the image to the Desktop. Check that the image is valid and the Desktop folder is writable."), + theApp.m_Language.GetString("ClipboardCarouselTitle","Clipboard Carousel"),MB_OK | MB_ICONWARNING); +} +void CClipboardCarousel::Tick() +{ + m_input.Tick(); + if(m_emptyUntil && GetTickCount64() >= m_emptyUntil) + { + m_emptyUntil = 0; + m_window.HidePreview(); + } + if(m_active && m_clickPending) PasteAtClick(); +} +void CClipboardCarousel::BeforeDatabaseClose() { Cancel(); StopWorker(); } +void CClipboardCarousel::StartWorker() +{ + if(!m_worker.joinable()) + { + m_stop = false; + m_worker = std::thread(&CClipboardCarousel::PreviewWorker,this); + } +} +void CClipboardCarousel::StopWorker() +{ + { + std::lock_guard lock(m_mutex); + m_stop = true; + m_requestPending = false; + } + m_condition.notify_one(); + if(m_worker.joinable()) m_worker.join(); +} +void CClipboardCarousel::PreviewWorker() +{ + while(true) + { + PreviewRequest request; + { + std::unique_lock lock(m_mutex); + m_condition.wait(lock,[&]() { return m_stop || m_requestPending; }); + if(m_stop) return; + request = m_request; + m_requestPending = false; + } + CClipFormat format(CF_DIB); + if(!theApp.GetClipData(request.id,format)) + { + format.Free(); format.Clear(); + format.m_cfType = theApp.m_PNG_Format; + theApp.GetClipData(request.id,format); + } + HBITMAP image = NULL; + if(format.m_hgData) + { + Gdiplus::Bitmap* original = format.CreateGdiplusBitmap(); + if(original && original->GetWidth() && original->GetHeight()) + { + double scale = min(1.0,min(720.0/original->GetWidth(),420.0/original->GetHeight())); + INT width = max(1,(INT)(original->GetWidth()*scale)); + INT height = max(1,(INT)(original->GetHeight()*scale)); + Gdiplus::Bitmap thumbnail(width,height,PixelFormat32bppPARGB); + { + Gdiplus::Graphics graphics(&thumbnail); + graphics.SetCompositingMode(Gdiplus::CompositingModeSourceCopy); + graphics.SetInterpolationMode(Gdiplus::InterpolationModeHighQualityBicubic); + graphics.DrawImage(original,0,0,width,height); + } + thumbnail.GetHBITMAP(Gdiplus::Color(255,GetRValue(request.background), + GetGValue(request.background),GetBValue(request.background)),&image); + } + delete original; + } + ClipboardCarouselPreviewResult* result = new ClipboardCarouselPreviewResult; + result->generation = request.generation; + result->clipId = request.id; + result->image = image; + if(!PostMessage(m_notifyWnd,WM_CLIPBOARD_CAROUSEL_PREVIEW_READY,(WPARAM)result,0)) + { + if(image) DeleteObject(image); + delete result; + } + } +} +void CClipboardCarousel::HandlePreviewResult(ClipboardCarouselPreviewResult* result) +{ + if(!result) return; + if(m_active && !m_items.empty() && result->generation == m_previewGeneration && result->clipId == m_items[m_current].id) + { + m_window.SetImage(result->image); + result->image = NULL; + } + if(result->image) DeleteObject(result->image); + delete result; +} diff --git a/src/ClipboardCarousel.h b/src/ClipboardCarousel.h new file mode 100644 index 00000000..87aed4a1 --- /dev/null +++ b/src/ClipboardCarousel.h @@ -0,0 +1,76 @@ +#pragma once + +#include "ClipboardCarouselInput.h" +#include "ClipboardCarouselWnd.h" +#include +#include +#include +#include + +struct ClipboardCarouselPreviewResult +{ + ULONGLONG generation = 0; + int clipId = -1; + HBITMAP image = NULL; +}; + +class CClipboardCarousel +{ +public: + CClipboardCarousel(); + ~CClipboardCarousel(); + bool Initialize(CWnd* owner); + void Shutdown(); + void SuspendInput(); + void ResumeInput(); + bool HandleHotKey(WPARAM id) { return m_input.HandleHotKey(id); } + void HandleCommand(ClipboardCarouselCommand command, UINT_PTR generation); + void HandlePreviewResult(ClipboardCarouselPreviewResult* result); + void Tick(); + void Cancel(bool restoreFocus = true); + void BeforeDatabaseClose(); + void RefreshColors() { m_window.RefreshColors(); } + bool IsActive() const { return m_active; } + bool IsEnabled() const { return m_input.IsEnabled(); } + +private: + struct Item { int id; double order; CString description; }; + struct PreviewRequest { int id = -1; ULONGLONG generation = 0; COLORREF background = RGB(32,32,32); }; + void Start(); + bool LoadBatch(bool older, bool initial = false, bool restart = false); + void Move(bool older); + bool ClipExists(int id); + void RefreshPreview(); + void Confirm(bool clicked = false); + void EndSession(); + void PasteAtClick(); + void SaveDesktopImage(); + void StartWorker(); + void StopWorker(); + void PreviewWorker(); + + CWnd* m_owner; + HWND m_notifyWnd; + CClipboardCarouselInput m_input; + CClipboardCarouselWnd m_window; + bool m_active; + bool m_shutdown; + bool m_clickPending; + HWND m_target; + HWND m_focus; + DWORD m_targetProcess; + HWND m_clickFocus; + ULONGLONG m_clickDeadline; + ULONGLONG m_emptyUntil; + ULONGLONG m_previewGeneration; + double m_snapshotOrder; + int m_snapshotId; + std::deque m_items; + size_t m_current; + std::thread m_worker; + std::mutex m_mutex; + std::condition_variable m_condition; + bool m_stop; + bool m_requestPending; + PreviewRequest m_request; +}; diff --git a/src/ClipboardCarouselBinding.h b/src/ClipboardCarouselBinding.h new file mode 100644 index 00000000..63a74fc8 --- /dev/null +++ b/src/ClipboardCarouselBinding.h @@ -0,0 +1,31 @@ +#pragma once + +#include "HotKeys.h" + +enum class ClipboardCarouselTrigger : BYTE { None, Keyboard, MiddleButton, XButton1, XButton2 }; +enum class ClipboardCarouselCommand : WPARAM { Activate = 1, Older, Newer, Confirm, Cancel, ClickCancel, ClickPaste }; + +struct ClipboardCarouselBinding +{ + ClipboardCarouselTrigger trigger = ClipboardCarouselTrigger::None; + BYTE virtualKey = 0; + BYTE modifiers = 0; + + bool IsKeyboard() const { return trigger == ClipboardCarouselTrigger::Keyboard; } + bool IsMouse() const { return trigger >= ClipboardCarouselTrigger::MiddleButton && trigger <= ClipboardCarouselTrigger::XButton2; } + bool IsAssigned() const { return IsMouse() || (IsKeyboard() && virtualKey != 0); } + DWORD Encode() const { return (DWORD)trigger | ((DWORD)virtualKey << 8) | ((DWORD)modifiers << 16); } + static ClipboardCarouselBinding Decode(DWORD value) + { + ClipboardCarouselBinding result; + result.trigger = (ClipboardCarouselTrigger)(value & 0xFF); + result.virtualKey = (BYTE)(value >> 8); + result.modifiers = (BYTE)(value >> 16) & (HOTKEYF_SHIFT | HOTKEYF_CONTROL | HOTKEYF_ALT); + if(result.trigger > ClipboardCarouselTrigger::XButton2) + return ClipboardCarouselBinding(); + return result; + } +}; + +#define WM_CLIPBOARD_CAROUSEL_INPUT (WM_APP + 220) +#define WM_CLIPBOARD_CAROUSEL_PREVIEW_READY (WM_APP + 221) diff --git a/src/ClipboardCarouselInput.cpp b/src/ClipboardCarouselInput.cpp new file mode 100644 index 00000000..a1214a84 --- /dev/null +++ b/src/ClipboardCarouselInput.cpp @@ -0,0 +1,355 @@ +#include "stdafx.h" +#include "CP_Main.h" +#include "ClipboardCarouselInput.h" +#include "Options.h" + +namespace { const UINT HOOK_UPDATE = WM_APP + 222; } +CClipboardCarouselInput* CClipboardCarouselInput::s_owner = NULL; + +CClipboardCarouselInput::CClipboardCarouselInput() + : m_owner(NULL), m_preview(NULL), m_atom(0), m_registered(false), m_enabled(false), + m_suspended(false), m_active(false), m_generation(0), m_clickedWindow(NULL), m_clickX(0), m_clickY(0), + m_swallowedButtons(0), m_swallowedKeyCount(0), m_outsideDown(false), m_downRoot(NULL), + m_downGeneration(0), m_wheelRemainder(0), m_thread(NULL), m_threadId(0), m_ready(NULL), + m_keyboardWanted(false), m_hooksReady(false), m_mouseHook(NULL), m_keyboardHook(NULL) +{ + ZeroMemory(m_swallowedKeys, sizeof(m_swallowedKeys)); +} +CClipboardCarouselInput::~CClipboardCarouselInput() { Shutdown(); } + +bool CClipboardCarouselInput::Initialize(HWND owner, HWND preview) +{ + m_owner = owner; + m_preview = preview; + m_atom = GlobalAddAtom(_T("Ditto_SlimCarousel_Activation")); + CString error; + return m_atom && Resume(error); +} +void CClipboardCarouselInput::UnregisterActivation() +{ + if(m_registered) UnregisterHotKey(m_owner,m_atom); + m_registered = false; +} +void CClipboardCarouselInput::Shutdown() +{ + m_enabled = false; + EndSession(); + UnregisterActivation(); + StopHooks(); + if(m_atom) GlobalDeleteAtom(m_atom); + m_atom = 0; + m_owner = m_preview = NULL; +} +void CClipboardCarouselInput::Suspend() +{ + m_suspended = true; + EndSession(); + UnregisterActivation(); + StopHooks(); +} +bool CClipboardCarouselInput::Resume(CString& error) +{ + UnregisterActivation(); + StopHooks(); + m_binding = ClipboardCarouselBinding::Decode(CGetSetOptions::GetCarouselActivation()); + m_enabled = CGetSetOptions::GetCarouselEnabled() != FALSE; + m_suspended = false; + if(!m_enabled) return true; + if(!Validate(true,m_binding,error)) + { + m_enabled = false; + return false; + } + if(m_binding.IsKeyboard()) + { + m_registered = RegisterHotKey(m_owner,m_atom, + CHotKey::GetModifier(m_binding.modifiers) | MOD_NOREPEAT,m_binding.virtualKey) != FALSE; + if(!m_registered) + { + error = theApp.m_Language.GetString("ClipboardCarouselShortcutUnavailable", "The carousel activation shortcut is unavailable."); + m_enabled = false; + return false; + } + return true; + } + if(UpdateHooks(false,error)) return true; + m_enabled = false; + return false; +} +bool CClipboardCarouselInput::Validate(bool enabled, const ClipboardCarouselBinding& binding, CString& error) +{ + if(!enabled) return true; + if(!binding.IsAssigned() || (binding.IsKeyboard() && + (binding.virtualKey == VK_CONTROL || binding.virtualKey == VK_SHIFT || binding.virtualKey == VK_MENU || + (binding.modifiers == 0 && (binding.virtualKey < VK_F1 || binding.virtualKey > VK_F24))))) + { + error = theApp.m_Language.GetString("ClipboardCarouselAssignActivation", + "Assign a mouse button, a keyboard shortcut with modifiers, or a function key before enabling Clipboard Carousel."); + return false; + } + // Options suspends our own registration, so this also finds conflicts with other Ditto shortcuts. + if(binding.IsKeyboard()) + { + ATOM probe = GlobalAddAtom(_T("Ditto_SlimCarousel_Probe")); + bool available = probe && RegisterHotKey(NULL,probe, + CHotKey::GetModifier(binding.modifiers) | MOD_NOREPEAT,binding.virtualKey); + if(available) UnregisterHotKey(NULL,probe); + if(probe) GlobalDeleteAtom(probe); + if(!available) + { + error = theApp.m_Language.GetString("ClipboardCarouselShortcutUnavailable", "The carousel activation shortcut is unavailable."); + return false; + } + } + return true; +} +bool CClipboardCarouselInput::HandleHotKey(WPARAM id) +{ + if(id != m_atom) return false; + if(m_enabled && !m_suspended) PostCommand(ClipboardCarouselCommand::Activate); + return true; +} +bool CClipboardCarouselInput::BeginSession(CString& error) +{ + m_wheelRemainder = 0; + m_generation++; + m_active = true; + if(UpdateHooks(true,error)) return true; + EndSession(); + if(m_binding.IsMouse()) + { + CString idleError; + if(!UpdateHooks(false,idleError)) m_enabled = false; + } + return false; +} +void CClipboardCarouselInput::EndSession() +{ + m_active = false; + m_generation++; +} +void CClipboardCarouselInput::Tick() +{ + // Keep paired key/button releases suppressed before removing a session hook. + if(!m_active && m_swallowedButtons == 0 && m_swallowedKeyCount == 0) + { + if(!m_enabled || m_suspended || !m_binding.IsMouse()) + StopHooks(); + else if(m_keyboardWanted) + { + CString error; + UpdateHooks(false,error); + } + } +} +bool CClipboardCarouselInput::UpdateHooks(bool keyboard, CString& error) +{ + m_keyboardWanted = keyboard; + if(!m_thread) + { + m_ready = CreateEvent(NULL,TRUE,FALSE,NULL); + if(m_ready) + { + s_owner = this; + m_thread = CreateThread(NULL,0,HookThreadProc,this,0,&m_threadId); + } + } + else + { + ResetEvent(m_ready); + PostThreadMessage(m_threadId,HOOK_UPDATE,0,0); + } + if(m_thread && WaitForSingleObject(m_ready,2000) == WAIT_OBJECT_0 && m_hooksReady) + return true; + error = theApp.m_Language.GetString("ClipboardCarouselHookInstallError", "Unable to start carousel input handling."); + StopHooks(); + return false; +} +void CClipboardCarouselInput::StopHooks() +{ + if(m_thread) + { + if(!PostThreadMessage(m_threadId,WM_QUIT,0,0)) + { + HANDLE startup[] = { m_thread,m_ready }; + if(WaitForMultipleObjects(2,startup,FALSE,INFINITE) == WAIT_OBJECT_0 + 1) + PostThreadMessage(m_threadId,WM_QUIT,0,0); + } + WaitForSingleObject(m_thread,INFINITE); + CloseHandle(m_thread); + m_thread = NULL; + } + if(m_ready) CloseHandle(m_ready); + m_ready = NULL; + m_threadId = 0; + m_hooksReady = false; + m_keyboardWanted = false; + m_swallowedButtons = 0; + m_swallowedKeyCount = 0; + ZeroMemory(m_swallowedKeys,sizeof(m_swallowedKeys)); + m_outsideDown = false; + m_wheelRemainder = 0; + if(s_owner == this) s_owner = NULL; +} +DWORD WINAPI CClipboardCarouselInput::HookThreadProc(LPVOID parameter) +{ + return ((CClipboardCarouselInput*)parameter)->HookThread(); +} +DWORD CClipboardCarouselInput::HookThread() +{ + MSG message; + PeekMessage(&message,NULL,WM_USER,WM_USER,PM_NOREMOVE); + m_mouseHook = SetWindowsHookEx(WH_MOUSE_LL,MouseHookProc,AfxGetInstanceHandle(),0); + auto updateKeyboard = [&]() + { + if(m_keyboardWanted && !m_keyboardHook) + m_keyboardHook = SetWindowsHookEx(WH_KEYBOARD_LL,KeyboardHookProc,AfxGetInstanceHandle(),0); + else if(!m_keyboardWanted && m_keyboardHook) + { + UnhookWindowsHookEx(m_keyboardHook); + m_keyboardHook = NULL; + } + m_hooksReady = m_mouseHook && (!m_keyboardWanted || m_keyboardHook); + SetEvent(m_ready); + }; + updateKeyboard(); + while(m_hooksReady && GetMessage(&message,NULL,0,0) > 0) + { + if(message.message == HOOK_UPDATE) updateKeyboard(); + else { TranslateMessage(&message); DispatchMessage(&message); } + } + if(m_keyboardHook) UnhookWindowsHookEx(m_keyboardHook); + if(m_mouseHook) UnhookWindowsHookEx(m_mouseHook); + m_mouseHook = m_keyboardHook = NULL; + m_hooksReady = false; + return 0; +} +LRESULT CALLBACK CClipboardCarouselInput::MouseHookProc(int code, WPARAM message, LPARAM data) +{ + if(code == HC_ACTION && s_owner && s_owner->MouseEvent(message,*(MSLLHOOKSTRUCT*)data)) + return 1; + return CallNextHookEx(NULL,code,message,data); +} +LRESULT CALLBACK CClipboardCarouselInput::KeyboardHookProc(int code, WPARAM message, LPARAM data) +{ + if(code == HC_ACTION && s_owner && s_owner->KeyboardEvent(message,*(KBDLLHOOKSTRUCT*)data)) + return 1; + return CallNextHookEx(NULL,code,message,data); +} +BYTE CClipboardCarouselInput::Modifiers() const +{ + BYTE result = 0; + if(GetAsyncKeyState(VK_CONTROL) & 0x8000) result |= HOTKEYF_CONTROL; + if(GetAsyncKeyState(VK_SHIFT) & 0x8000) result |= HOTKEYF_SHIFT; + if(GetAsyncKeyState(VK_MENU) & 0x8000) result |= HOTKEYF_ALT; + if((GetAsyncKeyState(VK_LWIN) | GetAsyncKeyState(VK_RWIN)) & 0x8000) result |= HOTKEYF_EXT; + return result; +} +bool CClipboardCarouselInput::InsidePreview(POINT point) const +{ + HWND window = WindowFromPoint(point); + return window == m_preview || IsChild(m_preview,window); +} +bool CClipboardCarouselInput::PostCommand(ClipboardCarouselCommand command) +{ + return PostMessage(m_owner,WM_CLIPBOARD_CAROUSEL_INPUT,(WPARAM)command, + command == ClipboardCarouselCommand::Activate ? 0 : (LPARAM)m_generation.load()) != FALSE; +} +LRESULT CClipboardCarouselInput::MouseEvent(WPARAM message, const MSLLHOOKSTRUCT& event) +{ + if(event.flags & LLMHF_INJECTED) return 0; + ClipboardCarouselTrigger trigger = ClipboardCarouselTrigger::None; + bool down = false, up = false; + if(message == WM_MBUTTONDOWN || message == WM_MBUTTONUP) + { + trigger = ClipboardCarouselTrigger::MiddleButton; + down = message == WM_MBUTTONDOWN; up = !down; + } + else if(message == WM_XBUTTONDOWN || message == WM_XBUTTONUP) + { + trigger = HIWORD(event.mouseData) == XBUTTON1 ? ClipboardCarouselTrigger::XButton1 : ClipboardCarouselTrigger::XButton2; + down = message == WM_XBUTTONDOWN; up = !down; + } + UINT mask = trigger == ClipboardCarouselTrigger::None ? 0 : 1U << (int)trigger; + if(up && mask && (m_swallowedButtons.load() & mask)) + { + m_swallowedButtons.fetch_and(~mask); + return 1; + } + if(!m_enabled || m_suspended) return 0; + if(down && trigger == m_binding.trigger && Modifiers() == m_binding.modifiers) + { + m_swallowedButtons.fetch_or(mask); + if(!m_active) PostCommand(ClipboardCarouselCommand::Activate); + return 1; + } + if(!m_active) return 0; + if(message == WM_MOUSEWHEEL) + { + m_wheelRemainder += (SHORT)HIWORD(event.mouseData); + while(abs(m_wheelRemainder) >= WHEEL_DELTA) + { + bool upward = m_wheelRemainder > 0; + PostCommand(upward ? ClipboardCarouselCommand::Newer : ClipboardCarouselCommand::Older); + m_wheelRemainder += upward ? -WHEEL_DELTA : WHEEL_DELTA; + } + return 1; + } + if(message == WM_LBUTTONDOWN) + { + m_outsideDown = !InsidePreview(event.pt); + m_downRoot = GetAncestor(WindowFromPoint(event.pt),GA_ROOT); + m_downGeneration = m_generation; + } + else if(message == WM_LBUTTONUP && m_outsideDown) + { + m_outsideDown = false; + HWND clicked = WindowFromPoint(event.pt); + if(m_downGeneration == m_generation && !InsidePreview(event.pt) && GetAncestor(clicked,GA_ROOT) == m_downRoot) + { + m_clickedWindow = clicked; + m_clickX = event.pt.x; m_clickY = event.pt.y; + PostCommand(ClipboardCarouselCommand::ClickPaste); + } + } + else if((message == WM_RBUTTONDOWN || down) && !InsidePreview(event.pt)) + PostCommand(ClipboardCarouselCommand::ClickCancel); + return 0; +} +LRESULT CClipboardCarouselInput::KeyboardEvent(WPARAM message, const KBDLLHOOKSTRUCT& event) +{ + if((event.flags & LLKHF_INJECTED) || event.vkCode >= 256) return 0; + bool down = message == WM_KEYDOWN || message == WM_SYSKEYDOWN; + if(message == WM_KEYUP || message == WM_SYSKEYUP) + { + if(m_swallowedKeys[event.vkCode]) + { + m_swallowedKeys[event.vkCode] = false; + m_swallowedKeyCount--; + return 1; + } + return 0; + } + if(!down) return 0; + if(m_swallowedKeys[event.vkCode]) + { + // Drain repeats as well as key-up after closing, without dispatching + // another command or forwarding a held Enter/Escape to the target. + if(m_active && !m_suspended && Modifiers() == 0 && (event.vkCode == VK_UP || event.vkCode == VK_DOWN)) + PostCommand(event.vkCode == VK_UP ? ClipboardCarouselCommand::Newer : ClipboardCarouselCommand::Older); + return 1; + } + if(!m_active || m_suspended) return 0; + ClipboardCarouselCommand command; + if(event.vkCode == VK_ESCAPE) command = ClipboardCarouselCommand::Cancel; + else if(event.vkCode == VK_RETURN) command = ClipboardCarouselCommand::Confirm; + else if(Modifiers() == 0 && event.vkCode == VK_UP) + command = ClipboardCarouselCommand::Newer; + else if(Modifiers() == 0 && event.vkCode == VK_DOWN) + command = ClipboardCarouselCommand::Older; + else return 0; + m_swallowedKeys[event.vkCode] = true; + m_swallowedKeyCount++; + PostCommand(command); + return 1; +} diff --git a/src/ClipboardCarouselInput.h b/src/ClipboardCarouselInput.h new file mode 100644 index 00000000..dab39e40 --- /dev/null +++ b/src/ClipboardCarouselInput.h @@ -0,0 +1,66 @@ +#pragma once + +#include "ClipboardCarouselBinding.h" +#include + +class CClipboardCarouselInput +{ +public: + CClipboardCarouselInput(); + ~CClipboardCarouselInput(); + bool Initialize(HWND owner, HWND preview); + void Shutdown(); + void Suspend(); + bool Resume(CString& error); + bool HandleHotKey(WPARAM id); + bool BeginSession(CString& error); + void EndSession(); + void Tick(); + bool IsEnabled() const { return m_enabled; } + UINT_PTR Generation() const { return m_generation; } + HWND ClickedWindow() const { return m_clickedWindow; } + POINT ClickedPoint() const { POINT point = { m_clickX.load(), m_clickY.load() }; return point; } + static bool Validate(bool enabled, const ClipboardCarouselBinding& binding, CString& error); + +private: + bool UpdateHooks(bool keyboard, CString& error); + void StopHooks(); + void UnregisterActivation(); + bool PostCommand(ClipboardCarouselCommand command); + bool InsidePreview(POINT point) const; + BYTE Modifiers() const; + static DWORD WINAPI HookThreadProc(LPVOID parameter); + DWORD HookThread(); + static LRESULT CALLBACK MouseHookProc(int code, WPARAM message, LPARAM data); + static LRESULT CALLBACK KeyboardHookProc(int code, WPARAM message, LPARAM data); + LRESULT MouseEvent(WPARAM message, const MSLLHOOKSTRUCT& event); + LRESULT KeyboardEvent(WPARAM message, const KBDLLHOOKSTRUCT& event); + + HWND m_owner; + HWND m_preview; + ATOM m_atom; + bool m_registered; + ClipboardCarouselBinding m_binding; + std::atomic m_enabled; + std::atomic m_suspended; + std::atomic m_active; + std::atomic m_generation; + std::atomic m_clickedWindow; + std::atomic m_clickX, m_clickY; + std::atomic m_swallowedButtons; + std::atomic m_swallowedKeyCount; + bool m_swallowedKeys[256]; + bool m_outsideDown; + HWND m_downRoot; + UINT_PTR m_downGeneration; + std::atomic m_wheelRemainder; + + HANDLE m_thread; + DWORD m_threadId; + HANDLE m_ready; + std::atomic m_keyboardWanted; + std::atomic m_hooksReady; + HHOOK m_mouseHook; + HHOOK m_keyboardHook; + static CClipboardCarouselInput* s_owner; +}; diff --git a/src/ClipboardCarouselWnd.cpp b/src/ClipboardCarouselWnd.cpp new file mode 100644 index 00000000..21ac112c --- /dev/null +++ b/src/ClipboardCarouselWnd.cpp @@ -0,0 +1,413 @@ +#include "stdafx.h" +#include "CP_Main.h" +#include "ClipboardCarouselWnd.h" +#include "Misc.h" + +namespace +{ + // Dark palette shared with the other flyouts; logical sizes are DPI-scaled at use. + const COLORREF BACKGROUND = RGB(28,28,32); + const COLORREF SURFACE = RGB(37,37,43); + const COLORREF SURFACE_BORDER = RGB(50,50,58); + const COLORREF FOREGROUND = RGB(236,236,240); + const COLORREF MUTED = RGB(150,150,162); + const COLORREF BORDER = RGB(62,62,72); + const int OUTER_PADDING = 8; + const int INNER_PADDING = 12; + const int SURFACE_RADIUS = 8; + const int IMAGE_RADIUS = 6; + + HMODULE LoadSystemLibrary(LPCTSTR name) + { + return LoadLibraryEx(name,NULL,LOAD_LIBRARY_SEARCH_SYSTEM32); + } + + // Windows 11 rounds popup corners and draws the border; earlier versions report failure. + bool SetDwmAttribute(HWND window, DWORD attribute, DWORD value) + { + typedef HRESULT(WINAPI *DwmSetWindowAttributeFn)(HWND,DWORD,LPCVOID,DWORD); + static DwmSetWindowAttributeFn setAttribute = []() -> DwmSetWindowAttributeFn + { + HMODULE dwm = LoadSystemLibrary(_T("dwmapi.dll")); + return dwm ? (DwmSetWindowAttributeFn)GetProcAddress(dwm,"DwmSetWindowAttribute") : NULL; + }(); + return setAttribute && SUCCEEDED(setAttribute(window,attribute,&value,sizeof(value))); + } + + void SetControlTheme(HWND window, LPCWSTR theme) + { + typedef HRESULT(WINAPI *SetWindowThemeFn)(HWND,LPCWSTR,LPCWSTR); + static SetWindowThemeFn setTheme = []() -> SetWindowThemeFn + { + HMODULE uxtheme = LoadSystemLibrary(_T("uxtheme.dll")); + return uxtheme ? (SetWindowThemeFn)GetProcAddress(uxtheme,"SetWindowTheme") : NULL; + }(); + if(setTheme) setTheme(window,theme,NULL); + } + + Gdiplus::Color ToColor(COLORREF color) + { + return Gdiplus::Color(255,GetRValue(color),GetGValue(color),GetBValue(color)); + } + + void AddRoundRect(Gdiplus::GraphicsPath& path, const Gdiplus::RectF& rect, Gdiplus::REAL radius) + { + Gdiplus::REAL diameter = min(radius*2,min(rect.Width,rect.Height)); + if(diameter < 1) { path.AddRectangle(rect); return; } + path.AddArc(rect.X,rect.Y,diameter,diameter,180,90); + path.AddArc(rect.GetRight()-diameter,rect.Y,diameter,diameter,270,90); + path.AddArc(rect.GetRight()-diameter,rect.GetBottom()-diameter,diameter,diameter,0,90); + path.AddArc(rect.X,rect.GetBottom()-diameter,diameter,diameter,90,90); + path.CloseFigure(); + } +} + +BEGIN_MESSAGE_MAP(CClipboardCarouselWnd, CWnd) + ON_WM_PAINT() + ON_WM_ERASEBKGND() + ON_WM_MOUSEACTIVATE() + ON_WM_SIZE() + ON_WM_CTLCOLOR() + ON_MESSAGE(WM_DPICHANGED, OnDpiChanged) +END_MESSAGE_MAP() + +CClipboardCarouselWnd::CClipboardCarouselWnd() + : m_image(NULL), m_imagePreview(false), m_positioning(false), m_anchor{}, m_monitor(NULL), + m_background(BACKGROUND), m_foreground(FOREGROUND), m_border(BORDER), m_surface(SURFACE), + m_surfaceBorder(SURFACE_BORDER), m_muted(MUTED), m_roundedByDwm(false) +{ +} + +CClipboardCarouselWnd::~CClipboardCarouselWnd() { DeleteImage(); } + +bool CClipboardCarouselWnd::Create(CWnd* owner) +{ + CString className = AfxRegisterWndClass(CS_HREDRAW | CS_VREDRAW | CS_DROPSHADOW, + LoadCursor(NULL, IDC_ARROW), NULL, NULL); + if(!CreateEx(WS_EX_TOPMOST | WS_EX_TOOLWINDOW, className, + theApp.m_Language.GetString("ClipboardCarouselTitle", "Clipboard Carousel"), + WS_POPUP | WS_CLIPCHILDREN, CRect(0,0,1,1), owner, 0)) + return false; + m_dpi.SetHwnd(m_hWnd); + if(!m_textEdit.Create(WS_CHILD | ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL | WS_VSCROLL, + CRect(0,0,1,1), this, 1)) + return false; + RefreshColors(); + m_textEdit.SetLimitText(4096); + RecreateFont(); + return true; +} + +void CClipboardCarouselWnd::RefreshColors() +{ + HIGHCONTRAST contrast = {}; + contrast.cbSize = sizeof(contrast); + bool highContrast = SystemParametersInfo(SPI_GETHIGHCONTRAST, sizeof(contrast), &contrast, 0) && + (contrast.dwFlags & HCF_HIGHCONTRASTON); + m_background = highContrast ? GetSysColor(COLOR_WINDOW) : BACKGROUND; + m_foreground = highContrast ? GetSysColor(COLOR_WINDOWTEXT) : FOREGROUND; + m_border = highContrast ? GetSysColor(COLOR_WINDOWTEXT) : BORDER; + m_surface = highContrast ? m_background : SURFACE; + m_surfaceBorder = highContrast ? m_foreground : SURFACE_BORDER; + m_muted = highContrast ? m_foreground : MUTED; + m_textBrush.DeleteObject(); + m_textBrush.CreateSolidBrush(m_surface); + ApplyWindowStyle(highContrast); + if(GetSafeHwnd()) Invalidate(); +} + +void CClipboardCarouselWnd::ApplyWindowStyle(bool highContrast) +{ + if(!GetSafeHwnd()) return; + const DWORD DWMWA_WINDOW_CORNER_PREFERENCE_ = 33, DWMWA_BORDER_COLOR_ = 34, DWMWCP_ROUND_ = 2; + m_roundedByDwm = SetDwmAttribute(m_hWnd,DWMWA_WINDOW_CORNER_PREFERENCE_,DWMWCP_ROUND_); + if(m_roundedByDwm) SetDwmAttribute(m_hWnd,DWMWA_BORDER_COLOR_,m_border); + // Dark scrollbar for the text preview; High Contrast keeps the system theme. + if(m_textEdit.GetSafeHwnd()) + SetControlTheme(m_textEdit.GetSafeHwnd(),highContrast ? NULL : L"DarkMode_Explorer"); +} + +CRect CClipboardCarouselWnd::SurfaceRect() +{ + CRect surface; + GetClientRect(surface); + surface.DeflateRect(m_dpi.Scale(OUTER_PADDING),m_dpi.Scale(OUTER_PADDING)); + return surface; +} + +CRect CClipboardCarouselWnd::ContentRect() +{ + CRect content = SurfaceRect(); + content.DeflateRect(m_dpi.Scale(INNER_PADDING),m_dpi.Scale(INNER_PADDING)); + return content; +} + +void CClipboardCarouselWnd::RecreateFont() +{ + LOGFONT font = {}; + font.lfHeight = -m_dpi.Scale(15); + font.lfQuality = CLEARTYPE_QUALITY; + _tcscpy_s(font.lfFaceName, LF_FACESIZE, _T("Segoe UI")); + m_font.DeleteObject(); + m_font.CreateFontIndirect(&font); + if(m_textEdit.GetSafeHwnd()) + { + m_textEdit.SetFont(&m_font); + m_textEdit.SendMessage(EM_SETMARGINS, EC_LEFTMARGIN | EC_RIGHTMARGIN, + MAKELPARAM(m_dpi.Scale(3), m_dpi.Scale(3))); + } +} + +void CClipboardCarouselWnd::DeleteImage() +{ + if(m_image) DeleteObject(m_image); + m_image = NULL; +} + +void CClipboardCarouselWnd::SetPreview(const CString& text, bool imagePreview) +{ + DeleteImage(); + // An image description can be a raw clipboard format name (such as CF_DIB). + // Keep its loading frame empty until the thumbnail is ready. + m_text = imagePreview ? CString() : text.Left(4096); + if(!m_text.IsEmpty() && m_text[m_text.GetLength()-1] >= 0xD800 && m_text[m_text.GetLength()-1] <= 0xDBFF) + m_text.Truncate(m_text.GetLength()-1); + m_imagePreview = imagePreview; + m_textEdit.SetWindowText(m_text); + m_textEdit.SetSel(0,0); + m_textEdit.ShowWindow(imagePreview ? SW_HIDE : SW_SHOWNA); + if(IsWindowVisible()) LayoutPreview(); + Invalidate(); +} + +void CClipboardCarouselWnd::SetImage(HBITMAP image) +{ + DeleteImage(); + m_image = image; + if(!image) + m_text = theApp.m_Language.GetString("ClipboardCarouselNoPreview","No preview available."); + if(IsWindowVisible()) Invalidate(); +} + +void CClipboardCarouselWnd::ShowPreview() +{ + if(!IsWindowVisible()) + { + GetCursorPos(&m_anchor); + m_monitor = MonitorFromPoint(m_anchor, MONITOR_DEFAULTTONEAREST); + // Move a hidden, tiny window first so Windows establishes destination DPI. + // The final rectangle is then computed from the actual window DPI once. + m_positioning = true; + SetWindowPos(NULL, m_anchor.x, m_anchor.y, 1, 1, SWP_NOACTIVATE | SWP_NOZORDER); + typedef UINT(WINAPI *WindowDpi)(HWND); + static WindowDpi windowDpi = (WindowDpi)GetProcAddress(GetModuleHandle(_T("user32.dll")),"GetDpiForWindow"); + if(windowDpi) m_dpi.Update(windowDpi(m_hWnd)); + else m_dpi.SetHwnd(m_hWnd); + RecreateFont(); + m_positioning = false; + } + LayoutPreview(); + ShowWindow(SW_SHOWNOACTIVATE); +} + +void CClipboardCarouselWnd::LayoutPreview() +{ + if(m_positioning || !GetSafeHwnd()) return; + MONITORINFO info = {}; + info.cbSize = sizeof(info); + if(!GetMonitorInfo(m_monitor, &info)) return; + CRect work(info.rcWork); + int width = min(m_dpi.Scale(480), work.Width()); + int height = min(m_dpi.Scale(300), work.Height()); + int x = max(work.left, min(m_anchor.x + m_dpi.Scale(16), work.right - width)); + int y = max(work.top, min(m_anchor.y + m_dpi.Scale(20), work.bottom - height)); + m_positioning = true; + SetWindowPos(&wndTopMost, x, y, width, height, SWP_NOACTIVATE); + m_positioning = false; + PositionText(); + Invalidate(); +} + +void CClipboardCarouselWnd::PositionText() +{ + if(!m_textEdit.GetSafeHwnd()) return; + CRect content = ContentRect(); + m_textEdit.SetWindowPos(NULL, content.left, content.top, max(1,content.Width()), + max(1,content.Height()), SWP_NOZORDER | SWP_NOACTIVATE); + // Measure wrapped lines at the full available width before reserving a scrollbar. + m_textEdit.ShowScrollBar(SB_VERT,FALSE); + if(!m_imagePreview) + { + CRect formatting; + m_textEdit.GetRect(formatting); + CClientDC dc(&m_textEdit); + CFont* previous = m_font.GetSafeHandle() ? dc.SelectObject(&m_font) : NULL; + TEXTMETRIC metrics = {}; + dc.GetTextMetrics(&metrics); + if(previous) dc.SelectObject(previous); + int visibleLines = max(1,formatting.Height() / max(1L,metrics.tmHeight)); + m_textEdit.ShowScrollBar(SB_VERT,m_textEdit.GetLineCount() > visibleLines); + } +} + +void CClipboardCarouselWnd::HidePreview() +{ + if(GetSafeHwnd()) ShowWindow(SW_HIDE); + DeleteImage(); +} + +bool CClipboardCarouselWnd::HasFocus() const +{ + HWND focus = ::GetFocus(); + return focus == GetSafeHwnd() || (GetSafeHwnd() && ::IsChild(GetSafeHwnd(), focus)); +} + +CString CClipboardCarouselWnd::SelectedText() const +{ + if(m_imagePreview) return CString(); + int start = 0, end = 0; + m_textEdit.GetSel(start, end); + CString displayed; + m_textEdit.GetWindowText(displayed); + return end > start ? displayed.Mid(start, end - start) : CString(); +} + +BOOL CClipboardCarouselWnd::PreTranslateMessage(MSG* message) +{ + if(message->message == WM_KEYDOWN && HasFocus() && (::GetKeyState(VK_CONTROL) & 0x8000)) + { + if(message->wParam == 'A') + { + m_textEdit.SetSel(0,-1); + return TRUE; + } + if(message->wParam == 'C') + { + CString text = SelectedText(); + if(!text.IsEmpty()) + { + HGLOBAL data = NewGlobalP((LPVOID)(LPCTSTR)text, (text.GetLength()+1)*sizeof(TCHAR)); + if(data) + { + COleDataSource* source = new COleDataSource; + source->CacheGlobalData(CF_UNICODETEXT, data); + source->SetClipboard(); + } + } + return TRUE; + } + } + return CWnd::PreTranslateMessage(message); +} + +void CClipboardCarouselWnd::OnPaint() +{ + CPaintDC paint(this); + CRect client; + GetClientRect(client); + if(client.Width() <= 0 || client.Height() <= 0) return; + + // Compose off-screen so the rounded surface and image never flicker while cycling. + CDC dc; + dc.CreateCompatibleDC(&paint); + CBitmap buffer; + buffer.CreateCompatibleBitmap(&paint, client.Width(), client.Height()); + CBitmap* previousBuffer = dc.SelectObject(&buffer); + dc.FillSolidRect(client, m_background); + if(!m_roundedByDwm) dc.Draw3dRect(client, m_border, m_border); + + CRect surface = SurfaceRect(); + Gdiplus::REAL line = (Gdiplus::REAL)max(1,m_dpi.Scale(1)); + { + Gdiplus::Graphics graphics(dc.GetSafeHdc()); + graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); + graphics.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHighQuality); + Gdiplus::GraphicsPath path; + AddRoundRect(path, Gdiplus::RectF(surface.left+line/2, surface.top+line/2, + surface.Width()-line, surface.Height()-line), (Gdiplus::REAL)m_dpi.Scale(SURFACE_RADIUS)); + Gdiplus::SolidBrush fill(ToColor(m_surface)); + graphics.FillPath(&fill, &path); + Gdiplus::Pen pen(ToColor(m_surfaceBorder), line); + graphics.DrawPath(&pen, &path); + } + + CRect content = ContentRect(); + if(m_imagePreview && content.Width() > 0 && content.Height() > 0) + { + if(m_image) + { + BITMAP bitmap = {}; + GetObject(m_image, sizeof(bitmap), &bitmap); + CDC imageDC; + imageDC.CreateCompatibleDC(&dc); + HGDIOBJ previous = imageDC.SelectObject(m_image); + double scale = min((double)content.Width()/max(1L,bitmap.bmWidth), + (double)content.Height()/max(1L,bitmap.bmHeight)); + int width = max(1,(int)(bitmap.bmWidth*scale)); + int height = max(1,(int)(bitmap.bmHeight*scale)); + int x = content.CenterPoint().x-width/2, y = content.CenterPoint().y-height/2; + dc.SetStretchBltMode(HALFTONE); + SetBrushOrgEx(dc.GetSafeHdc(), 0, 0, NULL); + dc.StretchBlt(x, y, width, height, &imageDC, 0,0, bitmap.bmWidth,bitmap.bmHeight, SRCCOPY); + imageDC.SelectObject(previous); + + // Round the image corners by painting the surface back over them, anti-aliased. + Gdiplus::Graphics graphics(dc.GetSafeHdc()); + graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); + graphics.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHighQuality); + Gdiplus::GraphicsPath corners(Gdiplus::FillModeAlternate); + corners.AddRectangle(Gdiplus::RectF((Gdiplus::REAL)x-1, (Gdiplus::REAL)y-1, + (Gdiplus::REAL)width+2, (Gdiplus::REAL)height+2)); + AddRoundRect(corners, Gdiplus::RectF((Gdiplus::REAL)x, (Gdiplus::REAL)y, + (Gdiplus::REAL)width, (Gdiplus::REAL)height), (Gdiplus::REAL)m_dpi.Scale(IMAGE_RADIUS)); + Gdiplus::SolidBrush fill(ToColor(m_surface)); + graphics.FillPath(&fill, &corners); + } + else if(!m_text.IsEmpty()) + { + CFont* previous = dc.SelectObject(&m_font); + dc.SetTextColor(m_muted); + dc.SetBkMode(TRANSPARENT); + CRect measured = content; + dc.DrawText(m_text, measured, DT_WORDBREAK | DT_NOPREFIX | DT_CENTER | DT_CALCRECT); + CRect text = content; + text.top = max(content.top, content.CenterPoint().y - measured.Height()/2); + dc.DrawText(m_text, text, DT_WORDBREAK | DT_NOPREFIX | DT_CENTER); + dc.SelectObject(previous); + } + } + + paint.BitBlt(0, 0, client.Width(), client.Height(), &dc, 0, 0, SRCCOPY); + dc.SelectObject(previousBuffer); +} + +BOOL CClipboardCarouselWnd::OnEraseBkgnd(CDC*) { return TRUE; } +int CClipboardCarouselWnd::OnMouseActivate(CWnd*, UINT, UINT) { return MA_ACTIVATE; } +void CClipboardCarouselWnd::OnSize(UINT type, int cx, int cy) +{ + CWnd::OnSize(type,cx,cy); + PositionText(); +} +HBRUSH CClipboardCarouselWnd::OnCtlColor(CDC* dc, CWnd* wnd, UINT type) +{ + if(wnd && wnd->GetSafeHwnd() == m_textEdit.GetSafeHwnd()) + { + LOGBRUSH brush = {}; + m_textBrush.GetLogBrush(&brush); + dc->SetBkColor(brush.lbColor); + dc->SetTextColor(m_foreground); + return (HBRUSH)m_textBrush.GetSafeHandle(); + } + return CWnd::OnCtlColor(dc,wnd,type); +} +LRESULT CClipboardCarouselWnd::OnDpiChanged(WPARAM dpi, LPARAM) +{ + m_dpi.Update(HIWORD(dpi)); + RecreateFont(); + if(!m_positioning) + { + m_monitor = MonitorFromWindow(m_hWnd, MONITOR_DEFAULTTONEAREST); + LayoutPreview(); + } + return 0; +} diff --git a/src/ClipboardCarouselWnd.h b/src/ClipboardCarouselWnd.h new file mode 100644 index 00000000..58f033b5 --- /dev/null +++ b/src/ClipboardCarouselWnd.h @@ -0,0 +1,58 @@ +#pragma once + +#include "DPI.h" + +class CClipboardCarouselWnd : public CWnd +{ +public: + CClipboardCarouselWnd(); + virtual ~CClipboardCarouselWnd(); + bool Create(CWnd* owner); + void SetPreview(const CString& text, bool imagePreview = false); + void SetImage(HBITMAP image); + void ShowPreview(); + void HidePreview(); + void RefreshColors(); + CString SelectedText() const; + bool HasFocus() const; + bool IsImagePreview() const { return m_imagePreview; } + // Image thumbnails are composed onto the content surface they are drawn on. + COLORREF BackgroundColor() const { return m_surface; } + virtual BOOL PreTranslateMessage(MSG* message); + +protected: + afx_msg void OnPaint(); + afx_msg BOOL OnEraseBkgnd(CDC*); + afx_msg int OnMouseActivate(CWnd*, UINT, UINT); + afx_msg void OnSize(UINT type, int cx, int cy); + afx_msg HBRUSH OnCtlColor(CDC* dc, CWnd* wnd, UINT ctlColor); + afx_msg LRESULT OnDpiChanged(WPARAM wParam, LPARAM lParam); + DECLARE_MESSAGE_MAP() + +private: + void LayoutPreview(); + void PositionText(); + void RecreateFont(); + void DeleteImage(); + void ApplyWindowStyle(bool highContrast); + CRect SurfaceRect(); + CRect ContentRect(); + + CDPI m_dpi; + CEdit m_textEdit; + CFont m_font; + CBrush m_textBrush; + CString m_text; + HBITMAP m_image; + bool m_imagePreview; + bool m_positioning; + POINT m_anchor; + HMONITOR m_monitor; + COLORREF m_background; + COLORREF m_foreground; + COLORREF m_border; + COLORREF m_surface; + COLORREF m_surfaceBorder; + COLORREF m_muted; + bool m_roundedByDwm; +}; diff --git a/src/ExternalWindowTracker.cpp b/src/ExternalWindowTracker.cpp index 88641d40..fc71a96f 100644 --- a/src/ExternalWindowTracker.cpp +++ b/src/ExternalWindowTracker.cpp @@ -116,6 +116,18 @@ bool ExternalWindowTracker::TrackActiveWnd(bool force) return true; } +bool ExternalWindowTracker::RestoreTrackedWnd(HWND activeWnd, HWND focusWnd) +{ + if(!IsWindow(activeWnd)) + return false; + + m_activeWnd = activeWnd; + m_focusWnd = IsWindow(focusWnd) && GetAncestor(focusWnd, GA_ROOT) == activeWnd ? focusWnd : activeWnd; + m_dittoHasFocus = false; + m_desktopHasFocus = false; + return true; +} + bool ExternalWindowTracker::WaitForActiveWnd(HWND activeWnd, int timeout) { DWORD start = GetTickCount(); diff --git a/src/ExternalWindowTracker.h b/src/ExternalWindowTracker.h index d8fbe9c4..2c748f5b 100644 --- a/src/ExternalWindowTracker.h +++ b/src/ExternalWindowTracker.h @@ -16,6 +16,7 @@ class ExternalWindowTracker CString ActiveWndName(); CString WndName(HWND hWnd); bool TrackActiveWnd(bool force); + bool RestoreTrackedWnd(HWND activeWnd, HWND focusWnd); bool ActivateTarget(); bool ReleaseFocus(); CPoint FocusCaret(); diff --git a/src/MainFrm.cpp b/src/MainFrm.cpp index b3c4de1e..0ef88f89 100644 --- a/src/MainFrm.cpp +++ b/src/MainFrm.cpp @@ -81,6 +81,8 @@ BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) ON_COMMAND(ID_FIRST_DELETEALLNONUSEDCLIPS, &CMainFrame::OnFirstDeleteallnonusedclips) ON_MESSAGE(WM_PASTE_CLIP, OnPasteClip) ON_MESSAGE(WM_EDIT_CLIP, OnEditClip) + ON_MESSAGE(WM_CLIPBOARD_CAROUSEL_INPUT, OnClipboardCarouselInput) + ON_MESSAGE(WM_CLIPBOARD_CAROUSEL_PREVIEW_READY, OnClipboardCarouselPreviewReady) ON_WM_SETFOCUS() END_MESSAGE_MAP() @@ -181,6 +183,12 @@ int CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct) m_ulCopyGap = CGetSetOptions::GetCopyGap(); theApp.AfterMainCreate(); + if(!m_clipboardCarousel.Initialize(this)) + { + Log(_T("Unable to initialize Clipboard Carousel")); + } + if(m_clipboardCarousel.IsEnabled()) + SetTimer(CLIPBOARD_CAROUSEL_TIMER, 30, NULL); m_thread.Start(this); @@ -275,6 +283,11 @@ void CMainFrame::OnFirstExit() LRESULT CMainFrame::OnHotKey(WPARAM wParam, LPARAM lParam) { + if(m_clipboardCarousel.HandleHotKey(wParam)) + return TRUE; + if(m_clipboardCarousel.IsActive()) + m_clipboardCarousel.Cancel(); + if(theApp.m_pDittoHotKey && wParam == theApp.m_pDittoHotKey->m_Atom || theApp.m_pDittoHotKey2 && wParam == theApp.m_pDittoHotKey2->m_Atom || theApp.m_pDittoHotKey3 && wParam == theApp.m_pDittoHotKey3->m_Atom) @@ -492,6 +505,7 @@ LRESULT CMainFrame::OnHotKey(WPARAM wParam, LPARAM lParam) void CMainFrame::ShowQPasteWithActiveWindowCheck() { + m_clipboardCarousel.Cancel(); //Before we show our window find the current focused window for paste into theApp.m_activeWnd.TrackActiveWnd(true); @@ -789,7 +803,7 @@ void CMainFrame::OnTimer(UINT_PTR nIDEvent) case ACTIVE_WINDOW_TIMER: { - if(theApp.m_bShowingQuickPaste) + if(!m_clipboardCarousel.IsActive() && theApp.m_bShowingQuickPaste) { theApp.m_activeWnd.TrackActiveWnd(false); } @@ -817,6 +831,7 @@ void CMainFrame::OnTimer(UINT_PTR nIDEvent) { if (theApp.EnterGroupID(m_doubleClickGroupId, FALSE, TRUE)) { + m_clipboardCarousel.Cancel(); theApp.m_activeWnd.TrackActiveWnd(true); StartKeyModifierTimer(); m_quickPaste.ShowQPasteWnd(this, false, true, FALSE); @@ -842,6 +857,7 @@ void CMainFrame::OnTimer(UINT_PTR nIDEvent) case DELAYED_SHOW_DITTO_TIMER: { KillTimer(DELAYED_SHOW_DITTO_TIMER); + m_clipboardCarousel.Cancel(); m_quickPaste.ShowQPasteWnd(this, false, false, FALSE); } break; @@ -853,6 +869,7 @@ void CMainFrame::OnTimer(UINT_PTR nIDEvent) if (theme == _T("")) { CGetSetOptions::m_Theme.Load(theme); + m_clipboardCarousel.RefreshColors(); auto visible = m_quickPaste.IsWindowVisibleEx(); m_quickPaste.CloseQPasteWnd(); @@ -870,6 +887,10 @@ void CMainFrame::OnTimer(UINT_PTR nIDEvent) theApp.CloseNoDbWindow(); break; + case CLIPBOARD_CAROUSEL_TIMER: + m_clipboardCarousel.Tick(); + break; + } CFrameWnd::OnTimer(nIDEvent); @@ -900,6 +921,7 @@ LRESULT CMainFrame::OnShowTrayIcon(WPARAM wParam, LPARAM lParam) void CMainFrame::OnFirstShowquickpaste() { + m_clipboardCarousel.Cancel(); m_quickPaste.ShowQPasteWnd(this, false, false, FALSE); } @@ -966,6 +988,8 @@ void CMainFrame::OnClose() } CloseAllOpenDialogs(); + KillTimer(CLIPBOARD_CAROUSEL_TIMER); + m_clipboardCarousel.Shutdown(); Log(_T("OnClose - before stop MainFrm thread")); m_thread.Stop(); @@ -1231,6 +1255,8 @@ LRESULT CMainFrame::OnOpenCloseWindow(WPARAM wParam, LPARAM lParam) void CMainFrame::OnDestroy() { + m_clipboardCarousel.Shutdown(); + if (m_pEditFrameWnd) { m_pEditFrameWnd->DestroyWindow(); @@ -1254,14 +1280,23 @@ void CMainFrame::OnFirstOption() } else { + m_clipboardCarousel.SuspendInput(); m_pOptions = new COptionsSheet(_T("")); if(m_pOptions != NULL) { ((COptionsSheet*)m_pOptions)->SetNotifyWnd(m_hWnd); - m_pOptions->Create(); - m_pOptions->ShowWindow(SW_SHOW); + if(m_pOptions->Create()) + m_pOptions->ShowWindow(SW_SHOW); + else + { + delete m_pOptions; + m_pOptions = NULL; + m_clipboardCarousel.ResumeInput(); + } } + else + m_clipboardCarousel.ResumeInput(); } } @@ -1302,10 +1337,16 @@ LRESULT CMainFrame::OnOptionsClosed(WPARAM wParam, LPARAM lParam) delete m_pOptions; m_pOptions = NULL; + m_clipboardCarousel.ResumeInput(); + if(m_clipboardCarousel.IsEnabled()) + SetTimer(CLIPBOARD_CAROUSEL_TIMER, 30, NULL); + else + KillTimer(CLIPBOARD_CAROUSEL_TIMER); if (themeChanged) { CGetSetOptions::m_Theme.Load(CGetSetOptions::GetTheme()); + m_clipboardCarousel.RefreshColors(); m_quickPaste.CloseQPasteWnd(); } @@ -1444,6 +1485,7 @@ LRESULT CMainFrame::OnReOpenDatabase(WPARAM wParam, LPARAM lParam) { Sleep(CGetSetOptions::GetWindowsResumeDelayReOpenDbMS()); m_quickPaste.CloseQPasteWnd(); + m_clipboardCarousel.BeforeDatabaseClose(); theApp.m_db.close(); OpenDatabase(CGetSetOptions::GetDBPath()); } @@ -1454,6 +1496,18 @@ LRESULT CMainFrame::OnReOpenDatabase(WPARAM wParam, LPARAM lParam) return TRUE; } +LRESULT CMainFrame::OnClipboardCarouselInput(WPARAM wParam, LPARAM lParam) +{ + m_clipboardCarousel.HandleCommand((ClipboardCarouselCommand)wParam, (UINT_PTR)lParam); + return 0; +} + +LRESULT CMainFrame::OnClipboardCarouselPreviewReady(WPARAM wParam, LPARAM) +{ + m_clipboardCarousel.HandlePreviewResult((ClipboardCarouselPreviewResult*)wParam); + return 0; +} + LRESULT CMainFrame::OnShowMsgWindow(WPARAM wParam, LPARAM lParam) { CString *pMsg = (CString*)wParam; diff --git a/src/MainFrm.h b/src/MainFrm.h index 41d4fe4f..78ed095a 100644 --- a/src/MainFrm.h +++ b/src/MainFrm.h @@ -9,6 +9,7 @@ #include "DittoPopupWindow.h" #include "NTray.h" #include "EditFrameWnd.h" +#include "ClipboardCarousel.h" #define CLOSE_WINDOW_TIMER 1 #define HIDE_ICON_TIMER 2 @@ -25,6 +26,7 @@ #define DELAYED_SHOW_DITTO_TIMER 16 #define SET_WINDOWS_THEME_TIMER 17 #define CLOSE_NO_DB_WINDOW_TIMER 18 +#define CLIPBOARD_CAROUSEL_TIMER 19 class CMainFrame: public CFrameWnd { @@ -75,6 +77,7 @@ class CMainFrame: public CFrameWnd int m_doubleClickGroupId; DWORD m_doubleClickGroupStartTime; CPowerManager m_PowerManager; + CClipboardCarousel m_clipboardCarousel; int m_startupScreenWidth; int m_startupScreenHeight; CRichEditCtrlEx m_richEditTextConverter; @@ -153,5 +156,7 @@ DECLARE_MESSAGE_MAP()public: afx_msg void OnFirstDeleteallnonusedclips(); afx_msg LRESULT OnPasteClip(WPARAM wParam, LPARAM lParam); afx_msg LRESULT OnEditClip(WPARAM wParam, LPARAM lParam); + afx_msg LRESULT OnClipboardCarouselInput(WPARAM wParam, LPARAM lParam); + afx_msg LRESULT OnClipboardCarouselPreviewReady(WPARAM wParam, LPARAM lParam); afx_msg void OnSetFocus(CWnd* pOldWnd); }; diff --git a/src/Options.cpp b/src/Options.cpp index 3bb1a932..25059cb0 100644 --- a/src/Options.cpp +++ b/src/Options.cpp @@ -3285,4 +3285,29 @@ void CGetSetOptions::SetEnforceClipboardIgnoreFormats(BOOL val) BOOL CGetSetOptions::GetEnforceClipboardIgnoreFormats() { return GetProfileLong("EnforceClipboardIgnoreFormats", TRUE); -} \ No newline at end of file +} + +void CGetSetOptions::SetCarouselEnabled(BOOL value) +{ + SetProfileLong("ClipboardCarouselSlimEnabled",value); +} +BOOL CGetSetOptions::GetCarouselEnabled() +{ + return GetProfileLong("ClipboardCarouselSlimEnabled",FALSE); +} +void CGetSetOptions::SetCarouselActivation(DWORD value) +{ + SetProfileLong("ClipboardCarouselSlimActivation",value); +} +DWORD CGetSetOptions::GetCarouselActivation() +{ + return (DWORD)GetProfileLong("ClipboardCarouselSlimActivation",0); +} +void CGetSetOptions::SetCarouselPasteAtClick(BOOL value) +{ + SetProfileLong("ClipboardCarouselSlimPasteAtClick",value); +} +BOOL CGetSetOptions::GetCarouselPasteAtClick() +{ + return GetProfileLong("ClipboardCarouselSlimPasteAtClick",TRUE); +} diff --git a/src/Options.h b/src/Options.h index 9f1196f7..e5e4ba37 100644 --- a/src/Options.h +++ b/src/Options.h @@ -743,7 +743,14 @@ class CGetSetOptions static BOOL m_enforceClipboardIgnoreFormats; static void SetEnforceClipboardIgnoreFormats(BOOL val); static BOOL GetEnforceClipboardIgnoreFormats(); + + static void SetCarouselEnabled(BOOL value); + static BOOL GetCarouselEnabled(); + static void SetCarouselActivation(DWORD value); + static DWORD GetCarouselActivation(); + static void SetCarouselPasteAtClick(BOOL value); + static BOOL GetCarouselPasteAtClick(); }; // global for easy access and for initialization of fast access variables -extern CGetSetOptions g_Opt; \ No newline at end of file +extern CGetSetOptions g_Opt; diff --git a/src/OptionsClipboardCarousel.cpp b/src/OptionsClipboardCarousel.cpp new file mode 100644 index 00000000..87ab0e32 --- /dev/null +++ b/src/OptionsClipboardCarousel.cpp @@ -0,0 +1,126 @@ +#include "stdafx.h" +#include "CP_Main.h" +#include "OptionsClipboardCarousel.h" +#include "ClipboardCarouselInput.h" +#include "Options.h" + +IMPLEMENT_DYNAMIC(COptionsClipboardCarousel,CPropertyPage) +BEGIN_MESSAGE_MAP(COptionsClipboardCarousel,CPropertyPage) + ON_BN_CLICKED(IDC_CC_KEYBOARD,OnKeyboard) + ON_BN_CLICKED(IDC_CC_MOUSE,OnMouse) + ON_BN_CLICKED(IDC_CC_CLEAR,OnClear) +END_MESSAGE_MAP() + +COptionsClipboardCarousel::COptionsClipboardCarousel() + : CPropertyPage(IDD), m_initialEnabled(false) +{ + m_title = theApp.m_Language.GetString("ClipboardCarouselTitle","Clipboard Carousel"); + m_psp.pszTitle = m_title; + m_psp.dwFlags |= PSP_USETITLE; +} +void COptionsClipboardCarousel::DoDataExchange(CDataExchange* exchange) +{ + CPropertyPage::DoDataExchange(exchange); + DDX_Control(exchange,IDC_CC_HOTKEY,m_hotKey); + DDX_Control(exchange,IDC_CC_MOUSE_TRIGGER,m_mouseButton); +} +BOOL COptionsClipboardCarousel::OnInitDialog() +{ + CPropertyPage::OnInitDialog(); + struct Label { int id; const char* key; const char* fallback; }; + const Label labels[] = { + { IDC_CC_ENABLE,"ClipboardCarouselEnable","Enable Clipboard Carousel" }, + { IDC_CC_EDITOR_GROUP,"ClipboardCarouselActivation","Activate carousel" }, + { IDC_CC_KEYBOARD,"ClipboardCarouselKeyboardShortcut","Keyboard shortcut" }, + { IDC_CC_MOUSE,"ClipboardCarouselMouseWithModifiers","Mouse button + modifiers" }, + { IDC_CC_MODIFIERS_LABEL,"ClipboardCarouselMouseModifiers","Mouse modifiers (optional):" }, + { IDC_CC_CLEAR,"ClipboardCarouselClear","Clear" }, + { IDC_CC_SHIFT,"ClipboardCarouselModifierShift","Shift" }, + { IDC_CC_CONTROL,"ClipboardCarouselModifierCtrl","Ctrl" }, + { IDC_CC_ALT,"ClipboardCarouselModifierAlt","Alt" }, + { IDC_CC_PASTE_CLICK,"ClipboardCarouselPasteAtClick","Paste when clicking outside the carousel" }, + { IDC_CC_HELP,"ClipboardCarouselSlimNavigationHelp", + "Down: older; Up: newer (wheel or arrows). Enter: paste. Escape: cancel.\r\nClick inside to select text; Ctrl+C copies the selection without closing.\r\nAn outside left click pastes the selection or the complete clip.\r\nNavigation wraps at both ends. Disable outside-click paste to cancel instead.\r\nOpened from the desktop: click the desktop to save an image as PNG." } + }; + for(const auto& label : labels) + SetDlgItemText(label.id,theApp.m_Language.GetString(label.key,label.fallback)); + m_mouseButton.AddString(theApp.m_Language.GetString("ClipboardCarouselMiddleButton","Middle Button")); + m_mouseButton.AddString(theApp.m_Language.GetString("ClipboardCarouselXButton1","XButton1")); + m_mouseButton.AddString(theApp.m_Language.GetString("ClipboardCarouselXButton2","XButton2")); + m_initialBinding = ClipboardCarouselBinding::Decode(CGetSetOptions::GetCarouselActivation()); + m_initialEnabled = CGetSetOptions::GetCarouselEnabled() != FALSE; + CheckDlgButton(IDC_CC_ENABLE,m_initialEnabled ? BST_CHECKED : BST_UNCHECKED); + CheckDlgButton(IDC_CC_PASTE_CLICK,CGetSetOptions::GetCarouselPasteAtClick() ? BST_CHECKED : BST_UNCHECKED); + CheckRadioButton(IDC_CC_KEYBOARD,IDC_CC_MOUSE,m_initialBinding.IsMouse() ? IDC_CC_MOUSE : IDC_CC_KEYBOARD); + m_hotKey.SetHotKey(m_initialBinding.virtualKey,m_initialBinding.modifiers); + m_mouseButton.SetCurSel(m_initialBinding.IsMouse() ? (int)m_initialBinding.trigger - (int)ClipboardCarouselTrigger::MiddleButton : 0); + CheckDlgButton(IDC_CC_SHIFT,(m_initialBinding.modifiers & HOTKEYF_SHIFT) ? BST_CHECKED : BST_UNCHECKED); + CheckDlgButton(IDC_CC_CONTROL,(m_initialBinding.modifiers & HOTKEYF_CONTROL) ? BST_CHECKED : BST_UNCHECKED); + CheckDlgButton(IDC_CC_ALT,(m_initialBinding.modifiers & HOTKEYF_ALT) ? BST_CHECKED : BST_UNCHECKED); + SelectInput(!m_initialBinding.IsMouse()); + return TRUE; +} +void COptionsClipboardCarousel::OnKeyboard() { SelectInput(true); } +void COptionsClipboardCarousel::OnMouse() { SelectInput(false); } +void COptionsClipboardCarousel::SelectInput(bool keyboard) +{ + CheckRadioButton(IDC_CC_KEYBOARD,IDC_CC_MOUSE,keyboard ? IDC_CC_KEYBOARD : IDC_CC_MOUSE); + GetDlgItem(IDC_CC_HOTKEY)->ShowWindow(keyboard ? SW_SHOW : SW_HIDE); + GetDlgItem(IDC_CC_MOUSE_TRIGGER)->ShowWindow(keyboard ? SW_HIDE : SW_SHOW); + const int modifierControls[] = { IDC_CC_MODIFIERS_LABEL,IDC_CC_SHIFT,IDC_CC_CONTROL,IDC_CC_ALT }; + for(int id : modifierControls) GetDlgItem(id)->EnableWindow(!keyboard); +} +void COptionsClipboardCarousel::OnClear() +{ + m_hotKey.SetHotKey(0,0); + CheckDlgButton(IDC_CC_SHIFT,BST_UNCHECKED); + CheckDlgButton(IDC_CC_CONTROL,BST_UNCHECKED); + CheckDlgButton(IDC_CC_ALT,BST_UNCHECKED); + SelectInput(true); +} +ClipboardCarouselBinding COptionsClipboardCarousel::ReadBinding() +{ + ClipboardCarouselBinding binding; + if(IsDlgButtonChecked(IDC_CC_KEYBOARD) == BST_CHECKED) + { + WORD key = 0, modifiers = 0; + m_hotKey.GetHotKey(key,modifiers); + binding.virtualKey = (BYTE)key; + binding.modifiers = (BYTE)modifiers; + binding.modifiers &= HOTKEYF_SHIFT | HOTKEYF_CONTROL | HOTKEYF_ALT; + if(binding.virtualKey) binding.trigger = ClipboardCarouselTrigger::Keyboard; + } + else + { + binding.trigger = (ClipboardCarouselTrigger)((int)ClipboardCarouselTrigger::MiddleButton + max(0,m_mouseButton.GetCurSel())); + if(IsDlgButtonChecked(IDC_CC_SHIFT) == BST_CHECKED) binding.modifiers |= HOTKEYF_SHIFT; + if(IsDlgButtonChecked(IDC_CC_CONTROL) == BST_CHECKED) binding.modifiers |= HOTKEYF_CONTROL; + if(IsDlgButtonChecked(IDC_CC_ALT) == BST_CHECKED) binding.modifiers |= HOTKEYF_ALT; + } + return binding; +} +BOOL COptionsClipboardCarousel::OnApply() +{ + bool enabled = IsDlgButtonChecked(IDC_CC_ENABLE) == BST_CHECKED; + ClipboardCarouselBinding binding = ReadBinding(); + CString error; + if(!CClipboardCarouselInput::Validate(enabled,binding,error)) + { + MessageBox(error,m_title,MB_OK | MB_ICONWARNING); + return FALSE; + } + if(enabled && binding.IsMouse() && binding.modifiers == 0 && + (!m_initialEnabled || binding.Encode() != m_initialBinding.Encode())) + { + if(MessageBox(theApp.m_Language.GetString("ClipboardCarouselBareMouseWarning", + "This mouse button will replace its normal application action while Clipboard Carousel is enabled. Continue?"), + m_title,MB_YESNO | MB_ICONWARNING) != IDYES) + return FALSE; + } + CGetSetOptions::SetCarouselEnabled(enabled); + CGetSetOptions::SetCarouselActivation(binding.Encode()); + CGetSetOptions::SetCarouselPasteAtClick(IsDlgButtonChecked(IDC_CC_PASTE_CLICK) == BST_CHECKED); + m_initialEnabled = enabled; + m_initialBinding = binding; + return CPropertyPage::OnApply(); +} diff --git a/src/OptionsClipboardCarousel.h b/src/OptionsClipboardCarousel.h new file mode 100644 index 00000000..ceafea45 --- /dev/null +++ b/src/OptionsClipboardCarousel.h @@ -0,0 +1,27 @@ +#pragma once + +#include "ClipboardCarouselBinding.h" + +class COptionsClipboardCarousel : public CPropertyPage +{ + DECLARE_DYNAMIC(COptionsClipboardCarousel) +public: + COptionsClipboardCarousel(); + enum { IDD = IDD_OPTIONS_CLIPBOARD_CAROUSEL }; +protected: + virtual void DoDataExchange(CDataExchange* exchange); + virtual BOOL OnInitDialog(); + virtual BOOL OnApply(); + afx_msg void OnKeyboard(); + afx_msg void OnMouse(); + afx_msg void OnClear(); + DECLARE_MESSAGE_MAP() +private: + ClipboardCarouselBinding ReadBinding(); + void SelectInput(bool keyboard); + CString m_title; + CHotKeyCtrl m_hotKey; + CComboBox m_mouseButton; + ClipboardCarouselBinding m_initialBinding; + bool m_initialEnabled; +}; diff --git a/src/OptionsSheet.cpp b/src/OptionsSheet.cpp index 1c1a97e6..7a0cf4af 100644 --- a/src/OptionsSheet.cpp +++ b/src/OptionsSheet.cpp @@ -14,6 +14,7 @@ #include "OptionsCopyBuffers.h" #include "Misc.h" #include "QuickPasteKeyboard.h" +#include "OptionsClipboardCarousel.h" #ifdef _DEBUG #define new DEBUG_NEW @@ -41,6 +42,7 @@ COptionsSheet::COptionsSheet(LPCTSTR pszCaption, CWnd* pParentWnd, UINT iSelectP m_pFriends = NULL; m_pCopyBuffers = NULL; m_pQuickPasteShortCuts = NULL; + m_pClipboardCarousel = NULL; m_pUtilites = NULL; m_hWndParent = NULL; @@ -51,6 +53,7 @@ COptionsSheet::COptionsSheet(LPCTSTR pszCaption, CWnd* pParentWnd, UINT iSelectP m_pKeyBoardOptions = new COptionsKeyBoard; //m_pQuickPasteOptions = new COptionsQuickPaste; m_pQuickPasteShortCuts = new CQuickPasteKeyboard; + m_pClipboardCarousel = new COptionsClipboardCarousel; m_pCopyBuffers = new COptionsCopyBuffers; m_pStats = new COptionsStats; @@ -63,6 +66,7 @@ COptionsSheet::COptionsSheet(LPCTSTR pszCaption, CWnd* pParentWnd, UINT iSelectP AddPage(m_pCopyBuffers); //AddPage(m_pQuickPasteOptions); AddPage(m_pQuickPasteShortCuts); + AddPage(m_pClipboardCarousel); if(CGetSetOptions::GetAllowFriends()) { m_pFriends = new COptionFriends; @@ -86,6 +90,7 @@ COptionsSheet::~COptionsSheet() delete m_pFriends; delete m_pUtilites; delete m_pQuickPasteShortCuts; + delete m_pClipboardCarousel; } BEGIN_MESSAGE_MAP(COptionsSheet, CPropertySheet) diff --git a/src/OptionsSheet.h b/src/OptionsSheet.h index a5cd5bc9..9c5adb42 100644 --- a/src/OptionsSheet.h +++ b/src/OptionsSheet.h @@ -56,6 +56,7 @@ class COptionsSheet : public CPropertySheet CPropertyPage *m_pFriends; CPropertyPage *m_pCopyBuffers; CPropertyPage *m_pQuickPasteShortCuts; + CPropertyPage *m_pClipboardCarousel; //CShowTaskBarIcon m_taskBar; diff --git a/src/SendKeys.cpp b/src/SendKeys.cpp index 45320481..b7379bd9 100644 --- a/src/SendKeys.cpp +++ b/src/SendKeys.cpp @@ -256,8 +256,12 @@ void CSendKeys::AllKeysUp() SendKeyUp(VK_RSHIFT); SendKeyUp(VK_LCONTROL); SendKeyUp(VK_RCONTROL); - SendKeyUp(VK_LMENU); - SendKeyUp(VK_RMENU); + // An unpaired Alt key-up activates access-key hints in applications such as + // Windows 11 Notepad. Only release Alt keys that are actually held. + if(GetAsyncKeyState(VK_LMENU) & 0x8000) + SendKeyUp(VK_LMENU); + if(GetAsyncKeyState(VK_RMENU) & 0x8000) + SendKeyUp(VK_RMENU); } // Generates KEYUP