// PreviewTipWnd.cpp #include "stdafx.h" #include "PreviewTipWnd.h" #include "resource.h" // IDI_ICON_SNAPSHOT(循环模式标题栏图标) #include // IUnknown / IStream — gdiplus.h 依赖它们已声明 #include #pragma comment(lib, "gdiplus.lib") using namespace Gdiplus; namespace { constexpr int PADDING = 8; constexpr int IMAGE_TEXT_GAP = 6; // 图像与下方文本之间的留白 constexpr int MIN_TEXT_W = 360; constexpr int MAX_TEXT_W = 720; // 文本可换行宽度上限(垂直布局,宽度不再受图像挤压) constexpr int MAX_IMAGE_W = 960; // 显示上限(防止 LAN 档 1280 的 JPEG 把窗口顶到屏幕外) constexpr int LOADING_W = 480; // 占位的最小宽度(图像未到达时) constexpr int LOADING_H = 270; } // namespace BEGIN_MESSAGE_MAP(CPreviewTipWnd, CWnd) ON_WM_PAINT() ON_WM_ERASEBKGND() ON_WM_DESTROY() ON_WM_SIZE() ON_WM_GETMINMAXINFO() END_MESSAGE_MAP() CPreviewTipWnd::CPreviewTipWnd() = default; CPreviewTipWnd::~CPreviewTipWnd() { // m_image 通过 unique_ptr 自动释放 if (m_hIconSmall) { ::DestroyIcon(m_hIconSmall); m_hIconSmall = nullptr; } if (m_hIconBig) { ::DestroyIcon(m_hIconBig); m_hIconBig = nullptr; } } BOOL CPreviewTipWnd::Create(CWnd* pParent, CPoint anchor, const CStringW& text, int imageReserveW, bool loopMode, const CStringW& windowTitle) { m_text = text; m_loopMode = loopMode; // 注意:影响后续 SetImageFromJpeg / OnPaint / OnSize 行为 m_imageReserveW = imageReserveW > 0 ? min(imageReserveW, MAX_IMAGE_W) : 0; m_imageReserveH = m_imageReserveW > 0 ? (m_imageReserveW * 9 / 16) : 0; if (m_imageReserveW > 0 && m_imageReserveW < LOADING_W) { m_imageReserveW = LOADING_W; m_imageReserveH = LOADING_H; } // 创建字体(项目是 MBCS,但浮窗用宽字符;显式 LOGFONTW + W 版 API 避免编码混淆) LOGFONTW lf = {}; HFONT hSysFont = (HFONT)::GetStockObject(DEFAULT_GUI_FONT); if (hSysFont && ::GetObjectW(hSysFont, sizeof(lf), &lf) == 0) { hSysFont = nullptr; } if (!hSysFont) { lf.lfHeight = -12; wcscpy_s(lf.lfFaceName, L"Microsoft YaHei"); } HFONT hF = ::CreateFontIndirectW(&lf); if (hF) m_font.Attach(hF); // 窗口类:单发 tooltip 走 CS_SAVEBITS(短时显示,省 BitBlt), // 循环窗口是长存窗口,加 CS_HREDRAW|CS_VREDRAW 让拉伸时实时重绘 UINT classStyle = loopMode ? (CS_HREDRAW | CS_VREDRAW) : CS_SAVEBITS; LPCTSTR kClass = AfxRegisterWndClass( classStyle, ::LoadCursor(NULL, IDC_ARROW), (HBRUSH)(COLOR_BTNFACE + 1), NULL); // 临时尺寸;RecalcLayoutAndResize 会在创建后调整 CRect rc(anchor.x, anchor.y, anchor.x + 400, anchor.y + 200); // 样式选择: // 单发 tooltip —— WS_POPUP|WS_BORDER + EX_TOPMOST|TOOLWINDOW|NOACTIVATE // (不激活、不上任务栏、置顶;光标移开会被主对话框关掉) // 循环监视窗口 —— WS_OVERLAPPEDWINDOW (标题栏 / 系统菜单 / 最大化 / 最小化 / 可调整边框) // + WS_EX_APPWINDOW(强制独立任务栏图标,便于多窗口切换) DWORD style = loopMode ? (WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN) : (WS_POPUP | WS_BORDER); DWORD styleEx = loopMode ? WS_EX_APPWINDOW : (WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE); // 单发模式下窗口文本无意义(无标题栏可显示);循环模式下用宽字符 SetWindowTextW 显式设。 BOOL bOk = CWnd::CreateEx(styleEx, kClass, _T(""), style, rc, pParent, 0); if (!bOk) return FALSE; if (loopMode && !windowTitle.IsEmpty()) { ::SetWindowTextW(GetSafeHwnd(), windowTitle); } // 循环模式:给窗口装上眼睛图标(标题栏 + 任务栏 + Alt-Tab)。 // ICO 资源里包含 16x16 与 32x32 两份,ICON_SMALL/ICON_BIG 各取最佳尺寸。 // HICON 由本类持有,析构时 DestroyIcon(WM_SETICON 不转移所有权)。 if (loopMode) { m_hIconSmall = (HICON)::LoadImage(AfxGetInstanceHandle(), MAKEINTRESOURCE(IDI_ICON_SNAPSHOT), IMAGE_ICON, ::GetSystemMetrics(SM_CXSMICON), ::GetSystemMetrics(SM_CYSMICON), LR_DEFAULTCOLOR); m_hIconBig = (HICON)::LoadImage(AfxGetInstanceHandle(), MAKEINTRESOURCE(IDI_ICON_SNAPSHOT), IMAGE_ICON, ::GetSystemMetrics(SM_CXICON), ::GetSystemMetrics(SM_CYICON), LR_DEFAULTCOLOR); if (m_hIconSmall) SendMessage(WM_SETICON, ICON_SMALL, (LPARAM)m_hIconSmall); if (m_hIconBig) SendMessage(WM_SETICON, ICON_BIG, (LPARAM)m_hIconBig); } RecalcLayoutAndResize(); // 单发:不激活;循环:正常显示,允许接收焦点(系统菜单 / 拖拽 / 最小化都需要可激活) ShowWindow(loopMode ? SW_SHOWNORMAL : SW_SHOWNOACTIVATE); if (loopMode) { // 任务栏图标 + 标题在多窗口情况下立刻可见 UpdateWindow(); } return TRUE; } void CPreviewTipWnd::SetImageFromJpeg(const BYTE* data, size_t bytes) { if (!data || bytes == 0) return; HGLOBAL hMem = ::GlobalAlloc(GMEM_MOVEABLE, bytes); if (!hMem) return; void* p = ::GlobalLock(hMem); if (!p) { ::GlobalFree(hMem); return; } memcpy(p, data, bytes); ::GlobalUnlock(hMem); IStream* stream = nullptr; if (FAILED(::CreateStreamOnHGlobal(hMem, TRUE, &stream))) { ::GlobalFree(hMem); return; } std::unique_ptr bmp(new Bitmap(stream, FALSE)); stream->Release(); // CreateStreamOnHGlobal(..., TRUE) 释放 stream 时会释放 hMem if (!bmp || bmp->GetLastStatus() != Ok) { return; } m_image = std::move(bmp); m_hasImage = true; m_unavailable = false; // 循环模式下窗口尺寸交给用户控制(OnSize / WS_THICKFRAME),后续帧不再自动重排版, // 只 Invalidate 触发重绘;图像在 OnPaint 中按 client rect 等比例适配。 if (!m_loopMode) { RecalcLayoutAndResize(); } if (GetSafeHwnd()) Invalidate(); } void CPreviewTipWnd::MarkPreviewUnavailable() { if (m_hasImage) return; // 已经有图就不再覆盖 m_unavailable = true; if (GetSafeHwnd()) Invalidate(); } void CPreviewTipWnd::SetLoopOwner(HWND ownerHwnd, UINT msgId, uint64_t clientId) { // 仅更新回调订阅;m_loopMode 由 Create() 一次性设定,不在这里改 —— 否则 // CloseLoopTip 用 SetLoopOwner(NULL,0,0) 解订阅时会顺手把 loopMode 翻成 false, // 导致 PostNcDestroy 跳过 delete this → 每关一次循环窗口泄漏一个对象。 m_loopOwner = ownerHwnd; m_loopMsg = msgId; m_clientId = clientId; } void CPreviewTipWnd::OnDestroy() { // 在窗口实际销毁前回告 owner(仅循环模式)。owner 据此从映射表擦掉本条目。 // 注意:把字段先置零,避免极端情况下被 PostNcDestroy 再触发一次。 if (m_loopMode && m_loopOwner && ::IsWindow(m_loopOwner) && m_loopMsg != 0) { HWND owner = m_loopOwner; UINT msg = m_loopMsg; m_loopOwner = nullptr; m_loopMsg = 0; // 64 位 clientId 用 LPARAM 指针传递;WPARAM 在 32 位 Windows 不够宽。 auto* pId = new uint64_t(m_clientId); if (!::PostMessageA(owner, msg, 0, (LPARAM)pId)) { delete pId; } } CWnd::OnDestroy(); } void CPreviewTipWnd::PostNcDestroy() { // 仅循环模式自管理生命周期;单次提示窗口由主对话框 SAFE_DELETE 释放。 bool selfDelete = m_loopMode; CWnd::PostNcDestroy(); if (selfDelete) { delete this; } } void CPreviewTipWnd::RecalcLayoutAndResize() { HWND hWnd = GetSafeHwnd(); if (!hWnd) return; // 估算图像尺寸(先算图像,因为文本宽度要参考图像宽度对齐) if (m_image) { int iw = (int)m_image->GetWidth(); int ih = (int)m_image->GetHeight(); if (iw > MAX_IMAGE_W) { ih = (int)((double)ih * MAX_IMAGE_W / iw + 0.5); iw = MAX_IMAGE_W; } m_imageDrawW = iw; m_imageDrawH = ih; } else { m_imageDrawW = m_imageReserveW; m_imageDrawH = m_imageReserveH; } // 文本换行宽度:与图像同宽(让文本视觉上对齐到图像下方),但不超过 MAX_TEXT_W // 没有图像时退化到 MAX_TEXT_W int textWrapW = m_imageDrawW > 0 ? min((int)MAX_TEXT_W, m_imageDrawW) : (int)MAX_TEXT_W; if (textWrapW < MIN_TEXT_W) textWrapW = MIN_TEXT_W; // 估算文本尺寸(项目是 MBCS,但浮窗文本是宽字符,必须显式调用 W 版本) CClientDC dc(this); CFont* old = dc.SelectObject(&m_font); CRect rcText(0, 0, textWrapW, 32767); ::DrawTextW(dc.GetSafeHdc(), m_text, m_text.GetLength(), &rcText, DT_CALCRECT | DT_LEFT | DT_WORDBREAK | DT_NOPREFIX); m_textW = max((int)MIN_TEXT_W, rcText.Width()); if (m_imageDrawW > 0) m_textW = max(m_textW, m_imageDrawW); // 至少与图像同宽 m_textH = rcText.Height(); dc.SelectObject(old); int contentW = max(m_imageDrawW, m_textW); int gap = (m_imageDrawW > 0 && m_textH > 0) ? IMAGE_TEXT_GAP : 0; int totalW = PADDING + contentW + PADDING; int totalH = PADDING + m_imageDrawH + gap + m_textH + PADDING; // 当前左上角 CRect rc; GetWindowRect(&rc); int newX = rc.left; int newY = rc.top; // 防止越出屏幕:右下夹紧到工作区 HMONITOR hMon = MonitorFromPoint(CPoint(newX, newY), MONITOR_DEFAULTTONEAREST); MONITORINFO mi = { sizeof(mi) }; if (hMon && GetMonitorInfo(hMon, &mi)) { if (newX + totalW > mi.rcWork.right) newX = max((int)mi.rcWork.left, mi.rcWork.right - totalW); if (newY + totalH > mi.rcWork.bottom) newY = max((int)mi.rcWork.top, mi.rcWork.bottom - totalH); } SetWindowPos(NULL, newX, newY, totalW, totalH, SWP_NOZORDER | SWP_NOACTIVATE); } BOOL CPreviewTipWnd::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 在 OnPaint 里整体填充 } void CPreviewTipWnd::OnPaint() { CPaintDC pdc(this); CRect rcClient; GetClientRect(&rcClient); if (rcClient.IsRectEmpty()) return; // 双缓冲 CDC memDC; memDC.CreateCompatibleDC(&pdc); CBitmap memBmp; memBmp.CreateCompatibleBitmap(&pdc, rcClient.Width(), rcClient.Height()); CBitmap* oldBmp = memDC.SelectObject(&memBmp); // 单发 tooltip 用 COLOR_INFOBK(系统提示底色);循环窗口用 COLOR_BTNFACE(普通窗口背景) COLORREF bg = m_loopMode ? ::GetSysColor(COLOR_BTNFACE) : ::GetSysColor(COLOR_INFOBK); memDC.FillSolidRect(&rcClient, bg); CFont* oldFont = memDC.SelectObject(&m_font); memDC.SetTextColor(::GetSysColor(m_loopMode ? COLOR_WINDOWTEXT : COLOR_INFOTEXT)); memDC.SetBkMode(TRANSPARENT); if (m_loopMode) { // 循环模式:基于 client rect 动态分配;图像区按 client 大小自适应,文本固定在底部 CRect rcImg, rcText; LayoutForLoopMode(rcClient, rcImg, rcText); if (!rcImg.IsRectEmpty()) DrawImageArea(memDC, rcImg); if (!rcText.IsRectEmpty()) DrawTextArea(memDC, rcText); } else { // 单发 tooltip:保持原行为,按 RecalcLayoutAndResize 算好的固定尺寸排版 int curY = PADDING; if (m_imageDrawW > 0 && m_imageDrawH > 0) { CRect rcImg(PADDING, curY, PADDING + m_imageDrawW, curY + m_imageDrawH); DrawImageArea(memDC, rcImg); curY += m_imageDrawH + IMAGE_TEXT_GAP; } CRect rcText(PADDING, curY, PADDING + m_textW, curY + m_textH); DrawTextArea(memDC, rcText); } memDC.SelectObject(oldFont); pdc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(oldBmp); } void CPreviewTipWnd::LayoutForLoopMode(const CRect& client, CRect& outImg, CRect& outText) { outImg.SetRectEmpty(); outText.SetRectEmpty(); int innerW = client.Width() - 2 * PADDING; if (innerW < 50) return; // 太窄,啥也别画,避免计算崩溃 // 计算文本以当前可用宽度换行后的高度(用一份临时 DC) int textH = 0; { CClientDC dc(this); CFont* old = dc.SelectObject(&m_font); CRect rcCalc(0, 0, innerW, 32767); ::DrawTextW(dc.GetSafeHdc(), m_text, m_text.GetLength(), &rcCalc, DT_CALCRECT | DT_LEFT | DT_WORDBREAK | DT_NOPREFIX); textH = rcCalc.Height(); dc.SelectObject(old); } // 文本固定在底部 outText.SetRect(client.left + PADDING, client.bottom - PADDING - textH, client.right - PADDING, client.bottom - PADDING); // 图像区占顶部剩余空间 outImg.SetRect(client.left + PADDING, client.top + PADDING, client.right - PADDING, outText.top - IMAGE_TEXT_GAP); // 高度不足以放图像(被压扁)→ 只画文本 if (outImg.Height() < 60) { outImg.SetRectEmpty(); // 文本提到中间,避免压在底部边缘 outText.MoveToY(client.top + PADDING); } } void CPreviewTipWnd::DrawImageArea(CDC& dc, const CRect& rc) { if (rc.IsRectEmpty()) return; // 边框 dc.Draw3dRect(&rc, ::GetSysColor(COLOR_3DSHADOW), ::GetSysColor(COLOR_3DSHADOW)); CRect rcInner = rc; rcInner.DeflateRect(1, 1); if (m_hasImage && m_image) { if (m_loopMode) { // 循环模式:保留长宽比适配 rect,黑色背景充当 letterbox 留白 dc.FillSolidRect(&rcInner, RGB(32, 32, 32)); double imgW = (double)m_image->GetWidth(); double imgH = (double)m_image->GetHeight(); if (imgW > 0 && imgH > 0 && rcInner.Width() > 0 && rcInner.Height() > 0) { double sx = rcInner.Width() / imgW; double sy = rcInner.Height() / imgH; double scale = sx < sy ? sx : sy; int drawW = (int)(imgW * scale + 0.5); int drawH = (int)(imgH * scale + 0.5); int x = rcInner.left + (rcInner.Width() - drawW) / 2; int y = rcInner.top + (rcInner.Height() - drawH) / 2; Graphics g(dc.GetSafeHdc()); g.SetInterpolationMode(InterpolationModeHighQualityBicubic); g.SetSmoothingMode(SmoothingModeHighQuality); g.DrawImage(m_image.get(), x, y, drawW, drawH); } } else { // 单发:与原行为一致,铺满 rc Graphics g(dc.GetSafeHdc()); g.SetInterpolationMode(InterpolationModeHighQualityBicubic); g.SetSmoothingMode(SmoothingModeHighQuality); g.DrawImage(m_image.get(), rc.left + 1, rc.top + 1, rc.Width() - 2, rc.Height() - 2); } } else { // 占位灰色背景 dc.FillSolidRect(&rcInner, RGB(245, 245, 245)); const wchar_t* placeholder = m_unavailable ? L"Preview Unavailable" : L"Loading Preview ..."; UINT fmt = DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX; dc.SetTextColor(m_unavailable ? RGB(160, 80, 80) : RGB(120, 120, 120)); RECT rcInnerRaw = rcInner; ::DrawTextW(dc.GetSafeHdc(), placeholder, -1, &rcInnerRaw, fmt); dc.SetTextColor(::GetSysColor(m_loopMode ? COLOR_WINDOWTEXT : COLOR_INFOTEXT)); } } void CPreviewTipWnd::OnSize(UINT nType, int cx, int cy) { CWnd::OnSize(nType, cx, cy); if (m_loopMode && GetSafeHwnd()) { // 循环模式:所有排版交给 OnPaint 读取 client rect,重画即可 Invalidate(); } } void CPreviewTipWnd::OnGetMinMaxInfo(MINMAXINFO* lpMMI) { if (m_loopMode && lpMMI) { // 防止用户把窗口拽到肉眼不可见。240x200 大致能保证标题栏 + 一行文本 + 缩略图可见。 lpMMI->ptMinTrackSize.x = 240; lpMMI->ptMinTrackSize.y = 200; } CWnd::OnGetMinMaxInfo(lpMMI); } void CPreviewTipWnd::DrawTextArea(CDC& dc, const CRect& rc) { RECT r = rc; ::DrawTextW(dc.GetSafeHdc(), m_text, m_text.GetLength(), &r, DT_LEFT | DT_TOP | DT_WORDBREAK | DT_NOPREFIX); }