feat(server,ui,ui-html): 候选窗可严格按文字宽度收紧

皮肤新增 [candidate_window] fit_content,设置页「外观」新增「候选窗口按文字收紧宽度」
(appearance.candidate_window_fit_content),两者取或。打开后:

- D2D 不再垫 160 DIP 卡片最小宽度和列表 80 DIP 下限,行尾不再多留 6 DIP,
  右侧留白与序号左侧留白相同;
- WebView2 去掉 .container 的 7em 下限;
- 同一输入里宽度不再只增不减,高度仍保持。

皮肤的 min_width_dip 与装饰图宽度照常生效;内置皮肤清单出厂为 false。
This commit is contained in:
fanlusky committed 2026-10-12 02:03:59 +08:00
1 parent fe12f90b2a
commit 797c0f5652
31 files changed
+201 -17

No files matched your search

+1
View File
@@ -97,6 +97,7 @@ icon = "#f6e4eb"
- 所有颜色键都可以写十六进制 `#rgb` / `#rgba` / `#rrggbb` / `#rrggbbaa`,也可以写 `rgb()` / `rgba()` 或 `transparent`。十六进制必须带 `#`,不透明度在末尾(`#RRGGBBAA`),详见 [schema/README.md](schema/README.md#颜色格式)。
- `font_family` 排在用户设置的字体前面,缺字时回落到用户字体;字号始终由用户设置决定。
- `page_arrows = true` 可以打开候选框里的翻页箭头;不写时沿用 `base` 内置皮肤的设置(出厂为关闭)。
- `fit_content = true` 让卡片严格按候选文字的宽度收紧,不再垫出厂最小宽度;`min_width_dip` 和装饰图宽度仍然生效。不写时沿用 `base` 内置皮肤的设置(出厂为关闭)。
- `[toolbar]` 下的所有键都可以省略,没写的沿用 `base` 皮肤;配置对 D2D 和 WebView2 两种工具栏渲染器都生效。
- `[supports].themes` 里声明的每套主题都要有对应的 `[candidate.<theme>]`;写了 `[toolbar]` 的话,也要有对应的 `[toolbar.<theme>]`。
+1
View File
@@ -20,6 +20,7 @@
- `shadow`:卡片阴影,`none` | `soft` | `strong`;不写则沿用 `base`。只影响候选卡片,不影响右键菜单
- `font_family`:候选字体名,UTF-8 编码后最长 64 字节(约 21 个汉字),不能含引号、反斜杠、逗号、分号、尖括号、花括号和反引号。排在用户设置的字体前面,缺字时仍回落到用户字体;字号不由皮肤决定
- `page_arrows`:候选框里的翻页箭头,`true` | `false`。横排时是候选右侧贴着最后一行的一小列,竖排时是最后一行下方的一矮行、“‹”与序号左对齐;颜色取 `number`,悬停底色取 `hover`。不写则沿用 `base` 的默认皮肤设置(数据目录 `skins\default\<base>\skin.toml`,出厂为 `false`)
- `fit_content`:卡片是否严格按候选文字所需的宽度收紧,`true` | `false`。打开后不再垫出厂的最小宽度(D2D 160 DIP、WebView2 `7em`),同一次输入里后到的翻译等内容消失时宽度也立刻缩回;`min_width_dip` 和装饰图 `width_dip` 仍然生效。不写则沿用 `base` 的默认皮肤设置(出厂为 `false`)
### `[candidate_window.decoration]`(可选)
@@ -53,6 +53,8 @@ class ManifestTests(unittest.TestCase):
self.assertIn(window["shadow"], ("none", "soft", "strong"))
if "page_arrows" in window:
self.assertIsInstance(window["page_arrows"], bool, "page_arrows")
if "fit_content" in window:
self.assertIsInstance(window["fit_content"], bool, "fit_content")
if "font_family" in window:
# Mirrors the Server catalog: the name is pasted into a quoted CSS font-family value.
self.assertRegex(window["font_family"], r"^[^\x00-\x1f\x7f\"'\\,;{}<>`]+$")
@@ -78,6 +80,7 @@ class ManifestTests(unittest.TestCase):
self.assertEqual(data["schema_version"], 1)
self.assertEqual(data["id"], skin_id)
self.assertIsInstance(data.get("candidate_window", {}).get("page_arrows", True), bool)
self.assertIsInstance(data.get("candidate_window", {}).get("fit_content", True), bool)
if __name__ == "__main__":
@@ -182,6 +182,8 @@ candidate_window_preedit_font_size = 15
cand_text_color = "auto"
# 每页候选词数量,可选:3, 4, 5, 6, 7, 8, 9
page_size = 6
# 候选窗口严格按文字所需的宽度收紧,不垫出厂最小宽度;皮肤 skin.toml 的 fit_content 打开时始终收紧
candidate_window_fit_content = false
# 候选窗口跟随光标
candidate_window_follow_cursor = true
# 候选窗口排列方式,可选:"vertical", "horizontal"
+2
View File
@@ -182,6 +182,8 @@ candidate_window_preedit_font_size = 16
cand_text_color = "auto"
# 每页候选词数量,可选:3, 4, 5, 6, 7, 8, 9
page_size = 8
# 候选窗口严格按文字所需的宽度收紧,不垫出厂最小宽度;皮肤 skin.toml 的 fit_content 打开时始终收紧
candidate_window_fit_content = false
# 候选窗口跟随光标
candidate_window_follow_cursor = true
# 候选窗口排列方式,可选:"vertical", "horizontal"
+2
View File
@@ -182,6 +182,7 @@ bool g_paired_punctuation_enabled = true;
std::string g_punctuation_lock = "follow";
std::string g_candidate_window_layout = "vertical";
bool g_candidate_window_follow_cursor = true;
bool g_candidate_window_fit_content = false;
std::string g_ui_backend = "d2d";
std::string g_ui_backend_active = "d2d";
std::string g_candidate_skin = "fluent";
@@ -667,6 +668,7 @@ bool LoadImeConfig()
const std::string layout = tbl["appearance"]["candidate_window_layout"].value_or(std::string("vertical"));
g_candidate_window_layout = layout == "horizontal" ? "horizontal" : "vertical";
g_candidate_window_follow_cursor = tbl["appearance"]["candidate_window_follow_cursor"].value_or(true);
g_candidate_window_fit_content = tbl["appearance"]["candidate_window_fit_content"].value_or(false);
g_ui_backend = NormalizeSmallWindowUiBackend(tbl["appearance"]["ui_backend"].value_or(std::string("d2d")));
const std::string skin = tbl["appearance"]["candidate_skin"].value_or(std::string("fluent"));
g_candidate_skin = IsValidCandidateSkinId(skin) ? skin : "fluent";
+3
View File
@@ -527,6 +527,9 @@ const std::string &GetConfiguredCandidateWindowLayout();
bool SetConfiguredCandidateWindowLayout(const std::string &layout);
bool GetConfiguredCandidateWindowFollowCursor();
bool SetConfiguredCandidateWindowFollowCursor(bool enabled);
// 候选框严格按文字宽度收紧;与皮肤 skin.toml 的 fit_content 取或,任一打开即生效。
bool GetConfiguredCandidateWindowFitContent();
bool SetConfiguredCandidateWindowFitContent(bool enabled);
// 固定排位候选词徽标:总开关 + 样式枚举("paperclip" | "pushpin" | "dot")。
bool GetConfiguredCandidateFixedBadge();
bool SetConfiguredCandidateFixedBadge(bool enabled);
@@ -207,6 +207,21 @@ bool SetConfiguredCandidateWindowFollowCursor(bool enabled)
return true;
}
bool GetConfiguredCandidateWindowFitContent()
{
return g_candidate_window_fit_content;
}
bool SetConfiguredCandidateWindowFitContent(bool enabled)
{
if (!WriteConfiguredValue("appearance", "candidate_window_fit_content", enabled ? "true" : "false"))
{
return false;
}
g_candidate_window_fit_content = enabled;
return true;
}
const std::string &GetConfiguredUiBackend()
{
return g_ui_backend;
+1
View File
@@ -226,6 +226,7 @@ extern bool g_paired_punctuation_enabled;
extern std::string g_punctuation_lock;
extern std::string g_candidate_window_layout;
extern bool g_candidate_window_follow_cursor;
extern bool g_candidate_window_fit_content;
extern std::string g_ui_backend;
extern std::string g_ui_backend_active;
extern std::string g_candidate_skin;
+3
View File
@@ -611,6 +611,7 @@ std::wstring BuildConfigMessage(bool refresh_skin_catalog)
{"settings_window_linger", GetConfiguredSettingsWindowLinger()},
{"candidate_window_layout", GetConfiguredCandidateWindowLayout()},
{"candidate_window_follow_cursor", GetConfiguredCandidateWindowFollowCursor()},
{"candidate_window_fit_content", GetConfiguredCandidateWindowFitContent()},
{"candidate_skin", GetConfiguredCandidateSkin()},
{"candidate_window_preedit_style", GetConfiguredCandidateWindowPreeditStyle()},
{"candidate_window_preedit_shuangpin_quanpin", GetConfiguredCandidateWindowPreeditShuangpinQuanpin()},
@@ -872,6 +873,8 @@ bool ApplyConfigUpdate(const json::object &data)
return SetConfiguredCandidateWindowLayout(json::value_to<std::string>(data.at("value")));
if (path == "appearance.candidate_window_follow_cursor")
return SetConfiguredCandidateWindowFollowCursor(json::value_to<bool>(data.at("value")));
if (path == "appearance.candidate_window_fit_content")
return SetConfiguredCandidateWindowFitContent(json::value_to<bool>(data.at("value")));
if (path == "appearance.candidate_skin")
return SetConfiguredCandidateSkin(json::value_to<std::string>(data.at("value")));
if (path == "appearance.candidate_window_preedit_style")
@@ -419,6 +419,11 @@ std::optional<Package> Load(const std::filesystem::path &skinsRoot, const std::s
SetError(error, "candidate_window.page_arrows 必须是 true 或 false");
return std::nullopt;
}
if (!ReadOptionalBool(*window, "fit_content", package.fitContent))
{
SetError(error, "candidate_window.fit_content 必须是 true 或 false");
return std::nullopt;
}
if (const toml::node *backgroundNode = window->get("background"))
{
const auto *background = backgroundNode->as_table();
@@ -570,6 +575,11 @@ std::optional<DefaultSkin> LoadDefault(const std::filesystem::path &skinsRoot, c
SetError(error, "candidate_window.page_arrows 必须是 true 或 false");
return std::nullopt;
}
if (!ReadOptionalBool(*window, "fit_content", skin.fitContent))
{
SetError(error, "candidate_window.fit_content 必须是 true 或 false");
return std::nullopt;
}
}
return skin;
}
@@ -591,4 +601,15 @@ bool ResolvePageArrows(const std::filesystem::path &skinsRoot, const std::string
const auto defaults = LoadDefault(skinsRoot, IsBuiltIn(base) ? base : "fluent");
return defaults && defaults->pageArrows ? *defaults->pageArrows : kDefaultPageArrows;
}
bool ResolveFitContent(const std::filesystem::path &skinsRoot, const std::string &skinId, const Package *package)
{
if (package && package->fitContent)
{
return *package->fitContent;
}
const std::string base = package ? package->base : skinId;
const auto defaults = LoadDefault(skinsRoot, IsBuiltIn(base) ? base : "fluent");
return defaults && defaults->fitContent ? *defaults->fitContent : kDefaultFitContent;
}
} // namespace CandidateSkinCatalog
+8
View File
@@ -88,6 +88,9 @@ struct Package
std::string decorationAlign = "right"; // left / center / right,相对卡片
// 候选框里的翻页箭头;未设置时沿用 base 默认皮肤的设置(见 ResolvePageArrows)。
std::optional<bool> pageArrows;
// 卡片严格按候选文字所需的宽度收紧,不再垫出厂最小宽度、也不在同一输入里只增不减;
// min_width_dip 与装饰图宽度仍然生效。未设置时沿用 base 默认皮肤的设置(见 ResolveFitContent)。
std::optional<bool> fitContent;
CandidateColors dark;
CandidateColors light;
// 覆盖基础皮肤的悬浮工具栏外框圆角;未设置时沿用基础皮肤。
@@ -115,12 +118,15 @@ struct DefaultSkin
std::string id;
std::string name;
std::optional<bool> pageArrows;
std::optional<bool> fitContent;
};
// 默认皮肤清单所在的子目录名,Scan 跳过它,外部皮肤也不能占用这个 ID。
inline constexpr const char *kDefaultSkinsFolder = "default";
// 默认皮肤清单缺失或没写 page_arrows 时的取值,与出厂清单一致:翻页箭头默认关闭。
inline constexpr bool kDefaultPageArrows = false;
// 默认皮肤清单缺失或没写 fit_content 时的取值:卡片保留出厂最小宽度。
inline constexpr bool kDefaultFitContent = false;
// 五个内置皮肤的 ID,按设置页的展示顺序。
const std::vector<std::string> &BuiltInIds();
@@ -137,4 +143,6 @@ std::optional<DefaultSkin> LoadDefault(const std::filesystem::path &skinsRoot, c
// skinId 是配置里的候选皮肤:内置皮肤读自己的默认清单;外部皮肤先看自己的 page_arrows,
// 没写再看 base 的默认清单。package 为外部皮肤已加载的清单,内置皮肤传 nullptr。
bool ResolvePageArrows(const std::filesystem::path &skinsRoot, const std::string &skinId, const Package *package);
// fit_content 的解析顺序同 ResolvePageArrows。
bool ResolveFitContent(const std::filesystem::path &skinsRoot, const std::string &skinId, const Package *package);
} // namespace CandidateSkinCatalog
@@ -174,7 +174,9 @@ window.MsimeStickyCandidateCard = function (reset, key) {
const follow = function (keptValue, natural) {
return natural >= keptValue || natural < keptValue * kStickyShrinkRatio ? natural : keptValue;
};
let width = follow(kept.width, rect.width);
// Skins with fit_content want the card exactly as wide as the text.
const fitContent = document.documentElement.dataset.fitContent === 'on';
let width = fitContent ? rect.width : follow(kept.width, rect.width);
const maxWidth = Number.parseFloat(box.style.maxWidth);
if (maxWidth > 0) width = Math.min(width, maxWidth);
const height = follow(kept.height, rect.height);
+22 -9
View File
@@ -471,23 +471,32 @@ bool InjectExternalToolbarSkin(std::wstring &html, const CandidateSkinCatalog::P
void InjectCandidateDocumentSkin(std::wstring &html, const std::wstring &builtInCss, const std::string &skin,
const std::string &base, const std::string &layout, const std::string &theme,
bool pageArrows)
bool pageArrows, bool fitContent)
{
if (html.empty())
return;
const size_t htmlTagEnd = html.find(L'>', html.find(L"<html"));
if (htmlTagEnd != std::wstring::npos)
{
// data-page-arrows 由候选页的共享样式读取,决定翻页箭头显示与否。
html.insert(htmlTagEnd, fmt::format(L" data-candidate-skin=\"{}\" data-candidate-base=\"{}\" "
L"data-candidate-layout=\"{}\" data-candidate-theme=\"{}\" "
L"data-page-arrows=\"{}\"",
string_to_wstring(skin), string_to_wstring(base), string_to_wstring(layout),
string_to_wstring(theme), pageArrows ? L"on" : L"off"));
// data-page-arrows 由候选页的共享样式读取,决定翻页箭头显示与否;
// data-fit-content 由下面的规则和卡片的只增不减脚本读取。
html.insert(htmlTagEnd,
fmt::format(L" data-candidate-skin=\"{}\" data-candidate-base=\"{}\" "
L"data-candidate-layout=\"{}\" data-candidate-theme=\"{}\" "
L"data-page-arrows=\"{}\" data-fit-content=\"{}\"",
string_to_wstring(skin), string_to_wstring(base), string_to_wstring(layout),
string_to_wstring(theme), pageArrows ? L"on" : L"off", fitContent ? L"on" : L"off"));
}
const size_t headEnd = html.find(L"</head>");
if (headEnd != std::wstring::npos && !builtInCss.empty())
html.insert(headEnd, L"<style id=\"built-in-candidate-skin\">" + builtInCss + L"</style>");
{
// fit_content 去掉各皮肤 .container 的 7em 下限,只留外部皮肤的 min_width_dip 与装饰图宽度
// (内置皮肤这两个变量未定义,取 0);选择器比外部皮肤的 .container:not(:empty) 更具体。
html.insert(headEnd, L"<style id=\"built-in-candidate-skin\">" + builtInCss +
L"\nhtml[data-fit-content=\"on\"] .container:not(:empty) { min-width: "
L"max(var(--msime-skin-min-width, 0px), var(--msime-skin-decoration-width, 0px)); "
L"}\n</style>");
}
}
} // namespace
@@ -551,8 +560,12 @@ int PrepareHtmlForWnds()
const bool pageArrows =
CandidateSkinCatalog::ResolvePageArrows(std::filesystem::path(assetPath + L"\\skins"), candidateSkin,
activeExternalCandidateSkin ? &*activeExternalCandidateSkin : nullptr);
const bool fitContent =
GetConfiguredCandidateWindowFitContent() ||
CandidateSkinCatalog::ResolveFitContent(std::filesystem::path(assetPath + L"\\skins"), candidateSkin,
activeExternalCandidateSkin ? &*activeExternalCandidateSkin : nullptr);
InjectCandidateDocumentSkin(::HTMLStringCandWnd, builtInCandidateCss, candidateSkin, baseCandidateSkin,
candidateLayout, candidateTheme, pageArrows);
candidateLayout, candidateTheme, pageArrows, fitContent);
if (activeExternalCandidateSkin)
{
const std::wstring skinsRoot = assetPath + L"\\skins";
+13 -3
View File
@@ -480,7 +480,7 @@ void CandidatePresenter::ApplySkin()
<< GetConfiguredCandidateFont() << '|' << GetConfiguredCandidateFontSize() << '|'
<< GetConfiguredCandidateWindowPreeditFontSize() << '|' << GetConfiguredCandidateWindowLayout() << '|'
<< GetConfiguredCandidateTextColor() << '|' << GetConfiguredCandidateWindowPreeditStyle();
fingerprint << '|' << GetConfiguredCandidateEnglishFont();
fingerprint << '|' << GetConfiguredCandidateEnglishFont() << '|' << GetConfiguredCandidateWindowFitContent();
// 同一个外部皮肤的 skin.toml 或图片改了,只有强制重载能看出来;带上重载代数让 D2D 也重读 manifest。
fingerprint << '|' << GetCandidateSkinReloadRevision();
for (const auto &font : GetConfiguredCandidateFallbackFonts())
@@ -526,6 +526,9 @@ void CandidatePresenter::ApplySkin()
RebuildScene();
lastSkinFingerprint_ = skinKey;
}
fitContent_ = GetConfiguredCandidateWindowFitContent() ||
CandidateSkinCatalog::ResolveFitContent(std::filesystem::path(skinsRoot), skinId,
package ? &*package : nullptr);
const float fontSize = static_cast<float>((std::max)(12, GetConfiguredCandidateFontSize()));
if (baseSkin == "fluent")
{
@@ -841,6 +844,10 @@ void CandidatePresenter::ApplySkin()
appearance.textPadLeft = 5.0f;
appearance.labelGap = tokens.labelGap;
appearance.minItemWidth = tokens.minItemWidth;
// 收紧宽度时右侧留白与序号左侧的留白相同,卡片两边对称。
appearance.fitContent = fitContent_;
if (fitContent_)
appearance.contentPadRight = appearance.contentPadLeft + appearance.textPadLeft;
appearance.selectedBarWidth = tokens.selectedBarWidth;
appearance.selectedBarHeight = fontSize * tokens.selectedBarHeightEm;
appearance.selectedBarInside = tokens.selectedBarInside;
@@ -962,7 +969,7 @@ void CandidatePresenter::ApplySkin()
// 底边贴着卡片顶边、不与卡片重叠,按 align 贴卡片左/中/右,图片在盒内等比居中(contain)。
decorationTopDip_ = 0.0f;
decorationWidthDip_ = 0.0f;
cardMinWidthDip_ = kCandidateMinWidthDip;
cardMinWidthDip_ = fitContent_ ? 0.0f : kCandidateMinWidthDip;
std::wstring decorationPath;
std::wstring backgroundPath;
const std::wstring packageRoot = package ? skinsRoot + L"\\" + string_to_wstring(package->id) + L"\\" : L"";
@@ -1565,7 +1572,10 @@ void CandidatePresenter::ShowFromGlobalState(POINT caret)
}
const msimeui::RectF natural = impl_->card->GetBounds();
const float maxCardWidthDip = (std::max)(cardMinWidthDip_, maxW - kShadowPadLeft - kShadowPadRight);
stickyCardWidthDip_ = (std::min)(StickyCardExtent(stickyCardWidthDip_, natural.width), maxCardWidthDip);
// fit_content 的卡片严格跟随文字宽度,只保留高度的只增不减。
stickyCardWidthDip_ = fitContent_
? natural.width
: (std::min)(StickyCardExtent(stickyCardWidthDip_, natural.width), maxCardWidthDip);
stickyCardHeightDip_ = StickyCardExtent(stickyCardHeightDip_, natural.height);
if (stickyCardWidthDip_ > natural.width + 0.5f || stickyCardHeightDip_ > natural.height + 0.5f)
{
+2
View File
@@ -62,6 +62,8 @@ class CandidatePresenter
// 卡片最小宽度:默认 160,外部皮肤的 min_width_dip 与装饰图宽度可以把它撑大,
// 装饰图因此始终落在卡片宽度之内(与 WebView2 端的 min-width 规则一致)。
float cardMinWidthDip_ = 160.0f;
// 皮肤开了 fit_content:没有默认的 160,卡片宽度也不在同一输入里只增不减。
bool fitContent_ = false;
std::string lastSkinFingerprint_;
int lastHostWidthPx_ = 0;
int lastHostHeightPx_ = 0;
+5 -1
View File
@@ -502,6 +502,7 @@ LRESULT CALLBACK WndProcCandWindow(HWND hwnd, UINT message, WPARAM wParam, LPARA
const std::string previous_character_set = GetConfiguredCharacterSet();
const std::string previous_layout = GetConfiguredCandidateWindowLayout();
const std::string previous_candidate_skin = GetConfiguredCandidateSkin();
const bool previous_fit_content = GetConfiguredCandidateWindowFitContent();
const bool previous_floating_toolbar = GetConfiguredFloatingToolbarEnabled();
const bool previous_caret_state_indicator = GetConfiguredCaretStateIndicatorEnabled();
const FloatingToolbarItemsConfig previous_floating_toolbar_items = GetConfiguredFloatingToolbarItems();
@@ -557,7 +558,9 @@ LRESULT CALLBACK WndProcCandWindow(HWND hwnd, UINT message, WPARAM wParam, LPARA
UpdateFtbCharacterSetState(::webviewFtbWnd);
FanyNamedPipe::EnqueueRefreshCandidatePageTask();
}
if (previous_layout != GetConfiguredCandidateWindowLayout())
// 收紧宽度写在候选页的 <html> 属性上,开关变了与换排列方式一样重建候选页;D2D 由皮肤指纹重新套用。
if (previous_layout != GetConfiguredCandidateWindowLayout() ||
previous_fit_content != GetConfiguredCandidateWindowFitContent())
ApplyConfiguredCandidateWindowLayout();
if (previous_candidate_skin != GetConfiguredCandidateSkin() ||
previous_theme_mode != GetConfiguredThemeMode() ||
@@ -577,6 +580,7 @@ LRESULT CALLBACK WndProcCandWindow(HWND hwnd, UINT message, WPARAM wParam, LPARA
}
if (::is_global_wnd_cand_shown &&
(previous_layout != GetConfiguredCandidateWindowLayout() ||
previous_fit_content != GetConfiguredCandidateWindowFitContent() ||
previous_candidate_skin != GetConfiguredCandidateSkin() ||
previous_theme_mode != GetConfiguredThemeMode() ||
previous_theme_cand != GetConfiguredThemeCand() || previous_font != GetConfiguredCandidateFont() ||
@@ -115,6 +115,36 @@ TEST_CASE(candidate_skin_page_arrows_reject_non_boolean_values)
Cleanup(root);
}
// fit_content resolves like page_arrows: the package's own value, else its base's default manifest, else off.
TEST_CASE(candidate_skin_fit_content_resolves_like_page_arrows)
{
const auto root = MakeSkinsRoot(L"fit-content");
REQUIRE(!CandidateSkinCatalog::kDefaultFitContent);
REQUIRE(!CandidateSkinCatalog::ResolveFitContent(root, "graphite", nullptr));
WriteDefaultSkin(root, "graphite", "[candidate_window]\nfit_content = true\n");
REQUIRE(CandidateSkinCatalog::ResolveFitContent(root, "graphite", nullptr));
REQUIRE(!CandidateSkinCatalog::ResolveFitContent(root, "fluent", nullptr));
// The two switches are independent.
REQUIRE(!CandidateSkinCatalog::ResolvePageArrows(root, "graphite", nullptr));
WriteExternalSkin(root, "inherit", "graphite", "");
WriteExternalSkin(root, "override", "graphite", "fit_content = false\n");
const auto inherit = CandidateSkinCatalog::Load(root, "inherit");
REQUIRE(inherit.has_value());
REQUIRE(!inherit->fitContent.has_value());
REQUIRE(CandidateSkinCatalog::ResolveFitContent(root, "inherit", &*inherit));
const auto explicitOff = CandidateSkinCatalog::Load(root, "override");
REQUIRE(explicitOff.has_value());
REQUIRE(!CandidateSkinCatalog::ResolveFitContent(root, "override", &*explicitOff));
WriteExternalSkin(root, "bad", "fluent", "fit_content = \"yes\"\n");
std::string error;
REQUIRE(!CandidateSkinCatalog::Load(root, "bad", &error).has_value());
REQUIRE(!error.empty());
Cleanup(root);
}
// skins/default holds the built-in skins' settings, not a skin package: the scan must not list it as a broken
// external skin, and it cannot be selected as one either.
TEST_CASE(candidate_skin_scan_skips_default_settings_folder)
+2 -1
View File
@@ -27,6 +27,7 @@
| --- | --- |
| `schema_version` / `id` / `name` | `id` 必须与目录名一致 |
| `[candidate_window]` `page_arrows` | 候选框里是否显示翻页箭头(`true` / `false`,出厂与缺省都是 `false`) |
| `[candidate_window]` `fit_content` | 候选框是否严格按文字所需的宽度收紧(`true` / `false`,出厂与缺省都是 `false`)。打开后不再垫出厂的最小宽度(D2D 160 DIP、WebView2 `7em`),同一次输入里后到的翻译等内容消失时宽度也立刻跟着缩回 |
外部皮肤没写的开关沿用它 `base` 对应的这份清单。改完后在设置页刷新皮肤列表,或重新选择一次皮肤即可生效;设置页「外观」和「皮肤」里的候选框预览会一并显示或隐藏箭头。
@@ -62,7 +63,7 @@ my-skin/
| `id` / `name` / `version` / `author` / `description` | 基本信息,`id` 建议与目录名一致 |
| `base` | 继承的内置皮肤,如 `fluent`;未写的键沿用 base |
| `[supports]` | `layouts`(`horizontal` / `vertical`)、`themes`(`dark` / `light`) |
| `[candidate_window]` | `min_width_dip`、`corner_radius_dip`(0–32)、`border_width_dip`(0–4)、`item_corner_radius_dip`(高亮圆角,0–16)、`shadow`(`none` / `soft` / `strong`)、`font_family`(排在用户字体前面)、`page_arrows`(翻页箭头:横排在候选右侧,竖排在最后一行下方、与序号左对齐;不写时沿用 base 的[默认皮肤设置](#默认皮肤设置default),出厂关闭) |
| `[candidate_window]` | `min_width_dip`、`corner_radius_dip`(0–32)、`border_width_dip`(0–4)、`item_corner_radius_dip`(高亮圆角,0–16)、`shadow`(`none` / `soft` / `strong`)、`font_family`(排在用户字体前面)、`page_arrows`(翻页箭头:横排在候选右侧,竖排在最后一行下方、与序号左对齐;不写时沿用 base 的[默认皮肤设置](#默认皮肤设置default),出厂关闭)、`fit_content`(卡片严格按文字宽度收紧,`min_width_dip` 与装饰图宽度仍然生效;不写时沿用 base 的默认皮肤设置,出厂关闭) |
| `[candidate_window.decoration]` | 卡片上方的装饰图:`image`、`top_inset_dip`、`width_dip`、`align`(`left` / `center` / `right`) |
| `[candidate_window.background]` | 卡片背景图:`image`、`fit`(`cover` / `contain` / `stretch`)、`opacity`(0–1) |
| `[candidate.dark]` / `[candidate.light]` | 候选配色:`accent`、`selected`、`hover`、`surface`、`border`、`text`、`number`、`translation`、`show_selected_bar`;细分配色 `candidate_text`、`preedit_text`、`preedit_caret`、`selected_text`、`selected_number`、`selected_translation`、`translation_pos`、`translation_separator`、`selected_translation_pos`、`selected_translation_separator`、`selected_bar`、`preedit_background`、`preedit_divider` |
+2
View File
@@ -7,3 +7,5 @@ name = "秋桂 Autumn osmanthus"
[candidate_window]
# 候选框里的翻页箭头(默认关闭):横排在候选右侧、贴着最后一行,竖排在最后一行下方、与序号左对齐
page_arrows = false
# 候选框严格按文字所需的宽度收紧(默认关闭):不垫出厂最小宽度,同一输入里也不只增不减
fit_content = false
+2
View File
@@ -7,3 +7,5 @@ name = "Fluent 主题"
[candidate_window]
# 候选框里的翻页箭头(默认关闭):横排在候选右侧、贴着最后一行,竖排在最后一行下方、与序号左对齐
page_arrows = false
# 候选框严格按文字所需的宽度收紧(默认关闭):不垫出厂最小宽度,同一输入里也不只增不减
fit_content = false
+2
View File
@@ -7,3 +7,5 @@ name = "石墨 Graphite"
[candidate_window]
# 候选框里的翻页箭头(默认关闭):横排在候选右侧、贴着最后一行,竖排在最后一行下方、与序号左对齐
page_arrows = false
# 候选框严格按文字所需的宽度收紧(默认关闭):不垫出厂最小宽度,同一输入里也不只增不减
fit_content = false
+2
View File
@@ -7,3 +7,5 @@ name = "微软 Microsoft"
[candidate_window]
# 候选框里的翻页箭头:与微软拼音一样默认打开。横排在候选右侧、隔一条分隔线,竖排在最后一行下方、与序号左对齐
page_arrows = true
# 候选框严格按文字所需的宽度收紧(默认关闭):不垫出厂最小宽度,同一输入里也不只增不减
fit_content = false
+2
View File
@@ -7,3 +7,5 @@ name = "微信绿主题"
[candidate_window]
# 候选框里的翻页箭头(默认关闭):横排在候选右侧、贴着最后一行,竖排在最后一行下方、与序号左对齐
page_arrows = false
# 候选框严格按文字所需的宽度收紧(默认关闭):不垫出厂最小宽度,同一输入里也不只增不减
fit_content = false
+2
View File
@@ -7,3 +7,5 @@ name = "杨柳青 Willow green"
[candidate_window]
# 候选框里的翻页箭头(默认关闭):横排在候选右侧、贴着最后一行,竖排在最后一行下方、与序号左对齐
page_arrows = false
# 候选框严格按文字所需的宽度收紧(默认关闭):不垫出厂最小宽度,同一输入里也不只增不减
fit_content = false
@@ -32,6 +32,7 @@ export type CandidateAppearanceConfig = {
candidate_window_preedit_font_size?: number;
cand_text_color?: string;
page_size?: number;
candidate_window_fit_content?: boolean;
candidate_window_follow_cursor?: boolean;
candidate_fixed_badge?: boolean;
candidate_fixed_badge_style?: string;
@@ -507,6 +508,9 @@ export function applyAppearanceConfig(
previewPageSize = candidateAppearance.page_size;
applyDropdownValue('candPageSizeBtn', 'candPageSizeMenu', String(candidateAppearance.page_size));
}
if (typeof candidateAppearance?.candidate_window_fit_content === 'boolean') {
applyToggleState('candidateFitContentToggleBtn', candidateAppearance.candidate_window_fit_content);
}
if (typeof candidateAppearance?.candidate_window_follow_cursor === 'boolean') {
applyToggleState('candidateFollowCursorToggleBtn', candidateAppearance.candidate_window_follow_cursor);
}
@@ -594,6 +598,9 @@ export async function setupAppearance() {
// 候选项排列方式
setupDropdownMenu('arrangeBtn', 'arrangeMenu', 'changeCandidateArrange');
setupToggleButton('candidateFitContentToggleBtn', (active) => {
postConfigUpdate('appearance.candidate_window_fit_content', active);
});
setupToggleButton('candidateFollowCursorToggleBtn', (active) => {
postConfigUpdate('appearance.candidate_window_follow_cursor', active);
});
@@ -54,6 +54,7 @@ function applyConfigData(data: Record<string, any>, target?: string): void {
candidate_window_preedit_font_size: data?.appearance?.candidate_window_preedit_font_size,
cand_text_color: data?.appearance?.cand_text_color,
page_size: data?.appearance?.page_size,
candidate_window_fit_content: data?.appearance?.candidate_window_fit_content,
candidate_window_follow_cursor: data?.appearance?.candidate_window_follow_cursor,
candidate_fixed_badge: data?.appearance?.candidate_fixed_badge,
candidate_fixed_badge_style: data?.appearance?.candidate_fixed_badge_style,
@@ -68,6 +68,16 @@
<!-- 候选窗样式 -->
<div class="section sec-cand-style">
<div class="section-header">
<div>
<div class="section-title">候选窗口按文字收紧宽度</div>
<div class="floating-toolbar-description">窗口宽度严格按候选文字所需渲染,不保留最小宽度;皮肤自身开启时始终收紧</div>
</div>
<div class="ftb-toggle-btn" id="candidateFitContentToggleBtn" role="switch" aria-checked="false" tabindex="0">
<div class="ftb-toggle-knob"></div>
</div>
</div>
<div class="divider"></div>
<div class="section-header">
<div>
<div class="section-title">候选窗口跟随光标</div>
+4
View File
@@ -555,6 +555,10 @@ class CandidateList : public Visual
// 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;
// Measure exactly to the content: no 80 DIP minimum list width and no
// slack after the text beyond the annotation gap. Pair it with a
// contentPadRight equal to contentPadLeft + textPadLeft for even margins.
bool fitContent = false;
float contentPadLeft = 5.0f;
float contentPadRight = 5.0f;
// Part of itemHeight that is the row's bottom padding. Lines stacked
+4 -2
View File
@@ -450,10 +450,12 @@ SizeF CandidateList::Measure(const SizeF &availableSize)
const float gap = appearance_.itemGap;
const bool horizontal = orientation_ == Orientation::Horizontal;
std::vector<float> widths;
float maxWidth = 80.0f;
float maxWidth = appearance_.fitContent ? 0.0f : 80.0f;
for (const auto &item : items_)
{
const float textWidth = EstimateTextWidth(item.text, appearance_.fontSize) + 6.0f +
// fitContent keeps only the gap MeasureItem puts before an inline annotation.
const float textSlack = !appearance_.fitContent ? 6.0f : (item.annotation.empty() ? 0.0f : 4.0f);
const float textWidth = EstimateTextWidth(item.text, appearance_.fontSize) + textSlack +
EstimateTextWidth(item.annotation, appearance_.annotationFontSize);
const float translationWidth = EstimateTextWidth(item.translation, appearance_.fontSize * 0.78f);
const float contentWidth =
+24
View File
@@ -397,6 +397,30 @@ TEST_CASE(horizontal_candidates_are_widened_to_the_minimum_item_width)
REQUIRE(list.MeasureInLayout({1000.0f, 1000.0f}).width < 90.0f);
}
TEST_CASE(fit_content_candidates_measure_without_minimum_width_or_slack)
{
CandidateList list(28.0f);
list.SetOrientation(CandidateList::Orientation::Vertical);
list.SetItems({{L"1", L"a", L"", L""}});
// Without fitContent a short list is floored to 80 DIP.
REQUIRE_NEAR(list.MeasureInLayout({1000.0f, 1000.0f}).width, 80.0f);
CandidateList::Appearance appearance;
appearance.itemHeight = 28.0f;
appearance.fitContent = true;
appearance.contentPadLeft = 0.0f;
appearance.textPadLeft = 5.0f;
appearance.contentPadRight = 0.0f;
list.SetAppearance(appearance);
const float bare = list.MeasureInLayout({1000.0f, 1000.0f}).width;
REQUIRE(bare < 80.0f);
// The trailing padding is the only thing after the text, so mirroring the leading one adds exactly that much.
appearance.contentPadRight = 5.0f;
list.SetAppearance(appearance);
REQUIRE_NEAR(list.MeasureInLayout({1000.0f, 1000.0f}).width, bare + 5.0f);
}
TEST_CASE(vertical_candidate_translation_stays_on_the_same_line)
{
CandidateList list(28.0f);