diff --git a/server/src/window/candidate_presenter.cpp b/server/src/window/candidate_presenter.cpp index 7b2c889c..80ab5b66 100644 --- a/server/src/window/candidate_presenter.cpp +++ b/server/src/window/candidate_presenter.cpp @@ -109,6 +109,8 @@ struct CandSkinTokens float itemExtraHeight = 2.0f; float itemPadLeft = 0.0f; float itemPadRight = 0.0f; + // 横排时把卡片多出的宽度均分给同一行各项,铺满高亮的皮肤(杨柳青)末项才能贴到卡片右边。 + bool justifyRows = false; msimeui::Thickness preeditMargin{}; D2D1_COLOR_F rowTextSelected = D2D1::ColorF(0, 0.0f); D2D1_COLOR_F rowLabelSelected = D2D1::ColorF(0, 0.0f); @@ -424,6 +426,7 @@ void CandidatePresenter::ApplySkin() tokens.itemRadius = 0.0f; tokens.outerItemRadius = 9.0f; tokens.itemGap = 0.0f; + tokens.justifyRows = true; // 行内边距对齐 CSS 的 .row.cand padding(加上 .text 的左内边距),预编辑行对齐 .row.pinyin。 if (GetConfiguredCandidateWindowLayout() == "horizontal") { @@ -620,6 +623,9 @@ void CandidatePresenter::ApplySkin() appearance.annotationFontSize = fontSize; appearance.contentPadLeft = tokens.itemPadLeft; appearance.contentPadRight = tokens.itemPadRight; + // itemExtraHeight 上下各半:横排翻译换到第二行时从下半段内边距上方开始,内边距留在翻译下面, + // 对应 CSS 里 .row.cand 的 padding 包住整个 .text(杨柳青横排上下各 6px)。 + appearance.contentPadBottom = tokens.itemExtraHeight * 0.5f; appearance.textPadLeft = 5.0f; appearance.labelGap = 1.5f; appearance.selectedBarWidth = 3.0f; @@ -631,6 +637,7 @@ void CandidatePresenter::ApplySkin() const bool preeditHidden = GetConfiguredCandidateWindowPreeditStyle() == "empty"; appearance.outerCornerRadius = tokens.outerItemRadius; appearance.outerTopCornersEnabled = preeditHidden; + appearance.justifyHorizontalRows = tokens.justifyRows; // 隐藏的预编辑行高度为 0,但 body 仍在它和列表之间留 2px 行距;贴角皮肤把它抵掉, // 让首项贴着卡片的内容顶边(秋桂四边内边距一致,杨柳青高亮直抵卡片顶边)。 if (preeditHidden) diff --git a/ui/include/msimeui/Controls.h b/ui/include/msimeui/Controls.h index f83f9e9b..87e73466 100644 --- a/ui/include/msimeui/Controls.h +++ b/ui/include/msimeui/Controls.h @@ -530,8 +530,16 @@ class CandidateList : public Visual // row) sits between the list and the frame's top edge. float outerCornerRadius = 0.0f; bool outerTopCornersEnabled = true; + // Horizontal only: when the list is arranged wider than a line of + // items, spread the spare width evenly over that line's items so the + // last one reaches the list's right edge (full-bleed highlights). + bool justifyHorizontalRows = false; float contentPadLeft = 5.0f; float contentPadRight = 5.0f; + // Part of itemHeight that is the row's bottom padding. Lines stacked + // under the first one (wrapped annotation, horizontal translation) + // start above it, and the padding is restored below the last line. + float contentPadBottom = 0.0f; float textPadLeft = 0.0f; float labelGap = 1.5f; float selectedBarWidth = 3.0f; diff --git a/ui/src/CandidateList.cpp b/ui/src/CandidateList.cpp index 55939081..d25fcc98 100644 --- a/ui/src/CandidateList.cpp +++ b/ui/src/CandidateList.cpp @@ -325,6 +325,10 @@ CandidateList::ItemGeometry CandidateList::MeasureItem(size_t index, float width geometry.text = {textX, 0.0f, textW, textH}; float height = textH; float lineEnd = textW; + // height 末尾属于行底内边距的部分:只有单行高度(itemHeight)里才含这段内边距, + // 换到下方的辅助码和翻译从它上面开始排,排完再把它补回最后一行下面。 + const float padBottom = std::clamp(appearance_.contentPadBottom, 0.0f, appearance_.itemHeight * 0.5f); + float padBelow = textH > appearance_.itemHeight ? 0.0f : padBottom; // 短字段保持原有并排方式;空间不足时让辅助码和翻译完整换行。 if (!item.annotation.empty()) @@ -334,9 +338,13 @@ CandidateList::ItemGeometry CandidateList::MeasureItem(size_t index, float width const bool inlineAnnotation = lineEnd + 4.0f + annotationW <= contentWidth; const float annotationH = std::max( appearance_.itemHeight, MeasureTextHeight(item.annotation, appearance_.annotationFontSize, annotationW)); - geometry.annotation = {textX + (inlineAnnotation ? lineEnd + 4.0f : 0.0f), inlineAnnotation ? 0.0f : height, - annotationW, annotationH}; - height = std::max(height, geometry.annotation.y + annotationH); + geometry.annotation = {textX + (inlineAnnotation ? lineEnd + 4.0f : 0.0f), + inlineAnnotation ? 0.0f : height - padBelow, annotationW, annotationH}; + if (geometry.annotation.y + annotationH > height) + { + height = geometry.annotation.y + annotationH; + padBelow = annotationH > appearance_.itemHeight ? 0.0f : padBottom; + } lineEnd = geometry.annotation.x - textX + annotationW; } if (!item.translation.empty()) @@ -353,9 +361,9 @@ CandidateList::ItemGeometry CandidateList::MeasureItem(size_t index, float width : (naturalTranslationWidth <= contentWidth ? fontSize * 1.25f : std::max(fontSize * 1.25f, MeasureTextHeight(item.translation, fontSize, translationW))); - geometry.translation = {textX + (inlineTranslation ? lineEnd + gap : 0.0f), inlineTranslation ? 0.0f : height, - translationW, translationH}; - height = std::max(height, geometry.translation.y + translationH); + geometry.translation = {textX + (inlineTranslation ? lineEnd + gap : 0.0f), + inlineTranslation ? 0.0f : height - padBelow, translationW, translationH}; + height = std::max(height, geometry.translation.y + translationH + (inlineTranslation ? 0.0f : padBelow)); } geometry.bounds = {0.0f, 0.0f, width, height}; return geometry; @@ -377,6 +385,26 @@ RectF CandidateList::ItemRect(size_t index) const rect.x = bounds_.x; rect.width = bounds_.width; } + else if (appearance_.justifyHorizontalRows) + { + // 横排铺满:列表比这一行的自然宽度宽时(卡片保持了同一输入下更宽的尺寸),把多出的宽度 + // 均分给这一行的每一项,末项右边贴着列表右边,高亮不会在行尾留下一截空白。 + const float lineY = itemGeometry_[index].bounds.y; + size_t first = index; + while (first > 0 && itemGeometry_[first - 1].bounds.y == lineY) + --first; + size_t last = index; + while (last + 1 < itemGeometry_.size() && itemGeometry_[last + 1].bounds.y == lineY) + ++last; + const RectF &tail = itemGeometry_[last].bounds; + const float extra = bounds_.width - (tail.x + tail.width); + if (extra > 0.0f) + { + const float share = extra / static_cast(last - first + 1); + rect.x += share * static_cast(index - first); + rect.width += share; + } + } return rect; } diff --git a/ui/tests/src/test_layout.cpp b/ui/tests/src/test_layout.cpp index 9a9f62d9..05a9509f 100644 --- a/ui/tests/src/test_layout.cpp +++ b/ui/tests/src/test_layout.cpp @@ -244,6 +244,46 @@ TEST_CASE(horizontal_candidate_translation_uses_a_second_line) REQUIRE_NEAR(list.MeasureInLayout({1000.0f, 1000.0f}).height, plain.height); } +TEST_CASE(horizontal_candidate_translation_keeps_the_row_bottom_padding_below_it) +{ + // 行高里的底部内边距挪到翻译下面,而不是夹在候选与翻译之间;挪动不应让整行变高。 + CandidateList list(38.0f); + CandidateList::Appearance appearance; + appearance.itemHeight = 38.0f; + appearance.contentPadBottom = 5.0f; + list.SetAppearance(appearance); + list.SetOrientation(CandidateList::Orientation::Horizontal); + list.SetItems({{L"1", L"candidate", L"", L"word"}}); + const float translationH = appearance.fontSize * 0.78f * 1.25f; + REQUIRE_NEAR(list.MeasureInLayout({1000.0f, 1000.0f}).height, 38.0f + translationH); + list.ArrangeInLayout({0.0f, 0.0f, 1000.0f, 38.0f + translationH}); + const RectF item = list.GetItemBounds(0); + REQUIRE_NEAR(item.height, 38.0f + translationH); +} + +TEST_CASE(justified_horizontal_candidates_spread_spare_width_to_the_right_edge) +{ + CandidateList list(28.0f); + CandidateList::Appearance appearance; + appearance.itemHeight = 28.0f; + appearance.itemGap = 0.0f; + appearance.justifyHorizontalRows = true; + list.SetAppearance(appearance); + list.SetOrientation(CandidateList::Orientation::Horizontal); + list.SetItems({{L"1", L"候选", L"", L""}, {L"2", L"候选", L"", L""}}); + const SizeF natural = list.MeasureInLayout({1000.0f, 1000.0f}); + const float naturalFirstWidth = list.GetItemBounds(0).width; + + // 卡片保持了更宽的尺寸:多出的 40 均分给两项,末项右边贴着列表右边。 + list.ArrangeInLayout({10.0f, 0.0f, natural.width + 40.0f, natural.height}); + const RectF first = list.GetItemBounds(0); + const RectF second = list.GetItemBounds(1); + REQUIRE_NEAR(first.x, 10.0f); + REQUIRE_NEAR(first.width, naturalFirstWidth + 20.0f); + REQUIRE_NEAR(second.x, first.x + first.width); + REQUIRE_NEAR(second.x + second.width, 10.0f + natural.width + 40.0f); +} + TEST_CASE(vertical_candidate_rows_fill_a_wider_arranged_width) { // 竖排候选项的自然宽度较窄,但被分配到更宽的行宽时(如卡片最小宽度撑大),