feat(server): 新增仿微软拼音的内置皮肤 microsoft

- 候选窗:深色 #2C2C2C 底、#383838 选中底、#E183D9 紫色选中条,序号与候选同字号,
  横排每项最小 4.35em、间距 0.28em(换行的行间同样隔 0.28em);尺寸均按字号缩放
- 翻页:默认开启,实心圆角三角,与候选行等高,横排前加分隔线
- 悬浮工具栏沿用默认页面,拖拽条改为紫色
- ui:PagerArrows 新增 Glyph::Triangle 与分隔线,三角用单一路径填充,半透明时不出现描边重叠;
  CandidateList 新增 minItemWidth 与 selectedBarInside
- 设置页新增皮肤卡片与预览;更新内置皮肤列表、文档与配置注释,补充测试
This commit is contained in:
fanlusky committed 2026-09-30 17:35:38 +08:00
1 parent 9b6c369a29
commit ec00892121
29 files changed
+1270 -35

No files matched your search

+2 -2
View File
@@ -77,7 +77,7 @@ Windows 端的全部一方源码都在本仓,一次 clone 就能拿到完整
- 输入统计(默认关闭):只在本机记录字符数量与活跃时长,可在设置 → 统计中查看与清理。
- 快捷短语(K,可选混入候选)、日期时间(T)、Unicode(U)、Emoji(E)、颜文字(M)、超级简拼(J)、临时英文(Y)、临时日语(R)。
- 智能标点(中文标点后按空格转英文标点,可撤回)、成对标点补全、以词定字、简繁转换。
- 多种皮肤:Fluent、微信绿、石墨 Graphite、杨柳青 Willow green、秋桂 Autumn osmanthus,均支持深色 / 浅色。
- 多种皮肤:Fluent、微信绿、石墨 Graphite、杨柳青 Willow green、秋桂 Autumn osmanthus、微软 Microsoft,均支持深色 / 浅色。
## 核心功能指南
@@ -276,7 +276,7 @@ ASR 提供商可选豆包(流式)、OpenAI、SiliconFlow、Groq。豆包支
- **手写识别板**:鼠标或触控书写后显示候选汉字。若没有结果,请确认 Windows 已安装中文手写包。
- **屏幕键盘**:用鼠标或触控输入文字和快捷按键。
- **悬浮工具栏**:可调整缩放和图标尺寸;中英文切换始终显示,其余组件(全角、标点、简繁、表情、屏幕键盘、设置)可按需勾选。
- **皮肤**:Fluent 默认、微信绿、石墨 Graphite、杨柳青 Willow green、秋桂 Autumn osmanthus。候选窗、工具栏等界面可分别跟随全局主题,或单独指定深色 / 浅色。
- **皮肤**:Fluent 默认、微信绿、石墨 Graphite、杨柳青 Willow green、秋桂 Autumn osmanthus、微软 Microsoft。候选窗、工具栏等界面可分别跟随全局主题,或单独指定深色 / 浅色。
### 词库
+1 -1
View File
@@ -6,7 +6,7 @@
## 顶层字段
- 必填:`schema_version`、`id`、`name`、`version`、`base`(继承的内置皮肤:`fluent`、`wechat`、`graphite`、`willow_green`、`autumn_osmanthus`)
- 必填:`schema_version`、`id`、`name`、`version`、`base`(继承的内置皮肤:`fluent`、`wechat`、`graphite`、`willow_green`、`autumn_osmanthus`、`microsoft`)
- 可选:`author`、`description`
- `[supports]`:`layouts`(`horizontal` / `vertical`)、`themes`(`dark` / `light`)
- `[license]`:`code`、`assets`
@@ -8,7 +8,7 @@ import unittest
ROOT = Path(__file__).resolve().parents[1]
# MSIME_SKINS_DIR points the same checks at another skin collection, e.g. the repository's skins/.
SKINS_DIR = Path(os.environ.get("MSIME_SKINS_DIR", ROOT / "skins")).resolve()
BUILTIN_SKINS = ("fluent", "wechat", "graphite", "willow_green", "autumn_osmanthus")
BUILTIN_SKINS = ("fluent", "wechat", "graphite", "willow_green", "autumn_osmanthus", "microsoft")
class ManifestTests(unittest.TestCase):
+1 -1
View File
@@ -164,7 +164,7 @@ page_size = 6
candidate_window_follow_cursor = true
# 候选窗口排列方式,可选:"vertical", "horizontal"
candidate_window_layout = "vertical"
# 候选窗口与悬浮工具栏皮肤,可选:"fluent", "wechat"
# 候选窗口与悬浮工具栏皮肤,可选:"fluent", "wechat", "graphite", "willow_green", "autumn_osmanthus", "microsoft",或外部皮肤的 id
candidate_skin = "willow_green"
# 候选窗口顶部预编辑,可选:"pinyin", "empty"
# pinyin -> 显示分词预编辑(与候选框当前行为一致)
+1 -1
View File
@@ -163,7 +163,7 @@ page_size = 8
candidate_window_follow_cursor = true
# 候选窗口排列方式,可选:"vertical", "horizontal"
candidate_window_layout = "vertical"
# 候选窗口与悬浮工具栏皮肤,可选:"fluent", "wechat"
# 候选窗口与悬浮工具栏皮肤,可选:"fluent", "wechat", "graphite", "willow_green", "autumn_osmanthus", "microsoft",或外部皮肤的 id
candidate_skin = "fluent"
# 候选窗口顶部预编辑,可选:"pinyin", "empty"
# pinyin -> 显示分词预编辑(与候选框当前行为一致)
+2 -1
View File
@@ -436,7 +436,8 @@ const std::string &GetConfiguredUiBackend();
bool SetConfiguredUiBackend(const std::string &backend);
// Process-lifetime renderer chosen at InitImeConfig. Independent of later reloads.
bool UseD2dSmallWindowUi();
// "fluent" | "wechat" | "graphite" | "willow_green" | "autumn_osmanthus" — candidate-window and floating-toolbar skin.
// "fluent" | "wechat" | "graphite" | "willow_green" | "autumn_osmanthus" | "microsoft"
// — candidate-window and floating-toolbar skin.
const std::string &GetConfiguredCandidateSkin();
bool SetConfiguredCandidateSkin(const std::string &skin);
const std::string &GetConfiguredCandidateWindowPreeditStyle();
+2 -1
View File
@@ -269,7 +269,8 @@ bool ReadToolbarColors(const toml::node *node, ToolbarColors &out)
const std::vector<std::string> &BuiltInIds()
{
static const std::vector<std::string> ids = {"fluent", "wechat", "graphite", "willow_green", "autumn_osmanthus"};
static const std::vector<std::string> ids = {"fluent", "wechat", "graphite",
"willow_green", "autumn_osmanthus", "microsoft"};
return ids;
}
@@ -607,6 +607,15 @@ int PrepareHtmlForWnds()
}
std::wstring entireHtmlPathFtbWnd = assetPath + htmlFtbWnd;
::HTMLStringFtbWnd = ReadHtmlFileWithFallback(entireHtmlPathFtbWnd, assetPath + L"/html/webview2/ftb/default.html");
if (baseCandidateSkin == "microsoft")
{
// 微软皮肤沿用默认工具栏页面,只把拖拽条换成候选框选中条的紫色;与 ResolveFloatingToolbarSkin 同值。
// 插在外部皮肤的覆盖之前,外部皮肤写了 handle 时仍以它为准。
const size_t headEnd = ::HTMLStringFtbWnd.find(L"</head>");
if (headEnd != std::wstring::npos)
::HTMLStringFtbWnd.insert(
headEnd, L"<style id=\"built-in-toolbar-skin\">.drag-handle { background: #E183D9; }</style>");
}
if (activeExternalCandidateSkin && !::HTMLStringFtbWnd.empty())
{
const size_t htmlTag = ::HTMLStringFtbWnd.find(L"<html");
+101 -7
View File
@@ -111,6 +111,19 @@ struct CandSkinTokens
float itemPadRight = 0.0f;
// 横排时把卡片多出的宽度均分给同一行各项,铺满高亮的皮肤(杨柳青)末项才能贴到卡片右边。
bool justifyRows = false;
// 序号字号相对候选字号的比例、序号与候选之间的空、横排每项的最小宽度(0 为不限)。
float labelScale = 0.8f;
float labelGap = 1.5f;
float minItemWidth = 0.0f;
// 选中条的尺寸;selectedBarInside 让它落在高亮左缘之内,而不是骑在左缘上。
float selectedBarWidth = 3.0f;
float selectedBarHeightEm = 0.85f;
bool selectedBarInside = false;
// 翻页箭头画成候选文字色的实心三角(微软拼音那样),横排时前面再加一条分隔线。
bool trianglePager = false;
// 预编辑行隐藏时抵掉它与列表之间的 2px 行距,让卡片上下内边距一致。贴角皮肤总是这样做。
bool trimHiddenPreeditGap = false;
D2D1_COLOR_F pagerDivider = D2D1::ColorF(0, 0.0f);
msimeui::Thickness preeditMargin{};
D2D1_COLOR_F rowTextSelected = D2D1::ColorF(0, 0.0f);
D2D1_COLOR_F rowLabelSelected = D2D1::ColorF(0, 0.0f);
@@ -468,6 +481,7 @@ void CandidatePresenter::ApplySkin()
RebuildScene();
lastSkinFingerprint_ = skinKey;
}
const float fontSize = static_cast<float>((std::max)(12, GetConfiguredCandidateFontSize()));
if (baseSkin == "fluent")
{
if (horizontalLayout)
@@ -627,6 +641,53 @@ void CandidatePresenter::ApplySkin()
tokens.menuHover = ColorFromRgb(0x637885);
}
}
else if (baseSkin == "microsoft")
{
// 几何随字号缩放,对应 CSS 里以 em 写的尺寸(ui-html/webview2/candwnd/skins/microsoft/)。
tokens.borderWidth = 1.0f;
tokens.radius = 8.0f;
tokens.containerPad = std::round(fontSize * 0.3f);
tokens.itemRadius = 4.0f;
tokens.itemExtraHeight = std::round(fontSize * 0.47f);
// 序号左端距高亮左缘 0.76em,其中 5 DIP 是列表固定的 textPadLeft。
tokens.itemPadLeft = (std::max)(0.0f, std::round(fontSize * 0.76f) - 5.0f);
tokens.itemPadRight = std::round(fontSize * 0.9f);
if (horizontalLayout)
{
tokens.itemGap = std::round(fontSize * 0.28f);
tokens.minItemWidth = std::round(fontSize * 4.35f);
}
tokens.labelScale = 1.0f;
tokens.labelGap = std::round(fontSize * 0.2f);
tokens.selectedBarWidth = (std::max)(3.0f, fontSize * 0.24f);
tokens.selectedBarHeightEm = 1.1f;
tokens.selectedBarInside = true;
tokens.trianglePager = true;
tokens.trimHiddenPreeditGap = true;
tokens.accent = ColorFromRgb(0xE183D9);
if (candLight)
{
tokens.selected = ColorFromRgb(0xEAEAEA);
tokens.hover = ColorFromRgb(0xF0F0F0);
tokens.number = ColorFromRgb(0x5F5F5F);
tokens.pagerDivider = ColorFromRgb(0xE0E0E0);
tokens.menuFill = ColorFromRgb(0xF9F9F9);
tokens.menuBorder = D2D1::ColorF(0, 0.1f);
tokens.menuText = ColorFromRgb(0x1A1A1A);
tokens.menuHover = ColorFromRgb(0xEAEAEA);
}
else
{
tokens.selected = ColorFromRgb(0x383838);
tokens.hover = ColorFromRgb(0x353535);
tokens.number = ColorFromRgb(0xCFCFCF);
tokens.pagerDivider = ColorFromRgb(0x3D3D3D);
tokens.menuFill = ColorFromRgb(0x2C2C2C);
tokens.menuBorder = ColorFromRgb(0x1C1C1C);
tokens.menuText = ColorFromRgb(0xFFFFFF);
tokens.menuHover = ColorFromRgb(0x383838);
}
}
if (package)
{
@@ -693,7 +754,6 @@ void CandidatePresenter::ApplySkin()
theme.windowBackground = D2D1::ColorF(0, 0.0f);
msimeui::ThemeManager::SetCurrent(theme);
const float fontSize = static_cast<float>((std::max)(12, GetConfiguredCandidateFontSize()));
const float preeditSize = static_cast<float>((std::max)(12, GetConfiguredCandidateWindowPreeditFontSize()));
msimeui::CandidateList::Appearance appearance;
appearance.fontFamily = string_to_wstring(ResolveSystemFontFamilyForCss(GetConfiguredCandidateEnglishFont()));
@@ -708,7 +768,7 @@ void CandidatePresenter::ApplySkin()
appearance.itemHeight = fontSize * 1.35f + tokens.itemExtraHeight;
appearance.itemGap = tokens.itemGap;
appearance.fontSize = fontSize;
appearance.labelFontSize = fontSize * 0.8f;
appearance.labelFontSize = fontSize * tokens.labelScale;
appearance.annotationFontSize = fontSize;
appearance.contentPadLeft = tokens.itemPadLeft;
appearance.contentPadRight = tokens.itemPadRight;
@@ -716,9 +776,11 @@ void CandidatePresenter::ApplySkin()
// 对应 CSS 里 .row.cand 的 padding 包住整个 .text(杨柳青横排上下各 6px)。
appearance.contentPadBottom = tokens.itemExtraHeight * 0.5f;
appearance.textPadLeft = 5.0f;
appearance.labelGap = 1.5f;
appearance.selectedBarWidth = 3.0f;
appearance.selectedBarHeight = fontSize * 0.85f;
appearance.labelGap = tokens.labelGap;
appearance.minItemWidth = tokens.minItemWidth;
appearance.selectedBarWidth = tokens.selectedBarWidth;
appearance.selectedBarHeight = fontSize * tokens.selectedBarHeightEm;
appearance.selectedBarInside = tokens.selectedBarInside;
appearance.showSelectedBar = tokens.showSelectedBar;
appearance.selectedBarColor = tokens.selectedBar.a > 0.001f ? tokens.selectedBar : tokens.accent;
appearance.cornerRadius = tokens.itemRadius;
@@ -730,7 +792,8 @@ void CandidatePresenter::ApplySkin()
// 隐藏的预编辑行高度为 0,但 body 仍在它和列表之间留 2px 行距;贴角皮肤把它抵掉,
// 让首项贴着卡片的内容顶边(秋桂四边内边距一致,杨柳青高亮直抵卡片顶边)。
if (preeditHidden)
impl_->preedit->SetMargin({0.0f, 0.0f, 0.0f, tokens.outerItemRadius > 0.0f ? -2.0f : 0.0f});
impl_->preedit->SetMargin(
{0.0f, 0.0f, 0.0f, tokens.outerItemRadius > 0.0f || tokens.trimHiddenPreeditGap ? -2.0f : 0.0f});
else
impl_->preedit->SetMargin(tokens.preeditMargin);
appearance.textColor = palette.candidateText;
@@ -765,8 +828,39 @@ void CandidatePresenter::ApplySkin()
pagerAppearance.disabledGlyphColor = WithAlpha(tokens.number, 0.3f);
pagerAppearance.hoverFill = tokens.hover;
pagerAppearance.pressedFill = tokens.selected.a > 0.001f ? tokens.selected : tokens.hover;
if (tokens.trianglePager)
{
// 微软拼音式:候选文字色的实心三角,按钮与候选行等高。横排时按钮更宽,前面隔一条分隔线,
// 与 CSS 的 .page-arrow 同一组 em 尺寸。
pagerAppearance.glyph = msimeui::PagerArrows::Glyph::Triangle;
// 高 0.56em、宽 0.47em,三个角各圆掉高度的四分之一,尖端是钝的。
pagerAppearance.glyphSize = fontSize * 0.56f;
pagerAppearance.strokeWidth = pagerAppearance.glyphSize * 0.25f;
pagerAppearance.cornerRadius = tokens.itemRadius;
pagerAppearance.gap = 0.0f;
pagerAppearance.glyphColor = palette.candidateText;
pagerAppearance.disabledGlyphColor = WithAlpha(palette.candidateText, 0.25f);
pagerAppearance.buttonHeight = std::round(appearance.itemHeight);
if (horizontalLayout)
{
pagerAppearance.buttonWidth = std::round(fontSize * 1.73f);
pagerAppearance.dividerWidth = 1.0f;
pagerAppearance.dividerGap = std::round(fontSize * 0.12f);
pagerAppearance.dividerColor = tokens.pagerDivider;
}
else
{
pagerAppearance.buttonWidth = std::round(fontSize * 1.2f);
pagerAppearance.buttonHeight = std::round(fontSize * 1.2f);
}
}
impl_->pager->SetAppearance(pagerAppearance);
if (horizontalLayout)
if (horizontalLayout && tokens.trianglePager)
{
// 与最后一项隔 0.3em,按钮与行等高,不用再抬。
impl_->pager->SetMargin({std::round(fontSize * 0.3f), 0.0f, 0.0f, 0.0f});
}
else if (horizontalLayout)
{
// 横排:窄列与候选之间留一点空,箭头竖直居中在最后一行上。
const float lift = (std::max)(0.0f, (appearance.itemHeight - pagerAppearance.buttonHeight) * 0.5f);
@@ -121,6 +121,13 @@ CandidateSkinPalette ResolveCandidateSkinPalette(const std::string &skinId, bool
: CandidateSkinPalette{CandidateColorFromRgb(0x1C1F23), CandidateColorFromRgb(0x30353B),
CandidateColorFromRgb(0xAEB6C2)};
}
else if (paletteSkinId == "microsoft")
{
palette = light ? CandidateSkinPalette{CandidateColorFromRgb(0xF9F9F9), D2D1::ColorF(0, 0.1f),
CandidateColorFromRgb(0x1A1A1A)}
: CandidateSkinPalette{CandidateColorFromRgb(0x2C2C2C), CandidateColorFromRgb(0x1C1C1C),
CandidateColorFromRgb(0xFFFFFF)};
}
if (packageColors)
{
if (!packageColors->surface.empty())
@@ -56,6 +56,11 @@ FloatingToolbarSkin ResolveFloatingToolbarSkin(const std::string &skinId, bool l
skin.hover = light ? CandidateColorFromRgb(0xFFE399, 0.70f) : CandidateColorFromRgb(0xF97D0A, 0.28f);
skin.radius = 10.0f;
}
else if (skinId == "microsoft")
{
// 其余沿用默认(Fluent)工具栏,只有拖拽条跟候选框选中条同为紫色。
skin.handle = CandidateColorFromRgb(0xE183D9);
}
return skin;
}
@@ -42,6 +42,19 @@ TEST_CASE(candidate_skin_palette_matches_autumn_osmanthus_light_and_dark_preview
REQUIRE_EQ(FlattenCandidateColor(light.text, light.surface), RGB(31, 49, 56));
}
TEST_CASE(candidate_skin_palette_matches_microsoft_light_and_dark_preview)
{
const CandidateSkinPalette dark = ResolveCandidateSkinPalette("microsoft", false, "auto");
REQUIRE_EQ(FlattenCandidateColor(dark.surface, dark.surface), RGB(44, 44, 44));
REQUIRE_EQ(FlattenCandidateColor(dark.border, dark.surface), RGB(28, 28, 28));
REQUIRE_EQ(FlattenCandidateColor(dark.text, dark.surface), RGB(255, 255, 255));
const CandidateSkinPalette light = ResolveCandidateSkinPalette("microsoft", true, "auto");
REQUIRE_EQ(FlattenCandidateColor(light.surface, light.surface), RGB(249, 249, 249));
REQUIRE(std::abs(light.border.a - 0.1f) < 0.001f);
REQUIRE_EQ(FlattenCandidateColor(light.text, light.surface), RGB(26, 26, 26));
}
TEST_CASE(candidate_skin_palette_custom_missing_colors_inherit_package_base)
{
CandidateSkinCatalog::CandidateColors colors;
@@ -40,6 +40,17 @@ TEST_CASE(floating_toolbar_skin_follows_builtin_skin_pages)
const FloatingToolbarSkin autumnLight = ResolveFloatingToolbarSkin("autumn_osmanthus", true);
REQUIRE_EQ(FlattenCandidateColor(autumnLight.fill, autumnLight.fill), RGB(214, 236, 240));
REQUIRE_EQ(FlattenCandidateColor(autumnLight.glyph, autumnLight.fill), RGB(26, 26, 26));
// Microsoft keeps the default toolbar and only takes its purple selection bar colour for the handle.
for (const bool light : {false, true})
{
const FloatingToolbarSkin microsoft = ResolveFloatingToolbarSkin("microsoft", light);
const FloatingToolbarSkin fluent = ResolveFloatingToolbarSkin("fluent", light);
REQUIRE_EQ(FlattenCandidateColor(microsoft.handle, microsoft.fill), RGB(225, 131, 217));
REQUIRE_EQ(FlattenCandidateColor(microsoft.fill, microsoft.fill),
FlattenCandidateColor(fluent.fill, fluent.fill));
REQUIRE_EQ(microsoft.radius, fluent.radius);
}
}
// An external skin's [toolbar] tables reach the D2D toolbar too, not only the WebView2 page.
+1 -1
View File
@@ -18,7 +18,7 @@
## 默认皮肤设置(`default/`)
[`default/`](default/) 不是外部皮肤,而是五个内置皮肤(`fluent`、`wechat`、`graphite`、`willow_green`、`autumn_osmanthus`)各自的设置清单。安装包会把它们放到数据目录的 `skins\default\<id>\skin.toml`,升级时只补缺失的文件、不覆盖已有的,所以可以直接在那里改。
[`default/`](default/) 不是外部皮肤,而是六个内置皮肤(`fluent`、`wechat`、`graphite`、`willow_green`、`autumn_osmanthus`、`microsoft`)各自的设置清单。安装包会把它们放到数据目录的 `skins\default\<id>\skin.toml`,升级时只补缺失的文件、不覆盖已有的,所以可以直接在那里改。
内置皮肤的外观仍由输入法内置样式决定,这份清单只承载皮肤级的开关,目前支持:
+9
View File
@@ -0,0 +1,9 @@
# 内置皮肤「微软」的设置清单。外观由输入法内置的样式决定,这里只放皮肤级的开关;
# 基于 microsoft 的外部皮肤没写的开关沿用这里的值。
schema_version = 1
id = "microsoft"
name = "微软 Microsoft"
[candidate_window]
# 候选框里的翻页箭头:与微软拼音一样默认打开。横排在候选右侧、隔一条分隔线,竖排在最后一行下方、与序号左对齐
page_arrows = true
+3 -2
View File
@@ -13,14 +13,15 @@ skins/
├─ wechat/
├─ graphite/
├─ willow_green/
└─ autumn_osmanthus/
├─ autumn_osmanthus/
└─ microsoft/
```
每个皮肤目录包含 `horizontal_dark.css`、`horizontal_light.css`、`vertical_dark.css` 和 `vertical_light.css`。Server 根据基础皮肤、候选框布局和明暗模式组合资源路径,并以内联 `<style>` 注入共享 HTML;启用外部皮肤时,Server 再根据其 `skin.toml` 生成一段 CSS,以内联 `<style>` 追加在后面,用于覆盖基础皮肤。
`ApplyCandidateFrame` 只隐藏用不到的候选行、不删除它们,所以 `:last-child` 不一定是最后一个可见行;需要定位尾项的皮肤用它维护的 `.row-wrapper.last-visible`。
翻页箭头 `.page-arrows` 不在 body 模板里:Server 每次用模板换掉容器内容后调用页面的 `SetCandidatePager(hasPrevious, hasNext)`,由它追加到 `#realContainer` 与 `#measureContainer` 末尾(测量容器调用时不带参数,只补元素不改状态),同时给最后一个候选行标上 `.last-visible`;竖排时按最后一个序号的位置算出左外边距,让“‹”与序号左对齐。设置页的预览用的是 `ui-html/webview2/settings/ime-settings/src/styles/modules/candidate/page-arrows.css` 里的同一套尺寸。是否显示由 Server 写在 `<html>` 上的 `data-page-arrows="on|off"` 决定,取值来自皮肤的 `page_arrows`(内置皮肤读数据目录 `skins/default/<id>/skin.toml`)。点击发出 `candidatePage` 消息,数据为 `previous` / `next`。样式在两份 HTML 里各有一份,改动要两边一起改。
翻页箭头 `.page-arrows` 不在 body 模板里:Server 每次用模板换掉容器内容后调用页面的 `SetCandidatePager(hasPrevious, hasNext)`,由它追加到 `#realContainer` 与 `#measureContainer` 末尾(测量容器调用时不带参数,只补元素不改状态),同时给最后一个候选行标上 `.last-visible`;竖排时按最后一个序号的位置算出左外边距,让“‹”与序号左对齐。设置页的预览用的是 `ui-html/webview2/settings/ime-settings/src/styles/modules/candidate/page-arrows.css` 里的同一套尺寸。是否显示由 Server 写在 `<html>` 上的 `data-page-arrows="on|off"` 决定,取值来自皮肤的 `page_arrows`(内置皮肤读数据目录 `skins/default/<id>/skin.toml`)。`microsoft` 皮肤在自己的 CSS 里把箭头改成实心三角、横排前加分隔线(D2D 端对应 `PagerArrows` 的 `Glyph::Triangle` 与 divider)。点击发出 `candidatePage` 消息,数据为 `previous` / `next`。样式在两份 HTML 里各有一份,改动要两边一起改。
外部皮肤使用 `skin.toml` 声明几何与候选配色,可选 `[toolbar]`、`[toolbar.dark]`、`[toolbar.light]` 覆盖悬浮工具栏的圆角与配色(D2D 与 WebView2 两个渲染器都生效)。不提供候选框 HTML、`cand.css` 或工具栏 CSS。
@@ -0,0 +1,212 @@
/* 微软风格:仿 Windows 11 微软拼音的候选框。尺寸以 em 写,随候选字号缩放;
D2D 端在 CandidatePresenter::ApplySkin 的 microsoft 分支里用同一组值。 */
:root {
--cand-font-family: 'Segoe UI', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
--cand-font-size: 16px;
--preedit-font-size: var(--cand-font-size);
--cand-text: #ffffff;
--cand-num: #cfcfcf;
--ms-accent: #e183d9;
--ms-selected: #383838;
--ms-hover: #353535;
--ms-divider: #3d3d3d;
/* Fallback before C++ injects host-width DIP. Wrap limit = 宿主宽度. */
--msime-max-width-dip: 100%;
--msime-max-height-dip: 100%;
}
body {
font-family: var(--cand-font-family);
font-size: var(--cand-font-size);
height: 100vh;
margin: 0;
overflow: hidden;
border-radius: 8px;
background: transparent;
}
.containerParent {
width: fit-content;
max-width: var(--msime-max-width-dip);
}
.container {
margin-top: 0px;
margin-left: 0px;
background: #2c2c2c;
/* 候选行换行时行间也隔 0.28em(写在 .row-wrapper 的下外边距上),底边内边距扣掉这一段,四周仍是 0.3em。 */
padding: 0.3em 0.3em 0.02em;
border-radius: 8px;
box-shadow: 8px 10px 24px rgba(0, 0, 0, 0.34), 2px 3px 8px rgba(0, 0, 0, 0.22);
min-width: 7em;
/* Wrap only when content reaches the host (injected) max width. */
width: min(max-content, var(--msime-max-width-dip));
max-width: var(--msime-max-width-dip);
/* Host height in DIP (injected). Do not use min(100%, …): parent height is
content-sized so 100% never resolves and tall vertical lists get clipped
by the HWND instead of scrolling. */
max-height: var(--msime-max-height-dip);
box-sizing: border-box;
overflow-x: hidden;
overflow-y: auto;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
user-select: none;
border: 1px solid #1c1c1c;
}
.cursor {
display: inline-block;
width: 1.5px;
height: 1.2em;
background: var(--ms-accent);
transform: translate(1.3px, 10%);
}
.row {
margin: 0;
}
/* 每项至少 4.35em 宽、项间隔 0.28em,与微软拼音一样短候选也占一格。 */
.row-wrapper {
position: relative;
display: inline-block;
vertical-align: top;
max-width: 100%;
min-width: 0;
margin-right: 0.28em;
margin-bottom: 0.28em;
box-sizing: border-box;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.container > .row-wrapper:is(:last-child, .last-visible) {
margin-right: 0;
}
.cand {
position: relative;
box-sizing: border-box;
min-width: 4.35em;
padding: 0.235em 0.9em 0.235em 0.76em;
border-radius: 4px;
}
.hover-active .cand:not(.first):hover {
background-color: var(--ms-hover);
}
.first {
background-color: var(--ms-selected);
}
/* 选中条落在高亮左缘之内,比字高一些。 */
.first::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
height: 1.1em;
width: max(3px, 0.24em);
background: var(--ms-accent);
border-radius: 99px;
}
.text {
padding-left: 5px;
padding-right: 5px;
color: var(--cand-text);
display: block;
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.cand .text {
padding: 0;
line-height: 1.35;
}
/* 序号与候选同字号。 */
.num {
color: var(--cand-num);
margin-right: 0.2em;
}
.pinyin {
font-size: var(--preedit-font-size);
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 2px;
}
.hidden-pinyin {
visibility: hidden;
}
/* Hide candidate-window top preedit when configured as empty */
.container.preedit-hidden > .row.pinyin {
display: none;
}
/* Hide container when empty */
.container:empty {
padding: 0;
border: none;
box-shadow: none;
min-width: 0;
}
/* 翻页:实心三角,与候选行等高,前面一条分隔线。 */
html[data-page-arrows="on"][data-candidate-layout="horizontal"] .page-arrows {
vertical-align: top;
gap: 0;
margin: 0 0 0.28em 0.3em;
padding-left: 0.12em;
border-left: 1px solid var(--ms-divider);
}
.page-arrow {
width: 1.73em;
height: 1.82em;
border-radius: 4px;
color: var(--cand-text);
}
.page-arrow::before {
width: 0.47em;
height: 0.56em;
border: none;
transform: none;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 12'%3E%3Cpath d='M7.43 4.46L2.57 1.54Q0 0 0 3L0 9Q0 12 2.57 10.46L7.43 7.54Q10 6 7.43 4.46Z' fill='black'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 12'%3E%3Cpath d='M7.43 4.46L2.57 1.54Q0 0 0 3L0 9Q0 12 2.57 10.46L7.43 7.54Q10 6 7.43 4.46Z' fill='black'/%3E%3C/svg%3E") center / contain no-repeat;
}
.page-arrow-prev::before {
transform: scaleX(-1);
}
.page-arrow-next::before {
transform: none;
}
.page-arrow.disabled {
opacity: 0.25;
}
.hover-active .page-arrow:not(.disabled):hover {
background: var(--ms-hover);
}
.context-menu { display:none; position:fixed; z-index:1000; background:#2c2c2c; color:#ffffff;
border:1px solid #1c1c1c; border-radius:8px; padding:1px 0; min-width:86px; font-size:14px; line-height:20px; }
.context-menu-item { position:relative; padding:1px 20px 1px 4px; margin:2px; border-radius:4px; cursor:default; white-space:nowrap; }
.context-menu-item:hover { background:#383838; }
.context-menu-parent::after { content:"›"; position:absolute; right:6px; }
.context-submenu { display:none; position:absolute; left:100%; top:-2px; min-width:0;
background:#2c2c2c; border:1px solid #1c1c1c; border-radius:8px; padding:1px 0; }
.context-submenu .context-menu-item { padding-right: 8px; }
.context-menu-parent:hover>.context-submenu { display:block; }
.context-menu-separator { height:1px; margin:3px 5px; background:#ffffff20; }
@@ -0,0 +1,212 @@
/* 微软风格:仿 Windows 11 微软拼音的候选框。尺寸以 em 写,随候选字号缩放;
D2D 端在 CandidatePresenter::ApplySkin 的 microsoft 分支里用同一组值。 */
:root {
--cand-font-family: 'Segoe UI', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
--cand-font-size: 16px;
--preedit-font-size: var(--cand-font-size);
--cand-text: #1a1a1a;
--cand-num: #5f5f5f;
--ms-accent: #e183d9;
--ms-selected: #eaeaea;
--ms-hover: #f0f0f0;
--ms-divider: #e0e0e0;
/* Fallback before C++ injects host-width DIP. Wrap limit = 宿主宽度. */
--msime-max-width-dip: 100%;
--msime-max-height-dip: 100%;
}
body {
font-family: var(--cand-font-family);
font-size: var(--cand-font-size);
height: 100vh;
margin: 0;
overflow: hidden;
border-radius: 8px;
background: transparent;
}
.containerParent {
width: fit-content;
max-width: var(--msime-max-width-dip);
}
.container {
margin-top: 0px;
margin-left: 0px;
background: #f9f9f9;
/* 候选行换行时行间也隔 0.28em(写在 .row-wrapper 的下外边距上),底边内边距扣掉这一段,四周仍是 0.3em。 */
padding: 0.3em 0.3em 0.02em;
border-radius: 8px;
box-shadow: 8px 10px 24px rgba(0, 0, 0, 0.18), 2px 3px 8px rgba(0, 0, 0, 0.10);
min-width: 7em;
/* Wrap only when content reaches the host (injected) max width. */
width: min(max-content, var(--msime-max-width-dip));
max-width: var(--msime-max-width-dip);
/* Host height in DIP (injected). Do not use min(100%, …): parent height is
content-sized so 100% never resolves and tall vertical lists get clipped
by the HWND instead of scrolling. */
max-height: var(--msime-max-height-dip);
box-sizing: border-box;
overflow-x: hidden;
overflow-y: auto;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
user-select: none;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.cursor {
display: inline-block;
width: 1.5px;
height: 1.2em;
background: var(--ms-accent);
transform: translate(1.3px, 10%);
}
.row {
margin: 0;
}
/* 每项至少 4.35em 宽、项间隔 0.28em,与微软拼音一样短候选也占一格。 */
.row-wrapper {
position: relative;
display: inline-block;
vertical-align: top;
max-width: 100%;
min-width: 0;
margin-right: 0.28em;
margin-bottom: 0.28em;
box-sizing: border-box;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.container > .row-wrapper:is(:last-child, .last-visible) {
margin-right: 0;
}
.cand {
position: relative;
box-sizing: border-box;
min-width: 4.35em;
padding: 0.235em 0.9em 0.235em 0.76em;
border-radius: 4px;
}
.hover-active .cand:not(.first):hover {
background-color: var(--ms-hover);
}
.first {
background-color: var(--ms-selected);
}
/* 选中条落在高亮左缘之内,比字高一些。 */
.first::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
height: 1.1em;
width: max(3px, 0.24em);
background: var(--ms-accent);
border-radius: 99px;
}
.text {
padding-left: 5px;
padding-right: 5px;
color: var(--cand-text);
display: block;
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.cand .text {
padding: 0;
line-height: 1.35;
}
/* 序号与候选同字号。 */
.num {
color: var(--cand-num);
margin-right: 0.2em;
}
.pinyin {
font-size: var(--preedit-font-size);
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 2px;
}
.hidden-pinyin {
visibility: hidden;
}
/* Hide candidate-window top preedit when configured as empty */
.container.preedit-hidden > .row.pinyin {
display: none;
}
/* Hide container when empty */
.container:empty {
padding: 0;
border: none;
box-shadow: none;
min-width: 0;
}
/* 翻页:实心三角,与候选行等高,前面一条分隔线。 */
html[data-page-arrows="on"][data-candidate-layout="horizontal"] .page-arrows {
vertical-align: top;
gap: 0;
margin: 0 0 0.28em 0.3em;
padding-left: 0.12em;
border-left: 1px solid var(--ms-divider);
}
.page-arrow {
width: 1.73em;
height: 1.82em;
border-radius: 4px;
color: var(--cand-text);
}
.page-arrow::before {
width: 0.47em;
height: 0.56em;
border: none;
transform: none;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 12'%3E%3Cpath d='M7.43 4.46L2.57 1.54Q0 0 0 3L0 9Q0 12 2.57 10.46L7.43 7.54Q10 6 7.43 4.46Z' fill='black'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 12'%3E%3Cpath d='M7.43 4.46L2.57 1.54Q0 0 0 3L0 9Q0 12 2.57 10.46L7.43 7.54Q10 6 7.43 4.46Z' fill='black'/%3E%3C/svg%3E") center / contain no-repeat;
}
.page-arrow-prev::before {
transform: scaleX(-1);
}
.page-arrow-next::before {
transform: none;
}
.page-arrow.disabled {
opacity: 0.25;
}
.hover-active .page-arrow:not(.disabled):hover {
background: var(--ms-hover);
}
.context-menu { display:none; position:fixed; z-index:1000; background:#f9f9f9; color:#1a1a1a;
border:1px solid rgba(0, 0, 0, 0.1); border-radius:8px; padding:1px 0; min-width:86px; font-size:14px; line-height:20px; }
.context-menu-item { position:relative; padding:1px 20px 1px 4px; margin:2px; border-radius:4px; cursor:default; white-space:nowrap; }
.context-menu-item:hover { background:#eaeaea; }
.context-menu-parent::after { content:"›"; position:absolute; right:6px; }
.context-submenu { display:none; position:absolute; left:100%; top:-2px; min-width:0;
background:#f9f9f9; border:1px solid rgba(0, 0, 0, 0.1); border-radius:8px; padding:1px 0; }
.context-submenu .context-menu-item { padding-right: 8px; }
.context-menu-parent:hover>.context-submenu { display:block; }
.context-menu-separator { height:1px; margin:3px 5px; background:#0000001a; }
@@ -0,0 +1,199 @@
/* 微软风格:仿 Windows 11 微软拼音的候选框。尺寸以 em 写,随候选字号缩放;
D2D 端在 CandidatePresenter::ApplySkin 的 microsoft 分支里用同一组值。 */
:root {
--cand-font-family: 'Segoe UI', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
--cand-font-size: 16px;
--preedit-font-size: var(--cand-font-size);
--cand-text: #ffffff;
--cand-num: #cfcfcf;
--ms-accent: #e183d9;
--ms-selected: #383838;
--ms-hover: #353535;
--ms-divider: #3d3d3d;
/* Fallback before C++ injects host-width DIP. Wrap limit = 宿主宽度. */
--msime-max-width-dip: 100%;
--msime-max-height-dip: 100%;
}
body {
font-family: var(--cand-font-family);
font-size: var(--cand-font-size);
height: 100vh;
margin: 0;
overflow: hidden;
border-radius: 8px;
background: transparent;
}
.containerParent {
width: fit-content;
max-width: var(--msime-max-width-dip);
}
.container {
margin-top: 0px;
margin-left: 0px;
background: #2c2c2c;
padding: 0.3em;
border-radius: 8px;
box-shadow: 8px 10px 24px rgba(0, 0, 0, 0.34), 2px 3px 8px rgba(0, 0, 0, 0.22);
min-width: 7em;
width: min(fit-content, var(--msime-max-width-dip));
max-width: var(--msime-max-width-dip);
/* Host height in DIP (injected). Do not use min(100%, …): parent height is
content-sized so 100% never resolves and tall vertical lists get clipped
by the HWND instead of scrolling. */
max-height: var(--msime-max-height-dip);
box-sizing: border-box;
overflow-x: hidden;
overflow-y: auto;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
user-select: none;
border: 1px solid #1c1c1c;
}
.cursor {
display: inline-block;
width: 1.5px;
height: 1.2em;
background: var(--ms-accent);
transform: translate(1.3px, 10%);
}
.row {
margin: 0;
}
.row-wrapper {
position: relative;
}
.row-wrapper + .row-wrapper {
margin-top: 2px;
}
.cand {
position: relative;
box-sizing: border-box;
padding: 0.235em 0.9em 0.235em 0.76em;
border-radius: 4px;
}
.hover-active .cand:not(.first):hover {
background-color: var(--ms-hover);
}
.first {
background-color: var(--ms-selected);
}
/* 选中条落在高亮左缘之内,比字高一些。 */
.first::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
height: 1.1em;
width: max(3px, 0.24em);
background: var(--ms-accent);
border-radius: 99px;
}
.text {
padding-left: 5px;
padding-right: 5px;
color: var(--cand-text);
display: block;
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.cand .text {
padding: 0;
line-height: 1.35;
}
/* 序号与候选同字号。 */
.cand-no {
color: var(--cand-num);
margin-right: 0.2em;
}
.pinyin {
font-size: var(--preedit-font-size);
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 2px;
}
.hidden-pinyin {
visibility: hidden;
}
/* Hide candidate-window top preedit when configured as empty */
.container.preedit-hidden > .row.pinyin {
display: none;
}
/* Hide container when empty */
.container:empty {
padding: 0;
border: none;
box-shadow: none;
min-width: 0;
}
/* 翻页:实心三角。竖排是最后一项下面的一矮行;共享脚本按 0.72em 的按钮对齐序号,
这里的按钮宽 1.2em,三角左侧多出 0.145em,用 prev 的负外边距抵掉。 */
html[data-page-arrows="on"] .page-arrows {
gap: 0;
}
.page-arrow {
width: 1.2em;
height: 1.2em;
border-radius: 4px;
color: var(--cand-text);
}
.page-arrow::before {
width: 0.47em;
height: 0.56em;
border: none;
transform: none;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 12'%3E%3Cpath d='M7.43 4.46L2.57 1.54Q0 0 0 3L0 9Q0 12 2.57 10.46L7.43 7.54Q10 6 7.43 4.46Z' fill='black'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 12'%3E%3Cpath d='M7.43 4.46L2.57 1.54Q0 0 0 3L0 9Q0 12 2.57 10.46L7.43 7.54Q10 6 7.43 4.46Z' fill='black'/%3E%3C/svg%3E") center / contain no-repeat;
}
.page-arrow-prev {
margin-left: -0.145em;
}
.page-arrow-prev::before {
transform: scaleX(-1);
}
.page-arrow-next::before {
transform: none;
}
.page-arrow.disabled {
opacity: 0.25;
}
.hover-active .page-arrow:not(.disabled):hover {
background: var(--ms-hover);
}
.context-menu { display:none; position:fixed; z-index:1000; background:#2c2c2c; color:#ffffff;
border:1px solid #1c1c1c; border-radius:8px; padding:1px 0; min-width:86px; font-size:14px; line-height:20px; }
.context-menu-item { position:relative; padding:1px 20px 1px 4px; margin:2px; border-radius:4px; cursor:default; white-space:nowrap; }
.context-menu-item:hover { background:#383838; }
.context-menu-parent::after { content:"›"; position:absolute; right:6px; }
.context-submenu { display:none; position:absolute; left:100%; top:-2px; min-width:0;
background:#2c2c2c; border:1px solid #1c1c1c; border-radius:8px; padding:1px 0; }
.context-submenu .context-menu-item { padding-right: 8px; }
.context-menu-parent:hover>.context-submenu { display:block; }
.context-menu-separator { height:1px; margin:3px 5px; background:#ffffff20; }
.cand-translation { margin-left: 0.65em; font-size: 0.78em; opacity: 0.62; }
@@ -0,0 +1,199 @@
/* 微软风格:仿 Windows 11 微软拼音的候选框。尺寸以 em 写,随候选字号缩放;
D2D 端在 CandidatePresenter::ApplySkin 的 microsoft 分支里用同一组值。 */
:root {
--cand-font-family: 'Segoe UI', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
--cand-font-size: 16px;
--preedit-font-size: var(--cand-font-size);
--cand-text: #1a1a1a;
--cand-num: #5f5f5f;
--ms-accent: #e183d9;
--ms-selected: #eaeaea;
--ms-hover: #f0f0f0;
--ms-divider: #e0e0e0;
/* Fallback before C++ injects host-width DIP. Wrap limit = 宿主宽度. */
--msime-max-width-dip: 100%;
--msime-max-height-dip: 100%;
}
body {
font-family: var(--cand-font-family);
font-size: var(--cand-font-size);
height: 100vh;
margin: 0;
overflow: hidden;
border-radius: 8px;
background: transparent;
}
.containerParent {
width: fit-content;
max-width: var(--msime-max-width-dip);
}
.container {
margin-top: 0px;
margin-left: 0px;
background: #f9f9f9;
padding: 0.3em;
border-radius: 8px;
box-shadow: 8px 10px 24px rgba(0, 0, 0, 0.18), 2px 3px 8px rgba(0, 0, 0, 0.10);
min-width: 7em;
width: min(fit-content, var(--msime-max-width-dip));
max-width: var(--msime-max-width-dip);
/* Host height in DIP (injected). Do not use min(100%, …): parent height is
content-sized so 100% never resolves and tall vertical lists get clipped
by the HWND instead of scrolling. */
max-height: var(--msime-max-height-dip);
box-sizing: border-box;
overflow-x: hidden;
overflow-y: auto;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
user-select: none;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.cursor {
display: inline-block;
width: 1.5px;
height: 1.2em;
background: var(--ms-accent);
transform: translate(1.3px, 10%);
}
.row {
margin: 0;
}
.row-wrapper {
position: relative;
}
.row-wrapper + .row-wrapper {
margin-top: 2px;
}
.cand {
position: relative;
box-sizing: border-box;
padding: 0.235em 0.9em 0.235em 0.76em;
border-radius: 4px;
}
.hover-active .cand:not(.first):hover {
background-color: var(--ms-hover);
}
.first {
background-color: var(--ms-selected);
}
/* 选中条落在高亮左缘之内,比字高一些。 */
.first::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
height: 1.1em;
width: max(3px, 0.24em);
background: var(--ms-accent);
border-radius: 99px;
}
.text {
padding-left: 5px;
padding-right: 5px;
color: var(--cand-text);
display: block;
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.cand .text {
padding: 0;
line-height: 1.35;
}
/* 序号与候选同字号。 */
.cand-no {
color: var(--cand-num);
margin-right: 0.2em;
}
.pinyin {
font-size: var(--preedit-font-size);
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 2px;
}
.hidden-pinyin {
visibility: hidden;
}
/* Hide candidate-window top preedit when configured as empty */
.container.preedit-hidden > .row.pinyin {
display: none;
}
/* Hide container when empty */
.container:empty {
padding: 0;
border: none;
box-shadow: none;
min-width: 0;
}
/* 翻页:实心三角。竖排是最后一项下面的一矮行;共享脚本按 0.72em 的按钮对齐序号,
这里的按钮宽 1.2em,三角左侧多出 0.145em,用 prev 的负外边距抵掉。 */
html[data-page-arrows="on"] .page-arrows {
gap: 0;
}
.page-arrow {
width: 1.2em;
height: 1.2em;
border-radius: 4px;
color: var(--cand-text);
}
.page-arrow::before {
width: 0.47em;
height: 0.56em;
border: none;
transform: none;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 12'%3E%3Cpath d='M7.43 4.46L2.57 1.54Q0 0 0 3L0 9Q0 12 2.57 10.46L7.43 7.54Q10 6 7.43 4.46Z' fill='black'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 12'%3E%3Cpath d='M7.43 4.46L2.57 1.54Q0 0 0 3L0 9Q0 12 2.57 10.46L7.43 7.54Q10 6 7.43 4.46Z' fill='black'/%3E%3C/svg%3E") center / contain no-repeat;
}
.page-arrow-prev {
margin-left: -0.145em;
}
.page-arrow-prev::before {
transform: scaleX(-1);
}
.page-arrow-next::before {
transform: none;
}
.page-arrow.disabled {
opacity: 0.25;
}
.hover-active .page-arrow:not(.disabled):hover {
background: var(--ms-hover);
}
.context-menu { display:none; position:fixed; z-index:1000; background:#f9f9f9; color:#1a1a1a;
border:1px solid rgba(0, 0, 0, 0.1); border-radius:8px; padding:1px 0; min-width:86px; font-size:14px; line-height:20px; }
.context-menu-item { position:relative; padding:1px 20px 1px 4px; margin:2px; border-radius:4px; cursor:default; white-space:nowrap; }
.context-menu-item:hover { background:#eaeaea; }
.context-menu-parent::after { content:"›"; position:absolute; right:6px; }
.context-submenu { display:none; position:absolute; left:100%; top:-2px; min-width:0;
background:#f9f9f9; border:1px solid rgba(0, 0, 0, 0.1); border-radius:8px; padding:1px 0; }
.context-submenu .context-menu-item { padding-right: 8px; }
.context-menu-parent:hover>.context-submenu { display:block; }
.context-menu-separator { height:1px; margin:3px 5px; background:#0000001a; }
.cand-translation { margin-left: 0.65em; font-size: 0.78em; opacity: 0.62; }
@@ -33,12 +33,12 @@ type ExternalSkin = {
};
type SkinScanIssue = { folder: string; reason: string };
const BUILTIN_SKINS = ['fluent', 'wechat', 'graphite', 'willow_green', 'autumn_osmanthus'] as const;
const BUILTIN_SKINS = ['fluent', 'wechat', 'graphite', 'willow_green', 'autumn_osmanthus', 'microsoft'] as const;
type BuiltinSkin = typeof BUILTIN_SKINS[number];
const previewOverrides: Record<string, SkinPreviewTheme | null> = {
fluent: null, wechat: null, graphite: null, willow_green: null, autumn_osmanthus: null
fluent: null, wechat: null, graphite: null, willow_green: null, autumn_osmanthus: null, microsoft: null
};
const BUILTIN_PREVIEW_CLASSES = ['skin-wechat', 'skin-graphite', 'skin-willow-green', 'skin-autumn-osmanthus'];
const BUILTIN_PREVIEW_CLASSES = ['skin-wechat', 'skin-graphite', 'skin-willow-green', 'skin-autumn-osmanthus', 'skin-microsoft'];
const loadedExternalStyleIds = new Set<string>();
let activeTheme: SkinPreviewTheme = 'dark';
let activeSkin: CandidateSkin = 'fluent';
@@ -70,6 +70,7 @@ function builtinPreviewClass(skinId: string): string {
if (skinId === 'graphite') return 'skin-graphite';
if (skinId === 'willow_green') return 'skin-willow-green';
if (skinId === 'autumn_osmanthus') return 'skin-autumn-osmanthus';
if (skinId === 'microsoft') return 'skin-microsoft';
const external = findExternalSkin(skinId);
return external ? builtinPreviewClass(external.base) : '';
}
@@ -429,7 +430,8 @@ function applyBuiltinCardTheme(skin: BuiltinSkin, theme: SkinPreviewTheme): void
if (title) {
const name = skin === 'wechat' ? '微信绿主题' : skin === 'graphite' ? '石墨 Graphite'
: skin === 'willow_green' ? '杨柳青 Willow green'
: skin === 'autumn_osmanthus' ? '秋桂 Autumn osmanthus' : 'Fluent 主题';
: skin === 'autumn_osmanthus' ? '秋桂 Autumn osmanthus'
: skin === 'microsoft' ? '微软 Microsoft' : 'Fluent 主题';
title.textContent = `${name}(${theme === 'light' ? 'Light' : 'Dark'})`;
}
card.querySelectorAll<HTMLButtonElement>('[data-skin-preview-switch]').forEach((button) => {
@@ -23,7 +23,7 @@
<div class="skin-preview-stage"><div class="candidate wnd-v" data-skin-vertical></div></div>
</div>
<div class="skin-preview-row skin-preview-toolbar">
<div class="skin-preview-stage"><div class="ftb-preview-host" data-skin-toolbar></div></div>
<div class="skin-preview-stage"><div class="ftb-preview-host skin-microsoft" data-skin-toolbar></div></div>
</div>
</div>
</div>
@@ -136,6 +136,33 @@
</div>
</div>
<div class="section skin-theme-card" data-candidate-skin="microsoft">
<div class="skin-theme-header">
<div>
<div class="section-title" id="microsoftSkinTitle">微软 Microsoft(Dark)</div>
<div class="skin-theme-description">仿 Windows 11 微软拼音:宽候选格、紫色选中条与实心翻页三角</div>
</div>
<div class="skin-theme-actions">
<div class="ftb-toggle-btn skin-theme-toggle" data-skin-switch="microsoft" role="switch" aria-checked="false" tabindex="0">
<div class="ftb-toggle-knob"></div>
</div>
<button type="button" class="skin-preview-switch" data-skin-preview-switch title="切换明暗预览">预览浅色</button>
</div>
</div>
<div class="skin-preview-list">
<div class="skin-preview-row skin-preview-horizontal">
<div class="skin-preview-stage"><div class="candidate wnd-h skin-microsoft" data-skin-horizontal></div></div>
</div>
<div class="skin-preview-row skin-preview-vertical">
<div class="skin-preview-stage"><div class="candidate wnd-v skin-microsoft" data-skin-vertical></div></div>
</div>
<div class="skin-preview-row skin-preview-toolbar">
<div class="skin-preview-stage"><div class="ftb-preview-host skin-microsoft" data-skin-toolbar></div></div>
</div>
</div>
</div>
<div class="external-skin-heading">
<div>
<div class="section-title">外部皮肤</div>
@@ -134,7 +134,7 @@
}
/* 同候选窗 fluent 横排:首项、尾项落在外框四角上的高亮角与外框同为 6px;其余内置皮肤在 skin.css 自带规则。 */
.wnd-h:not(.skin-wechat, .skin-graphite, .skin-willow-green, .skin-autumn-osmanthus) {
.wnd-h:not(.skin-wechat, .skin-graphite, .skin-willow-green, .skin-autumn-osmanthus, .skin-microsoft) {
.container > .pinyin + .row-wrapper > .cand { border-bottom-left-radius: 6px; }
.container.preedit-hidden > .pinyin + .row-wrapper > .cand { border-top-left-radius: 6px; }
.container > .row-wrapper:is(:last-child, .last-visible) > .cand { border-bottom-right-radius: 6px; }
@@ -382,6 +382,103 @@
.ftb-preview-host.skin-autumn-osmanthus.theme-dark .drag-handle { background: #f97d0a; }
.ftb-preview-host.skin-autumn-osmanthus.theme-light .drag-handle { background: #e6a817; }
/* Preview equivalents of the Microsoft skin CSS in candwnd/skins/microsoft/. The toolbar is the default one with a purple handle. */
.ftb-preview-host.skin-microsoft .drag-handle { background: #e183d9; }
.candidate.skin-microsoft.theme-dark {
--accent-strong: #e183d9;
--cand-bg: #2c2c2c;
--cand-border: #1c1c1c;
--cand-shadow: 8px 10px 24px rgba(0, 0, 0, .34), 2px 3px 8px rgba(0, 0, 0, .22);
--cand-text: #ffffff;
--cand-num: #cfcfcf;
--cand-hover: #353535;
--cand-selected: #383838;
--ms-divider: #3d3d3d;
}
.candidate.skin-microsoft.theme-light {
--accent-strong: #e183d9;
--cand-bg: #f9f9f9;
--cand-border: rgba(0, 0, 0, .1);
--cand-shadow: 8px 10px 24px rgba(0, 0, 0, .18), 2px 3px 8px rgba(0, 0, 0, .10);
--cand-text: #1a1a1a;
--cand-num: #5f5f5f;
--cand-hover: #f0f0f0;
--cand-selected: #eaeaea;
--ms-divider: #e0e0e0;
}
.candidate.skin-microsoft .container {
padding: 0.3em;
border-width: 1px;
border-radius: 8px;
}
.candidate.skin-microsoft .row.cand {
margin: 0;
padding: 0.235em 0.9em 0.235em 0.76em;
border-radius: 4px;
}
.candidate.skin-microsoft .row.pinyin {
margin-top: 0;
margin-bottom: 2px;
}
.candidate.skin-microsoft .cand .text {
padding: 0;
line-height: 1.35;
}
.candidate.skin-microsoft :is(.num, .cand-no) {
font-size: 1em;
margin-right: 0.2em;
}
.candidate.skin-microsoft .first::before {
top: 50%;
height: 1.1em;
width: max(3px, 0.24em);
border-radius: 99px;
}
.candidate.skin-microsoft.wnd-v .row-wrapper + .row-wrapper { margin-top: 2px; }
/* 换行的候选行之间隔 0.28em:挂在每项的下外边距上,底边内边距扣掉这一段。 */
.candidate.skin-microsoft.wnd-h .container { letter-spacing: -0.31em; padding-bottom: 0.02em; }
.candidate.skin-microsoft.wnd-h .row.pinyin { letter-spacing: normal; }
.candidate.skin-microsoft.wnd-h .row-wrapper {
letter-spacing: normal;
margin: 0 0.28em 0.28em 0;
}
.candidate.skin-microsoft.wnd-h .container > .row-wrapper:is(:last-child, .last-visible) { margin-right: 0; }
.candidate.skin-microsoft.wnd-h .row.cand {
box-sizing: border-box;
min-width: 4.35em;
}
/* 翻页:实心三角。横排与候选行等高、前面一条分隔线;竖排是 1.2em 见方的按钮,三角左端对齐序号。 */
.candidate.skin-microsoft.page-arrows-on .page-arrows { gap: 0; letter-spacing: normal; }
.candidate.skin-microsoft.page-arrows-on.wnd-h .page-arrows {
vertical-align: top;
margin: 0 0 0.28em 0.3em;
padding-left: 0.12em;
border-left: 1px solid var(--ms-divider);
}
.candidate.skin-microsoft.page-arrows-on.wnd-v .page-arrows { margin-left: -0.365em; }
.candidate.skin-microsoft .page-arrow {
width: 1.2em;
height: 1.2em;
border-radius: 4px;
color: var(--cand-text);
}
.candidate.skin-microsoft.wnd-h .page-arrow {
width: 1.73em;
height: 1.82em;
}
.candidate.skin-microsoft .page-arrow::before,
.candidate.skin-microsoft .page-arrow-next::before {
width: 0.47em;
height: 0.56em;
border: none;
transform: none;
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 12'%3E%3Cpath d='M7.43 4.46L2.57 1.54Q0 0 0 3L0 9Q0 12 2.57 10.46L7.43 7.54Q10 6 7.43 4.46Z' fill='black'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 12'%3E%3Cpath d='M7.43 4.46L2.57 1.54Q0 0 0 3L0 9Q0 12 2.57 10.46L7.43 7.54Q10 6 7.43 4.46Z' fill='black'/%3E%3C/svg%3E") center / contain no-repeat;
}
.candidate.skin-microsoft .page-arrow-prev::before { transform: scaleX(-1); }
.candidate.skin-microsoft .page-arrow.disabled { opacity: 0.25; }
.external-skin-heading {
display: flex; align-items: flex-start; justify-content: space-between; gap: 18px;
margin: 28px 4px 12px;
+28 -6
View File
@@ -534,6 +534,9 @@ class CandidateList : public Visual
// items, spread the spare width evenly over that line's items so the
// last one reaches the list's right edge (full-bleed highlights).
bool justifyHorizontalRows = false;
// Horizontal only: items narrower than this are widened to it, content
// stays leading. 0 keeps every item at its natural width.
float minItemWidth = 0.0f;
float contentPadLeft = 5.0f;
float contentPadRight = 5.0f;
// Part of itemHeight that is the row's bottom padding. Lines stacked
@@ -545,6 +548,8 @@ class CandidateList : public Visual
float selectedBarWidth = 3.0f;
float selectedBarHeight = 12.8f;
bool showSelectedBar = true;
// false centres the bar on the row's leading edge; true puts it just inside the row.
bool selectedBarInside = false;
D2D1_COLOR_F rowFillHover = D2D1::ColorF(0x343434);
D2D1_COLOR_F rowFillPressed = D2D1::ColorF(0x353535);
D2D1_COLOR_F rowFillSelected = D2D1::ColorF(0x3E3E3E, 0.725f);
@@ -662,9 +667,10 @@ class CandidateList : public Visual
ContextMenuHandler onContextMenu_;
};
// A pair of small "previous" / "next" chevron buttons side by side, e.g. to page a
// list. Chevrons are drawn as strokes, so they do not depend on any font. A
// disabled button keeps its slot (the layout does not jump) and ignores clicks.
// A pair of small "previous" / "next" buttons side by side, e.g. to page a list.
// The arrows are drawn as geometry (stroked chevrons or filled triangles), so they
// do not depend on any font. A disabled button keeps its slot (the layout does
// not jump) and ignores clicks. An optional vertical divider sits before them.
class PagerArrows : public Visual
{
public:
@@ -675,26 +681,42 @@ class PagerArrows : public Visual
Next,
};
enum class Glyph
{
Chevron,
Triangle,
};
struct Appearance
{
// Size of one button; the control is two buttons plus gap wide.
float buttonWidth = 12.0f;
float buttonHeight = 14.0f;
float gap = 0.0f;
// Chevron height and stroke, centred in its button.
Glyph glyph = Glyph::Chevron;
// Glyph height, centred in its button. strokeWidth is the chevron's line width, or for a
// triangle how far each corner is rounded off along its edges.
float glyphSize = 7.0f;
float strokeWidth = 1.3f;
float cornerRadius = 3.0f;
// A vertical rule as tall as the buttons, dividerGap before the previous
// button. dividerWidth 0 draws nothing and takes no space.
float dividerWidth = 0.0f;
float dividerGap = 0.0f;
D2D1_COLOR_F dividerColor = D2D1::ColorF(0, 0.0f);
D2D1_COLOR_F glyphColor = D2D1::ColorF(0xE9E8E8, 0.8f);
D2D1_COLOR_F disabledGlyphColor = D2D1::ColorF(0xE9E8E8, 0.25f);
D2D1_COLOR_F hoverFill = D2D1::ColorF(0x414141);
D2D1_COLOR_F pressedFill = D2D1::ColorF(0x353535);
// Horizontal extent of one chevron, so callers can line its tip up with neighbouring content.
// Horizontal extent of one glyph, so callers can line its tip up with neighbouring content.
float GlyphWidth() const
{
return glyphSize * 0.5f * 0.55f;
return glyph == Glyph::Triangle ? glyphSize * kTriangleAspect : glyphSize * 0.5f * 0.55f;
}
// Width of a triangle over its height.
static constexpr float kTriangleAspect = 0.84f;
};
using ClickHandler = std::function<void(Part part)>;
+7 -4
View File
@@ -432,9 +432,11 @@ SizeF CandidateList::Measure(const SizeF &availableSize)
horizontal
? std::max(textWidth, translationWidth)
: textWidth + (item.translation.empty() ? 0.0f : appearance_.fontSize * 0.65f + translationWidth);
const float width = appearance_.contentPadLeft + appearance_.textPadLeft +
std::max(EstimateTextWidth(item.label, appearance_.labelFontSize), 9.0f) +
appearance_.labelGap + contentWidth + appearance_.contentPadRight;
float width = appearance_.contentPadLeft + appearance_.textPadLeft +
std::max(EstimateTextWidth(item.label, appearance_.labelFontSize), 9.0f) + appearance_.labelGap +
contentWidth + appearance_.contentPadRight;
if (horizontal)
width = std::max(width, appearance_.minItemWidth);
widths.push_back(std::min(width, availableWidth));
maxWidth = std::max(maxWidth, width);
}
@@ -546,7 +548,8 @@ void CandidateList::Render(DeviceResources &deviceResources)
const float barWidth = (std::max)(appearance_.selectedBarWidth, 3.0f);
const float barHeight = (std::max)(appearance_.selectedBarHeight, barWidth * 2.0f);
const float barY = itemRect.y + std::max((itemRect.height - barHeight) * 0.5f, 0.0f);
const RectF selectedBar = {itemRect.x - barWidth * 0.5f, barY, barWidth, barHeight};
const float barX = appearance_.selectedBarInside ? itemRect.x : itemRect.x - barWidth * 0.5f;
const RectF selectedBar = {barX, barY, barWidth, barHeight};
FillRoundedRect(deviceResources, selectedBar, barWidth * 0.5f, appearance_.selectedBarColor,
appearance_.selectedBarColor, 0.0f);
}
+69 -1
View File
@@ -7,12 +7,62 @@
#include "ControlsInternal.h"
#include <algorithm>
#include <cmath>
#include <utility>
namespace msimeui
{
using namespace controls_detail;
namespace
{
// A solid triangle pointing at the leading ("◀") or trailing ("▶") side, centred in box. Each
// corner is cut roundness along both of its edges and joined by a curve through the corner, so
// the shape is one fill: a translucent (disabled) brush shows no darker overlap anywhere.
void RenderTriangle(ID2D1RenderTarget *target, const RectF &box, bool previous, float glyphSize, float roundness,
ID2D1SolidColorBrush *brush)
{
ComPtr<ID2D1Factory> factory;
target->GetFactory(factory.GetAddressOf());
ComPtr<ID2D1PathGeometry> geometry;
ComPtr<ID2D1GeometrySink> sink;
if (!factory || FAILED(factory->CreatePathGeometry(geometry.GetAddressOf())) ||
FAILED(geometry->Open(sink.GetAddressOf())))
{
return;
}
const float height = std::min(glyphSize, box.height);
const float width = height * PagerArrows::Appearance::kTriangleAspect;
const float cx = box.x + box.width * 0.5f;
const float cy = box.y + box.height * 0.5f;
const float tipX = previous ? cx - width * 0.5f : cx + width * 0.5f;
const float baseX = previous ? cx + width * 0.5f : cx - width * 0.5f;
const D2D1_POINT_2F corners[3] = {D2D1::Point2F(tipX, cy), D2D1::Point2F(baseX, cy - height * 0.5f),
D2D1::Point2F(baseX, cy + height * 0.5f)};
// Half the shortest edge at most, so neighbouring cuts never cross.
const float radius = std::clamp(roundness, 0.0f, height * 0.5f);
const auto toward = [radius](D2D1_POINT_2F from, D2D1_POINT_2F to) {
const float dx = to.x - from.x;
const float dy = to.y - from.y;
const float length = std::sqrt(dx * dx + dy * dy);
const float t = length > 0.0f ? radius / length : 0.0f;
return D2D1::Point2F(from.x + dx * t, from.y + dy * t);
};
sink->BeginFigure(toward(corners[0], corners[1]), D2D1_FIGURE_BEGIN_FILLED);
for (int i = 1; i <= 3; ++i)
{
const D2D1_POINT_2F corner = corners[i % 3];
sink->AddLine(toward(corner, corners[i - 1]));
sink->AddQuadraticBezier(D2D1::QuadraticBezierSegment(corner, toward(corner, corners[(i + 1) % 3])));
}
sink->EndFigure(D2D1_FIGURE_END_CLOSED);
if (SUCCEEDED(sink->Close()))
{
target->FillGeometry(geometry.Get(), brush);
}
}
} // namespace
void PagerArrows::SetAppearance(Appearance appearance)
{
appearance_ = std::move(appearance);
@@ -94,7 +144,8 @@ PagerArrows::Part PagerArrows::HitTestPart(const PointF &point) const
SizeF PagerArrows::Measure(const SizeF &availableSize)
{
(void)availableSize;
return {appearance_.buttonWidth * 2.0f + appearance_.gap, appearance_.buttonHeight};
const float divider = appearance_.dividerWidth > 0.0f ? appearance_.dividerWidth + appearance_.dividerGap : 0.0f;
return {divider + appearance_.buttonWidth * 2.0f + appearance_.gap, appearance_.buttonHeight};
}
void PagerArrows::Arrange(const RectF &finalRect)
@@ -110,6 +161,18 @@ void PagerArrows::Render(DeviceResources &deviceResources)
return;
}
if (appearance_.dividerWidth > 0.0f && appearance_.dividerColor.a > 0.001f)
{
// Measured against the previous button rather than bounds_, so a wider arranged box keeps them together.
const RectF previous = GetPartBounds(Part::Previous);
const RectF divider = {previous.x - appearance_.dividerGap - appearance_.dividerWidth, previous.y,
appearance_.dividerWidth, previous.height};
if (divider.x >= bounds_.x - 0.5f)
{
FillRoundedRect(deviceResources, divider, 0.0f, appearance_.dividerColor, D2D1::ColorF(0, 0.0f), 0.0f);
}
}
for (const Part part : {Part::Previous, Part::Next})
{
const RectF box = GetPartBounds(part);
@@ -126,6 +189,11 @@ void PagerArrows::Render(DeviceResources &deviceResources)
{
continue;
}
if (appearance_.glyph == Glyph::Triangle)
{
RenderTriangle(target, box, part == Part::Previous, appearance_.glyphSize, appearance_.strokeWidth, brush);
continue;
}
// A chevron twice as tall as it is wide: "<" for previous, ">" for next.
const float half = std::min(appearance_.glyphSize, box.height) * 0.5f;
const float depth = std::min(appearance_.GlyphWidth(), half * 0.55f);
+21
View File
@@ -376,6 +376,27 @@ TEST_CASE(horizontal_candidate_translation_area_activates_the_candidate)
REQUIRE(activated == 1);
}
TEST_CASE(horizontal_candidates_are_widened_to_the_minimum_item_width)
{
CandidateList list(28.0f);
CandidateList::Appearance appearance;
appearance.itemHeight = 28.0f;
appearance.itemGap = 4.0f;
appearance.minItemWidth = 90.0f;
list.SetAppearance(appearance);
list.SetOrientation(CandidateList::Orientation::Horizontal);
list.SetItems({{L"1", L"a", L"", L""}, {L"2", L"b", L"", L""}});
const SizeF size = list.MeasureInLayout({1000.0f, 1000.0f});
list.ArrangeInLayout({0.0f, 0.0f, size.width, size.height});
REQUIRE_NEAR(list.GetItemBounds(0).width, 90.0f);
REQUIRE_NEAR(list.GetItemBounds(1).x, 94.0f);
REQUIRE_NEAR(size.width, 184.0f);
// Vertical rows already span the list, so the minimum does not apply there.
list.SetOrientation(CandidateList::Orientation::Vertical);
REQUIRE(list.MeasureInLayout({1000.0f, 1000.0f}).width < 90.0f);
}
TEST_CASE(vertical_candidate_translation_stays_on_the_same_line)
{
CandidateList list(28.0f);
+22
View File
@@ -52,6 +52,28 @@ TEST_CASE(PagerArrowsKeepButtonsInTrailingBottomCorner)
REQUIRE(!pager->HitTest({105.0f, 55.0f}));
}
TEST_CASE(PagerArrowsDividerTakesRoomBeforeButtons)
{
auto pager = std::make_shared<PagerArrows>();
PagerArrows::Appearance appearance;
appearance.buttonWidth = 10.0f;
appearance.buttonHeight = 12.0f;
appearance.glyph = PagerArrows::Glyph::Triangle;
appearance.glyphSize = 10.0f;
appearance.dividerWidth = 1.0f;
appearance.dividerGap = 3.0f;
pager->SetAppearance(appearance);
const SizeF measured = pager->MeasureInLayout({500.0f, 500.0f});
REQUIRE_NEAR(measured.width, 24.0f);
REQUIRE_NEAR(measured.height, 12.0f);
REQUIRE_NEAR(appearance.GlyphWidth(), 8.4f);
// The divider is drawn left of the buttons, so the buttons still sit in the trailing corner.
pager->Arrange({0.0f, 0.0f, 24.0f, 12.0f});
REQUIRE_NEAR(pager->GetPartBounds(PagerArrows::Part::Previous).x, 4.0f);
REQUIRE(pager->HitTestPart({2.0f, 6.0f}) == PagerArrows::Part::None);
}
TEST_CASE(PagerArrowsTrackEnabledParts)
{
auto pager = MakePager();