From a5b49faa6b74547be6d5157da771b3343631fe04 Mon Sep 17 00:00:00 2001 From: kingzcheung Date: Thu, 27 Aug 2026 16:11:41 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(input-engine):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=80=81=E6=96=87=E6=9C=AC=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正 RIME 引擎回显分隔符处理,区分原始键入串与展示串 - 非 T9 方案使用 preeditText 带分隔符回显供候选栏展示 - 保留 inputText 作为原始键入串用于提交路径,避免分隔符混入 - 修复联想态候选栏布局对齐问题,添加空占位保持垂直基准一致 - 清输入态时统一使用 inputText 作为 lastClearedText,确保撤回正确性 --- .../kingzcheung/xime/service/ImeKeyRouter.kt | 10 ++++----- .../xime/service/ImeKeyboardCallbacks.kt | 4 +++- .../xime/service/ImeSessionController.kt | 21 ++++++++++--------- .../xime/ui/keyboard/CandidateBar.kt | 12 +++++++++++ 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/kingzcheung/xime/service/ImeKeyRouter.kt b/app/src/main/java/com/kingzcheung/xime/service/ImeKeyRouter.kt index e86db62e..52b0b7ab 100644 --- a/app/src/main/java/com/kingzcheung/xime/service/ImeKeyRouter.kt +++ b/app/src/main/java/com/kingzcheung/xime/service/ImeKeyRouter.kt @@ -104,8 +104,6 @@ internal class ImeKeyRouter(private val service: XimeInputMethodService) { if (hasInputState(candState)) { // 输入态:只清输入态(等价于 clear_composition),并记录 lastClearedText 供下滑撤回。 // 需在 clearInputStateForKeys() 之前记录(该函数会清空 preeditText/inputText)。 - val codeInInputBox = SettingsPreferences.getInputTextLocation(service) == - SettingsPreferences.INPUT_TEXT_INPUT_BOX val pendingEnglish = candState.pendingEnglishText if (pendingEnglish.isNotEmpty()) { // 直接上屏模式:英文编码已逐字落盘,"清输入态"需回删屏上对应字符; @@ -119,10 +117,10 @@ internal class ImeKeyRouter(private val service: XimeInputMethodService) { } if (removed) service.lastClearedText = pendingEnglish } else { - service.lastClearedText = when { - codeInInputBox -> candState.preeditText - else -> candState.inputText - } + // 撤回重放用 inputText(原始键入串):preeditText 现为带回显分隔符的 + // 展示串(如 ni'hao),上屏必须无分隔符版本。T9 时 inputText 为合成显示态, + // 与 preeditText 同值,行为不变。 + service.lastClearedText = candState.inputText } clearInputStateForKeys() } else { diff --git a/app/src/main/java/com/kingzcheung/xime/service/ImeKeyboardCallbacks.kt b/app/src/main/java/com/kingzcheung/xime/service/ImeKeyboardCallbacks.kt index 0c1216cc..e7531413 100644 --- a/app/src/main/java/com/kingzcheung/xime/service/ImeKeyboardCallbacks.kt +++ b/app/src/main/java/com/kingzcheung/xime/service/ImeKeyboardCallbacks.kt @@ -327,7 +327,9 @@ internal fun rememberImeKeyboardCallbacks( if (text.isNotEmpty()) service.commitText(text) } } else if (cs.preeditText.isNotEmpty()) { - service.commitText(cs.preeditText) + // 提交原始键入串而非 preeditText:后者现为带回显分隔符的展示串(如 ni'hao), + // 直接上屏会混入分隔符。 + service.commitText(cs.inputText) service.rimeEngine.clearComposition() } } diff --git a/app/src/main/java/com/kingzcheung/xime/service/ImeSessionController.kt b/app/src/main/java/com/kingzcheung/xime/service/ImeSessionController.kt index 0cc1a9f4..0b32dac1 100644 --- a/app/src/main/java/com/kingzcheung/xime/service/ImeSessionController.kt +++ b/app/src/main/java/com/kingzcheung/xime/service/ImeSessionController.kt @@ -62,9 +62,11 @@ internal class ImeSessionController(private val service: XimeInputMethodService) displayComments = display.displayComments isComposing = display.isComposing } else { - // 非 T9 方案(如双拼)使用原始输入文本显示, - // 避免显示 rime speller 展开后的编码(如双拼 vjv → zhan b) - displayText = inputText + // 非 T9 方案:preeditText 用引擎回显(带音节分隔符,如全拼 ni'hao)供候选栏展示; + // inputText 保留原始键入串(无分隔)——空格/切模式等提交路径会把它直接上屏, + // 不能混入回显字符。历史上曾整体用 input 规避自装双拼插件的展开编码显示, + // 代价是全拼丢失分隔符;现仅显示层恢复 librime 标准回显。 + displayText = if (preeditText.isNotEmpty()) preeditText else inputText displayCandidates = filteredTexts displayComments = filteredComments isComposing = inputText.isNotEmpty() @@ -76,7 +78,8 @@ internal class ImeSessionController(private val service: XimeInputMethodService) } service.candidateState.value = service.candidateState.value.copy( - inputText = displayText, + // T9 保持合成显示态同源;非 T9 仅显示层用 preedit 回显,inputText 保留原始键入串 + inputText = if (isT9Schema) displayText else inputText, preeditText = displayText, candidates = displayCandidates, candidateComments = displayComments, @@ -162,11 +165,9 @@ internal class ImeSessionController(private val service: XimeInputMethodService) displayComments = display.displayComments isComposing = display.isComposing } else { - val lowerInput = result.inputText.lowercase() - val hasExtraContent = result.preeditText.any { c -> - !c.isWhitespace() && c != '\'' && !lowerInput.contains(c.lowercaseChar()) - } - displayText = if (result.preeditText.isNotEmpty() && hasExtraContent) result.preeditText else result.inputText + // 非 T9 方案:preeditText 用引擎回显(带音节分隔符,如全拼 ni'hao),空则回退 input; + // inputText 保留原始键入串供提交路径直接上屏(见 applyComposition 注释)。 + displayText = if (result.preeditText.isNotEmpty()) result.preeditText else result.inputText displayCandidates = filteredTexts displayComments = filteredComments isComposing = result.inputText.isNotEmpty() @@ -178,7 +179,7 @@ internal class ImeSessionController(private val service: XimeInputMethodService) } service.candidateState.value = service.candidateState.value.copy( - inputText = displayText, + inputText = if (isT9Schema) displayText else result.inputText, preeditText = displayText, candidates = displayCandidates, candidateComments = displayComments, diff --git a/app/src/main/java/com/kingzcheung/xime/ui/keyboard/CandidateBar.kt b/app/src/main/java/com/kingzcheung/xime/ui/keyboard/CandidateBar.kt index 42a01332..20a567bf 100644 --- a/app/src/main/java/com/kingzcheung/xime/ui/keyboard/CandidateBar.kt +++ b/app/src/main/java/com/kingzcheung/xime/ui/keyboard/CandidateBar.kt @@ -307,6 +307,18 @@ fun CandidateBar( .padding(horizontal = 0.dp) ) } + } else if (state is CandidateBarState.AssociationOnly && + !showInputBoxStyle && showInputTextRow + ) { + // 联想态没有编码可显示,但需与打字态的候选行处于同一垂直位置: + // 补一个与上方编码行完全等高的空占位(含相同 vertical padding), + // 使两种形态下候选行在 50dp 栏内的布局基准一致。 + Spacer( + modifier = Modifier + .padding(vertical = 0.5.dp) + .fillMaxWidth() + .height(16.dp) + ) } Row( From 013706e36466998dc53a64774e5d25c9f1aed2bc Mon Sep 17 00:00:00 2001 From: kingzcheung Date: Thu, 27 Aug 2026 18:13:43 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(CandidateBar):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E6=82=AC=E6=B5=AE=E6=B0=94=E6=B3=A1=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=B9=B6=E4=BC=98=E5=8C=96=E5=80=99=E9=80=89=E6=A0=8F?= =?UTF-8?q?UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现编码悬浮气泡绘制功能,在候选栏顶部显示拼音编码 - 重构候选栏布局,将编码显示从栏内移到悬浮气泡中 - 优化关联词分隔线显示逻辑,避免在纯联想态下显示多余分割线 - 改进深色/浅色主题适配,确保气泡在不同主题下正常显示 - 更新依赖子项目提交记录 --- .../xime/ui/keyboard/CandidateBar.kt | 172 +++++++++++++----- 1 file changed, 123 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/com/kingzcheung/xime/ui/keyboard/CandidateBar.kt b/app/src/main/java/com/kingzcheung/xime/ui/keyboard/CandidateBar.kt index 20a567bf..c78b364e 100644 --- a/app/src/main/java/com/kingzcheung/xime/ui/keyboard/CandidateBar.kt +++ b/app/src/main/java/com/kingzcheung/xime/ui/keyboard/CandidateBar.kt @@ -36,12 +36,27 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign @@ -222,10 +237,24 @@ fun CandidateBar( candidateListState.scrollToItem(0) } + // 编码气泡:候选栏内计算编码文本后回写此状态,供 Column 的 drawBehind 读取绘制。 + // drawBehind 在下一帧读取最新值,无需同步;初始值取自当前 state 保证首帧即显示。 + var preeditBubbleText by remember(state) { + mutableStateOf((state as? CandidateBarState.ChineseCandidates)?.preeditText + ?: (state as? CandidateBarState.ChineseCandidates)?.inputText ?: "") + } + val showPreeditBubble = showInputTextRow && preeditBubbleText.isNotEmpty() && !showInputBoxStyle + Column( modifier = modifier .fillMaxWidth() .height(50.dp) + .drawPreeditBubble( + text = preeditBubbleText, + enabled = showPreeditBubble, + bubbleColor = visuals.backgroundColor, + textColor = visuals.textColor + ) .background(visuals.backgroundColor) .padding(horizontal = horizontalPadding), verticalArrangement = Arrangement.Center @@ -273,53 +302,9 @@ fun CandidateBar( val displayText = (state as? CandidateBarState.ChineseCandidates)?.preeditText ?: (state as? CandidateBarState.ChineseCandidates)?.inputText ?: "" - val showInputText = showInputTextRow && displayText.isNotEmpty() && !showInputBoxStyle - - if (showInputText) { - val inputTextInteractionSource = remember { MutableInteractionSource() } - val isInputTextPressed by inputTextInteractionSource.collectIsPressedAsState() - - Box( - modifier = Modifier - .padding(vertical = 0.5.dp) - .fillMaxWidth() - .height(16.dp), - contentAlignment = Alignment.CenterStart, - - ) { - Text( - text = displayText, - color = visuals.textColor.copy(alpha = 0.8f), - fontSize = 12.sp, - fontWeight = FontWeight.Normal, - lineHeight = 16.sp, - maxLines = 1, - modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .background( - if (isInputTextPressed && callbacks.onInputTextClick != null) - (if (visuals.isDarkTheme) Color.White.copy(alpha = 0.15f) else Color.Black.copy( - alpha = 0.1f - )) - else - Color.Transparent - ) - .padding(horizontal = 0.dp) - ) - } - } else if (state is CandidateBarState.AssociationOnly && - !showInputBoxStyle && showInputTextRow - ) { - // 联想态没有编码可显示,但需与打字态的候选行处于同一垂直位置: - // 补一个与上方编码行完全等高的空占位(含相同 vertical padding), - // 使两种形态下候选行在 50dp 栏内的布局基准一致。 - Spacer( - modifier = Modifier - .padding(vertical = 0.5.dp) - .fillMaxWidth() - .height(16.dp) - ) - } + // 编码显示已改为候选栏顶部的悬浮气泡(drawBehind 绘制,见 drawPreeditBubble), + // 栏内不再为编码保留布局空间——打字态与联想态的候选行共用同一垂直位置。 + preeditBubbleText = displayText Row( modifier = Modifier @@ -427,7 +412,9 @@ fun CandidateBar( ) } - if (displayAssociation.isNotEmpty()) { + // 仅当左侧存在打字候选时才需要分隔线;纯联想态(无打字候选)下 + // 该竖线会孤悬列表最左缘,属多余元素。 + if (displayCandidates.isNotEmpty() && displayAssociation.isNotEmpty()) { item(key = "divider") { Box( modifier = Modifier @@ -650,3 +637,90 @@ fun CandidateItem( } } } + +/** + * 编码悬浮气泡:在候选栏顶部之上(栏外)绘制一个圆角胶囊气泡显示当前拼音编码。 + * + * 参考 SwipeBubble 的锚定方式,但为纯绘制实现: + * - 锚定宿主(候选栏 Column)左上角,气泡体向上悬浮于栏外空间; + * - drawBehind 绘制不参与布局、不拦截触摸事件——候选栏上方的快捷发送表单/ + * 手写区等 UI 不受任何布局影响; + * - IME 窗口为 MATCH_PARENT 全屏(onConfigureWindow),栏外绘制不会被窗口裁剪。 + * + * 视觉:浅色模式近白底/深色模式深灰底的圆角胶囊(92% 不透明),无边框无阴影; + * 编码文字在气泡内垂直居中;气泡与候选栏顶部之间留 2dp 间隙;宽度自适应, + * 超出宿主右缘时左移钳制。 + * + * 注意:不能使用传入的主题背景色——CandidateBarVisuals.backgroundColor 为 + * Color.Transparent(真实背景由外层绘制),以其合成会导致气泡无底色、 + * 文字与 app 内容混叠不可读。此处以候选文字亮度推断深浅模式取对比底色。 + */ +private fun Modifier.drawPreeditBubble( + text: String, + enabled: Boolean, + bubbleColor: Color, + textColor: Color +): Modifier = composed { + val density = LocalDensity.current + val cornerRadiusPx = with(density) { 4.dp.toPx() } + val horizontalPaddingPx = with(density) { 8.dp.toPx() } + val verticalPaddingPx = with(density) { 3.dp.toPx() } + val bubbleBottomGapPx = with(density) { 2.dp.toPx() } + val screenMarginPx = with(density) { 4.dp.toPx() } + val textSizePx = with(density) { 12.sp.toPx() } + + // 气泡基色:优先用传入的主题背景色;其为全透明(CandidateBarVisuals 传 + // Color.Transparent,真实背景由外层绘制)时按候选文字亮度推导, + // 保证浅色模式近白/深色模式深灰的可读对比。 + val isDarkTheme = textColor.luminance() > 0.5f + val bubbleBaseColor = if (bubbleColor.alpha > 0.01f) { + bubbleColor + } else { + if (isDarkTheme) Color(0xFF2D2F31) else Color(0xFFFAFAFA) + } + // 半透明:62% 不透明度 + val bubbleBgColor = bubbleBaseColor.copy(alpha = 0.62f) + + // 文本画笔:与 SwipeBubble 同款 nativeCanvas 绘制方式 + val bubbleTextPaint = remember(textColor, textSizePx) { + android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply { + textSize = textSizePx + color = textColor.copy(alpha = 0.9f).toArgb() + } + } + + drawBehind { + if (!enabled || text.isEmpty()) return@drawBehind + + val fontMetrics = bubbleTextPaint.fontMetrics + val textWidth = bubbleTextPaint.measureText(text) + // 文本实际渲染高度以可见字形区间(ascent..descent)计,避免 lineHeight 参与导致偏移 + val textHeight = fontMetrics.descent - fontMetrics.ascent + val bubbleWidth = textWidth + horizontalPaddingPx * 2 + val bubbleHeight = textHeight + verticalPaddingPx * 2 + + // 气泡贴候选栏左缘,右向延伸;超出宿主右缘时整体左移钳制。 + val clampedLeft = maxOf( + screenMarginPx, + minOf(0f, size.width - bubbleWidth - screenMarginPx).coerceAtLeast(screenMarginPx / 4f) + ) + // 底部间隙:气泡底缘距候选栏顶缘 1dp + val top = -bubbleBottomGapPx - bubbleHeight + + // 气泡主体 + drawRoundRect( + color = bubbleBgColor, + topLeft = Offset(clampedLeft, top), + size = Size(bubbleWidth, bubbleHeight), + cornerRadius = CornerRadius(cornerRadiusPx) + ) + + // 文本垂直居中:基线 = 气泡顶 + (气泡高 - (ascent + descent)) / 2, + // ascent/descent 均为负/正相对基线的偏移,该式把字形区中点对准气泡中点。 + drawIntoCanvas { composeCanvas -> + val baselineY = top + (bubbleHeight - (fontMetrics.ascent + fontMetrics.descent)) / 2f + composeCanvas.nativeCanvas.drawText(text, clampedLeft + horizontalPaddingPx, baselineY, bubbleTextPaint) + } + } +} +