Skip to content

Commit 4279b1a

Browse files
fix(widget): 修正声明式 FA 图标字形居中 / Center declarative FA icon glyphs
中文: - 声明式 icon 与 iconButton 仅按 DirectWrite 字形墨迹边界居中,移除重复的文字行框垂直偏移 - 为番茄钟暂停态真实预览增加圆形按钮与 FA 播放字形的像素中心回归断言 English: - Center declarative icon and iconButton content from DirectWrite glyph ink bounds without the duplicate text-line vertical offset - Add a real paused Pomodoro preview regression that compares the circular button and FA play-glyph pixel centers 验证 / Validation: - scripts/build.bat --reload-shell(Release 构建通过;仅有既有 C4456 警告 / Release build passed; existing C4456 warning only) - scripts/test.bat(74/74 通过 / 74/74 passed) - snowwidget lint widgets/pomodoro(0 错误,0 警告 / 0 errors, 0 warnings) - snowwidget validate widgets/pomodoro(通过 / passed) - 2x2 96 DPI 深色预览中心偏差 (0, 0 px),3x3 144 DPI 浅色预览偏差 (0, -0.5 px) / 2x2 96 DPI dark preview delta (0, 0 px), 3x3 144 DPI light preview delta (0, -0.5 px)
1 parent 89bebf0 commit 4279b1a

3 files changed

Lines changed: 190 additions & 2 deletions

File tree

‎CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,6 +1138,7 @@ if (BUILD_TESTING)
11381138
SnowDesktopWidgetAuthorPreviewCliTests
11391139
widget_author_preview_cli
11401140
SOURCES tests/widget_author_preview_cli_tests.cpp
1141+
LINK_LIBRARIES ole32 windowscodecs
11411142
COMMAND_ARGUMENTS
11421143
"$<TARGET_FILE:snowwidget>"
11431144
"$<TARGET_FILE:SnowDesktop>"

‎src/widget_engine.cpp‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19627,10 +19627,15 @@ static void DrawWidgetViewNode(D2DState* state,
1962719627
const float contentHeight = SUCCEEDED(
1962819628
layout->GetMetrics(&metrics))
1962919629
? std::min(layoutHeight, metrics.height) : layoutHeight;
19630+
// Icon ink is centered against the full layout box below.
19631+
// Applying the text metrics offset first would center the line
19632+
// box and then center the glyph a second time.
19633+
const float verticalOffset = iconNode ? 0.0f :
19634+
ViewTextVerticalOffset(
19635+
node, textHeight, contentHeight);
1963019636
D2D1_POINT_2F origin = D2D1::Point2F(
1963119637
textLeft, state->widgetRect.top + content.y +
19632-
ViewTextVerticalOffset(
19633-
node, textHeight, contentHeight));
19638+
verticalOffset);
1963419639
if (iconNode)
1963519640
{
1963619641
DWRITE_OVERHANG_METRICS overhang{};

‎tests/widget_author_preview_cli_tests.cpp‎

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
#include <windows.h>
2+
#include <wincodec.h>
3+
#include <wrl/client.h>
24

5+
#include <algorithm>
36
#include <array>
7+
#include <cmath>
8+
#include <cstdint>
49
#include <cstdlib>
510
#include <filesystem>
611
#include <fstream>
712
#include <iostream>
13+
#include <limits>
814
#include <string>
915
#include <vector>
1016

@@ -110,6 +116,167 @@ void CheckPng(const std::filesystem::path& path)
110116
"real renderer writes nontrivial preview pixels");
111117
}
112118

119+
struct PixelBounds
120+
{
121+
int left = std::numeric_limits<int>::max();
122+
int top = std::numeric_limits<int>::max();
123+
int right = -1;
124+
int bottom = -1;
125+
std::size_t count = 0;
126+
127+
void Include(int x, int y)
128+
{
129+
left = std::min(left, x);
130+
top = std::min(top, y);
131+
right = std::max(right, x);
132+
bottom = std::max(bottom, y);
133+
++count;
134+
}
135+
136+
bool Empty() const noexcept { return count == 0; }
137+
};
138+
139+
struct RgbaBitmap
140+
{
141+
UINT width = 0;
142+
UINT height = 0;
143+
std::vector<std::uint8_t> pixels;
144+
};
145+
146+
RgbaBitmap ReadPng(const std::filesystem::path& path)
147+
{
148+
using Microsoft::WRL::ComPtr;
149+
ComPtr<IWICImagingFactory> factory;
150+
Check(SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr,
151+
CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&factory))),
152+
"WIC factory is created");
153+
ComPtr<IWICBitmapDecoder> decoder;
154+
Check(SUCCEEDED(factory->CreateDecoderFromFilename(path.c_str(), nullptr,
155+
GENERIC_READ, WICDecodeMetadataCacheOnLoad, &decoder)),
156+
"preview PNG decoder is created");
157+
ComPtr<IWICBitmapFrameDecode> frame;
158+
Check(SUCCEEDED(decoder->GetFrame(0, &frame)),
159+
"preview PNG frame is available");
160+
ComPtr<IWICFormatConverter> converter;
161+
Check(SUCCEEDED(factory->CreateFormatConverter(&converter)) &&
162+
SUCCEEDED(converter->Initialize(frame.Get(),
163+
GUID_WICPixelFormat32bppRGBA, WICBitmapDitherTypeNone,
164+
nullptr, 0.0, WICBitmapPaletteTypeCustom)),
165+
"preview PNG converts to RGBA pixels");
166+
167+
RgbaBitmap bitmap;
168+
Check(SUCCEEDED(converter->GetSize(&bitmap.width, &bitmap.height)) &&
169+
bitmap.width > 0 && bitmap.height > 0,
170+
"preview PNG has nonzero dimensions");
171+
const UINT stride = bitmap.width * 4;
172+
bitmap.pixels.resize(static_cast<std::size_t>(stride) * bitmap.height);
173+
Check(SUCCEEDED(converter->CopyPixels(nullptr, stride,
174+
static_cast<UINT>(bitmap.pixels.size()),
175+
bitmap.pixels.data())),
176+
"preview PNG pixels are decoded");
177+
return bitmap;
178+
}
179+
180+
void CheckPomodoroPlayGlyphCentered(const std::filesystem::path& path)
181+
{
182+
const RgbaBitmap bitmap = ReadPng(path);
183+
const auto isTomato = [&](UINT x, UINT y) {
184+
const std::size_t offset =
185+
(static_cast<std::size_t>(y) * bitmap.width + x) * 4;
186+
const int red = bitmap.pixels[offset];
187+
const int green = bitmap.pixels[offset + 1];
188+
const int blue = bitmap.pixels[offset + 2];
189+
const int alpha = bitmap.pixels[offset + 3];
190+
return alpha > 0 && red > 140 && red > green + 35 &&
191+
red > blue + 30;
192+
};
193+
194+
std::vector<std::uint8_t> candidates(
195+
static_cast<std::size_t>(bitmap.width) * bitmap.height, 0);
196+
for (UINT y = 0; y < bitmap.height; ++y)
197+
{
198+
for (UINT x = 0; x < bitmap.width; ++x)
199+
{
200+
if (isTomato(x, y))
201+
candidates[static_cast<std::size_t>(y) * bitmap.width + x] = 1;
202+
}
203+
}
204+
205+
PixelBounds playButton;
206+
std::vector<std::size_t> pending;
207+
for (UINT y = 0; y < bitmap.height; ++y)
208+
{
209+
for (UINT x = 0; x < bitmap.width; ++x)
210+
{
211+
const std::size_t start =
212+
static_cast<std::size_t>(y) * bitmap.width + x;
213+
if (!candidates[start]) continue;
214+
candidates[start] = 0;
215+
pending.assign(1, start);
216+
PixelBounds component;
217+
while (!pending.empty())
218+
{
219+
const std::size_t index = pending.back();
220+
pending.pop_back();
221+
const UINT currentX =
222+
static_cast<UINT>(index % bitmap.width);
223+
const UINT currentY =
224+
static_cast<UINT>(index / bitmap.width);
225+
component.Include(static_cast<int>(currentX),
226+
static_cast<int>(currentY));
227+
const auto Visit = [&](UINT nextX, UINT nextY) {
228+
const std::size_t next =
229+
static_cast<std::size_t>(nextY) * bitmap.width + nextX;
230+
if (!candidates[next]) return;
231+
candidates[next] = 0;
232+
pending.push_back(next);
233+
};
234+
if (currentX > 0) Visit(currentX - 1, currentY);
235+
if (currentX + 1 < bitmap.width)
236+
Visit(currentX + 1, currentY);
237+
if (currentY > 0) Visit(currentX, currentY - 1);
238+
if (currentY + 1 < bitmap.height)
239+
Visit(currentX, currentY + 1);
240+
}
241+
if (component.count > playButton.count &&
242+
component.bottom > static_cast<int>(bitmap.height * 3 / 4) &&
243+
component.right < static_cast<int>(bitmap.width / 2))
244+
{
245+
playButton = component;
246+
}
247+
}
248+
}
249+
Check(playButton.count > 100,
250+
"Pomodoro play button surface is found in the preview");
251+
252+
PixelBounds glyph;
253+
for (int y = playButton.top; y <= playButton.bottom; ++y)
254+
{
255+
for (int x = playButton.left; x <= playButton.right; ++x)
256+
{
257+
const std::size_t offset =
258+
(static_cast<std::size_t>(y) * bitmap.width + x) * 4;
259+
const int red = bitmap.pixels[offset];
260+
const int green = bitmap.pixels[offset + 1];
261+
const int blue = bitmap.pixels[offset + 2];
262+
const int alpha = bitmap.pixels[offset + 3];
263+
const int darkest = std::min({ red, green, blue });
264+
const int lightest = std::max({ red, green, blue });
265+
if (alpha > 0 && darkest >= 200 && lightest - darkest <= 35)
266+
glyph.Include(x, y);
267+
}
268+
}
269+
Check(glyph.count > 20,
270+
"Pomodoro Font Awesome play glyph is found in the preview");
271+
const double buttonCenterX = (playButton.left + playButton.right) * 0.5;
272+
const double buttonCenterY = (playButton.top + playButton.bottom) * 0.5;
273+
const double glyphCenterX = (glyph.left + glyph.right) * 0.5;
274+
const double glyphCenterY = (glyph.top + glyph.bottom) * 0.5;
275+
Check(std::abs(glyphCenterX - buttonCenterX) <= 2.0 &&
276+
std::abs(glyphCenterY - buttonCenterY) <= 2.0,
277+
"Pomodoro Font Awesome play glyph is visually centered");
278+
}
279+
113280
void Write(const std::filesystem::path& path, std::string_view text)
114281
{
115282
std::ofstream output(path, std::ios::binary | std::ios::trunc);
@@ -194,6 +361,8 @@ return widget.define({
194361

195362
int wmain(int argc, wchar_t** argv)
196363
{
364+
Check(SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)),
365+
"COM initializes for preview pixel decoding");
197366
Check(argc == 4,
198367
"test receives snowwidget, SnowDesktop, and repository root");
199368
const std::filesystem::path snowwidget = argv[1];
@@ -220,6 +389,18 @@ int wmain(int argc, wchar_t** argv)
220389
"snowwidget preview reports a completed real API v2 render");
221390
CheckPng(output);
222391

392+
const auto pomodoroOutput = temporary.path / L"pomodoro.png";
393+
const auto pomodoroSource = repository / L"widgets" / L"pomodoro";
394+
const auto [pomodoroExit, pomodoroJson] = Run(snowwidget, {
395+
L"preview", pomodoroSource.wstring(), pomodoroOutput.wstring(),
396+
L"--dpi", L"96", L"--locale", L"zh-CN", L"--theme", L"dark",
397+
L"--host", host.wstring() });
398+
Check(pomodoroExit == 0 &&
399+
pomodoroJson.find("\"ok\":true") != std::string::npos,
400+
"Pomodoro paused-state preview renders successfully");
401+
CheckPng(pomodoroOutput);
402+
CheckPomodoroPlayGlyphCentered(pomodoroOutput);
403+
223404
const auto environmentSource =
224405
CreateEnvironmentFixture(temporary.path);
225406
const auto environmentOutput = temporary.path / L"environment.png";
@@ -260,5 +441,6 @@ int wmain(int argc, wchar_t** argv)
260441
"preview rejects an unknown deterministic data state");
261442

262443
std::cout << "widget author preview CLI tests passed\n";
444+
CoUninitialize();
263445
return 0;
264446
}

0 commit comments

Comments
 (0)