feat(server): 固定排位候选词显示可配置的徽标

固定排位徽标此前硬编码为 📌,嫌吵的用户既关不掉也换不了。
新增两个配置项:candidate_fixed_badge 总开关与 candidate_fixed_badge_style 样式枚举(paperclip/pushpin/dot),设置页外观分组提供开关与下拉,改动后立即刷新候选页。
配置存枚举名而非字面 emoji,TOML 与日志里不出现图形字符;展示层白名单与配置层同宽,手改 TOML 写错只会退成无徽标。默认样式改为 📎,比实心红钉安静。
This commit is contained in:
Jia Sui committed 2026-09-24 14:32:10 +08:00
1 parent 589092e603
commit eac9fa72b5
12 files changed
+201

No files matched your search

@@ -137,6 +137,10 @@ candidate_skin = "willow_green"
# pinyin -> 显示分词预编辑(与候选框当前行为一致)
# empty -> 隐藏顶部预编辑行
candidate_window_preedit_style = "pinyin"
# 固定排位候选词是否显示徽标
candidate_fixed_badge = true
# 固定排位候选词徽标样式,可选:"paperclip", "pushpin", "dot"
candidate_fixed_badge_style = "paperclip"
# TSF 行内预编辑样式,可选:"raw", "pinyin", "empty"
# raw -> 原始按键序列
# pinyin -> 分词后的拼音序列(Server 回包 Preedit)
+4
View File
@@ -136,6 +136,10 @@ candidate_skin = "fluent"
# pinyin -> 显示分词预编辑(与候选框当前行为一致)
# empty -> 隐藏顶部预编辑行
candidate_window_preedit_style = "pinyin"
# 固定排位候选词是否显示徽标
candidate_fixed_badge = true
# 固定排位候选词徽标样式,可选:"paperclip", "pushpin", "dot"
candidate_fixed_badge_style = "paperclip"
# TSF 行内预编辑样式,可选:"raw", "pinyin", "empty"
# raw -> 原始按键序列
# pinyin -> 分词后的拼音序列(Server 回包 Preedit)
+50
View File
@@ -157,6 +157,8 @@ std::string g_ui_backend = "d2d";
std::string g_ui_backend_active = "d2d";
std::string g_candidate_skin = "fluent";
std::string g_candidate_window_preedit_style = "pinyin";
bool g_candidate_fixed_badge = true;
std::string g_candidate_fixed_badge_style = "paperclip";
std::string g_theme_mode = "system";
std::string g_theme_settings = "follow";
std::string g_theme_cand = "follow";
@@ -211,6 +213,13 @@ bool IsValidCandidateSkinId(const std::string &skin)
});
}
// 固定排位徽标样式白名单。存枚举名而非字面 emoji:TOML/日志/diff 里不出现图形字符,
// 日后新增样式只改这里和展示层的映射。
bool IsValidCandidateFixedBadgeStyle(const std::string &style)
{
return style == "paperclip" || style == "pushpin" || style == "dot";
}
const std::vector<std::string_view> &AiAssistantProviders()
{
static const std::vector<std::string_view> providers{"deepseek", "openai", "siliconflow", "groq"};
@@ -1156,6 +1165,13 @@ bool LoadImeConfig()
tbl["appearance"]["candidate_window_preedit_style"].value_or(std::string("pinyin"));
g_candidate_window_preedit_style = preedit_style == "empty" ? "empty" : "pinyin";
}
g_candidate_fixed_badge = tbl["appearance"]["candidate_fixed_badge"].value_or(true);
{
// 非法样式一律回退到默认徽标,避免手改配置后出现无法删除的畸形标记
const std::string badge_style =
tbl["appearance"]["candidate_fixed_badge_style"].value_or(std::string("paperclip"));
g_candidate_fixed_badge_style = IsValidCandidateFixedBadgeStyle(badge_style) ? badge_style : "paperclip";
}
{
const std::string theme_mode = tbl["appearance"]["theme_mode"].value_or(std::string("system"));
if (theme_mode == "light" || theme_mode == "system" || theme_mode == "auto")
@@ -3064,6 +3080,40 @@ bool SetConfiguredCandidateWindowPreeditStyle(const std::string &style)
return true;
}
bool GetConfiguredCandidateFixedBadge()
{
return g_candidate_fixed_badge;
}
bool SetConfiguredCandidateFixedBadge(bool enabled)
{
if (!WriteConfiguredValue("appearance", "candidate_fixed_badge", enabled ? "true" : "false"))
{
return false;
}
g_candidate_fixed_badge = enabled;
return true;
}
const std::string &GetConfiguredCandidateFixedBadgeStyle()
{
return g_candidate_fixed_badge_style;
}
bool SetConfiguredCandidateFixedBadgeStyle(const std::string &style)
{
if (!IsValidCandidateFixedBadgeStyle(style))
{
return false;
}
if (!WriteConfiguredValue("appearance", "candidate_fixed_badge_style", EscapeTomlBasicString(style)))
{
return false;
}
g_candidate_fixed_badge_style = style;
return true;
}
namespace
{
std::string NormalizeThemeMode(const std::string &mode)
+5
View File
@@ -340,6 +340,11 @@ const std::string &GetConfiguredCandidateWindowLayout();
bool SetConfiguredCandidateWindowLayout(const std::string &layout);
bool GetConfiguredCandidateWindowFollowCursor();
bool SetConfiguredCandidateWindowFollowCursor(bool enabled);
// 固定排位候选词徽标:总开关 + 样式枚举("paperclip" | "pushpin" | "dot")。
bool GetConfiguredCandidateFixedBadge();
bool SetConfiguredCandidateFixedBadge(bool enabled);
const std::string &GetConfiguredCandidateFixedBadgeStyle();
bool SetConfiguredCandidateFixedBadgeStyle(const std::string &style);
// "d2d" | "webview2" — candidate, floating toolbar, and tray menu renderer.
// Changing this writes config immediately; the process snapshot does not switch
// until the IME server is restarted.
+5
View File
@@ -1169,6 +1169,10 @@ std::string BuildCurrentCandidatePage()
(current_scheme == SchemeType::Quanpin && GetConfiguredQuanpinHelpcodeEnabled() &&
GetConfiguredShowQuanpinHelpcodeInCandidateWindow()));
// 组页时读一次徽标配置,循环内不再逐条查
const bool show_fixed_badge = GetConfiguredCandidateFixedBadge();
const std::string fixed_badge_style = GetConfiguredCandidateFixedBadgeStyle();
const int start = ui.current_page_start();
const int loop = ui.current_page_count();
@@ -1208,6 +1212,7 @@ std::string BuildCurrentCandidatePage()
else if (item.source == CandidateSource::AiSuggestion)
view.badge = " 🤖";
view.fixed_position = item.fixed_position > 0;
ApplyFixedPositionBadge(view, show_fixed_badge, fixed_badge_style);
EnglishIme::TranslationQuery translation_query;
if (!translation_page && BuildTranslationQuery(item, translation_query))
{
+6
View File
@@ -399,6 +399,8 @@ std::wstring BuildConfigMessage(bool refresh_skin_catalog)
{"candidate_window_follow_cursor", GetConfiguredCandidateWindowFollowCursor()},
{"candidate_skin", GetConfiguredCandidateSkin()},
{"candidate_window_preedit_style", GetConfiguredCandidateWindowPreeditStyle()},
{"candidate_fixed_badge", GetConfiguredCandidateFixedBadge()},
{"candidate_fixed_badge_style", GetConfiguredCandidateFixedBadgeStyle()},
{"tsf_preedit_style", GetConfiguredTsfPreeditStyle()},
{"theme_mode", GetConfiguredThemeMode()},
{"theme_settings", GetConfiguredThemeSettings()},
@@ -614,6 +616,10 @@ bool ApplyConfigUpdate(const json::object &data)
return SetConfiguredCandidateSkin(json::value_to<std::string>(data.at("value")));
if (path == "appearance.candidate_window_preedit_style")
return SetConfiguredCandidateWindowPreeditStyle(json::value_to<std::string>(data.at("value")));
if (path == "appearance.candidate_fixed_badge")
return SetConfiguredCandidateFixedBadge(json::value_to<bool>(data.at("value")));
if (path == "appearance.candidate_fixed_badge_style")
return SetConfiguredCandidateFixedBadgeStyle(json::value_to<std::string>(data.at("value")));
if (path == "appearance.page_size")
return SetConfiguredCandidatePageSize(static_cast<int>(data.at("value").as_int64()));
if (path == "appearance.font")
+21
View File
@@ -3875,6 +3875,25 @@ HRESULT OnControllerCreatedSettingsWnd( //
PostSettingsConfig();
}
}
else if (path == "appearance.candidate_fixed_badge")
{
const bool value = json::value_to<bool>(data.at("value"));
if (SetConfiguredCandidateFixedBadge(value))
{
// 徽标在组页时拼进词条,不刷新的话要等下次上屏才看得见改动
FanyNamedPipe::EnqueueRefreshCandidatePageTask();
PostSettingsConfig();
}
}
else if (path == "appearance.candidate_fixed_badge_style")
{
const std::string value = json::value_to<std::string>(data.at("value"));
if (SetConfiguredCandidateFixedBadgeStyle(value))
{
FanyNamedPipe::EnqueueRefreshCandidatePageTask();
PostSettingsConfig();
}
}
else if (path == "appearance.page_size")
{
const int value = static_cast<int>(data.at("value").as_int64());
@@ -4665,6 +4684,8 @@ void PostSettingsConfig()
{"candidate_window_follow_cursor", GetConfiguredCandidateWindowFollowCursor()},
{"candidate_skin", GetConfiguredCandidateSkin()},
{"candidate_window_preedit_style", GetConfiguredCandidateWindowPreeditStyle()},
{"candidate_fixed_badge", GetConfiguredCandidateFixedBadge()},
{"candidate_fixed_badge_style", GetConfiguredCandidateFixedBadgeStyle()},
{"tsf_preedit_style", GetConfiguredTsfPreeditStyle()},
{"theme_mode", GetConfiguredThemeMode()},
{"theme_settings", GetConfiguredThemeSettings()},
+20
View File
@@ -8,6 +8,8 @@ struct CandidateViewItem
{
std::string text;
std::string annotation;
// 来源/状态徽标(☁️/🤖、固定排位 📎)。纯展示字段:D2D(text+badge 拼接)与
// WebView2(CandidateViewHtml)都只把它渲染进词条文本,page_words 与上屏内容不含它。
std::string badge;
std::string translation;
bool fixed_position = false;
@@ -44,6 +46,24 @@ inline std::string EscapeCandidateViewHtml(const std::string &text)
return result;
}
// 固定排位的词条补固定徽标:D2D 原生候选窗原本不显示任何固定标记,WebView2 侧也只有
// 整条变蓝这一种弱提示。复用 badge 的既有展示路径而非新增字段——D2D 拼 text+badge,
// WebView2 经 CandidateViewHtml 把 badge 转义进主文本 run(固定词条落在蓝色 span 内)。
// show/style 由调用方从配置求值后传入:本头是纯展示层,不读配置。样式默认 📎 而非 📌——
// 实心红钉在蓝底词条上像贴了张贴纸,回形针同样是「固定」语义但安静得多。
// 未列出的样式不加徽标,与配置层白名单同宽,手改 TOML 写错也不会渲染出畸形标记。
inline void ApplyFixedPositionBadge(CandidateViewItem &view, bool show, const std::string &style)
{
if (!view.fixed_position || !show)
return;
if (style == "pushpin")
view.badge += " \xF0\x9F\x93\x8C"; // 📌
else if (style == "dot")
view.badge += " \xC2\xB7"; // ·
else if (style == "paperclip")
view.badge += " \xF0\x9F\x93\x8E"; // 📎
}
inline std::string CandidateViewHtml(const CandidateViewItem &item)
{
std::string html = EscapeCandidateViewHtml(item.text + item.annotation + item.badge);
@@ -16,6 +16,53 @@ TEST_CASE(candidate_view_escapes_text_and_translation_without_turning_them_into_
REQUIRE_EQ(item.text, std::string("<&,\""));
}
TEST_CASE(fixed_position_candidates_get_pin_badge_visible_in_both_backends)
{
CandidateViewItem item;
item.text = "你好";
item.badge = " cloud";
item.fixed_position = true;
ApplyFixedPositionBadge(item, true, "paperclip");
// 追加而非覆盖:徽标并存时顺序稳定;WebView2 侧固定词条整体落在蓝色 span 内。
REQUIRE_EQ(item.badge, std::string(" cloud \xF0\x9F\x93\x8E"));
REQUIRE_EQ(CandidateViewHtml(item),
std::string("<span style=\"color:#379AD3\">你好 cloud \xF0\x9F\x93\x8E</span>"));
// 样式枚举逐个映射,存储的是名字而不是字面 emoji
CandidateViewItem pinned;
pinned.text = "钉";
pinned.fixed_position = true;
ApplyFixedPositionBadge(pinned, true, "pushpin");
REQUIRE_EQ(pinned.badge, std::string(" \xF0\x9F\x93\x8C"));
CandidateViewItem dotted;
dotted.text = "点";
dotted.fixed_position = true;
ApplyFixedPositionBadge(dotted, true, "dot");
REQUIRE_EQ(dotted.badge, std::string(" \xC2\xB7"));
// 开关关闭:不加徽标,但整条仍走蓝色 span(固定状态本身还有颜色提示)
CandidateViewItem hidden;
hidden.text = "你好";
hidden.fixed_position = true;
ApplyFixedPositionBadge(hidden, false, "paperclip");
REQUIRE(hidden.badge.empty());
REQUIRE_EQ(CandidateViewHtml(hidden), std::string("<span style=\"color:#379AD3\">你好</span>"));
// 未列入白名单的样式不加徽标,手改 TOML 写错也不会渲染出畸形标记
CandidateViewItem bogus;
bogus.text = "你好";
bogus.fixed_position = true;
ApplyFixedPositionBadge(bogus, true, "smiley");
REQUIRE(bogus.badge.empty());
// 未固定的词条不加徽标。
CandidateViewItem plain;
plain.text = "世界";
ApplyFixedPositionBadge(plain, true, "paperclip");
REQUIRE(plain.badge.empty());
REQUIRE_EQ(CandidateViewHtml(plain), std::string("世界"));
}
TEST_CASE(ui_backend_policy_keeps_settings_web_and_small_windows_native_by_default)
{
using namespace UiBackendPolicy;
@@ -33,6 +33,8 @@ export type CandidateAppearanceConfig = {
cand_text_color?: string;
page_size?: number;
candidate_window_follow_cursor?: boolean;
candidate_fixed_badge?: boolean;
candidate_fixed_badge_style?: string;
ui_backend?: string;
system_fonts?: string[];
};
@@ -487,6 +489,10 @@ export function applyAppearanceConfig(
if (typeof candidateAppearance?.candidate_window_follow_cursor === 'boolean') {
applyToggleState('candidateFollowCursorToggleBtn', candidateAppearance.candidate_window_follow_cursor);
}
if (typeof candidateAppearance?.candidate_fixed_badge === 'boolean') {
applyToggleState('candidateFixedBadgeToggleBtn', candidateAppearance.candidate_fixed_badge);
}
applyDropdownValue('candFixedBadgeStyleBtn', 'candFixedBadgeStyleMenu', candidateAppearance?.candidate_fixed_badge_style);
applyDropdownValue('uiBackendBtn', 'uiBackendMenu', candidateAppearance?.ui_backend);
populateFontMenus(candidateAppearance?.system_fonts);
renderFallbackFonts();
@@ -562,6 +568,10 @@ export async function setupAppearance() {
setupToggleButton('candidateFollowCursorToggleBtn', (active) => {
postConfigUpdate('appearance.candidate_window_follow_cursor', active);
});
setupToggleButton('candidateFixedBadgeToggleBtn', (active) => {
postConfigUpdate('appearance.candidate_fixed_badge', active);
});
setupDropdownMenu('candFixedBadgeStyleBtn', 'candFixedBadgeStyleMenu', '', true, 'appearance.candidate_fixed_badge_style');
// 候选窗样式
FONT_MENUS.forEach((menu) => {
@@ -55,6 +55,8 @@ function applyConfigData(data: Record<string, any>, target?: string): void {
cand_text_color: data?.appearance?.cand_text_color,
page_size: data?.appearance?.page_size,
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,
ui_backend: data?.appearance?.ui_backend,
system_fonts: data?.appearance?.system_fonts
}
@@ -54,6 +54,33 @@
</div>
</div>
<div class="divider"></div>
<div class="section-header">
<div>
<div class="section-title">固定候选徽标</div>
<div class="floating-toolbar-description">为手动固定的候选词显示角标,便于辨认</div>
</div>
<div class="ftb-toggle-btn active" id="candidateFixedBadgeToggleBtn" role="switch" aria-checked="true" tabindex="0">
<div class="ftb-toggle-knob"></div>
</div>
</div>
<div class="divider"></div>
<div class="section-header">
<div class="section-title">固定候选徽标样式</div>
<div class="dropdown">
<button class="dropdown-toggle" id="candFixedBadgeStyleBtn">
<span>回形针</span>
<div class="arrow">
<img src="./assets/arrow.svg" alt="arrow">
</div>
</button>
<div class="dropdown-menu" id="candFixedBadgeStyleMenu">
<div class="dropdown-item" data-value="paperclip">回形针</div>
<div class="dropdown-item" data-value="pushpin">图钉</div>
<div class="dropdown-item" data-value="dot">圆点</div>
</div>
</div>
</div>
<div class="divider"></div>
<div class="section-header">
<div class="section-title">候选窗主字体</div>
<div class="dropdown">