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:
1 parent
fe12f90b2a
commit
797c0f5652
31 files changed
+201
-17
No files matched your search
@@ -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>]`。
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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` |
|
||||
|
||||
@@ -7,3 +7,5 @@ name = "秋桂 Autumn osmanthus"
|
||||
[candidate_window]
|
||||
# 候选框里的翻页箭头(默认关闭):横排在候选右侧、贴着最后一行,竖排在最后一行下方、与序号左对齐
|
||||
page_arrows = false
|
||||
# 候选框严格按文字所需的宽度收紧(默认关闭):不垫出厂最小宽度,同一输入里也不只增不减
|
||||
fit_content = false
|
||||
@@ -7,3 +7,5 @@ name = "Fluent 主题"
|
||||
[candidate_window]
|
||||
# 候选框里的翻页箭头(默认关闭):横排在候选右侧、贴着最后一行,竖排在最后一行下方、与序号左对齐
|
||||
page_arrows = false
|
||||
# 候选框严格按文字所需的宽度收紧(默认关闭):不垫出厂最小宽度,同一输入里也不只增不减
|
||||
fit_content = false
|
||||
@@ -7,3 +7,5 @@ name = "石墨 Graphite"
|
||||
[candidate_window]
|
||||
# 候选框里的翻页箭头(默认关闭):横排在候选右侧、贴着最后一行,竖排在最后一行下方、与序号左对齐
|
||||
page_arrows = false
|
||||
# 候选框严格按文字所需的宽度收紧(默认关闭):不垫出厂最小宽度,同一输入里也不只增不减
|
||||
fit_content = false
|
||||
@@ -7,3 +7,5 @@ name = "微软 Microsoft"
|
||||
[candidate_window]
|
||||
# 候选框里的翻页箭头:与微软拼音一样默认打开。横排在候选右侧、隔一条分隔线,竖排在最后一行下方、与序号左对齐
|
||||
page_arrows = true
|
||||
# 候选框严格按文字所需的宽度收紧(默认关闭):不垫出厂最小宽度,同一输入里也不只增不减
|
||||
fit_content = false
|
||||
@@ -7,3 +7,5 @@ name = "微信绿主题"
|
||||
[candidate_window]
|
||||
# 候选框里的翻页箭头(默认关闭):横排在候选右侧、贴着最后一行,竖排在最后一行下方、与序号左对齐
|
||||
page_arrows = false
|
||||
# 候选框严格按文字所需的宽度收紧(默认关闭):不垫出厂最小宽度,同一输入里也不只增不减
|
||||
fit_content = false
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user