diff --git a/examples/skin-examples/README.md b/examples/skin-examples/README.md index 2663ff58..7df172de 100644 --- a/examples/skin-examples/README.md +++ b/examples/skin-examples/README.md @@ -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.]`;写了 `[toolbar]` 的话,也要有对应的 `[toolbar.]`。 diff --git a/examples/skin-examples/schema/README.md b/examples/skin-examples/schema/README.md index 0eec7c94..99b5af71 100644 --- a/examples/skin-examples/schema/README.md +++ b/examples/skin-examples/schema/README.md @@ -20,6 +20,7 @@ - `shadow`:卡片阴影,`none` | `soft` | `strong`;不写则沿用 `base`。只影响候选卡片,不影响右键菜单 - `font_family`:候选字体名,UTF-8 编码后最长 64 字节(约 21 个汉字),不能含引号、反斜杠、逗号、分号、尖括号、花括号和反引号。排在用户设置的字体前面,缺字时仍回落到用户字体;字号不由皮肤决定 - `page_arrows`:候选框里的翻页箭头,`true` | `false`。横排时是候选右侧贴着最后一行的一小列,竖排时是最后一行下方的一矮行、“‹”与序号左对齐;颜色取 `number`,悬停底色取 `hover`。不写则沿用 `base` 的默认皮肤设置(数据目录 `skins\default\\skin.toml`,出厂为 `false`) +- `fit_content`:卡片是否严格按候选文字所需的宽度收紧,`true` | `false`。打开后不再垫出厂的最小宽度(D2D 160 DIP、WebView2 `7em`),同一次输入里后到的翻译等内容消失时宽度也立刻缩回;`min_width_dip` 和装饰图 `width_dip` 仍然生效。不写则沿用 `base` 的默认皮肤设置(出厂为 `false`) ### `[candidate_window.decoration]`(可选) diff --git a/examples/skin-examples/tests/test_manifests.py b/examples/skin-examples/tests/test_manifests.py index c9b1297f..d5bc8d13 100644 --- a/examples/skin-examples/tests/test_manifests.py +++ b/examples/skin-examples/tests/test_manifests.py @@ -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__": diff --git a/installer/default_config/config.default.toml b/installer/default_config/config.default.toml index 2e2ca01f..776f1e0f 100644 --- a/installer/default_config/config.default.toml +++ b/installer/default_config/config.default.toml @@ -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" diff --git a/server/assets/config/config.toml b/server/assets/config/config.toml index bac95354..2847f28e 100644 --- a/server/assets/config/config.toml +++ b/server/assets/config/config.toml @@ -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" diff --git a/server/src/config/ime_config.cpp b/server/src/config/ime_config.cpp index 976f4cd6..059c8425 100644 --- a/server/src/config/ime_config.cpp +++ b/server/src/config/ime_config.cpp @@ -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"; diff --git a/server/src/config/ime_config.h b/server/src/config/ime_config.h index 32ee36c0..d91495c5 100644 --- a/server/src/config/ime_config.h +++ b/server/src/config/ime_config.h @@ -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); diff --git a/server/src/config/ime_config_appearance.cpp b/server/src/config/ime_config_appearance.cpp index 9404261c..54920aa2 100644 --- a/server/src/config/ime_config_appearance.cpp +++ b/server/src/config/ime_config_appearance.cpp @@ -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; diff --git a/server/src/config/ime_config_internal.h b/server/src/config/ime_config_internal.h index 649d5bef..597b95c5 100644 --- a/server/src/config/ime_config_internal.h +++ b/server/src/config/ime_config_internal.h @@ -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; diff --git a/server/src/settings/settings_app.cpp b/server/src/settings/settings_app.cpp index d4d8f091..c2075efd 100644 --- a/server/src/settings/settings_app.cpp +++ b/server/src/settings/settings_app.cpp @@ -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(data.at("value"))); if (path == "appearance.candidate_window_follow_cursor") return SetConfiguredCandidateWindowFollowCursor(json::value_to(data.at("value"))); + if (path == "appearance.candidate_window_fit_content") + return SetConfiguredCandidateWindowFitContent(json::value_to(data.at("value"))); if (path == "appearance.candidate_skin") return SetConfiguredCandidateSkin(json::value_to(data.at("value"))); if (path == "appearance.candidate_window_preedit_style") diff --git a/server/src/skin/candidate_skin_catalog.cpp b/server/src/skin/candidate_skin_catalog.cpp index be8bf28c..ccee07df 100644 --- a/server/src/skin/candidate_skin_catalog.cpp +++ b/server/src/skin/candidate_skin_catalog.cpp @@ -419,6 +419,11 @@ std::optional 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 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 diff --git a/server/src/skin/candidate_skin_catalog.h b/server/src/skin/candidate_skin_catalog.h index 3c70ba2f..dd8783e4 100644 --- a/server/src/skin/candidate_skin_catalog.h +++ b/server/src/skin/candidate_skin_catalog.h @@ -88,6 +88,9 @@ struct Package std::string decorationAlign = "right"; // left / center / right,相对卡片 // 候选框里的翻页箭头;未设置时沿用 base 默认皮肤的设置(见 ResolvePageArrows)。 std::optional pageArrows; + // 卡片严格按候选文字所需的宽度收紧,不再垫出厂最小宽度、也不在同一输入里只增不减; + // min_width_dip 与装饰图宽度仍然生效。未设置时沿用 base 默认皮肤的设置(见 ResolveFitContent)。 + std::optional fitContent; CandidateColors dark; CandidateColors light; // 覆盖基础皮肤的悬浮工具栏外框圆角;未设置时沿用基础皮肤。 @@ -115,12 +118,15 @@ struct DefaultSkin std::string id; std::string name; std::optional pageArrows; + std::optional 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 &BuiltInIds(); @@ -137,4 +143,6 @@ std::optional 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 diff --git a/server/src/webview2/windows_webview2_candidate.cpp b/server/src/webview2/windows_webview2_candidate.cpp index 4b268aaa..2a0ae6a2 100644 --- a/server/src/webview2/windows_webview2_candidate.cpp +++ b/server/src/webview2/windows_webview2_candidate.cpp @@ -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); diff --git a/server/src/webview2/windows_webview2_skin.cpp b/server/src/webview2/windows_webview2_skin.cpp index 808118da..686d0d55 100644 --- a/server/src/webview2/windows_webview2_skin.cpp +++ b/server/src/webview2/windows_webview2_skin.cpp @@ -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""); if (headEnd != std::wstring::npos && !builtInCss.empty()) - html.insert(headEnd, L""); + { + // fit_content 去掉各皮肤 .container 的 7em 下限,只留外部皮肤的 min_width_dip 与装饰图宽度 + // (内置皮肤这两个变量未定义,取 0);选择器比外部皮肤的 .container:not(:empty) 更具体。 + html.insert(headEnd, L""); + } } } // 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"; diff --git a/server/src/window/candidate_presenter.cpp b/server/src/window/candidate_presenter.cpp index b86427ba..7a6aaa54 100644 --- a/server/src/window/candidate_presenter.cpp +++ b/server/src/window/candidate_presenter.cpp @@ -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((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) { diff --git a/server/src/window/candidate_presenter.h b/server/src/window/candidate_presenter.h index d4ff9b62..6ca7f042 100644 --- a/server/src/window/candidate_presenter.h +++ b/server/src/window/candidate_presenter.h @@ -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; diff --git a/server/src/window/ime_windows_candidate.cpp b/server/src/window/ime_windows_candidate.cpp index 25625157..7dcfdf1c 100644 --- a/server/src/window/ime_windows_candidate.cpp +++ b/server/src/window/ime_windows_candidate.cpp @@ -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()) + // 收紧宽度写在候选页的 属性上,开关变了与换排列方式一样重建候选页;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() || diff --git a/server/tests/src/test_candidate_skin_catalog_page_arrows.cpp b/server/tests/src/test_candidate_skin_catalog_page_arrows.cpp index a866be8a..d8e9609c 100644 --- a/server/tests/src/test_candidate_skin_catalog_page_arrows.cpp +++ b/server/tests/src/test_candidate_skin_catalog_page_arrows.cpp @@ -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) diff --git a/skins/README.md b/skins/README.md index e7d95838..e0abc123 100644 --- a/skins/README.md +++ b/skins/README.md @@ -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` | diff --git a/skins/default/autumn_osmanthus/skin.toml b/skins/default/autumn_osmanthus/skin.toml index 8fdfc39d..e5b96594 100644 --- a/skins/default/autumn_osmanthus/skin.toml +++ b/skins/default/autumn_osmanthus/skin.toml @@ -7,3 +7,5 @@ name = "秋桂 Autumn osmanthus" [candidate_window] # 候选框里的翻页箭头(默认关闭):横排在候选右侧、贴着最后一行,竖排在最后一行下方、与序号左对齐 page_arrows = false +# 候选框严格按文字所需的宽度收紧(默认关闭):不垫出厂最小宽度,同一输入里也不只增不减 +fit_content = false diff --git a/skins/default/fluent/skin.toml b/skins/default/fluent/skin.toml index 1a0d4c81..7d971728 100644 --- a/skins/default/fluent/skin.toml +++ b/skins/default/fluent/skin.toml @@ -7,3 +7,5 @@ name = "Fluent 主题" [candidate_window] # 候选框里的翻页箭头(默认关闭):横排在候选右侧、贴着最后一行,竖排在最后一行下方、与序号左对齐 page_arrows = false +# 候选框严格按文字所需的宽度收紧(默认关闭):不垫出厂最小宽度,同一输入里也不只增不减 +fit_content = false diff --git a/skins/default/graphite/skin.toml b/skins/default/graphite/skin.toml index 7e4684b8..44cdfe9f 100644 --- a/skins/default/graphite/skin.toml +++ b/skins/default/graphite/skin.toml @@ -7,3 +7,5 @@ name = "石墨 Graphite" [candidate_window] # 候选框里的翻页箭头(默认关闭):横排在候选右侧、贴着最后一行,竖排在最后一行下方、与序号左对齐 page_arrows = false +# 候选框严格按文字所需的宽度收紧(默认关闭):不垫出厂最小宽度,同一输入里也不只增不减 +fit_content = false diff --git a/skins/default/microsoft/skin.toml b/skins/default/microsoft/skin.toml index 7ab338e4..f0e0fc91 100644 --- a/skins/default/microsoft/skin.toml +++ b/skins/default/microsoft/skin.toml @@ -7,3 +7,5 @@ name = "微软 Microsoft" [candidate_window] # 候选框里的翻页箭头:与微软拼音一样默认打开。横排在候选右侧、隔一条分隔线,竖排在最后一行下方、与序号左对齐 page_arrows = true +# 候选框严格按文字所需的宽度收紧(默认关闭):不垫出厂最小宽度,同一输入里也不只增不减 +fit_content = false diff --git a/skins/default/wechat/skin.toml b/skins/default/wechat/skin.toml index a3989b03..2fbda890 100644 --- a/skins/default/wechat/skin.toml +++ b/skins/default/wechat/skin.toml @@ -7,3 +7,5 @@ name = "微信绿主题" [candidate_window] # 候选框里的翻页箭头(默认关闭):横排在候选右侧、贴着最后一行,竖排在最后一行下方、与序号左对齐 page_arrows = false +# 候选框严格按文字所需的宽度收紧(默认关闭):不垫出厂最小宽度,同一输入里也不只增不减 +fit_content = false diff --git a/skins/default/willow_green/skin.toml b/skins/default/willow_green/skin.toml index 9688d827..2b2e2bd1 100644 --- a/skins/default/willow_green/skin.toml +++ b/skins/default/willow_green/skin.toml @@ -7,3 +7,5 @@ name = "杨柳青 Willow green" [candidate_window] # 候选框里的翻页箭头(默认关闭):横排在候选右侧、贴着最后一行,竖排在最后一行下方、与序号左对齐 page_arrows = false +# 候选框严格按文字所需的宽度收紧(默认关闭):不垫出厂最小宽度,同一输入里也不只增不减 +fit_content = false diff --git a/ui-html/webview2/settings/ime-settings/src/modules/appearance.ts b/ui-html/webview2/settings/ime-settings/src/modules/appearance.ts index bee9450e..70912bcc 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/appearance.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/appearance.ts @@ -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); }); diff --git a/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts b/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts index e5b413c2..651cc73d 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts @@ -54,6 +54,7 @@ function applyConfigData(data: Record, 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, diff --git a/ui-html/webview2/settings/ime-settings/src/partials/appearance.html b/ui-html/webview2/settings/ime-settings/src/partials/appearance.html index 61fd5305..f802f66d 100644 --- a/ui-html/webview2/settings/ime-settings/src/partials/appearance.html +++ b/ui-html/webview2/settings/ime-settings/src/partials/appearance.html @@ -68,6 +68,16 @@
+
+
+
候选窗口按文字收紧宽度
+
窗口宽度严格按候选文字所需渲染,不保留最小宽度;皮肤自身开启时始终收紧
+
+
+
+
+
+
候选窗口跟随光标
diff --git a/ui/include/msimeui/Controls.h b/ui/include/msimeui/Controls.h index 954c15ba..d2cae938 100644 --- a/ui/include/msimeui/Controls.h +++ b/ui/include/msimeui/Controls.h @@ -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 diff --git a/ui/src/CandidateList.cpp b/ui/src/CandidateList.cpp index b4c50269..f4b8458e 100644 --- a/ui/src/CandidateList.cpp +++ b/ui/src/CandidateList.cpp @@ -450,10 +450,12 @@ SizeF CandidateList::Measure(const SizeF &availableSize) const float gap = appearance_.itemGap; const bool horizontal = orientation_ == Orientation::Horizontal; std::vector 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 = diff --git a/ui/tests/src/test_layout.cpp b/ui/tests/src/test_layout.cpp index 7807e0f4..027cd8b6 100644 --- a/ui/tests/src/test_layout.cpp +++ b/ui/tests/src/test_layout.cpp @@ -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);