From b07ed786b98d37601ca436617bce44a316470a7f Mon Sep 17 00:00:00 2001 From: Shivang Date: Sun, 21 Dec 2025 14:12:53 -0500 Subject: [PATCH 01/20] fix: Align emoji background rendering with text positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add visualCol tracking to renderBackgrounds() to match renderText() - Handle surrogate pairs for proper actualCodePoint calculation - Expand emoji detection ranges to include Dingbats (U+2700-27BF) and Misc Symbols (U+2B00-2BFF) for emoji like ✅ and ⭐ - Skip standalone variation selectors (U+FE0F/FE0E) as they don't occupy visual space - Treat all emoji as 2-cell width for background rendering Fixes background misalignment for emoji with variation selectors (e.g., ⚠️, ☁️, ❤️) and emoji without them (e.g., ✅, ⭐). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../rendering/TerminalCanvasRenderer.kt | 84 +++++++++++++++---- 1 file changed, 68 insertions(+), 16 deletions(-) diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt index f3749b0b..307287c5 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt @@ -127,27 +127,79 @@ object TerminalCanvasRenderer { val line = snapshot.getLine(lineIndex) var col = 0 + var visualCol = 0 // Track visual position separately from buffer position while (col < ctx.visibleCols) { val char = line.charAt(col) val style = line.getStyleAt(col) - // Skip DWC markers + // Skip DWC markers (they don't occupy visual space) if (char == CharUtils.DWC) { col++ continue } + // Skip standalone variation selectors (they don't occupy visual space) + if (char.code == 0xFE0F || char.code == 0xFE0E) { + col++ + continue + } + // Round to pixel boundaries to avoid anti-aliasing artifacts - val x = kotlin.math.floor(col * ctx.cellWidth) + // Use visualCol for x position to match renderText + val x = kotlin.math.floor(visualCol * ctx.cellWidth) val y = kotlin.math.floor(row * ctx.cellHeight) - // Check if double-width - val isWcwidthDoubleWidth = char != ' ' && char != '\u0000' && - CharUtils.isDoubleWidthCharacter(char.code, ctx.ambiguousCharsAreDoubleWidth) + // Handle surrogate pairs (must match renderText logic lines 384-398) + val charAtCol1 = if (col + 1 < ctx.visibleCols) line.charAt(col + 1) else null + val charAtCol2 = if (col + 2 < ctx.visibleCols) line.charAt(col + 2) else null + + val lowSurrogate = if (Character.isHighSurrogate(char)) { + when { + charAtCol1 != null && Character.isLowSurrogate(charAtCol1) -> charAtCol1 + charAtCol1 == CharUtils.DWC && charAtCol2 != null && Character.isLowSurrogate(charAtCol2) -> charAtCol2 + else -> null + } + } else null + + val actualCodePoint = if (lowSurrogate != null && Character.isLowSurrogate(lowSurrogate)) { + Character.toCodePoint(char, lowSurrogate) + } else char.code + + // Check if double-width (must match renderText logic lines 400-402) + val wcwidthResult = char != ' ' && char != '\u0000' && + CharUtils.isDoubleWidthCharacter(actualCodePoint, ctx.ambiguousCharsAreDoubleWidth) + val isWcwidthDoubleWidth = charAtCol1 == CharUtils.DWC || wcwidthResult + + // Force double-width for high codepoints (must match renderText line 422) + val isDoubleWidth = if (actualCodePoint >= 0x1F100) true else isWcwidthDoubleWidth + + // Check for emoji with variation selector + // Extended ranges to cover all common emoji that render as 2 cells + val isEmojiOrWideSymbol = when (actualCodePoint) { + in 0x2600..0x26FF -> true // Misc symbols (includes ⚠ U+26A0, ☀ U+2600, ☁ U+2601) + in 0x2700..0x27BF -> true // Dingbats (includes ✅ U+2705, ❌ U+274C, ❤ U+2764) + in 0x2B00..0x2BFF -> true // Misc Symbols and Arrows (includes ⭐ U+2B50) + in 0x1F100..0x1F1FF -> true + in 0x1F300..0x1F9FF -> true + in 0x1F600..0x1F64F -> true + in 0x1F680..0x1F6FF -> true + else -> false + } + // Look for variation selector after the character (accounting for DWC if present) + val vsOffset = if (isWcwidthDoubleWidth) 2 else 1 + val vsChar = if (col + vsOffset < ctx.visibleCols) line.charAt(col + vsOffset) else null + val isEmojiWithVariationSelector = isEmojiOrWideSymbol && + vsChar != null && (vsChar.code == 0xFE0F || vsChar.code == 0xFE0E) + + // Determine visual width: all emoji render as 2 cells visually + val visualWidth = when { + isEmojiOrWideSymbol -> 2 // All emoji are 2 cells (with or without VS) + isDoubleWidth -> 2 + else -> 1 + } // Get attributes val isInverse = style?.hasOption(BossTextStyle.Option.INVERSE) ?: false - val isDim = style?.hasOption(BossTextStyle.Option.DIM) ?: false // Apply defaults FIRST, then swap if INVERSE val baseFg = style?.foreground?.let { ColorUtils.convertTerminalColor(it) } @@ -159,12 +211,10 @@ object TerminalCanvasRenderer { val bgColor = if (isInverse) baseFg else baseBg // Skip drawing if background matches default (canvas already has default bg) - // This avoids anti-aliasing artifacts from drawing same color on top if (bgColor != ctx.settings.defaultBackgroundColor) { - // Draw background (single or double width) - // Calculate end positions and round to pixel boundaries - val nextCol = if (isWcwidthDoubleWidth) col + 2 else col + 1 - val nextX = kotlin.math.ceil(nextCol * ctx.cellWidth) + // Calculate background dimensions using visual positions + val nextVisualCol = visualCol + visualWidth + val nextX = kotlin.math.ceil(nextVisualCol * ctx.cellWidth) val bgWidth = nextX - x val nextRow = row + 1 val nextY = kotlin.math.ceil(nextRow * ctx.cellHeight) @@ -180,12 +230,14 @@ object TerminalCanvasRenderer { ) } - // Skip next column if double-width - if (isWcwidthDoubleWidth) { - col++ - } - + // Advance buffer position (must match renderText col advancement) col++ + if (isWcwidthDoubleWidth) col++ // Skip DWC marker + if (isEmojiWithVariationSelector) col++ // Skip variation selector + if (lowSurrogate != null) col++ // Skip low surrogate + + // Advance visual position + visualCol += visualWidth } } } From 2557506844b455484cbbc1667ac464c6357988e4 Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 02:28:56 -0500 Subject: [PATCH 02/20] fix: Improve emoji rendering with explicit font and correct width classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use explicit Apple Color Emoji font for variation selector emoji on macOS (fixes ⚠️ rendering as text symbol instead of color emoji) - Fix isEmojiPresentation to only include Unicode Emoji_Presentation=Yes chars (fixes ✳ ✴ ✔ ✖ and others incorrectly rendering as 2-cell) - Add skip logic for ZWJ sequences, skin tone modifiers, and gender symbols in background rendering - Properly handle emoji with variation selectors in both rendering paths 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../bossterm/terminal/util/GraphemeUtils.kt | 66 +++++++- .../rendering/TerminalCanvasRenderer.kt | 142 ++++++++++++------ 2 files changed, 162 insertions(+), 46 deletions(-) diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt index 293345ae..ad21ccb2 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt @@ -112,7 +112,12 @@ object GraphemeUtils { // Fast path: single BMP character if (grapheme.length == 1) { - return CharUtils.mk_wcwidth(grapheme[0].code, ambiguousIsDWC).coerceAtLeast(0) + val codePoint = grapheme[0].code + // Check for emoji with Emoji_Presentation=Yes (should be 2 cells by default) + if (isEmojiPresentation(codePoint)) { + return 2 + } + return CharUtils.mk_wcwidth(codePoint, ambiguousIsDWC).coerceAtLeast(0) } // Check cache @@ -173,6 +178,12 @@ object GraphemeUtils { } } + // Check for single emoji with Emoji_Presentation=Yes (e.g., ✅, ⭐) + // These should be 2 cells even without variation selector + if (codePoints.size == 1 && isEmojiPresentation(codePoints.first())) { + return 2 + } + // For combining character sequences, only count base character var totalWidth = 0 var isFirst = true @@ -197,6 +208,59 @@ object GraphemeUtils { return totalWidth } + /** + * Checks if a code point has Emoji_Presentation=Yes in Unicode. + * These characters should render as emoji (2 cells) by default without needing U+FE0F. + * + * Note: This is a curated subset of characters that are commonly rendered as emoji. + * Characters like ❤ (U+2764) have Emoji_Presentation=No and need U+FE0F to render as emoji. + * + * @param codePoint The Unicode code point to check + * @return True if this character defaults to emoji presentation (2 cells) + */ + private fun isEmojiPresentation(codePoint: Int): Boolean { + return when (codePoint) { + // Weather and zodiac (selected) + 0x2614, 0x2615 -> true // Umbrella, coffee + in 0x2648..0x2653 -> true // Zodiac + 0x267F -> true // Wheelchair + 0x2693 -> true // Anchor + 0x26A1 -> true // High voltage + 0x26AA, 0x26AB -> true // Circles + 0x26BD, 0x26BE -> true // Sports balls + 0x26C4, 0x26C5 -> true // Snowman, sun/cloud + 0x26CE -> true // Ophiuchus + 0x26D4 -> true // No entry + 0x26EA -> true // Church + 0x26F2, 0x26F3 -> true // Fountain, golf + 0x26F5 -> true // Sailboat + 0x26FA -> true // Tent + 0x26FD -> true // Fuel pump + // Dingbats with Emoji_Presentation=Yes (verified against Unicode spec) + 0x2705 -> true // ✅ Check mark button + 0x2728 -> true // ✨ Sparkles + 0x274C -> true // ❌ Cross mark + 0x274E -> true // Cross mark button + in 0x2753..0x2755 -> true // Question/exclamation marks (❓❔❕) + 0x2757 -> true // ❗ Exclamation mark + in 0x2795..0x2797 -> true // Math operators (➕➖➗) + 0x27B0 -> true // ➰ Curly loop + 0x27BF -> true // ➿ Double curly loop + // Arrows and shapes + 0x2934, 0x2935 -> true // Curved arrows + in 0x2B05..0x2B07 -> true // Directional arrows + 0x2B1B, 0x2B1C -> true // Large squares + 0x2B50 -> true // ⭐ Star + 0x2B55 -> true // Heavy large circle + // Japanese symbols + 0x3030 -> true // Wavy dash + 0x303D -> true // Part alternation mark + 0x3297 -> true // Circled Ideograph Congratulation + 0x3299 -> true // Circled Ideograph Secret + else -> false + } + } + /** * Checks if a character is a grapheme extender (ZWJ, variation selector, skin tone, combining). * Used for incremental grapheme boundary detection in streaming scenarios. diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt index 307287c5..93650439 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt @@ -144,6 +144,42 @@ object TerminalCanvasRenderer { continue } + // Skip Zero-Width Joiner and all subsequent chars until DWC + // ZWJ sequences like 👨‍💻 are: [emoji1][ZWJ][emoji2][DWC] + // We already rendered emoji1, now skip ZWJ and everything after until DWC + if (char.code == 0x200D) { + col++ + // Skip all characters until we hit DWC (end of grapheme) + while (col < ctx.visibleCols) { + val nextChar = line.charAt(col) + if (nextChar == CharUtils.DWC) break + col++ + } + continue + } + + // Skip skin tone modifiers (U+1F3FB-U+1F3FF) - they extend the previous emoji + // These are surrogate pairs: high=0xD83C, low=0xDFFB-0xDFFF + if (Character.isHighSurrogate(char)) { + val nextChar = if (col + 1 < ctx.visibleCols) line.charAt(col + 1) else null + if (nextChar != null && Character.isLowSurrogate(nextChar)) { + val codePoint = Character.toCodePoint(char, nextChar) + // Skin tone modifiers + if (codePoint in 0x1F3FB..0x1F3FF) { + col += 2 // Skip both surrogate chars + continue + } + // Male/female signs used in ZWJ sequences (♀️ U+2640, ♂️ U+2642) + // These are BMP so handled below + } + } + + // Skip gender symbols that are part of ZWJ sequences (♀ U+2640, ♂ U+2642) + if (char.code == 0x2640 || char.code == 0x2642) { + col++ + continue + } + // Round to pixel boundaries to avoid anti-aliasing artifacts // Use visualCol for x position to match renderText val x = kotlin.math.floor(visualCol * ctx.cellWidth) @@ -168,35 +204,26 @@ object TerminalCanvasRenderer { // Check if double-width (must match renderText logic lines 400-402) val wcwidthResult = char != ' ' && char != '\u0000' && CharUtils.isDoubleWidthCharacter(actualCodePoint, ctx.ambiguousCharsAreDoubleWidth) - val isWcwidthDoubleWidth = charAtCol1 == CharUtils.DWC || wcwidthResult + // Check for DWC at col+1, OR DWC at col+2 when col+1 is variation selector + // For emoji+VS like ⚠️: Buffer = [⚠][FE0F][DWC] - DWC is at col+2 + val hasVariationSelector = charAtCol1 != null && (charAtCol1.code == 0xFE0F || charAtCol1.code == 0xFE0E) + val isWcwidthDoubleWidth = charAtCol1 == CharUtils.DWC || + (hasVariationSelector && charAtCol2 == CharUtils.DWC) || + wcwidthResult - // Force double-width for high codepoints (must match renderText line 422) - val isDoubleWidth = if (actualCodePoint >= 0x1F100) true else isWcwidthDoubleWidth + // Base double-width check + val isBaseDoubleWidth = if (actualCodePoint >= 0x1F100) true else isWcwidthDoubleWidth - // Check for emoji with variation selector - // Extended ranges to cover all common emoji that render as 2 cells - val isEmojiOrWideSymbol = when (actualCodePoint) { - in 0x2600..0x26FF -> true // Misc symbols (includes ⚠ U+26A0, ☀ U+2600, ☁ U+2601) - in 0x2700..0x27BF -> true // Dingbats (includes ✅ U+2705, ❌ U+274C, ❤ U+2764) - in 0x2B00..0x2BFF -> true // Misc Symbols and Arrows (includes ⭐ U+2B50) - in 0x1F100..0x1F1FF -> true - in 0x1F300..0x1F9FF -> true - in 0x1F600..0x1F64F -> true - in 0x1F680..0x1F6FF -> true - else -> false - } - // Look for variation selector after the character (accounting for DWC if present) - val vsOffset = if (isWcwidthDoubleWidth) 2 else 1 - val vsChar = if (col + vsOffset < ctx.visibleCols) line.charAt(col + vsOffset) else null - val isEmojiWithVariationSelector = isEmojiOrWideSymbol && - vsChar != null && (vsChar.code == 0xFE0F || vsChar.code == 0xFE0E) - - // Determine visual width: all emoji render as 2 cells visually - val visualWidth = when { - isEmojiOrWideSymbol -> 2 // All emoji are 2 cells (with or without VS) - isDoubleWidth -> 2 - else -> 1 - } + // Any character followed by variation selector (FE0F/FE0E) should be 2-cell + // This handles cases like ❤️ (U+2764 + FE0F) which is in Dingbats range + // hasVariationSelector already checks charAtCol1 for FE0F/FE0E + val isEmojiWithVariationSelector = hasVariationSelector + + // Emoji with variation selector should be double-width (must match renderText) + val isDoubleWidth = isBaseDoubleWidth || isEmojiWithVariationSelector + + // Determine visual width + val visualWidth = if (isDoubleWidth) 2 else 1 // Get attributes val isInverse = style?.hasOption(BossTextStyle.Option.INVERSE) ?: false @@ -432,7 +459,14 @@ object TerminalCanvasRenderer { val isCursiveOrMath = actualCodePoint in 0x1D400..0x1D7FF val isTechnicalSymbol = actualCodePoint in 0x23E9..0x23FF val isEmojiOrWideSymbol = when (actualCodePoint) { - in 0x2600..0x26FF -> true + // Misc symbols - but exclude text presentation symbols + in 0x2600..0x26FF -> when (actualCodePoint) { + // Exclude stars (text symbols) + 0x2605, 0x2606 -> false // ★ ☆ + // Exclude card suits (text symbols) + in 0x2660..0x2667 -> false // ♠ ♡ ♢ ♣ ♤ ♥ ♦ ♧ + else -> true + } in 0x1F100..0x1F1FF -> true in 0x1F300..0x1F9FF -> true in 0x1F600..0x1F64F -> true @@ -440,13 +474,16 @@ object TerminalCanvasRenderer { else -> false } - val isDoubleWidth = if (actualCodePoint >= 0x1F100) true else isWcwidthDoubleWidth + val isBaseDoubleWidth = if (actualCodePoint >= 0x1F100) true else isWcwidthDoubleWidth - // Check for variation selector + // Check for variation selector - any character followed by FE0F/FE0E is emoji presentation val nextCharOffset = if (isWcwidthDoubleWidth) 2 else 1 val nextChar = if (col + nextCharOffset < snapshot.width) line.charAt(col + nextCharOffset) else null - val isEmojiWithVariationSelector = isEmojiOrWideSymbol && - nextChar != null && (nextChar.code == 0xFE0F || nextChar.code == 0xFE0E) + val hasVariationSelector = nextChar != null && (nextChar.code == 0xFE0F || nextChar.code == 0xFE0E) + val isEmojiWithVariationSelector = hasVariationSelector + + // Emoji with variation selector should be double-width + val isDoubleWidth = isBaseDoubleWidth || isEmojiWithVariationSelector // Skip standalone variation selectors if ((char.code == 0xFE0F || char.code == 0xFE0E) && !isEmojiOrWideSymbol) { @@ -877,8 +914,17 @@ object TerminalCanvasRenderer { isMacOS // Default: system font on macOS, bundled on Linux } val fontForChar = if (isEmojiWithVariationSelector) { - // True color emoji (with variation selector) - use system font for color rendering - FontFamily.Default + // True color emoji (with variation selector) - use explicit emoji font for reliable color rendering + if (isMacOS) { + val appleColorEmoji = FontMgr.default.matchFamilyStyle("Apple Color Emoji", org.jetbrains.skia.FontStyle.NORMAL) + if (appleColorEmoji != null) { + FontFamily(androidx.compose.ui.text.platform.Typeface(appleColorEmoji)) + } else { + FontFamily.Default + } + } else { + FontFamily.Default + } } else if (isEmojiOrWideSymbol) { // Emoji/symbols without variation selector - platform specific if (useSystemFontForEmoji) { @@ -914,7 +960,14 @@ object TerminalCanvasRenderer { ) if (isDoubleWidth) { - val measurement = ctx.textMeasurer.measure(charTextToRender, textStyle) + // Include variation selector for emoji presentation (⚠️ needs FE0F to render as color emoji) + val textToRender = if (isEmojiWithVariationSelector && nextChar != null && + (nextChar.code == 0xFE0F || nextChar.code == 0xFE0E)) { + "$charTextToRender$nextChar" + } else { + charTextToRender + } + val measurement = ctx.textMeasurer.measure(textToRender, textStyle) val glyphWidth = measurement.size.width.toFloat() val allocatedWidth = ctx.cellWidth * 2 @@ -923,7 +976,7 @@ object TerminalCanvasRenderer { scale(scaleX = scaleX, scaleY = 1f, pivot = Offset(x, y + ctx.cellWidth)) { drawText( textMeasurer = ctx.textMeasurer, - text = charTextToRender, + text = textToRender, topLeft = Offset(x, y), style = textStyle ) @@ -933,7 +986,7 @@ object TerminalCanvasRenderer { val centeringOffset = emptySpace / 2f drawText( textMeasurer = ctx.textMeasurer, - text = charTextToRender, + text = textToRender, topLeft = Offset(x + centeringOffset, y), style = textStyle ) @@ -949,23 +1002,22 @@ object TerminalCanvasRenderer { val glyphWidth = measurement.size.width.toFloat() val glyphHeight = measurement.size.height.toFloat() - val targetWidth = ctx.cellWidth * 1.0f + // Emoji span 2 cells - use same approach as ZWJ sequences + val allocatedWidth = ctx.cellWidth * 2.0f val targetHeight = ctx.cellHeight * 1.0f - val widthScale = if (glyphWidth > 0) targetWidth / glyphWidth else 1.0f + val widthScale = if (glyphWidth > 0) allocatedWidth / glyphWidth else 1.0f val heightScale = if (glyphHeight > 0) targetHeight / glyphHeight else 1.0f - val scaleValue = minOf(widthScale, heightScale).coerceIn(1.0f, 2.5f) + val scaleValue = minOf(widthScale, heightScale).coerceIn(0.8f, 2.5f) val scaledWidth = glyphWidth * scaleValue - val scaledHeight = glyphHeight * scaleValue - val xOffset = (ctx.cellWidth - scaledWidth) / 2f - val yOffset = (ctx.cellHeight - scaledHeight) / 2f + val centerX = x + (allocatedWidth - scaledWidth) / 2f - scale(scaleX = scaleValue, scaleY = scaleValue, pivot = Offset(x + ctx.cellWidth/2, y + ctx.cellHeight/2)) { + scale(scaleX = scaleValue, scaleY = scaleValue, pivot = Offset(x, y + ctx.cellHeight / 2f)) { drawText( textMeasurer = ctx.textMeasurer, text = textToRender, - topLeft = Offset(x + xOffset, y + yOffset), + topLeft = Offset(x + (centerX - x) / scaleValue, y), style = textStyle ) } From 17de181e945263737b3099bf523417b3364d6f2d Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 16:14:30 -0500 Subject: [PATCH 03/20] fix: Add grapheme-aware selection rendering for emoji MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add buffer-to-visual column conversion functions in TerminalCanvasRenderer - Update selection highlight rendering to use visual columns - Add helper functions for grapheme cluster width calculation - Update ProperTerminal selection handling for proper grapheme boundaries 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../bossterm/terminal/model/BossTerminal.kt | 187 +++++++++++ .../rendering/TerminalCanvasRenderer.kt | 296 +++++++++++++++++- .../bossterm/compose/ui/ProperTerminal.kt | 44 ++- 3 files changed, 507 insertions(+), 20 deletions(-) diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/model/BossTerminal.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/model/BossTerminal.kt index 1cc3a393..2a1a684f 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/model/BossTerminal.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/model/BossTerminal.kt @@ -1696,6 +1696,193 @@ class BossTerminal( override val size: TermSize get() = TermSize(myTerminalWidth, myTerminalHeight) + /** + * Convert buffer column to visual column. + * Buffer columns include DWC markers, surrogate pairs, and other multi-code-point sequences. + * Visual columns represent what the user sees on screen. + */ + private fun bufferColToVisualCol(bufferCol: Int): Int { + if (bufferCol <= 0) return 0 + + val line = try { + terminalTextBuffer.getLine(myCursorY - 1) + } catch (e: Exception) { + return bufferCol // Fallback if line not accessible + } + + var visualCol = 0 + var col = 0 + val width = myTerminalWidth + + while (col < bufferCol && col < width) { + val char = line.charAt(col) + + // Skip DWC markers (they don't add visual width) + if (char == CharUtils.DWC) { + col++ + continue + } + + // Skip variation selectors + if (char.code == 0xFE0E || char.code == 0xFE0F) { + col++ + continue + } + + // Skip ZWJ + if (char.code == 0x200D) { + col++ + continue + } + + // Skip low surrogates (part of previous high surrogate) + if (Character.isLowSurrogate(char)) { + col++ + continue + } + + // Skip skin tone modifiers (U+1F3FB-U+1F3FF as surrogate pairs) + if (Character.isHighSurrogate(char) && col + 1 < width) { + val nextChar = line.charAt(col + 1) + if (Character.isLowSurrogate(nextChar)) { + val codePoint = Character.toCodePoint(char, nextChar) + if (codePoint in 0x1F3FB..0x1F3FF) { + col += 2 + continue + } + } + } + + // Skip gender symbols in ZWJ sequences + if (char.code == 0x2640 || char.code == 0x2642) { + if (col > 0 && line.charAt(col - 1).code == 0x200D) { + col++ + continue + } + } + + // Regular character - count visual width (1 or 2) + visualCol += getCharacterVisualWidth(line, col, width) + col++ + } + return visualCol + } + + /** + * Convert visual column to buffer column. + * Used when receiving cursor position commands from shell (which use visual columns). + */ + private fun visualColToBufferCol(visualCol: Int): Int { + if (visualCol <= 0) return 0 + + val line = try { + terminalTextBuffer.getLine(myCursorY - 1) + } catch (e: Exception) { + return visualCol // Fallback if line not accessible + } + + var currentVisualCol = 0 + var col = 0 + val width = myTerminalWidth + + while (col < width && currentVisualCol < visualCol) { + val char = line.charAt(col) + + // Skip DWC markers + if (char == CharUtils.DWC) { + col++ + continue + } + + // Skip variation selectors + if (char.code == 0xFE0E || char.code == 0xFE0F) { + col++ + continue + } + + // Skip ZWJ + if (char.code == 0x200D) { + col++ + continue + } + + // Skip low surrogates + if (Character.isLowSurrogate(char)) { + col++ + continue + } + + // Skip skin tone modifiers + if (Character.isHighSurrogate(char) && col + 1 < width) { + val nextChar = line.charAt(col + 1) + if (Character.isLowSurrogate(nextChar)) { + val codePoint = Character.toCodePoint(char, nextChar) + if (codePoint in 0x1F3FB..0x1F3FF) { + col += 2 + continue + } + } + } + + // Skip gender symbols in ZWJ sequences + if (char.code == 0x2640 || char.code == 0x2642) { + if (col > 0 && line.charAt(col - 1).code == 0x200D) { + col++ + continue + } + } + + // Regular character - count visual width + currentVisualCol += getCharacterVisualWidth(line, col, width) + col++ + } + return col + } + + /** + * Get visual width of character at buffer position (1 or 2 cells). + */ + private fun getCharacterVisualWidth(line: TerminalLine, col: Int, width: Int): Int { + if (col >= width) return 1 + + val char = line.charAt(col) + + // Simple case: next char is DWC (single BMP double-width char) + if (col + 1 < width && line.charAt(col + 1) == CharUtils.DWC) { + return 2 + } + + // For surrogate pairs, scan forward through extenders to find DWC + if (Character.isHighSurrogate(char)) { + var nextCol = col + 1 + while (nextCol < width) { + val nextChar = line.charAt(nextCol) + if (nextChar == CharUtils.DWC) return 2 + if (Character.isLowSurrogate(nextChar) || + nextChar.code == 0xFE0E || nextChar.code == 0xFE0F || + nextChar.code == 0x200D || + nextChar.code == 0x2640 || nextChar.code == 0x2642) { + nextCol++ + continue + } + // Check for skin tone modifier + if (Character.isHighSurrogate(nextChar) && nextCol + 1 < width) { + val afterNext = line.charAt(nextCol + 1) + if (Character.isLowSurrogate(afterNext)) { + val cp = Character.toCodePoint(nextChar, afterNext) + if (cp in 0x1F3FB..0x1F3FF) { + nextCol += 2 + continue + } + } + } + break + } + } + + return 1 // Default: single width + } + override val cursorX: Int get() = myCursorX + 1 diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt index 93650439..47109132 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt @@ -631,6 +631,7 @@ object TerminalCanvasRenderer { /** * Render selection highlight rectangles. + * Selection coordinates are in buffer columns but we render using visual columns. */ private fun DrawScope.renderSelectionHighlight(ctx: RenderingContext) { val start = ctx.selectionStart ?: return @@ -651,7 +652,13 @@ object TerminalCanvasRenderer { for (bufferRow in firstRow..lastRow) { val screenRow = bufferRow + ctx.scrollOffset if (screenRow in 0 until ctx.visibleRows) { - val (colStart, colEnd) = when (ctx.selectionMode) { + // Get the line for this row to convert buffer columns to visual columns + val lineIndex = bufferRow + snapshot.historyLinesCount + val line = if (lineIndex >= 0 && lineIndex < snapshot.height + snapshot.historyLinesCount) { + snapshot.getLine(lineIndex) + } else null + + val (bufColStart, bufColEnd) = when (ctx.selectionMode) { SelectionMode.BLOCK -> { minOf(firstCol, lastCol) to maxOf(firstCol, lastCol) } @@ -668,22 +675,285 @@ object TerminalCanvasRenderer { } } - for (col in colStart..colEnd) { - if (col in 0 until snapshot.width) { - val x = col * ctx.cellWidth - val y = screenRow * ctx.cellHeight - // Calculate size as difference to next cell to avoid floating-point gaps - val w = (col + 1) * ctx.cellWidth - x - val h = (screenRow + 1) * ctx.cellHeight - y - drawRect( - color = highlightColor, - topLeft = Offset(x, y), - size = Size(w, h) - ) + // Convert buffer columns to visual columns for proper rendering + val visualColStart = if (line != null) { + bufferColToVisualCol(line, bufColStart, snapshot.width) + } else bufColStart + val visualColEnd = if (line != null) { + bufferColToVisualCol(line, bufColEnd + 1, snapshot.width) + } else bufColEnd + 1 + + // Draw a single rectangle for the entire selection range on this row + if (visualColStart < visualColEnd) { + val x = visualColStart * ctx.cellWidth + val y = screenRow * ctx.cellHeight + val w = visualColEnd * ctx.cellWidth - x + val h = (screenRow + 1) * ctx.cellHeight - y + drawRect( + color = highlightColor, + topLeft = Offset(x, y), + size = Size(w, h) + ) + } + } + } + } + + /** + * Get the visual width of a character at a buffer position. + * Returns 2 for double-width characters (emoji, CJK), 1 otherwise. + * + * Detection strategy: Look ahead through grapheme extenders to find DWC marker. + * If DWC follows, character is double-width. + */ + private fun getCharacterVisualWidth(line: TerminalLine, col: Int, width: Int): Int { + if (col >= width) return 1 + + val char = line.charAt(col) + + // Simple case: next char is DWC (single BMP double-width char like CJK) + if (col + 1 < width && line.charAt(col + 1) == CharUtils.DWC) { + return 2 + } + + // For surrogate pairs and complex graphemes, scan forward through extenders + if (Character.isHighSurrogate(char)) { + var nextCol = col + 1 + while (nextCol < width) { + val nextChar = line.charAt(nextCol) + // Found DWC marker - this grapheme is double-width + if (nextChar == CharUtils.DWC) return 2 + // Continue through grapheme extenders + if (Character.isLowSurrogate(nextChar) || + nextChar.code == 0xFE0E || nextChar.code == 0xFE0F || + nextChar.code == 0x200D || + nextChar.code == 0x2640 || nextChar.code == 0x2642) { + nextCol++ + continue + } + // Check for skin tone modifier (surrogate pair starting with high surrogate) + if (Character.isHighSurrogate(nextChar) && nextCol + 1 < width) { + val afterNext = line.charAt(nextCol + 1) + if (Character.isLowSurrogate(afterNext)) { + val cp = Character.toCodePoint(nextChar, afterNext) + if (cp in 0x1F3FB..0x1F3FF) { + nextCol += 2 + continue + } + } + } + break + } + } + + return 1 // Default: single width + } + + /** + * Convert buffer column to visual column. + * Accounts for DWC markers, surrogate pairs, ZWJ sequences, and other grapheme extenders + * that don't consume visual space. + */ + fun bufferColToVisualCol(line: TerminalLine, bufferCol: Int, width: Int): Int { + var visualCol = 0 + var col = 0 + while (col < bufferCol && col < width) { + val char = line.charAt(col) + + // Skip DWC markers (they don't add visual width) + if (char == CharUtils.DWC) { + col++ + continue + } + + // Skip variation selectors (FE0E, FE0F) + if (char.code == 0xFE0E || char.code == 0xFE0F) { + col++ + continue + } + + // Skip ZWJ (U+200D) + if (char.code == 0x200D) { + col++ + continue + } + + // Skip low surrogates (they're part of previous high surrogate) + if (Character.isLowSurrogate(char)) { + col++ + continue + } + + // Skip skin tone modifiers (U+1F3FB-U+1F3FF, encoded as surrogate pairs) + if (Character.isHighSurrogate(char) && col + 1 < width) { + val nextChar = line.charAt(col + 1) + if (Character.isLowSurrogate(nextChar)) { + val codePoint = Character.toCodePoint(char, nextChar) + if (codePoint in 0x1F3FB..0x1F3FF) { + col += 2 + continue + } + } + } + + // Skip gender symbols when they're part of ZWJ sequences + if (char.code == 0x2640 || char.code == 0x2642) { + // Check if preceded by ZWJ + if (col > 0 && line.charAt(col - 1).code == 0x200D) { + col++ + continue + } + } + + // Regular character - count visual width (1 or 2 for double-width) + visualCol += getCharacterVisualWidth(line, col, width) + col++ + } + return visualCol + } + + /** + * Convert visual column to buffer column. + * Returns the buffer column at the START of the grapheme at the given visual position. + * This is used for click handling to snap to grapheme boundaries. + */ + fun visualColToBufferCol(line: TerminalLine, visualCol: Int, width: Int): Int { + var currentVisualCol = 0 + var col = 0 + var lastGraphemeStart = 0 + + while (col < width && currentVisualCol <= visualCol) { + val char = line.charAt(col) + + // Skip DWC markers + if (char == CharUtils.DWC) { + col++ + continue + } + + // Skip variation selectors + if (char.code == 0xFE0E || char.code == 0xFE0F) { + col++ + continue + } + + // Skip ZWJ + if (char.code == 0x200D) { + col++ + continue + } + + // Skip low surrogates + if (Character.isLowSurrogate(char)) { + col++ + continue + } + + // Skip skin tone modifiers + if (Character.isHighSurrogate(char) && col + 1 < width) { + val nextChar = line.charAt(col + 1) + if (Character.isLowSurrogate(nextChar)) { + val codePoint = Character.toCodePoint(char, nextChar) + if (codePoint in 0x1F3FB..0x1F3FF) { + col += 2 + continue + } + } + } + + // Skip gender symbols in ZWJ sequences + if (char.code == 0x2640 || char.code == 0x2642) { + if (col > 0 && line.charAt(col - 1).code == 0x200D) { + col++ + continue + } + } + + // Found a visual character + val charWidth = getCharacterVisualWidth(line, col, width) + + // Check if visualCol falls within this character's visual range + if (visualCol >= currentVisualCol && visualCol < currentVisualCol + charWidth) { + return col // Snap to start of this grapheme + } + + lastGraphemeStart = col + currentVisualCol += charWidth + col++ + } + + // If we're past the end, return last valid position + return if (col >= width) width - 1 else lastGraphemeStart + } + + /** + * Find the buffer column range for a grapheme at the given buffer column. + * Returns (startCol, endCol) where endCol is inclusive. + */ + fun findGraphemeBounds(line: TerminalLine, bufferCol: Int, width: Int): Pair { + // Find the start of the grapheme (scan backwards for non-extender) + var startCol = bufferCol + while (startCol > 0) { + val char = line.charAt(startCol) + // If this is a base character (not DWC, not extender), we found the start + if (char != CharUtils.DWC && + char.code != 0xFE0E && char.code != 0xFE0F && + char.code != 0x200D && + !Character.isLowSurrogate(char)) { + // Check if it's a skin tone modifier + if (Character.isHighSurrogate(char) && startCol + 1 < width) { + val next = line.charAt(startCol + 1) + if (Character.isLowSurrogate(next)) { + val cp = Character.toCodePoint(char, next) + if (cp in 0x1F3FB..0x1F3FF) { + startCol-- + continue + } } } + // Check if preceded by ZWJ (part of sequence) + if (startCol > 0 && line.charAt(startCol - 1).code == 0x200D) { + startCol-- + continue + } + break + } + startCol-- + } + + // Find the end of the grapheme (scan forward for extenders and DWC) + var endCol = startCol + while (endCol + 1 < width) { + val nextChar = line.charAt(endCol + 1) + // Continue if next is DWC, variation selector, ZWJ, low surrogate, or skin tone + if (nextChar == CharUtils.DWC || + nextChar.code == 0xFE0E || nextChar.code == 0xFE0F || + nextChar.code == 0x200D || + Character.isLowSurrogate(nextChar)) { + endCol++ + continue } + // Check for skin tone modifier (surrogate pair) + if (Character.isHighSurrogate(nextChar) && endCol + 2 < width) { + val afterNext = line.charAt(endCol + 2) + if (Character.isLowSurrogate(afterNext)) { + val cp = Character.toCodePoint(nextChar, afterNext) + if (cp in 0x1F3FB..0x1F3FF) { + endCol += 2 + continue + } + } + } + // Check for gender symbol after ZWJ + if (line.charAt(endCol).code == 0x200D && + (nextChar.code == 0x2640 || nextChar.code == 0x2642)) { + endCol++ + continue + } + break } + + return Pair(startCol, endCol) } /** diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ui/ProperTerminal.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ui/ProperTerminal.kt index b436c95d..d0cd6de6 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ui/ProperTerminal.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ui/ProperTerminal.kt @@ -356,14 +356,24 @@ fun ProperTerminal( // Update selection end to track scroll position (using buffer-relative coordinates) lastDragPosition?.let { pos -> - val col = (pos.x / cellWidthParam).toInt().coerceIn(0, textBuffer.width - 1) + val visualCol = (pos.x / cellWidthParam).toInt().coerceIn(0, textBuffer.width - 1) val screenRow = when { pos.y < 0 -> 0 // Top of visible area pos.y > height -> ((height / cellHeightParam).toInt()).coerceAtMost(textBuffer.height - 1) else -> (pos.y / cellHeightParam).toInt() } val bufferRow = screenRow - scrollOffset // Convert screen to buffer-relative row - selectionEnd = Pair(col, bufferRow) + + // Convert visual column to buffer column for grapheme-aware selection + val snapshot = textBuffer.createSnapshot() + val lineIndex = bufferRow + snapshot.historyLinesCount + val bufferCol = if (lineIndex >= 0 && lineIndex < snapshot.height + snapshot.historyLinesCount) { + val line = snapshot.getLine(lineIndex) + if (line != null) { + TerminalCanvasRenderer.visualColToBufferCol(line, visualCol, snapshot.width) + } else visualCol + } else visualCol + selectionEnd = Pair(bufferCol, bufferRow) } display.requestImmediateRedraw() @@ -1054,23 +1064,43 @@ fun ProperTerminal( // Detect Alt+Drag for block selection mode selectionMode = if (event.isAltPressed()) SelectionMode.BLOCK else SelectionMode.NORMAL - val startCol = (startPos.x / cellWidth).toInt() + val visualCol = (startPos.x / cellWidth).toInt() val screenRow = (startPos.y / cellHeight).toInt() val bufferRow = screenRow - scrollOffset // Convert screen to buffer-relative row - selectionStart = Pair(startCol, bufferRow) + + // Convert visual column to buffer column for grapheme-aware selection + val snapshot = textBuffer.createSnapshot() + val lineIndex = bufferRow + snapshot.historyLinesCount + val bufferCol = if (lineIndex >= 0 && lineIndex < snapshot.height + snapshot.historyLinesCount) { + val line = snapshot.getLine(lineIndex) + if (line != null) { + TerminalCanvasRenderer.visualColToBufferCol(line, visualCol, snapshot.width) + } else visualCol + } else visualCol + selectionStart = Pair(bufferCol, bufferRow) } // Update selection end point as mouse moves // Handle out-of-bounds coordinates for auto-scroll // Convert to buffer-relative coordinates for consistent selection model - val col = (pos.x / cellWidth).toInt().coerceIn(0, textBuffer.width - 1) + val visualEndCol = (pos.x / cellWidth).toInt().coerceIn(0, textBuffer.width - 1) val screenRow = when { pos.y < 0 -> 0 // Above canvas: first visible row pos.y > canvasSize.height -> ((canvasSize.height / cellHeight).toInt()).coerceAtMost(textBuffer.height - 1) else -> (pos.y / cellHeight).toInt() } - val bufferRow = screenRow - scrollOffset // Convert screen to buffer-relative row - selectionEnd = Pair(col, bufferRow) + val bufferEndRow = screenRow - scrollOffset // Convert screen to buffer-relative row + + // Convert visual column to buffer column for grapheme-aware selection + val endSnapshot = textBuffer.createSnapshot() + val endLineIndex = bufferEndRow + endSnapshot.historyLinesCount + val bufferEndCol = if (endLineIndex >= 0 && endLineIndex < endSnapshot.height + endSnapshot.historyLinesCount) { + val line = endSnapshot.getLine(endLineIndex) + if (line != null) { + TerminalCanvasRenderer.visualColToBufferCol(line, visualEndCol, endSnapshot.width) + } else visualEndCol + } else visualEndCol + selectionEnd = Pair(bufferEndCol, bufferEndRow) // Track position for auto-scroll updates lastDragPosition = pos From dc57570cca68d87f7ff6de53fafd48ff7669569d Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 16:25:08 -0500 Subject: [PATCH 04/20] refactor: Address code review feedback for emoji selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract column conversion to shared utility class (ColumnConversionUtils.kt) - Cache snapshot during drag for performance (reduces GC pressure) - Fix gender symbol bug - only skip if preceded by ZWJ - Remove duplicate helper functions from BossTerminal.kt and TerminalCanvasRenderer.kt 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../bossterm/terminal/model/BossTerminal.kt | 187 --------------- .../terminal/util/ColumnConversionUtils.kt | 213 ++++++++++++++++++ .../rendering/TerminalCanvasRenderer.kt | 186 +-------------- .../bossterm/compose/ui/ProperTerminal.kt | 16 +- 4 files changed, 233 insertions(+), 369 deletions(-) create mode 100644 bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/ColumnConversionUtils.kt diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/model/BossTerminal.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/model/BossTerminal.kt index 2a1a684f..1cc3a393 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/model/BossTerminal.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/model/BossTerminal.kt @@ -1696,193 +1696,6 @@ class BossTerminal( override val size: TermSize get() = TermSize(myTerminalWidth, myTerminalHeight) - /** - * Convert buffer column to visual column. - * Buffer columns include DWC markers, surrogate pairs, and other multi-code-point sequences. - * Visual columns represent what the user sees on screen. - */ - private fun bufferColToVisualCol(bufferCol: Int): Int { - if (bufferCol <= 0) return 0 - - val line = try { - terminalTextBuffer.getLine(myCursorY - 1) - } catch (e: Exception) { - return bufferCol // Fallback if line not accessible - } - - var visualCol = 0 - var col = 0 - val width = myTerminalWidth - - while (col < bufferCol && col < width) { - val char = line.charAt(col) - - // Skip DWC markers (they don't add visual width) - if (char == CharUtils.DWC) { - col++ - continue - } - - // Skip variation selectors - if (char.code == 0xFE0E || char.code == 0xFE0F) { - col++ - continue - } - - // Skip ZWJ - if (char.code == 0x200D) { - col++ - continue - } - - // Skip low surrogates (part of previous high surrogate) - if (Character.isLowSurrogate(char)) { - col++ - continue - } - - // Skip skin tone modifiers (U+1F3FB-U+1F3FF as surrogate pairs) - if (Character.isHighSurrogate(char) && col + 1 < width) { - val nextChar = line.charAt(col + 1) - if (Character.isLowSurrogate(nextChar)) { - val codePoint = Character.toCodePoint(char, nextChar) - if (codePoint in 0x1F3FB..0x1F3FF) { - col += 2 - continue - } - } - } - - // Skip gender symbols in ZWJ sequences - if (char.code == 0x2640 || char.code == 0x2642) { - if (col > 0 && line.charAt(col - 1).code == 0x200D) { - col++ - continue - } - } - - // Regular character - count visual width (1 or 2) - visualCol += getCharacterVisualWidth(line, col, width) - col++ - } - return visualCol - } - - /** - * Convert visual column to buffer column. - * Used when receiving cursor position commands from shell (which use visual columns). - */ - private fun visualColToBufferCol(visualCol: Int): Int { - if (visualCol <= 0) return 0 - - val line = try { - terminalTextBuffer.getLine(myCursorY - 1) - } catch (e: Exception) { - return visualCol // Fallback if line not accessible - } - - var currentVisualCol = 0 - var col = 0 - val width = myTerminalWidth - - while (col < width && currentVisualCol < visualCol) { - val char = line.charAt(col) - - // Skip DWC markers - if (char == CharUtils.DWC) { - col++ - continue - } - - // Skip variation selectors - if (char.code == 0xFE0E || char.code == 0xFE0F) { - col++ - continue - } - - // Skip ZWJ - if (char.code == 0x200D) { - col++ - continue - } - - // Skip low surrogates - if (Character.isLowSurrogate(char)) { - col++ - continue - } - - // Skip skin tone modifiers - if (Character.isHighSurrogate(char) && col + 1 < width) { - val nextChar = line.charAt(col + 1) - if (Character.isLowSurrogate(nextChar)) { - val codePoint = Character.toCodePoint(char, nextChar) - if (codePoint in 0x1F3FB..0x1F3FF) { - col += 2 - continue - } - } - } - - // Skip gender symbols in ZWJ sequences - if (char.code == 0x2640 || char.code == 0x2642) { - if (col > 0 && line.charAt(col - 1).code == 0x200D) { - col++ - continue - } - } - - // Regular character - count visual width - currentVisualCol += getCharacterVisualWidth(line, col, width) - col++ - } - return col - } - - /** - * Get visual width of character at buffer position (1 or 2 cells). - */ - private fun getCharacterVisualWidth(line: TerminalLine, col: Int, width: Int): Int { - if (col >= width) return 1 - - val char = line.charAt(col) - - // Simple case: next char is DWC (single BMP double-width char) - if (col + 1 < width && line.charAt(col + 1) == CharUtils.DWC) { - return 2 - } - - // For surrogate pairs, scan forward through extenders to find DWC - if (Character.isHighSurrogate(char)) { - var nextCol = col + 1 - while (nextCol < width) { - val nextChar = line.charAt(nextCol) - if (nextChar == CharUtils.DWC) return 2 - if (Character.isLowSurrogate(nextChar) || - nextChar.code == 0xFE0E || nextChar.code == 0xFE0F || - nextChar.code == 0x200D || - nextChar.code == 0x2640 || nextChar.code == 0x2642) { - nextCol++ - continue - } - // Check for skin tone modifier - if (Character.isHighSurrogate(nextChar) && nextCol + 1 < width) { - val afterNext = line.charAt(nextCol + 1) - if (Character.isLowSurrogate(afterNext)) { - val cp = Character.toCodePoint(nextChar, afterNext) - if (cp in 0x1F3FB..0x1F3FF) { - nextCol += 2 - continue - } - } - } - break - } - } - - return 1 // Default: single width - } - override val cursorX: Int get() = myCursorX + 1 diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/ColumnConversionUtils.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/ColumnConversionUtils.kt new file mode 100644 index 00000000..6a09a56f --- /dev/null +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/ColumnConversionUtils.kt @@ -0,0 +1,213 @@ +package ai.rever.bossterm.terminal.util + +import ai.rever.bossterm.terminal.model.TerminalLine + +/** + * Utility functions for converting between buffer columns and visual columns. + * + * Buffer columns include DWC markers, surrogate pairs, variation selectors, etc. + * Visual columns represent what the user sees on screen. + */ +object ColumnConversionUtils { + + /** + * Convert buffer column to visual column. + * Accounts for DWC markers, surrogate pairs, ZWJ sequences, and other grapheme extenders + * that don't consume visual space. + * + * @param line The terminal line to analyze + * @param bufferCol The buffer column to convert + * @param width The terminal width (max columns) + * @return The visual column corresponding to the buffer column + */ + fun bufferColToVisualCol(line: TerminalLine, bufferCol: Int, width: Int): Int { + if (bufferCol <= 0) return 0 + + var visualCol = 0 + var col = 0 + + while (col < bufferCol && col < width) { + val char = line.charAt(col) + + // Skip DWC markers (they don't add visual width) + if (char == CharUtils.DWC) { + col++ + continue + } + + // Skip variation selectors (FE0E, FE0F) + if (char.code == 0xFE0E || char.code == 0xFE0F) { + col++ + continue + } + + // Skip ZWJ (U+200D) + if (char.code == 0x200D) { + col++ + continue + } + + // Skip low surrogates (they're part of previous high surrogate) + if (Character.isLowSurrogate(char)) { + col++ + continue + } + + // Skip skin tone modifiers (U+1F3FB-U+1F3FF, encoded as surrogate pairs) + if (Character.isHighSurrogate(char) && col + 1 < width) { + val nextChar = line.charAt(col + 1) + if (Character.isLowSurrogate(nextChar)) { + val codePoint = Character.toCodePoint(char, nextChar) + if (codePoint in 0x1F3FB..0x1F3FF) { + col += 2 + continue + } + } + } + + // Skip gender symbols only when preceded by ZWJ (part of ZWJ sequences) + if (char.code == 0x2640 || char.code == 0x2642) { + if (col > 0 && line.charAt(col - 1).code == 0x200D) { + col++ + continue + } + } + + // Regular character - count visual width (1 or 2 for double-width) + visualCol += getCharacterVisualWidth(line, col, width) + col++ + } + return visualCol + } + + /** + * Convert visual column to buffer column. + * Returns the buffer column at the START of the grapheme at the given visual position. + * This is used for click handling to snap to grapheme boundaries. + * + * @param line The terminal line to analyze + * @param visualCol The visual column to convert + * @param width The terminal width (max columns) + * @return The buffer column corresponding to the visual column + */ + fun visualColToBufferCol(line: TerminalLine, visualCol: Int, width: Int): Int { + if (visualCol <= 0) return 0 + + var currentVisualCol = 0 + var col = 0 + + while (col < width && currentVisualCol < visualCol) { + val char = line.charAt(col) + + // Skip DWC markers + if (char == CharUtils.DWC) { + col++ + continue + } + + // Skip variation selectors + if (char.code == 0xFE0E || char.code == 0xFE0F) { + col++ + continue + } + + // Skip ZWJ + if (char.code == 0x200D) { + col++ + continue + } + + // Skip low surrogates + if (Character.isLowSurrogate(char)) { + col++ + continue + } + + // Skip skin tone modifiers + if (Character.isHighSurrogate(char) && col + 1 < width) { + val nextChar = line.charAt(col + 1) + if (Character.isLowSurrogate(nextChar)) { + val codePoint = Character.toCodePoint(char, nextChar) + if (codePoint in 0x1F3FB..0x1F3FF) { + col += 2 + continue + } + } + } + + // Skip gender symbols only when preceded by ZWJ + if (char.code == 0x2640 || char.code == 0x2642) { + if (col > 0 && line.charAt(col - 1).code == 0x200D) { + col++ + continue + } + } + + // Regular character - count visual width + val charWidth = getCharacterVisualWidth(line, col, width) + + // Check if visualCol falls within this character's visual range + if (visualCol < currentVisualCol + charWidth) { + return col // Snap to start of this grapheme + } + + currentVisualCol += charWidth + col++ + } + return col + } + + /** + * Get visual width of character at buffer position (1 or 2 cells). + * + * Detection strategy: Look ahead through grapheme extenders to find DWC marker. + * If DWC follows, character is double-width. + * + * @param line The terminal line + * @param col The buffer column + * @param width The terminal width + * @return 1 for single-width, 2 for double-width characters + */ + fun getCharacterVisualWidth(line: TerminalLine, col: Int, width: Int): Int { + if (col >= width) return 1 + + val char = line.charAt(col) + + // Simple case: next char is DWC (single BMP double-width char like CJK) + if (col + 1 < width && line.charAt(col + 1) == CharUtils.DWC) { + return 2 + } + + // For surrogate pairs and complex graphemes, scan forward through extenders to find DWC + if (Character.isHighSurrogate(char)) { + var nextCol = col + 1 + while (nextCol < width) { + val nextChar = line.charAt(nextCol) + // Found DWC marker - this grapheme is double-width + if (nextChar == CharUtils.DWC) return 2 + // Continue through grapheme extenders + if (Character.isLowSurrogate(nextChar) || + nextChar.code == 0xFE0E || nextChar.code == 0xFE0F || + nextChar.code == 0x200D || + nextChar.code == 0x2640 || nextChar.code == 0x2642) { + nextCol++ + continue + } + // Check for skin tone modifier (surrogate pair starting with high surrogate) + if (Character.isHighSurrogate(nextChar) && nextCol + 1 < width) { + val afterNext = line.charAt(nextCol + 1) + if (Character.isLowSurrogate(afterNext)) { + val cp = Character.toCodePoint(nextChar, afterNext) + if (cp in 0x1F3FB..0x1F3FF) { + nextCol += 2 + continue + } + } + } + break + } + } + + return 1 // Default: single width + } +} diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt index 47109132..d7a1d173 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt @@ -22,6 +22,7 @@ import ai.rever.bossterm.terminal.model.pool.VersionedBufferSnapshot import ai.rever.bossterm.terminal.model.image.ImageCell import ai.rever.bossterm.terminal.model.image.ImageDataCache import ai.rever.bossterm.terminal.util.CharUtils +import ai.rever.bossterm.terminal.util.ColumnConversionUtils import ai.rever.bossterm.terminal.TextStyle as BossTextStyle import org.jetbrains.skia.FontMgr @@ -699,192 +700,19 @@ object TerminalCanvasRenderer { } } - /** - * Get the visual width of a character at a buffer position. - * Returns 2 for double-width characters (emoji, CJK), 1 otherwise. - * - * Detection strategy: Look ahead through grapheme extenders to find DWC marker. - * If DWC follows, character is double-width. - */ - private fun getCharacterVisualWidth(line: TerminalLine, col: Int, width: Int): Int { - if (col >= width) return 1 - - val char = line.charAt(col) - - // Simple case: next char is DWC (single BMP double-width char like CJK) - if (col + 1 < width && line.charAt(col + 1) == CharUtils.DWC) { - return 2 - } - - // For surrogate pairs and complex graphemes, scan forward through extenders - if (Character.isHighSurrogate(char)) { - var nextCol = col + 1 - while (nextCol < width) { - val nextChar = line.charAt(nextCol) - // Found DWC marker - this grapheme is double-width - if (nextChar == CharUtils.DWC) return 2 - // Continue through grapheme extenders - if (Character.isLowSurrogate(nextChar) || - nextChar.code == 0xFE0E || nextChar.code == 0xFE0F || - nextChar.code == 0x200D || - nextChar.code == 0x2640 || nextChar.code == 0x2642) { - nextCol++ - continue - } - // Check for skin tone modifier (surrogate pair starting with high surrogate) - if (Character.isHighSurrogate(nextChar) && nextCol + 1 < width) { - val afterNext = line.charAt(nextCol + 1) - if (Character.isLowSurrogate(afterNext)) { - val cp = Character.toCodePoint(nextChar, afterNext) - if (cp in 0x1F3FB..0x1F3FF) { - nextCol += 2 - continue - } - } - } - break - } - } - - return 1 // Default: single width - } - /** * Convert buffer column to visual column. - * Accounts for DWC markers, surrogate pairs, ZWJ sequences, and other grapheme extenders - * that don't consume visual space. + * Delegates to shared ColumnConversionUtils. */ - fun bufferColToVisualCol(line: TerminalLine, bufferCol: Int, width: Int): Int { - var visualCol = 0 - var col = 0 - while (col < bufferCol && col < width) { - val char = line.charAt(col) - - // Skip DWC markers (they don't add visual width) - if (char == CharUtils.DWC) { - col++ - continue - } - - // Skip variation selectors (FE0E, FE0F) - if (char.code == 0xFE0E || char.code == 0xFE0F) { - col++ - continue - } - - // Skip ZWJ (U+200D) - if (char.code == 0x200D) { - col++ - continue - } - - // Skip low surrogates (they're part of previous high surrogate) - if (Character.isLowSurrogate(char)) { - col++ - continue - } - - // Skip skin tone modifiers (U+1F3FB-U+1F3FF, encoded as surrogate pairs) - if (Character.isHighSurrogate(char) && col + 1 < width) { - val nextChar = line.charAt(col + 1) - if (Character.isLowSurrogate(nextChar)) { - val codePoint = Character.toCodePoint(char, nextChar) - if (codePoint in 0x1F3FB..0x1F3FF) { - col += 2 - continue - } - } - } - - // Skip gender symbols when they're part of ZWJ sequences - if (char.code == 0x2640 || char.code == 0x2642) { - // Check if preceded by ZWJ - if (col > 0 && line.charAt(col - 1).code == 0x200D) { - col++ - continue - } - } - - // Regular character - count visual width (1 or 2 for double-width) - visualCol += getCharacterVisualWidth(line, col, width) - col++ - } - return visualCol - } + fun bufferColToVisualCol(line: TerminalLine, bufferCol: Int, width: Int): Int = + ColumnConversionUtils.bufferColToVisualCol(line, bufferCol, width) /** * Convert visual column to buffer column. - * Returns the buffer column at the START of the grapheme at the given visual position. - * This is used for click handling to snap to grapheme boundaries. + * Delegates to shared ColumnConversionUtils. */ - fun visualColToBufferCol(line: TerminalLine, visualCol: Int, width: Int): Int { - var currentVisualCol = 0 - var col = 0 - var lastGraphemeStart = 0 - - while (col < width && currentVisualCol <= visualCol) { - val char = line.charAt(col) - - // Skip DWC markers - if (char == CharUtils.DWC) { - col++ - continue - } - - // Skip variation selectors - if (char.code == 0xFE0E || char.code == 0xFE0F) { - col++ - continue - } - - // Skip ZWJ - if (char.code == 0x200D) { - col++ - continue - } - - // Skip low surrogates - if (Character.isLowSurrogate(char)) { - col++ - continue - } - - // Skip skin tone modifiers - if (Character.isHighSurrogate(char) && col + 1 < width) { - val nextChar = line.charAt(col + 1) - if (Character.isLowSurrogate(nextChar)) { - val codePoint = Character.toCodePoint(char, nextChar) - if (codePoint in 0x1F3FB..0x1F3FF) { - col += 2 - continue - } - } - } - - // Skip gender symbols in ZWJ sequences - if (char.code == 0x2640 || char.code == 0x2642) { - if (col > 0 && line.charAt(col - 1).code == 0x200D) { - col++ - continue - } - } - - // Found a visual character - val charWidth = getCharacterVisualWidth(line, col, width) - - // Check if visualCol falls within this character's visual range - if (visualCol >= currentVisualCol && visualCol < currentVisualCol + charWidth) { - return col // Snap to start of this grapheme - } - - lastGraphemeStart = col - currentVisualCol += charWidth - col++ - } - - // If we're past the end, return last valid position - return if (col >= width) width - 1 else lastGraphemeStart - } + fun visualColToBufferCol(line: TerminalLine, visualCol: Int, width: Int): Int = + ColumnConversionUtils.visualColToBufferCol(line, visualCol, width) /** * Find the buffer column range for a grapheme at the given buffer column. diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ui/ProperTerminal.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ui/ProperTerminal.kt index d0cd6de6..551a7f8c 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ui/ProperTerminal.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/ui/ProperTerminal.kt @@ -49,6 +49,7 @@ import ai.rever.bossterm.terminal.TerminalDisplay import ai.rever.bossterm.terminal.TerminalKeyEncoder import ai.rever.bossterm.terminal.emulator.mouse.MouseButtonCodes import ai.rever.bossterm.terminal.emulator.mouse.MouseMode +import ai.rever.bossterm.terminal.model.BufferSnapshot import ai.rever.bossterm.terminal.model.TerminalTextBuffer import ai.rever.bossterm.terminal.util.CharUtils import kotlinx.coroutines.Job @@ -274,6 +275,7 @@ fun ProperTerminal( // Drag state for text selection var isDragging by remember { mutableStateOf(false) } var dragStartPos by remember { mutableStateOf(null) } // Track initial mouse position for drag detection + var cachedDragSnapshot by remember { mutableStateOf(null) } // Cached snapshot for drag performance // Auto-scroll state for drag selection beyond bounds var autoScrollJob by remember { mutableStateOf(null) } @@ -365,7 +367,8 @@ fun ProperTerminal( val bufferRow = screenRow - scrollOffset // Convert screen to buffer-relative row // Convert visual column to buffer column for grapheme-aware selection - val snapshot = textBuffer.createSnapshot() + // Use cached snapshot for performance (created at drag start) + val snapshot = cachedDragSnapshot ?: textBuffer.createSnapshot() val lineIndex = bufferRow + snapshot.historyLinesCount val bufferCol = if (lineIndex >= 0 && lineIndex < snapshot.height + snapshot.historyLinesCount) { val line = snapshot.getLine(lineIndex) @@ -934,6 +937,7 @@ fun ProperTerminal( selectionEnd = null } isDragging = false + cachedDragSnapshot = null // Clear cached snapshot // Ensure focus is on terminal canvas after click focusRequester.requestFocus() } @@ -946,6 +950,7 @@ fun ProperTerminal( selectionStart = start selectionEnd = end isDragging = false + cachedDragSnapshot = null // Clear cached snapshot // Clear search when user manually selects text if (searchVisible) { @@ -965,6 +970,7 @@ fun ProperTerminal( selectionStart = start selectionEnd = end isDragging = false + cachedDragSnapshot = null // Clear cached snapshot // Clear search when user manually selects text if (searchVisible) { @@ -1069,7 +1075,9 @@ fun ProperTerminal( val bufferRow = screenRow - scrollOffset // Convert screen to buffer-relative row // Convert visual column to buffer column for grapheme-aware selection - val snapshot = textBuffer.createSnapshot() + // Cache snapshot at drag start for performance - reused during entire drag + cachedDragSnapshot = textBuffer.createSnapshot() + val snapshot = cachedDragSnapshot!! val lineIndex = bufferRow + snapshot.historyLinesCount val bufferCol = if (lineIndex >= 0 && lineIndex < snapshot.height + snapshot.historyLinesCount) { val line = snapshot.getLine(lineIndex) @@ -1092,7 +1100,8 @@ fun ProperTerminal( val bufferEndRow = screenRow - scrollOffset // Convert screen to buffer-relative row // Convert visual column to buffer column for grapheme-aware selection - val endSnapshot = textBuffer.createSnapshot() + // Use cached snapshot for performance (created at drag start) + val endSnapshot = cachedDragSnapshot ?: textBuffer.createSnapshot() val endLineIndex = bufferEndRow + endSnapshot.historyLinesCount val bufferEndCol = if (endLineIndex >= 0 && endLineIndex < endSnapshot.height + endSnapshot.historyLinesCount) { val line = endSnapshot.getLine(endLineIndex) @@ -1175,6 +1184,7 @@ fun ProperTerminal( // Reset drag state and cancel auto-scroll isDragging = false dragStartPos = null + cachedDragSnapshot = null // Clear cached snapshot autoScrollJob?.cancel() autoScrollJob = null lastDragPosition = null From 6c647cf3fd8f8e7023d8b159286e0a5716082a80 Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 16:35:22 -0500 Subject: [PATCH 05/20] fix: Address code review - gender symbol ZWJ check and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fixed gender symbol handling in renderBackgrounds() to only skip ♀/♂ symbols when preceded by ZWJ (matches ColumnConversionUtils fix) - Added documentation for grapheme-snapping behavior in selection rendering 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../compose/rendering/TerminalCanvasRenderer.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt index d7a1d173..433fef20 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt @@ -175,10 +175,12 @@ object TerminalCanvasRenderer { } } - // Skip gender symbols that are part of ZWJ sequences (♀ U+2640, ♂ U+2642) + // Skip gender symbols only when preceded by ZWJ (part of ZWJ sequences) if (char.code == 0x2640 || char.code == 0x2642) { - col++ - continue + if (col > 0 && line.charAt(col - 1).code == 0x200D) { + col++ + continue + } } // Round to pixel boundaries to avoid anti-aliasing artifacts @@ -633,6 +635,11 @@ object TerminalCanvasRenderer { /** * Render selection highlight rectangles. * Selection coordinates are in buffer columns but we render using visual columns. + * + * Note: Selection automatically snaps to grapheme boundaries - partial grapheme + * selection expands to include the entire grapheme. This is intentional behavior + * to ensure emoji, ZWJ sequences, and other multi-codepoint graphemes are + * always selected as complete units. */ private fun DrawScope.renderSelectionHighlight(ctx: RenderingContext) { val start = ctx.selectionStart ?: return From f92209b92d5a8671b915b154fd27e0792dd2eb0c Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 16:45:57 -0500 Subject: [PATCH 06/20] refactor: Extract shared CharacterAnalysis helper to eliminate duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created CharacterAnalysis data class to encapsulate character rendering info - Created analyzeCharacter() helper function shared by renderBackgrounds() and renderText() - Eliminates ~60 lines of duplicated surrogate pair handling, double-width detection, and variation selector logic - Future emoji range updates now only need to be applied in one place 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../rendering/TerminalCanvasRenderer.kt | 235 ++++++++++-------- 1 file changed, 129 insertions(+), 106 deletions(-) diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt index 433fef20..5de6cad7 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt @@ -86,6 +86,115 @@ data class RenderingContext( val terminalHeightCells: Int = 24 ) +/** + * Result of analyzing a character for rendering purposes. + * Encapsulates surrogate pair handling, double-width detection, and variation selector info. + */ +data class CharacterAnalysis( + val actualCodePoint: Int, + val lowSurrogate: Char?, + val charTextToRender: String, + val isWcwidthDoubleWidth: Boolean, + val isBaseDoubleWidth: Boolean, + val hasVariationSelector: Boolean, + val isEmojiWithVariationSelector: Boolean, + val isDoubleWidth: Boolean, + val visualWidth: Int, + // Character classification for font selection + val isCursiveOrMath: Boolean, + val isTechnicalSymbol: Boolean, + val isEmojiOrWideSymbol: Boolean +) + +/** + * Analyze a character at the given column position for rendering. + * Handles surrogate pairs, double-width detection, and variation selectors. + * This is shared between renderBackgrounds() and renderText() to avoid duplication. + */ +fun analyzeCharacter( + char: Char, + line: TerminalLine, + col: Int, + width: Int, + ambiguousCharsAreDoubleWidth: Boolean +): CharacterAnalysis { + val charAtCol1 = if (col + 1 < width) line.charAt(col + 1) else null + val charAtCol2 = if (col + 2 < width) line.charAt(col + 2) else null + + // Handle surrogate pairs + val lowSurrogate = if (Character.isHighSurrogate(char)) { + when { + charAtCol1 != null && Character.isLowSurrogate(charAtCol1) -> charAtCol1 + charAtCol1 == CharUtils.DWC && charAtCol2 != null && Character.isLowSurrogate(charAtCol2) -> charAtCol2 + else -> null + } + } else null + + val actualCodePoint = if (lowSurrogate != null && Character.isLowSurrogate(lowSurrogate)) { + Character.toCodePoint(char, lowSurrogate) + } else char.code + + val charTextToRender = if (lowSurrogate != null && Character.isLowSurrogate(lowSurrogate)) { + "$char$lowSurrogate" + } else { + char.toString() + } + + // Double-width detection + val wcwidthResult = char != ' ' && char != '\u0000' && + CharUtils.isDoubleWidthCharacter(actualCodePoint, ambiguousCharsAreDoubleWidth) + + // Check for DWC at col+1, OR DWC at col+2 when col+1 is variation selector + // For emoji+VS like ⚠️: Buffer = [⚠][FE0F][DWC] - DWC is at col+2 + val hasVariationSelectorAtCol1 = charAtCol1 != null && (charAtCol1.code == 0xFE0F || charAtCol1.code == 0xFE0E) + val isWcwidthDoubleWidth = charAtCol1 == CharUtils.DWC || + (hasVariationSelectorAtCol1 && charAtCol2 == CharUtils.DWC) || + wcwidthResult + + val isBaseDoubleWidth = if (actualCodePoint >= 0x1F100) true else isWcwidthDoubleWidth + + // Check for variation selector - handle both DWC and non-DWC cases + val nextCharOffset = if (isWcwidthDoubleWidth) 2 else 1 + val nextChar = if (col + nextCharOffset < width) line.charAt(col + nextCharOffset) else null + val hasVariationSelector = (nextChar != null && (nextChar.code == 0xFE0F || nextChar.code == 0xFE0E)) || + hasVariationSelectorAtCol1 + val isEmojiWithVariationSelector = hasVariationSelector + + val isDoubleWidth = isBaseDoubleWidth || isEmojiWithVariationSelector + val visualWidth = if (isDoubleWidth) 2 else 1 + + // Character classification for font selection + val isCursiveOrMath = actualCodePoint in 0x1D400..0x1D7FF + val isTechnicalSymbol = actualCodePoint in 0x23E9..0x23FF + val isEmojiOrWideSymbol = when (actualCodePoint) { + in 0x2600..0x26FF -> when (actualCodePoint) { + 0x2605, 0x2606 -> false // ★ ☆ (text symbols) + in 0x2660..0x2667 -> false // ♠ ♡ ♢ ♣ ♤ ♥ ♦ ♧ (card suits) + else -> true + } + in 0x1F100..0x1F1FF -> true + in 0x1F300..0x1F9FF -> true + in 0x1F600..0x1F64F -> true + in 0x1F680..0x1F6FF -> true + else -> false + } + + return CharacterAnalysis( + actualCodePoint = actualCodePoint, + lowSurrogate = lowSurrogate, + charTextToRender = charTextToRender, + isWcwidthDoubleWidth = isWcwidthDoubleWidth, + isBaseDoubleWidth = isBaseDoubleWidth, + hasVariationSelector = hasVariationSelector, + isEmojiWithVariationSelector = isEmojiWithVariationSelector, + isDoubleWidth = isDoubleWidth, + visualWidth = visualWidth, + isCursiveOrMath = isCursiveOrMath, + isTechnicalSymbol = isTechnicalSymbol, + isEmojiOrWideSymbol = isEmojiOrWideSymbol + ) +} + /** * Terminal canvas renderer that handles all drawing operations. * Separates rendering logic from the composable for better maintainability. @@ -188,45 +297,8 @@ object TerminalCanvasRenderer { val x = kotlin.math.floor(visualCol * ctx.cellWidth) val y = kotlin.math.floor(row * ctx.cellHeight) - // Handle surrogate pairs (must match renderText logic lines 384-398) - val charAtCol1 = if (col + 1 < ctx.visibleCols) line.charAt(col + 1) else null - val charAtCol2 = if (col + 2 < ctx.visibleCols) line.charAt(col + 2) else null - - val lowSurrogate = if (Character.isHighSurrogate(char)) { - when { - charAtCol1 != null && Character.isLowSurrogate(charAtCol1) -> charAtCol1 - charAtCol1 == CharUtils.DWC && charAtCol2 != null && Character.isLowSurrogate(charAtCol2) -> charAtCol2 - else -> null - } - } else null - - val actualCodePoint = if (lowSurrogate != null && Character.isLowSurrogate(lowSurrogate)) { - Character.toCodePoint(char, lowSurrogate) - } else char.code - - // Check if double-width (must match renderText logic lines 400-402) - val wcwidthResult = char != ' ' && char != '\u0000' && - CharUtils.isDoubleWidthCharacter(actualCodePoint, ctx.ambiguousCharsAreDoubleWidth) - // Check for DWC at col+1, OR DWC at col+2 when col+1 is variation selector - // For emoji+VS like ⚠️: Buffer = [⚠][FE0F][DWC] - DWC is at col+2 - val hasVariationSelector = charAtCol1 != null && (charAtCol1.code == 0xFE0F || charAtCol1.code == 0xFE0E) - val isWcwidthDoubleWidth = charAtCol1 == CharUtils.DWC || - (hasVariationSelector && charAtCol2 == CharUtils.DWC) || - wcwidthResult - - // Base double-width check - val isBaseDoubleWidth = if (actualCodePoint >= 0x1F100) true else isWcwidthDoubleWidth - - // Any character followed by variation selector (FE0F/FE0E) should be 2-cell - // This handles cases like ❤️ (U+2764 + FE0F) which is in Dingbats range - // hasVariationSelector already checks charAtCol1 for FE0F/FE0E - val isEmojiWithVariationSelector = hasVariationSelector - - // Emoji with variation selector should be double-width (must match renderText) - val isDoubleWidth = isBaseDoubleWidth || isEmojiWithVariationSelector - - // Determine visual width - val visualWidth = if (isDoubleWidth) 2 else 1 + // Use shared character analysis helper + val analysis = analyzeCharacter(char, line, col, ctx.visibleCols, ctx.ambiguousCharsAreDoubleWidth) // Get attributes val isInverse = style?.hasOption(BossTextStyle.Option.INVERSE) ?: false @@ -243,7 +315,7 @@ object TerminalCanvasRenderer { // Skip drawing if background matches default (canvas already has default bg) if (bgColor != ctx.settings.defaultBackgroundColor) { // Calculate background dimensions using visual positions - val nextVisualCol = visualCol + visualWidth + val nextVisualCol = visualCol + analysis.visualWidth val nextX = kotlin.math.ceil(nextVisualCol * ctx.cellWidth) val bgWidth = nextX - x val nextRow = row + 1 @@ -262,12 +334,12 @@ object TerminalCanvasRenderer { // Advance buffer position (must match renderText col advancement) col++ - if (isWcwidthDoubleWidth) col++ // Skip DWC marker - if (isEmojiWithVariationSelector) col++ // Skip variation selector - if (lowSurrogate != null) col++ // Skip low surrogate + if (analysis.isWcwidthDoubleWidth) col++ // Skip DWC marker + if (analysis.isEmojiWithVariationSelector) col++ // Skip variation selector + if (analysis.lowSurrogate != null) col++ // Skip low surrogate // Advance visual position - visualCol += visualWidth + visualCol += analysis.visualWidth } } } @@ -432,64 +504,15 @@ object TerminalCanvasRenderer { val x = visualCol * ctx.cellWidth val y = row * ctx.cellHeight - // Handle surrogate pairs - val charAtCol1 = if (col + 1 < snapshot.width) line.charAt(col + 1) else null - val charAtCol2 = if (col + 2 < snapshot.width) line.charAt(col + 2) else null - - val lowSurrogate = if (Character.isHighSurrogate(char)) { - when { - charAtCol1 != null && Character.isLowSurrogate(charAtCol1) -> charAtCol1 - charAtCol1 == CharUtils.DWC && charAtCol2 != null && Character.isLowSurrogate(charAtCol2) -> charAtCol2 - else -> null - } - } else null - - val actualCodePoint = if (lowSurrogate != null && Character.isLowSurrogate(lowSurrogate)) { - Character.toCodePoint(char, lowSurrogate) - } else char.code + // Use shared character analysis helper + val analysis = analyzeCharacter(char, line, col, snapshot.width, ctx.ambiguousCharsAreDoubleWidth) - val wcwidthResult = char != ' ' && char != '\u0000' && - CharUtils.isDoubleWidthCharacter(actualCodePoint, ctx.ambiguousCharsAreDoubleWidth) - val isWcwidthDoubleWidth = charAtCol1 == CharUtils.DWC || wcwidthResult - - val charTextToRender = if (lowSurrogate != null && Character.isLowSurrogate(lowSurrogate)) { - "$char$lowSurrogate" - } else { - char.toString() - } - - // Character classification - val isCursiveOrMath = actualCodePoint in 0x1D400..0x1D7FF - val isTechnicalSymbol = actualCodePoint in 0x23E9..0x23FF - val isEmojiOrWideSymbol = when (actualCodePoint) { - // Misc symbols - but exclude text presentation symbols - in 0x2600..0x26FF -> when (actualCodePoint) { - // Exclude stars (text symbols) - 0x2605, 0x2606 -> false // ★ ☆ - // Exclude card suits (text symbols) - in 0x2660..0x2667 -> false // ♠ ♡ ♢ ♣ ♤ ♥ ♦ ♧ - else -> true - } - in 0x1F100..0x1F1FF -> true - in 0x1F300..0x1F9FF -> true - in 0x1F600..0x1F64F -> true - in 0x1F680..0x1F6FF -> true - else -> false - } - - val isBaseDoubleWidth = if (actualCodePoint >= 0x1F100) true else isWcwidthDoubleWidth - - // Check for variation selector - any character followed by FE0F/FE0E is emoji presentation - val nextCharOffset = if (isWcwidthDoubleWidth) 2 else 1 + // Get nextChar for rendering emoji with variation selectors + val nextCharOffset = if (analysis.isWcwidthDoubleWidth) 2 else 1 val nextChar = if (col + nextCharOffset < snapshot.width) line.charAt(col + nextCharOffset) else null - val hasVariationSelector = nextChar != null && (nextChar.code == 0xFE0F || nextChar.code == 0xFE0E) - val isEmojiWithVariationSelector = hasVariationSelector - - // Emoji with variation selector should be double-width - val isDoubleWidth = isBaseDoubleWidth || isEmojiWithVariationSelector // Skip standalone variation selectors - if ((char.code == 0xFE0F || char.code == 0xFE0E) && !isEmojiOrWideSymbol) { + if ((char.code == 0xFE0F || char.code == 0xFE0E) && !analysis.isEmojiOrWideSymbol) { col++ continue } @@ -518,7 +541,7 @@ object TerminalCanvasRenderer { else -> true } - val canBatch = !isDoubleWidth && !isEmojiOrWideSymbol && !isCursiveOrMath && !isTechnicalSymbol && + val canBatch = !analysis.isDoubleWidth && !analysis.isEmojiOrWideSymbol && !analysis.isCursiveOrMath && !analysis.isTechnicalSymbol && !isHidden && isBlinkVisible && char != ' ' && char != '\u0000' val styleMatches = batchText.isNotEmpty() && @@ -541,23 +564,23 @@ object TerminalCanvasRenderer { if (char != ' ' && char != '\u0000' && !isHidden && isBlinkVisible) { renderCharacter( - ctx, x, y, charTextToRender, actualCodePoint, - isDoubleWidth, isEmojiOrWideSymbol, isEmojiWithVariationSelector, - isCursiveOrMath, isTechnicalSymbol, nextChar, + ctx, x, y, analysis.charTextToRender, analysis.actualCodePoint, + analysis.isDoubleWidth, analysis.isEmojiOrWideSymbol, analysis.isEmojiWithVariationSelector, + analysis.isCursiveOrMath, analysis.isTechnicalSymbol, nextChar, fgColor, isBold, isItalic, isUnderline ) - if (isEmojiWithVariationSelector) { + if (analysis.isEmojiWithVariationSelector) { col++ } } } - if (isWcwidthDoubleWidth) col++ + if (analysis.isWcwidthDoubleWidth) col++ col++ - if (lowSurrogate != null) col++ + if (analysis.lowSurrogate != null) col++ visualCol++ - if (isDoubleWidth) visualCol++ + if (analysis.isDoubleWidth) visualCol++ } flushBatch() From b4cc6d08bf008ac7ffe39c35776c9617ac69ddb5 Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 17:02:11 -0500 Subject: [PATCH 07/20] fix: Extend emoji coverage and extract skip logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Point 1 - Extended isEmojiPresentation() to cover: - All supplementary plane emoji (U+1F100-U+1FAFF) - BMP emoji with Emoji_Presentation=Yes - Previously only covered ~60 code points, now covers 1000+ emoji Point 3 - Extracted common skip logic in ColumnConversionUtils: - Created shouldSkipChar() helper with SkipResult data class - Eliminates ~80% code duplication between bufferColToVisualCol/visualColToBufferCol - Future changes to skip logic only need one update 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../terminal/util/ColumnConversionUtils.kt | 155 ++++++++---------- .../bossterm/terminal/util/GraphemeUtils.kt | 110 ++++++++----- 2 files changed, 138 insertions(+), 127 deletions(-) diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/ColumnConversionUtils.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/ColumnConversionUtils.kt index 6a09a56f..06e9e137 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/ColumnConversionUtils.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/ColumnConversionUtils.kt @@ -10,6 +10,69 @@ import ai.rever.bossterm.terminal.model.TerminalLine */ object ColumnConversionUtils { + /** + * Result of checking if a character should be skipped during column iteration. + * @param shouldSkip True if the character should be skipped + * @param colsToAdvance Number of columns to advance (1 for single char, 2 for surrogate pair) + */ + private data class SkipResult(val shouldSkip: Boolean, val colsToAdvance: Int = 1) + + /** + * Check if character at given column should be skipped (doesn't consume visual space). + * This encapsulates the common skip logic used by both bufferColToVisualCol and visualColToBufferCol. + * + * Characters that don't consume visual space: + * - DWC markers (placeholder for second cell of double-width char) + * - Variation selectors (FE0E, FE0F) + * - Zero-Width Joiner (ZWJ) + * - Low surrogates (part of previous high surrogate) + * - Skin tone modifiers (when part of emoji sequence) + * - Gender symbols (when preceded by ZWJ) + */ + private fun shouldSkipChar(line: TerminalLine, col: Int, width: Int): SkipResult { + val char = line.charAt(col) + + // Skip DWC markers (they don't add visual width) + if (char == CharUtils.DWC) { + return SkipResult(true, 1) + } + + // Skip variation selectors (FE0E, FE0F) + if (char.code == 0xFE0E || char.code == 0xFE0F) { + return SkipResult(true, 1) + } + + // Skip ZWJ (U+200D) + if (char.code == 0x200D) { + return SkipResult(true, 1) + } + + // Skip low surrogates (they're part of previous high surrogate) + if (Character.isLowSurrogate(char)) { + return SkipResult(true, 1) + } + + // Skip skin tone modifiers (U+1F3FB-U+1F3FF, encoded as surrogate pairs) + if (Character.isHighSurrogate(char) && col + 1 < width) { + val nextChar = line.charAt(col + 1) + if (Character.isLowSurrogate(nextChar)) { + val codePoint = Character.toCodePoint(char, nextChar) + if (codePoint in 0x1F3FB..0x1F3FF) { + return SkipResult(true, 2) + } + } + } + + // Skip gender symbols only when preceded by ZWJ (part of ZWJ sequences) + if (char.code == 0x2640 || char.code == 0x2642) { + if (col > 0 && line.charAt(col - 1).code == 0x200D) { + return SkipResult(true, 1) + } + } + + return SkipResult(false, 0) + } + /** * Convert buffer column to visual column. * Accounts for DWC markers, surrogate pairs, ZWJ sequences, and other grapheme extenders @@ -27,52 +90,12 @@ object ColumnConversionUtils { var col = 0 while (col < bufferCol && col < width) { - val char = line.charAt(col) - - // Skip DWC markers (they don't add visual width) - if (char == CharUtils.DWC) { - col++ + val skipResult = shouldSkipChar(line, col, width) + if (skipResult.shouldSkip) { + col += skipResult.colsToAdvance continue } - // Skip variation selectors (FE0E, FE0F) - if (char.code == 0xFE0E || char.code == 0xFE0F) { - col++ - continue - } - - // Skip ZWJ (U+200D) - if (char.code == 0x200D) { - col++ - continue - } - - // Skip low surrogates (they're part of previous high surrogate) - if (Character.isLowSurrogate(char)) { - col++ - continue - } - - // Skip skin tone modifiers (U+1F3FB-U+1F3FF, encoded as surrogate pairs) - if (Character.isHighSurrogate(char) && col + 1 < width) { - val nextChar = line.charAt(col + 1) - if (Character.isLowSurrogate(nextChar)) { - val codePoint = Character.toCodePoint(char, nextChar) - if (codePoint in 0x1F3FB..0x1F3FF) { - col += 2 - continue - } - } - } - - // Skip gender symbols only when preceded by ZWJ (part of ZWJ sequences) - if (char.code == 0x2640 || char.code == 0x2642) { - if (col > 0 && line.charAt(col - 1).code == 0x200D) { - col++ - continue - } - } - // Regular character - count visual width (1 or 2 for double-width) visualCol += getCharacterVisualWidth(line, col, width) col++ @@ -97,52 +120,12 @@ object ColumnConversionUtils { var col = 0 while (col < width && currentVisualCol < visualCol) { - val char = line.charAt(col) - - // Skip DWC markers - if (char == CharUtils.DWC) { - col++ - continue - } - - // Skip variation selectors - if (char.code == 0xFE0E || char.code == 0xFE0F) { - col++ - continue - } - - // Skip ZWJ - if (char.code == 0x200D) { - col++ + val skipResult = shouldSkipChar(line, col, width) + if (skipResult.shouldSkip) { + col += skipResult.colsToAdvance continue } - // Skip low surrogates - if (Character.isLowSurrogate(char)) { - col++ - continue - } - - // Skip skin tone modifiers - if (Character.isHighSurrogate(char) && col + 1 < width) { - val nextChar = line.charAt(col + 1) - if (Character.isLowSurrogate(nextChar)) { - val codePoint = Character.toCodePoint(char, nextChar) - if (codePoint in 0x1F3FB..0x1F3FF) { - col += 2 - continue - } - } - } - - // Skip gender symbols only when preceded by ZWJ - if (char.code == 0x2640 || char.code == 0x2642) { - if (col > 0 && line.charAt(col - 1).code == 0x200D) { - col++ - continue - } - } - // Regular character - count visual width val charWidth = getCharacterVisualWidth(line, col, width) diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt index ad21ccb2..be5c1f71 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt @@ -209,54 +209,82 @@ object GraphemeUtils { } /** - * Checks if a code point has Emoji_Presentation=Yes in Unicode. - * These characters should render as emoji (2 cells) by default without needing U+FE0F. + * Checks if a code point should render as emoji (2 cells width). * - * Note: This is a curated subset of characters that are commonly rendered as emoji. - * Characters like ❤ (U+2764) have Emoji_Presentation=No and need U+FE0F to render as emoji. + * This covers: + * - Characters with Emoji_Presentation=Yes in Unicode + * - Supplementary plane emoji (U+1F000+) which are always 2-cell wide + * - Common BMP emoji that typically render as 2 cells * * @param codePoint The Unicode code point to check - * @return True if this character defaults to emoji presentation (2 cells) + * @return True if this character should render as 2 cells */ private fun isEmojiPresentation(codePoint: Int): Boolean { - return when (codePoint) { - // Weather and zodiac (selected) - 0x2614, 0x2615 -> true // Umbrella, coffee - in 0x2648..0x2653 -> true // Zodiac - 0x267F -> true // Wheelchair - 0x2693 -> true // Anchor - 0x26A1 -> true // High voltage - 0x26AA, 0x26AB -> true // Circles - 0x26BD, 0x26BE -> true // Sports balls - 0x26C4, 0x26C5 -> true // Snowman, sun/cloud - 0x26CE -> true // Ophiuchus - 0x26D4 -> true // No entry - 0x26EA -> true // Church - 0x26F2, 0x26F3 -> true // Fountain, golf - 0x26F5 -> true // Sailboat - 0x26FA -> true // Tent - 0x26FD -> true // Fuel pump - // Dingbats with Emoji_Presentation=Yes (verified against Unicode spec) - 0x2705 -> true // ✅ Check mark button - 0x2728 -> true // ✨ Sparkles - 0x274C -> true // ❌ Cross mark - 0x274E -> true // Cross mark button - in 0x2753..0x2755 -> true // Question/exclamation marks (❓❔❕) - 0x2757 -> true // ❗ Exclamation mark - in 0x2795..0x2797 -> true // Math operators (➕➖➗) - 0x27B0 -> true // ➰ Curly loop - 0x27BF -> true // ➿ Double curly loop + return when { + // === SUPPLEMENTARY PLANE EMOJI (always 2-cell) === + // Enclosed Alphanumeric Supplement (U+1F100-U+1F1FF) - includes regional indicators + codePoint in 0x1F100..0x1F1FF -> true + // Misc Symbols and Pictographs (U+1F300-U+1F5FF) - weather, food, animals, objects + codePoint in 0x1F300..0x1F5FF -> true + // Emoticons (U+1F600-U+1F64F) - smileys & people + codePoint in 0x1F600..0x1F64F -> true + // Transport and Map Symbols (U+1F680-U+1F6FF) + codePoint in 0x1F680..0x1F6FF -> true + // Supplemental Symbols and Pictographs (U+1F900-U+1F9FF) + codePoint in 0x1F900..0x1F9FF -> true + // Symbols and Pictographs Extended-A (U+1FA70-U+1FAFF) + codePoint in 0x1FA70..0x1FAFF -> true + // Chess Symbols (U+1FA00-U+1FA6F) + codePoint in 0x1FA00..0x1FA6F -> true + + // === BMP EMOJI with Emoji_Presentation=Yes === + // Watch and hourglass + codePoint == 0x231A || codePoint == 0x231B -> true + // Media control symbols + codePoint in 0x23E9..0x23F3 -> true + codePoint in 0x23F8..0x23FA -> true + // Squares + codePoint == 0x25AA || codePoint == 0x25AB -> true + codePoint == 0x25B6 || codePoint == 0x25C0 -> true + codePoint in 0x25FB..0x25FE -> true + // Miscellaneous Symbols (U+2600-U+26FF) - weather, zodiac, misc + codePoint == 0x2614 || codePoint == 0x2615 -> true // Umbrella, coffee + codePoint in 0x2648..0x2653 -> true // Zodiac + codePoint == 0x267F -> true // Wheelchair + codePoint == 0x2693 -> true // Anchor + codePoint == 0x26A1 -> true // High voltage + codePoint == 0x26AA || codePoint == 0x26AB -> true // Circles + codePoint == 0x26BD || codePoint == 0x26BE -> true // Sports balls + codePoint == 0x26C4 || codePoint == 0x26C5 -> true // Snowman, sun/cloud + codePoint == 0x26CE -> true // Ophiuchus + codePoint == 0x26D4 -> true // No entry + codePoint == 0x26EA -> true // Church + codePoint == 0x26F2 || codePoint == 0x26F3 -> true // Fountain, golf + codePoint == 0x26F5 -> true // Sailboat + codePoint == 0x26FA -> true // Tent + codePoint == 0x26FD -> true // Fuel pump + // Dingbats with Emoji_Presentation=Yes + codePoint == 0x2705 -> true // ✅ Check mark button + codePoint == 0x2728 -> true // ✨ Sparkles + codePoint == 0x274C -> true // ❌ Cross mark + codePoint == 0x274E -> true // Cross mark button + codePoint in 0x2753..0x2755 -> true // Question/exclamation marks (❓❔❕) + codePoint == 0x2757 -> true // ❗ Exclamation mark + codePoint in 0x2795..0x2797 -> true // Math operators (➕➖➗) + codePoint == 0x27B0 -> true // ➰ Curly loop + codePoint == 0x27BF -> true // ➿ Double curly loop // Arrows and shapes - 0x2934, 0x2935 -> true // Curved arrows - in 0x2B05..0x2B07 -> true // Directional arrows - 0x2B1B, 0x2B1C -> true // Large squares - 0x2B50 -> true // ⭐ Star - 0x2B55 -> true // Heavy large circle + codePoint == 0x2934 || codePoint == 0x2935 -> true // Curved arrows + codePoint in 0x2B05..0x2B07 -> true // Directional arrows + codePoint == 0x2B1B || codePoint == 0x2B1C -> true // Large squares + codePoint == 0x2B50 -> true // ⭐ Star + codePoint == 0x2B55 -> true // Heavy large circle // Japanese symbols - 0x3030 -> true // Wavy dash - 0x303D -> true // Part alternation mark - 0x3297 -> true // Circled Ideograph Congratulation - 0x3299 -> true // Circled Ideograph Secret + codePoint == 0x3030 -> true // Wavy dash + codePoint == 0x303D -> true // Part alternation mark + codePoint == 0x3297 -> true // Circled Ideograph Congratulation + codePoint == 0x3299 -> true // Circled Ideograph Secret + else -> false } } From 6d4c31cb75c8fdd3ea8e3c3c4245f210843aca96 Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 17:05:22 -0500 Subject: [PATCH 08/20] fix: Remove media control symbols from 2-cell emoji list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symbols like ▶◀⏹⏺ are commonly used as 1-cell text symbols in TUI applications. They should only render as 2-cell emoji when explicitly followed by variation selector FE0F. Removed from isEmojiPresentation(): - Media controls (⏩⏪⏫⏬⏭⏮⏯⏰⏱⏲⏳⏸⏹⏺) - Play/reverse buttons (▶◀) - Small squares (▪▫◻◼◽◾) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../bossterm/terminal/util/GraphemeUtils.kt | 80 +++++++++---------- 1 file changed, 38 insertions(+), 42 deletions(-) diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt index be5c1f71..fa0a2e4b 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt @@ -209,15 +209,18 @@ object GraphemeUtils { } /** - * Checks if a code point should render as emoji (2 cells width). + * Checks if a code point should render as emoji (2 cells width) by default. * * This covers: - * - Characters with Emoji_Presentation=Yes in Unicode * - Supplementary plane emoji (U+1F000+) which are always 2-cell wide - * - Common BMP emoji that typically render as 2 cells + * - BMP characters that are UNAMBIGUOUSLY emoji (not commonly used as text symbols) + * + * NOTE: Many BMP symbols (▶◀⏹⏺ etc.) are intentionally NOT included here because + * they are often used as 1-cell text symbols in TUI applications. They will render + * as 2-cell emoji ONLY when followed by variation selector FE0F. * * @param codePoint The Unicode code point to check - * @return True if this character should render as 2 cells + * @return True if this character should render as 2 cells by default */ private fun isEmojiPresentation(codePoint: Int): Boolean { return when { @@ -237,53 +240,46 @@ object GraphemeUtils { // Chess Symbols (U+1FA00-U+1FA6F) codePoint in 0x1FA00..0x1FA6F -> true - // === BMP EMOJI with Emoji_Presentation=Yes === - // Watch and hourglass + // === BMP EMOJI that are unambiguously emoji (not used as text symbols) === + // Watch and hourglass - commonly rendered as emoji codePoint == 0x231A || codePoint == 0x231B -> true - // Media control symbols - codePoint in 0x23E9..0x23F3 -> true - codePoint in 0x23F8..0x23FA -> true - // Squares - codePoint == 0x25AA || codePoint == 0x25AB -> true - codePoint == 0x25B6 || codePoint == 0x25C0 -> true - codePoint in 0x25FB..0x25FE -> true - // Miscellaneous Symbols (U+2600-U+26FF) - weather, zodiac, misc - codePoint == 0x2614 || codePoint == 0x2615 -> true // Umbrella, coffee - codePoint in 0x2648..0x2653 -> true // Zodiac - codePoint == 0x267F -> true // Wheelchair - codePoint == 0x2693 -> true // Anchor - codePoint == 0x26A1 -> true // High voltage - codePoint == 0x26AA || codePoint == 0x26AB -> true // Circles - codePoint == 0x26BD || codePoint == 0x26BE -> true // Sports balls - codePoint == 0x26C4 || codePoint == 0x26C5 -> true // Snowman, sun/cloud - codePoint == 0x26CE -> true // Ophiuchus - codePoint == 0x26D4 -> true // No entry - codePoint == 0x26EA -> true // Church - codePoint == 0x26F2 || codePoint == 0x26F3 -> true // Fountain, golf - codePoint == 0x26F5 -> true // Sailboat - codePoint == 0x26FA -> true // Tent - codePoint == 0x26FD -> true // Fuel pump - // Dingbats with Emoji_Presentation=Yes + // Miscellaneous Symbols - only include clearly emoji ones + codePoint == 0x2614 || codePoint == 0x2615 -> true // ☔☕ Umbrella, coffee + codePoint in 0x2648..0x2653 -> true // Zodiac signs + codePoint == 0x267F -> true // ♿ Wheelchair + codePoint == 0x2693 -> true // ⚓ Anchor + codePoint == 0x26A1 -> true // ⚡ High voltage + codePoint == 0x26AA || codePoint == 0x26AB -> true // ⚪⚫ Circles + codePoint == 0x26BD || codePoint == 0x26BE -> true // ⚽⚾ Sports balls + codePoint == 0x26C4 || codePoint == 0x26C5 -> true // ⛄⛅ Snowman, sun/cloud + codePoint == 0x26CE -> true // ⛎ Ophiuchus + codePoint == 0x26D4 -> true // ⛔ No entry + codePoint == 0x26EA -> true // ⛪ Church + codePoint == 0x26F2 || codePoint == 0x26F3 -> true // ⛲⛳ Fountain, golf + codePoint == 0x26F5 -> true // ⛵ Sailboat + codePoint == 0x26FA -> true // ⛺ Tent + codePoint == 0x26FD -> true // ⛽ Fuel pump + // Dingbats - only clearly emoji ones codePoint == 0x2705 -> true // ✅ Check mark button codePoint == 0x2728 -> true // ✨ Sparkles codePoint == 0x274C -> true // ❌ Cross mark - codePoint == 0x274E -> true // Cross mark button - codePoint in 0x2753..0x2755 -> true // Question/exclamation marks (❓❔❕) + codePoint == 0x274E -> true // ❎ Cross mark button + codePoint in 0x2753..0x2755 -> true // ❓❔❕ Question/exclamation marks codePoint == 0x2757 -> true // ❗ Exclamation mark - codePoint in 0x2795..0x2797 -> true // Math operators (➕➖➗) + codePoint in 0x2795..0x2797 -> true // ➕➖➗ Math operators codePoint == 0x27B0 -> true // ➰ Curly loop codePoint == 0x27BF -> true // ➿ Double curly loop - // Arrows and shapes - codePoint == 0x2934 || codePoint == 0x2935 -> true // Curved arrows - codePoint in 0x2B05..0x2B07 -> true // Directional arrows - codePoint == 0x2B1B || codePoint == 0x2B1C -> true // Large squares + // Arrows - only the clearly emoji ones + codePoint == 0x2934 || codePoint == 0x2935 -> true // ⤴⤵ Curved arrows + codePoint in 0x2B05..0x2B07 -> true // ⬅⬆⬇ Directional arrows + codePoint == 0x2B1B || codePoint == 0x2B1C -> true // ⬛⬜ Large squares codePoint == 0x2B50 -> true // ⭐ Star - codePoint == 0x2B55 -> true // Heavy large circle + codePoint == 0x2B55 -> true // ⭕ Heavy large circle // Japanese symbols - codePoint == 0x3030 -> true // Wavy dash - codePoint == 0x303D -> true // Part alternation mark - codePoint == 0x3297 -> true // Circled Ideograph Congratulation - codePoint == 0x3299 -> true // Circled Ideograph Secret + codePoint == 0x3030 -> true // 〰 Wavy dash + codePoint == 0x303D -> true // 〽 Part alternation mark + codePoint == 0x3297 -> true // ㊗ Circled Ideograph Congratulation + codePoint == 0x3299 -> true // ㊙ Circled Ideograph Secret else -> false } From 560473a49d467795492297d9f1921ddb21cbc30b Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 17:09:40 -0500 Subject: [PATCH 09/20] fix: Align renderer emoji classification with buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer's isEmojiOrWideSymbol was too broad (all 0x2600-0x26FF), causing symbols like ☐☑☒☠☢☣☮☯☸ to render as 2-cell while buffer only allocated 1-cell (no DWC marker). Now isEmojiOrWideSymbol matches GraphemeUtils.isEmojiPresentation() exactly, ensuring renderer and buffer are consistent. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../rendering/TerminalCanvasRenderer.kt | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt index 5de6cad7..2f78cedf 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt @@ -166,16 +166,32 @@ fun analyzeCharacter( // Character classification for font selection val isCursiveOrMath = actualCodePoint in 0x1D400..0x1D7FF val isTechnicalSymbol = actualCodePoint in 0x23E9..0x23FF + // Only treat as emoji if it matches isEmojiPresentation() in GraphemeUtils + // to ensure renderer is consistent with buffer DWC markers val isEmojiOrWideSymbol = when (actualCodePoint) { - in 0x2600..0x26FF -> when (actualCodePoint) { - 0x2605, 0x2606 -> false // ★ ☆ (text symbols) - in 0x2660..0x2667 -> false // ♠ ♡ ♢ ♣ ♤ ♥ ♦ ♧ (card suits) - else -> true - } + // Miscellaneous Symbols - only specific emoji ones (must match GraphemeUtils.isEmojiPresentation) + 0x2614, 0x2615 -> true // ☔☕ Umbrella, coffee + in 0x2648..0x2653 -> true // Zodiac signs + 0x267F -> true // ♿ Wheelchair + 0x2693 -> true // ⚓ Anchor + 0x26A1 -> true // ⚡ High voltage + 0x26AA, 0x26AB -> true // ⚪⚫ Circles + 0x26BD, 0x26BE -> true // ⚽⚾ Sports balls + 0x26C4, 0x26C5 -> true // ⛄⛅ Snowman, sun/cloud + 0x26CE -> true // ⛎ Ophiuchus + 0x26D4 -> true // ⛔ No entry + 0x26EA -> true // ⛪ Church + 0x26F2, 0x26F3 -> true // ⛲⛳ Fountain, golf + 0x26F5 -> true // ⛵ Sailboat + 0x26FA -> true // ⛺ Tent + 0x26FD -> true // ⛽ Fuel pump + // Supplementary plane emoji (always 2-cell) in 0x1F100..0x1F1FF -> true - in 0x1F300..0x1F9FF -> true + in 0x1F300..0x1F5FF -> true in 0x1F600..0x1F64F -> true in 0x1F680..0x1F6FF -> true + in 0x1F900..0x1F9FF -> true + in 0x1FA00..0x1FAFF -> true else -> false } From 88b4622b44c0e02ab0a18e6c13a39f4649f58cbe Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 17:17:33 -0500 Subject: [PATCH 10/20] perf: Cache character analysis to avoid redundant computation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AnalysisCache to store CharacterAnalysis results between render passes: - renderBackgrounds() populates cache during Pass 1 - renderText() reuses cached analysis in Pass 2 - Fallback to compute if cache miss (shouldn't happen in normal flow) This eliminates ~50% of analyzeCharacter() calls in the hot render path. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../rendering/TerminalCanvasRenderer.kt | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt index 2f78cedf..a82c5591 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt @@ -211,6 +211,12 @@ fun analyzeCharacter( ) } +/** + * Cache for CharacterAnalysis results to avoid redundant analysis between render passes. + * Key: (row, col) pair, Value: CharacterAnalysis result + */ +private typealias AnalysisCache = MutableMap, CharacterAnalysis> + /** * Terminal canvas renderer that handles all drawing operations. * Separates rendering logic from the composable for better maintainability. @@ -220,20 +226,22 @@ object TerminalCanvasRenderer { /** * Main rendering entry point. Renders the entire terminal buffer. * Uses a 3-pass system: - * - Pass 1: Draw all backgrounds - * - Pass 2: Draw all text + * - Pass 1: Draw all backgrounds (and cache character analysis) + * - Pass 2: Draw all text (reuse cached analysis) * - Pass 3: Draw overlays (hyperlinks, search, selection, cursor) * * @return Map of row to detected hyperlinks for mouse hover detection */ fun DrawScope.renderTerminal(ctx: RenderingContext): Map> { val hyperlinksCache = mutableMapOf>() + // Cache character analysis to avoid redundant computation between passes + val analysisCache: AnalysisCache = mutableMapOf() - // Pass 1: Draw backgrounds - renderBackgrounds(ctx) + // Pass 1: Draw backgrounds and populate analysis cache + renderBackgrounds(ctx, analysisCache) - // Pass 2: Draw text and collect hyperlinks - val detectedHyperlinks = renderText(ctx) + // Pass 2: Draw text and collect hyperlinks (reuse cached analysis) + val detectedHyperlinks = renderText(ctx, analysisCache) hyperlinksCache.putAll(detectedHyperlinks) // Pass 3: Draw overlays @@ -244,8 +252,9 @@ object TerminalCanvasRenderer { /** * Pass 1: Render all cell backgrounds. + * Populates the analysis cache for reuse in renderText(). */ - private fun DrawScope.renderBackgrounds(ctx: RenderingContext) { + private fun DrawScope.renderBackgrounds(ctx: RenderingContext, analysisCache: AnalysisCache) { val snapshot = ctx.bufferSnapshot for (row in 0 until ctx.visibleRows) { @@ -313,8 +322,9 @@ object TerminalCanvasRenderer { val x = kotlin.math.floor(visualCol * ctx.cellWidth) val y = kotlin.math.floor(row * ctx.cellHeight) - // Use shared character analysis helper + // Use shared character analysis helper and cache the result val analysis = analyzeCharacter(char, line, col, ctx.visibleCols, ctx.ambiguousCharsAreDoubleWidth) + analysisCache[lineIndex to col] = analysis // Get attributes val isInverse = style?.hasOption(BossTextStyle.Option.INVERSE) ?: false @@ -362,9 +372,10 @@ object TerminalCanvasRenderer { /** * Pass 2: Render all text with proper font handling. + * Reuses character analysis from the cache populated by renderBackgrounds(). * Returns map of row to detected hyperlinks. */ - private fun DrawScope.renderText(ctx: RenderingContext): Map> { + private fun DrawScope.renderText(ctx: RenderingContext, analysisCache: AnalysisCache): Map> { val snapshot = ctx.bufferSnapshot val hyperlinksCache = mutableMapOf>() @@ -519,9 +530,11 @@ object TerminalCanvasRenderer { val x = visualCol * ctx.cellWidth val y = row * ctx.cellHeight + val lineIndex = row - ctx.scrollOffset - // Use shared character analysis helper - val analysis = analyzeCharacter(char, line, col, snapshot.width, ctx.ambiguousCharsAreDoubleWidth) + // Use cached analysis from renderBackgrounds, or compute if not found + val analysis = analysisCache[lineIndex to col] + ?: analyzeCharacter(char, line, col, snapshot.width, ctx.ambiguousCharsAreDoubleWidth) // Get nextChar for rendering emoji with variation selectors val nextCharOffset = if (analysis.isWcwidthDoubleWidth) 2 else 1 From eac56b950fb40b1a9b184085e37225b9776c74fc Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 17:29:50 -0500 Subject: [PATCH 11/20] feat: Add support for flag emoji (Regional Indicator sequences) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flag emoji like 🇺🇸 🇬🇧 🇯🇵 are composed of two Regional Indicator symbols (U+1F1E6-U+1F1FF). This change adds proper handling for them: GraphemeUtils.kt: - Added Regional Indicator sequence detection in calculateGraphemeWidth() - Flags now correctly return width 2 TerminalCanvasRenderer.kt: - Added checkRegionalIndicatorSequence() helper function - renderBackgrounds() now handles flags as single 2-cell units - renderText() now renders flags via renderZWJSequence() 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../bossterm/terminal/util/GraphemeUtils.kt | 7 ++ .../rendering/TerminalCanvasRenderer.kt | 77 ++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt index fa0a2e4b..f25ac4ec 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt @@ -157,6 +157,13 @@ object GraphemeUtils { return 2 } + // Check for Regional Indicator sequence (flag emoji) + // Regional Indicators are in range U+1F1E6 to U+1F1FF + // Two consecutive Regional Indicators form a flag (e.g., 🇺🇸 = U+1F1FA + U+1F1F8) + if (codePoints.size >= 2 && codePoints.all { it in 0x1F1E6..0x1F1FF }) { + return 2 // Flag emoji + } + // Check for variation selector val hasVariationSelector = codePoints.contains(0xFE0E) || codePoints.contains(0xFE0F) diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt index a82c5591..66e0108d 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt @@ -317,6 +317,48 @@ object TerminalCanvasRenderer { } } + // Handle Regional Indicator sequences (flag emoji) as a single unit + // Flags like 🇺🇸 are two Regional Indicators that should render as one 2-cell glyph + if (checkRegionalIndicatorSequence(line, col, ctx.visibleCols)) { + val x = kotlin.math.floor(visualCol * ctx.cellWidth) + val y = kotlin.math.floor(row * ctx.cellHeight) + + // Get attributes for background + val isInverse = style?.hasOption(BossTextStyle.Option.INVERSE) ?: false + val baseFg = style?.foreground?.let { ColorUtils.convertTerminalColor(it) } + ?: ctx.settings.defaultForegroundColor + val baseBg = style?.background?.let { ColorUtils.convertTerminalColor(it) } + ?: ctx.settings.defaultBackgroundColor + val bgColor = if (isInverse) baseFg else baseBg + + // Draw 2-cell background for the flag + if (bgColor != ctx.settings.defaultBackgroundColor) { + val nextVisualCol = visualCol + 2 + val nextX = kotlin.math.ceil(nextVisualCol * ctx.cellWidth) + val bgWidth = nextX - x + val nextRow = row + 1 + val nextY = kotlin.math.ceil(nextRow * ctx.cellHeight) + val bgHeight = if (ctx.settings.fillBackgroundInLineSpacing) { + nextY - y + } else { + ctx.baseCellHeight + } + drawRect( + color = bgColor, + topLeft = Offset(x.toFloat(), y.toFloat()), + size = Size(bgWidth.toFloat(), bgHeight.toFloat()) + ) + } + + // Skip all chars in the flag sequence (2 surrogate pairs + possible DWC markers) + col += 4 // Skip both Regional Indicators (2 surrogate pairs = 4 chars) + while (col < ctx.visibleCols && line.charAt(col) == CharUtils.DWC) { + col++ // Skip any trailing DWC markers + } + visualCol += 2 + continue + } + // Round to pixel boundaries to avoid anti-aliasing artifacts // Use visualCol for x position to match renderText val x = kotlin.math.floor(visualCol * ctx.cellWidth) @@ -511,12 +553,13 @@ object TerminalCanvasRenderer { val hasZWJ = cleanText.contains('\u200D') val hasSkinTone = checkFollowingSkinTone(line, col, snapshot.width) + val hasRegionalIndicator = checkRegionalIndicatorSequence(line, col, snapshot.width) - if (hasZWJ || hasSkinTone) { + if (hasZWJ || hasSkinTone || hasRegionalIndicator) { val graphemes = ai.rever.bossterm.terminal.util.GraphemeUtils.segmentIntoGraphemes(cleanText) if (graphemes.isNotEmpty()) { val grapheme = graphemes[0] - if (grapheme.hasZWJ || hasSkinTone) { + if (grapheme.hasZWJ || hasSkinTone || hasRegionalIndicator) { flushBatch() val (colsSkipped, visualWidth) = renderZWJSequence( ctx, row, visualCol, col, grapheme, line, snapshot.width, style @@ -932,6 +975,36 @@ object TerminalCanvasRenderer { return false } + /** + * Check if current position starts a Regional Indicator sequence (flag emoji). + * Regional Indicators are surrogate pairs with high surrogate 0xD83C and low surrogate 0xDDE6-0xDDFF. + * Two consecutive Regional Indicators form a flag (e.g., 🇺🇸 = U+1F1FA + U+1F1F8). + */ + private fun checkRegionalIndicatorSequence(line: TerminalLine, col: Int, width: Int): Boolean { + if (col + 3 >= width) return false // Need at least 4 chars for 2 surrogate pairs + + val c1 = line.charAt(col) + val c2 = line.charAt(col + 1) + + // Check if first char is high surrogate for Regional Indicator (0xD83C) + // and second char is low surrogate in Regional Indicator range (0xDDE6-0xDDFF) + if (c1 == '\uD83C' && c2.code in 0xDDE6..0xDDFF) { + // Check for second Regional Indicator (may have DWC between them) + var nextCol = col + 2 + if (nextCol < width && line.charAt(nextCol) == CharUtils.DWC) { + nextCol++ + } + if (nextCol + 1 < width) { + val c3 = line.charAt(nextCol) + val c4 = line.charAt(nextCol + 1) + if (c3 == '\uD83C' && c4.code in 0xDDE6..0xDDFF) { + return true + } + } + } + return false + } + /** * Render a ZWJ sequence (emoji family, skin tones, etc.). * Returns (columns skipped in buffer, visual width consumed). From 1399b6a22385aefcd854fa06c2863cbdfffd598e Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 17:41:04 -0500 Subject: [PATCH 12/20] refactor: Deduplicate emoji classification logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Made GraphemeUtils.isEmojiPresentation() public - Updated TerminalCanvasRenderer.analyzeCharacter() to use shared function - Eliminates ~25 lines of duplicated emoji code point checks - Ensures buffer and renderer stay in sync automatically 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../bossterm/terminal/util/GraphemeUtils.kt | 4 ++- .../rendering/TerminalCanvasRenderer.kt | 30 ++----------------- 2 files changed, 5 insertions(+), 29 deletions(-) diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt index f25ac4ec..f5030209 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt @@ -226,10 +226,12 @@ object GraphemeUtils { * they are often used as 1-cell text symbols in TUI applications. They will render * as 2-cell emoji ONLY when followed by variation selector FE0F. * + * Used by both buffer (for DWC markers) and renderer (for font selection). + * * @param codePoint The Unicode code point to check * @return True if this character should render as 2 cells by default */ - private fun isEmojiPresentation(codePoint: Int): Boolean { + fun isEmojiPresentation(codePoint: Int): Boolean { return when { // === SUPPLEMENTARY PLANE EMOJI (always 2-cell) === // Enclosed Alphanumeric Supplement (U+1F100-U+1F1FF) - includes regional indicators diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt index 66e0108d..dc372d9d 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt @@ -166,34 +166,8 @@ fun analyzeCharacter( // Character classification for font selection val isCursiveOrMath = actualCodePoint in 0x1D400..0x1D7FF val isTechnicalSymbol = actualCodePoint in 0x23E9..0x23FF - // Only treat as emoji if it matches isEmojiPresentation() in GraphemeUtils - // to ensure renderer is consistent with buffer DWC markers - val isEmojiOrWideSymbol = when (actualCodePoint) { - // Miscellaneous Symbols - only specific emoji ones (must match GraphemeUtils.isEmojiPresentation) - 0x2614, 0x2615 -> true // ☔☕ Umbrella, coffee - in 0x2648..0x2653 -> true // Zodiac signs - 0x267F -> true // ♿ Wheelchair - 0x2693 -> true // ⚓ Anchor - 0x26A1 -> true // ⚡ High voltage - 0x26AA, 0x26AB -> true // ⚪⚫ Circles - 0x26BD, 0x26BE -> true // ⚽⚾ Sports balls - 0x26C4, 0x26C5 -> true // ⛄⛅ Snowman, sun/cloud - 0x26CE -> true // ⛎ Ophiuchus - 0x26D4 -> true // ⛔ No entry - 0x26EA -> true // ⛪ Church - 0x26F2, 0x26F3 -> true // ⛲⛳ Fountain, golf - 0x26F5 -> true // ⛵ Sailboat - 0x26FA -> true // ⛺ Tent - 0x26FD -> true // ⛽ Fuel pump - // Supplementary plane emoji (always 2-cell) - in 0x1F100..0x1F1FF -> true - in 0x1F300..0x1F5FF -> true - in 0x1F600..0x1F64F -> true - in 0x1F680..0x1F6FF -> true - in 0x1F900..0x1F9FF -> true - in 0x1FA00..0x1FAFF -> true - else -> false - } + // Use shared emoji detection to ensure renderer is consistent with buffer DWC markers + val isEmojiOrWideSymbol = ai.rever.bossterm.terminal.util.GraphemeUtils.isEmojiPresentation(actualCodePoint) return CharacterAnalysis( actualCodePoint = actualCodePoint, From dbb6cd9bb54b6a05cec9dd257b16c97c03e7066f Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 17:44:39 -0500 Subject: [PATCH 13/20] fix: Prevent index out of bounds in Regional Indicator handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add bounds check before skipping flag sequence chars to prevent potential crash if flag emoji is at end of line or buffer is malformed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt index dc372d9d..0b6ea92a 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt @@ -325,7 +325,8 @@ object TerminalCanvasRenderer { } // Skip all chars in the flag sequence (2 surrogate pairs + possible DWC markers) - col += 4 // Skip both Regional Indicators (2 surrogate pairs = 4 chars) + // Use minOf to prevent index out of bounds if flag is at end of line + col += minOf(4, ctx.visibleCols - col) while (col < ctx.visibleCols && line.charAt(col) == CharUtils.DWC) { col++ // Skip any trailing DWC markers } From 84d1da3494c9d7bc932ce5f9c541fc9949399672 Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 17:48:27 -0500 Subject: [PATCH 14/20] refactor: Deduplicate skip logic in renderBackgrounds() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Made ColumnConversionUtils.SkipResult and shouldSkipChar() public - Refactored renderBackgrounds() to use shared shouldSkipChar() - Kept special ZWJ handling (skip until DWC) and Regional Indicator handling - Eliminates ~25 lines of duplicate skip logic 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../terminal/util/ColumnConversionUtils.kt | 6 +-- .../rendering/TerminalCanvasRenderer.kt | 47 ++++--------------- 2 files changed, 11 insertions(+), 42 deletions(-) diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/ColumnConversionUtils.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/ColumnConversionUtils.kt index 06e9e137..4ced81de 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/ColumnConversionUtils.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/ColumnConversionUtils.kt @@ -15,11 +15,11 @@ object ColumnConversionUtils { * @param shouldSkip True if the character should be skipped * @param colsToAdvance Number of columns to advance (1 for single char, 2 for surrogate pair) */ - private data class SkipResult(val shouldSkip: Boolean, val colsToAdvance: Int = 1) + data class SkipResult(val shouldSkip: Boolean, val colsToAdvance: Int = 1) /** * Check if character at given column should be skipped (doesn't consume visual space). - * This encapsulates the common skip logic used by both bufferColToVisualCol and visualColToBufferCol. + * This encapsulates the common skip logic used by column conversion and rendering. * * Characters that don't consume visual space: * - DWC markers (placeholder for second cell of double-width char) @@ -29,7 +29,7 @@ object ColumnConversionUtils { * - Skin tone modifiers (when part of emoji sequence) * - Gender symbols (when preceded by ZWJ) */ - private fun shouldSkipChar(line: TerminalLine, col: Int, width: Int): SkipResult { + fun shouldSkipChar(line: TerminalLine, col: Int, width: Int): SkipResult { val char = line.charAt(col) // Skip DWC markers (they don't add visual width) diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt index 0b6ea92a..42a3be52 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt @@ -241,54 +241,23 @@ object TerminalCanvasRenderer { val char = line.charAt(col) val style = line.getStyleAt(col) - // Skip DWC markers (they don't occupy visual space) - if (char == CharUtils.DWC) { - col++ - continue - } - - // Skip standalone variation selectors (they don't occupy visual space) - if (char.code == 0xFE0F || char.code == 0xFE0E) { - col++ - continue - } - - // Skip Zero-Width Joiner and all subsequent chars until DWC + // Special handling for ZWJ: skip all characters until DWC // ZWJ sequences like 👨‍💻 are: [emoji1][ZWJ][emoji2][DWC] // We already rendered emoji1, now skip ZWJ and everything after until DWC if (char.code == 0x200D) { col++ - // Skip all characters until we hit DWC (end of grapheme) - while (col < ctx.visibleCols) { - val nextChar = line.charAt(col) - if (nextChar == CharUtils.DWC) break + while (col < ctx.visibleCols && line.charAt(col) != CharUtils.DWC) { col++ } continue } - // Skip skin tone modifiers (U+1F3FB-U+1F3FF) - they extend the previous emoji - // These are surrogate pairs: high=0xD83C, low=0xDFFB-0xDFFF - if (Character.isHighSurrogate(char)) { - val nextChar = if (col + 1 < ctx.visibleCols) line.charAt(col + 1) else null - if (nextChar != null && Character.isLowSurrogate(nextChar)) { - val codePoint = Character.toCodePoint(char, nextChar) - // Skin tone modifiers - if (codePoint in 0x1F3FB..0x1F3FF) { - col += 2 // Skip both surrogate chars - continue - } - // Male/female signs used in ZWJ sequences (♀️ U+2640, ♂️ U+2642) - // These are BMP so handled below - } - } - - // Skip gender symbols only when preceded by ZWJ (part of ZWJ sequences) - if (char.code == 0x2640 || char.code == 0x2642) { - if (col > 0 && line.charAt(col - 1).code == 0x200D) { - col++ - continue - } + // Use shared skip logic for simple cases (DWC, variation selectors, + // low surrogates, skin tones, gender symbols after ZWJ) + val skipResult = ColumnConversionUtils.shouldSkipChar(line, col, ctx.visibleCols) + if (skipResult.shouldSkip) { + col += skipResult.colsToAdvance + continue } // Handle Regional Indicator sequences (flag emoji) as a single unit From 6d77c4cd6f1ef5d52dda29988573803c154646c8 Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 18:04:44 -0500 Subject: [PATCH 15/20] refactor: Extract Unicode constants to shared UnicodeConstants.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralizes grapheme extender magic numbers (~40 occurrences across 7 files) into a single UnicodeConstants.kt file for consistency and maintainability. Constants extracted: - Variation Selectors (FE0E, FE0F) - Zero-Width Joiner (200D) - Skin Tone Range (1F3FB-1F3FF) - Gender Symbols (2640, 2642) - Regional Indicator Range (1F1E6-1F1FF) Helper functions added: - isVariationSelector() - isSkinToneModifier() - isGenderSymbol() - isRegionalIndicator() - isRegionalIndicatorHighSurrogate() - isRegionalIndicatorLowSurrogate() Files updated: - GraphemeUtils.kt - GraphemeCluster.kt - ColumnConversionUtils.kt - GraphemeMetadata.kt - GraphemeBoundaryUtils.kt - TerminalCanvasRenderer.kt 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../terminal/util/ColumnConversionUtils.kt | 18 ++--- .../terminal/util/GraphemeBoundaryUtils.kt | 4 +- .../bossterm/terminal/util/GraphemeCluster.kt | 8 +-- .../terminal/util/GraphemeMetadata.kt | 4 +- .../bossterm/terminal/util/GraphemeUtils.kt | 21 +++--- .../terminal/util/UnicodeConstants.kt | 68 +++++++++++++++++++ .../rendering/TerminalCanvasRenderer.kt | 41 ++++++----- 7 files changed, 118 insertions(+), 46 deletions(-) create mode 100644 bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/UnicodeConstants.kt diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/ColumnConversionUtils.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/ColumnConversionUtils.kt index 4ced81de..143b932b 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/ColumnConversionUtils.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/ColumnConversionUtils.kt @@ -38,12 +38,12 @@ object ColumnConversionUtils { } // Skip variation selectors (FE0E, FE0F) - if (char.code == 0xFE0E || char.code == 0xFE0F) { + if (UnicodeConstants.isVariationSelector(char)) { return SkipResult(true, 1) } // Skip ZWJ (U+200D) - if (char.code == 0x200D) { + if (char.code == UnicodeConstants.ZWJ) { return SkipResult(true, 1) } @@ -57,15 +57,15 @@ object ColumnConversionUtils { val nextChar = line.charAt(col + 1) if (Character.isLowSurrogate(nextChar)) { val codePoint = Character.toCodePoint(char, nextChar) - if (codePoint in 0x1F3FB..0x1F3FF) { + if (UnicodeConstants.isSkinToneModifier(codePoint)) { return SkipResult(true, 2) } } } // Skip gender symbols only when preceded by ZWJ (part of ZWJ sequences) - if (char.code == 0x2640 || char.code == 0x2642) { - if (col > 0 && line.charAt(col - 1).code == 0x200D) { + if (UnicodeConstants.isGenderSymbol(char.code)) { + if (col > 0 && line.charAt(col - 1).code == UnicodeConstants.ZWJ) { return SkipResult(true, 1) } } @@ -170,9 +170,9 @@ object ColumnConversionUtils { if (nextChar == CharUtils.DWC) return 2 // Continue through grapheme extenders if (Character.isLowSurrogate(nextChar) || - nextChar.code == 0xFE0E || nextChar.code == 0xFE0F || - nextChar.code == 0x200D || - nextChar.code == 0x2640 || nextChar.code == 0x2642) { + UnicodeConstants.isVariationSelector(nextChar) || + nextChar.code == UnicodeConstants.ZWJ || + UnicodeConstants.isGenderSymbol(nextChar.code)) { nextCol++ continue } @@ -181,7 +181,7 @@ object ColumnConversionUtils { val afterNext = line.charAt(nextCol + 1) if (Character.isLowSurrogate(afterNext)) { val cp = Character.toCodePoint(nextChar, afterNext) - if (cp in 0x1F3FB..0x1F3FF) { + if (UnicodeConstants.isSkinToneModifier(cp)) { nextCol += 2 continue } diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeBoundaryUtils.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeBoundaryUtils.kt index 618e4b34..92024100 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeBoundaryUtils.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeBoundaryUtils.kt @@ -78,8 +78,8 @@ object GraphemeBoundaryUtils { private fun needsGraphemeAnalysis(c: Char): Boolean { return c.isHighSurrogate() || c.isLowSurrogate() || - c.code == 0x200D || // ZWJ - c.code == 0xFE0E || c.code == 0xFE0F || // Variation selectors + c.code == UnicodeConstants.ZWJ || + UnicodeConstants.isVariationSelector(c) || c.code in 0x0300..0x036F || // Combining diacritics GraphemeUtils.isGraphemeExtender(c) } diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeCluster.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeCluster.kt index 50a5eaa7..f37db481 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeCluster.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeCluster.kt @@ -46,7 +46,7 @@ data class GraphemeCluster( // Supplemental Symbols (U+1F900-U+1F9FF) first in 0x1F900..0x1F9FF -> true // Misc Symbols with emoji presentation - hasVariationSelector(0xFE0F) -> true + hasVariationSelector(UnicodeConstants.VARIATION_SELECTOR_EMOJI) -> true else -> false } } @@ -58,7 +58,7 @@ data class GraphemeCluster( return if (selector != null) { codePoints.contains(selector) } else { - codePoints.contains(0xFE0E) || codePoints.contains(0xFE0F) + codePoints.any { UnicodeConstants.isVariationSelector(it) } } } @@ -66,14 +66,14 @@ data class GraphemeCluster( * Checks if this grapheme contains a skin tone modifier (U+1F3FB-U+1F3FF). */ val hasSkinTone: Boolean - get() = codePoints.any { it in 0x1F3FB..0x1F3FF } + get() = codePoints.any { UnicodeConstants.isSkinToneModifier(it) } /** * Checks if this grapheme contains a Zero-Width Joiner (U+200D). * ZWJ is used to join multiple emoji into a single visual unit. */ val hasZWJ: Boolean - get() = codePoints.contains(0x200D) + get() = codePoints.contains(UnicodeConstants.ZWJ) /** * Checks if this grapheme is a surrogate pair (outside BMP, U+10000+). diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeMetadata.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeMetadata.kt index 2c839546..c128c18a 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeMetadata.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeMetadata.kt @@ -130,8 +130,8 @@ class GraphemeMetadata private constructor( // Check for surrogates, emoji, combining characters if (c.isHighSurrogate() || c.isLowSurrogate() || c.code in 0x0300..0x036F || // Combining diacritics - c.code == 0x200D || // ZWJ - c.code == 0xFE0E || c.code == 0xFE0F // Variation selectors + c.code == UnicodeConstants.ZWJ || + UnicodeConstants.isVariationSelector(c) ) { needsMetadata = true break diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt index f5030209..8c191709 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt @@ -152,23 +152,22 @@ object GraphemeUtils { } // Check for ZWJ sequence (multiple emoji joined) - if (codePoints.contains(0x200D)) { + if (codePoints.contains(UnicodeConstants.ZWJ)) { // ZWJ sequence: treat as single emoji (width 2) return 2 } // Check for Regional Indicator sequence (flag emoji) - // Regional Indicators are in range U+1F1E6 to U+1F1FF // Two consecutive Regional Indicators form a flag (e.g., 🇺🇸 = U+1F1FA + U+1F1F8) - if (codePoints.size >= 2 && codePoints.all { it in 0x1F1E6..0x1F1FF }) { + if (codePoints.size >= 2 && codePoints.all { UnicodeConstants.isRegionalIndicator(it) }) { return 2 // Flag emoji } // Check for variation selector - val hasVariationSelector = codePoints.contains(0xFE0E) || codePoints.contains(0xFE0F) + val hasVariationSelector = codePoints.any { UnicodeConstants.isVariationSelector(it) } // Check for skin tone modifier - val hasSkinTone = codePoints.any { it in 0x1F3FB..0x1F3FF } + val hasSkinTone = codePoints.any { UnicodeConstants.isSkinToneModifier(it) } // For emoji with variation selector or skin tone, calculate base emoji width only if (hasVariationSelector || hasSkinTone) { @@ -303,10 +302,10 @@ object GraphemeUtils { */ fun isGraphemeExtender(c: Char): Boolean { return when (c.code) { - 0x200D -> true // Zero-Width Joiner - 0xFE0E, 0xFE0F -> true // Variation selectors + UnicodeConstants.ZWJ -> true // Zero-Width Joiner + UnicodeConstants.VARIATION_SELECTOR_TEXT, UnicodeConstants.VARIATION_SELECTOR_EMOJI -> true // Variation selectors in 0x0300..0x036F -> true // Combining diacritics - in 0x1F3FB..0x1F3FF -> true // Skin tone modifiers (requires surrogate pair check) + in UnicodeConstants.SKIN_TONE_RANGE -> true // Skin tone modifiers (requires surrogate pair check) in 0x20D0..0x20FF -> true // Combining marks for symbols in 0x0591..0x05BD -> true // Hebrew combining marks in 0x0610..0x061A -> true // Arabic combining marks @@ -320,10 +319,10 @@ object GraphemeUtils { */ fun isGraphemeExtender(codePoint: Int): Boolean { return when (codePoint) { - 0x200D -> true // ZWJ - 0xFE0E, 0xFE0F -> true // Variation selectors + UnicodeConstants.ZWJ -> true // ZWJ + UnicodeConstants.VARIATION_SELECTOR_TEXT, UnicodeConstants.VARIATION_SELECTOR_EMOJI -> true // Variation selectors in 0x0300..0x036F -> true // Combining diacritics - in 0x1F3FB..0x1F3FF -> true // Skin tone modifiers + in UnicodeConstants.SKIN_TONE_RANGE -> true // Skin tone modifiers in 0x20D0..0x20FF -> true // Combining marks for symbols in 0x0591..0x05BD -> true // Hebrew combining marks in 0x0610..0x061A -> true // Arabic combining marks diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/UnicodeConstants.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/UnicodeConstants.kt new file mode 100644 index 00000000..70e47e11 --- /dev/null +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/UnicodeConstants.kt @@ -0,0 +1,68 @@ +package ai.rever.bossterm.terminal.util + +/** + * Unicode constants for grapheme cluster handling. + * Centralized to ensure consistency and ease of maintenance. + * + * These constants are used across multiple files for: + * - Grapheme segmentation (GraphemeUtils, GraphemeBoundaryUtils) + * - Column conversion (ColumnConversionUtils) + * - Character analysis (GraphemeCluster, GraphemeMetadata) + * - Rendering (TerminalCanvasRenderer) + */ +object UnicodeConstants { + // === Variation Selectors === + /** VS15 - text presentation selector */ + const val VARIATION_SELECTOR_TEXT = 0xFE0E + /** VS16 - emoji presentation selector */ + const val VARIATION_SELECTOR_EMOJI = 0xFE0F + + // === Zero-Width Joiner === + /** ZWJ - joins emoji into composite sequences (e.g., family emoji) */ + const val ZWJ = 0x200D + + // === Skin Tone Modifiers (Fitzpatrick scale) === + /** Range of skin tone modifier code points (U+1F3FB to U+1F3FF) */ + val SKIN_TONE_RANGE = 0x1F3FB..0x1F3FF + + // === Gender Symbols (used in ZWJ sequences) === + /** Female sign - used in gendered emoji sequences */ + const val FEMALE_SIGN = 0x2640 + /** Male sign - used in gendered emoji sequences */ + const val MALE_SIGN = 0x2642 + + // === Regional Indicators (flag emoji) === + /** Range of Regional Indicator code points (U+1F1E6 to U+1F1FF) */ + val REGIONAL_INDICATOR_RANGE = 0x1F1E6..0x1F1FF + /** High surrogate for Regional Indicators */ + const val REGIONAL_INDICATOR_HIGH_SURROGATE = '\uD83C' + /** Low surrogate range for Regional Indicators */ + val REGIONAL_INDICATOR_LOW_SURROGATE_RANGE = 0xDDE6..0xDDFF + + // === Helper Functions === + + /** Check if code point is a variation selector (VS15 or VS16) */ + fun isVariationSelector(codePoint: Int): Boolean = + codePoint == VARIATION_SELECTOR_TEXT || codePoint == VARIATION_SELECTOR_EMOJI + + /** Check if char is a variation selector */ + fun isVariationSelector(char: Char): Boolean = isVariationSelector(char.code) + + /** Check if code point is a skin tone modifier */ + fun isSkinToneModifier(codePoint: Int): Boolean = codePoint in SKIN_TONE_RANGE + + /** Check if code point is a gender symbol */ + fun isGenderSymbol(codePoint: Int): Boolean = + codePoint == FEMALE_SIGN || codePoint == MALE_SIGN + + /** Check if code point is a Regional Indicator */ + fun isRegionalIndicator(codePoint: Int): Boolean = codePoint in REGIONAL_INDICATOR_RANGE + + /** Check if char is a Regional Indicator high surrogate */ + fun isRegionalIndicatorHighSurrogate(char: Char): Boolean = + char == REGIONAL_INDICATOR_HIGH_SURROGATE + + /** Check if char code is a Regional Indicator low surrogate */ + fun isRegionalIndicatorLowSurrogate(charCode: Int): Boolean = + charCode in REGIONAL_INDICATOR_LOW_SURROGATE_RANGE +} diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt index 42a3be52..6a09ba16 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt @@ -23,6 +23,7 @@ import ai.rever.bossterm.terminal.model.image.ImageCell import ai.rever.bossterm.terminal.model.image.ImageDataCache import ai.rever.bossterm.terminal.util.CharUtils import ai.rever.bossterm.terminal.util.ColumnConversionUtils +import ai.rever.bossterm.terminal.util.UnicodeConstants import ai.rever.bossterm.terminal.TextStyle as BossTextStyle import org.jetbrains.skia.FontMgr @@ -146,7 +147,7 @@ fun analyzeCharacter( // Check for DWC at col+1, OR DWC at col+2 when col+1 is variation selector // For emoji+VS like ⚠️: Buffer = [⚠][FE0F][DWC] - DWC is at col+2 - val hasVariationSelectorAtCol1 = charAtCol1 != null && (charAtCol1.code == 0xFE0F || charAtCol1.code == 0xFE0E) + val hasVariationSelectorAtCol1 = charAtCol1 != null && UnicodeConstants.isVariationSelector(charAtCol1) val isWcwidthDoubleWidth = charAtCol1 == CharUtils.DWC || (hasVariationSelectorAtCol1 && charAtCol2 == CharUtils.DWC) || wcwidthResult @@ -156,7 +157,7 @@ fun analyzeCharacter( // Check for variation selector - handle both DWC and non-DWC cases val nextCharOffset = if (isWcwidthDoubleWidth) 2 else 1 val nextChar = if (col + nextCharOffset < width) line.charAt(col + nextCharOffset) else null - val hasVariationSelector = (nextChar != null && (nextChar.code == 0xFE0F || nextChar.code == 0xFE0E)) || + val hasVariationSelector = (nextChar != null && UnicodeConstants.isVariationSelector(nextChar)) || hasVariationSelectorAtCol1 val isEmojiWithVariationSelector = hasVariationSelector @@ -244,7 +245,7 @@ object TerminalCanvasRenderer { // Special handling for ZWJ: skip all characters until DWC // ZWJ sequences like 👨‍💻 are: [emoji1][ZWJ][emoji2][DWC] // We already rendered emoji1, now skip ZWJ and everything after until DWC - if (char.code == 0x200D) { + if (char.code == UnicodeConstants.ZWJ) { col++ while (col < ctx.visibleCols && line.charAt(col) != CharUtils.DWC) { col++ @@ -528,7 +529,7 @@ object TerminalCanvasRenderer { val nextChar = if (col + nextCharOffset < snapshot.width) line.charAt(col + nextCharOffset) else null // Skip standalone variation selectors - if ((char.code == 0xFE0F || char.code == 0xFE0E) && !analysis.isEmojiOrWideSymbol) { + if (UnicodeConstants.isVariationSelector(char) && !analysis.isEmojiOrWideSymbol) { col++ continue } @@ -771,22 +772,22 @@ object TerminalCanvasRenderer { val char = line.charAt(startCol) // If this is a base character (not DWC, not extender), we found the start if (char != CharUtils.DWC && - char.code != 0xFE0E && char.code != 0xFE0F && - char.code != 0x200D && + !UnicodeConstants.isVariationSelector(char) && + char.code != UnicodeConstants.ZWJ && !Character.isLowSurrogate(char)) { // Check if it's a skin tone modifier if (Character.isHighSurrogate(char) && startCol + 1 < width) { val next = line.charAt(startCol + 1) if (Character.isLowSurrogate(next)) { val cp = Character.toCodePoint(char, next) - if (cp in 0x1F3FB..0x1F3FF) { + if (UnicodeConstants.isSkinToneModifier(cp)) { startCol-- continue } } } // Check if preceded by ZWJ (part of sequence) - if (startCol > 0 && line.charAt(startCol - 1).code == 0x200D) { + if (startCol > 0 && line.charAt(startCol - 1).code == UnicodeConstants.ZWJ) { startCol-- continue } @@ -801,8 +802,8 @@ object TerminalCanvasRenderer { val nextChar = line.charAt(endCol + 1) // Continue if next is DWC, variation selector, ZWJ, low surrogate, or skin tone if (nextChar == CharUtils.DWC || - nextChar.code == 0xFE0E || nextChar.code == 0xFE0F || - nextChar.code == 0x200D || + UnicodeConstants.isVariationSelector(nextChar) || + nextChar.code == UnicodeConstants.ZWJ || Character.isLowSurrogate(nextChar)) { endCol++ continue @@ -812,15 +813,15 @@ object TerminalCanvasRenderer { val afterNext = line.charAt(endCol + 2) if (Character.isLowSurrogate(afterNext)) { val cp = Character.toCodePoint(nextChar, afterNext) - if (cp in 0x1F3FB..0x1F3FF) { + if (UnicodeConstants.isSkinToneModifier(cp)) { endCol += 2 continue } } } // Check for gender symbol after ZWJ - if (line.charAt(endCol).code == 0x200D && - (nextChar.code == 0x2640 || nextChar.code == 0x2642)) { + if (line.charAt(endCol).code == UnicodeConstants.ZWJ && + UnicodeConstants.isGenderSymbol(nextChar.code)) { endCol++ continue } @@ -908,8 +909,10 @@ object TerminalCanvasRenderer { if (checkCol < width - 1) { val c1 = line.charAt(checkCol) + // Skin tones U+1F3FB-U+1F3FF use same high surrogate (0xD83C) as Regional Indicators if (c1 == '\uD83C' && checkCol + 1 < width) { val c2 = line.charAt(checkCol + 1) + // Skin tone low surrogates: 0xDFFB..0xDFFF if (c2.code in 0xDFFB..0xDFFF) { return true } @@ -930,9 +933,10 @@ object TerminalCanvasRenderer { val c1 = line.charAt(col) val c2 = line.charAt(col + 1) - // Check if first char is high surrogate for Regional Indicator (0xD83C) - // and second char is low surrogate in Regional Indicator range (0xDDE6-0xDDFF) - if (c1 == '\uD83C' && c2.code in 0xDDE6..0xDDFF) { + // Check if first char is high surrogate for Regional Indicator + // and second char is low surrogate in Regional Indicator range + if (UnicodeConstants.isRegionalIndicatorHighSurrogate(c1) && + UnicodeConstants.isRegionalIndicatorLowSurrogate(c2.code)) { // Check for second Regional Indicator (may have DWC between them) var nextCol = col + 2 if (nextCol < width && line.charAt(nextCol) == CharUtils.DWC) { @@ -941,7 +945,8 @@ object TerminalCanvasRenderer { if (nextCol + 1 < width) { val c3 = line.charAt(nextCol) val c4 = line.charAt(nextCol + 1) - if (c3 == '\uD83C' && c4.code in 0xDDE6..0xDDFF) { + if (UnicodeConstants.isRegionalIndicatorHighSurrogate(c3) && + UnicodeConstants.isRegionalIndicatorLowSurrogate(c4.code)) { return true } } @@ -1136,7 +1141,7 @@ object TerminalCanvasRenderer { if (isDoubleWidth) { // Include variation selector for emoji presentation (⚠️ needs FE0F to render as color emoji) val textToRender = if (isEmojiWithVariationSelector && nextChar != null && - (nextChar.code == 0xFE0F || nextChar.code == 0xFE0E)) { + UnicodeConstants.isVariationSelector(nextChar)) { "$charTextToRender$nextChar" } else { charTextToRender From 9097cd34269fe19854bc119aa467aed5a030e442 Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 18:08:34 -0500 Subject: [PATCH 16/20] refactor: Add combining character ranges to UnicodeConstants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends UnicodeConstants.kt with combining character ranges: - COMBINING_DIACRITICS_RANGE (0x0300-0x036F) - COMBINING_MARKS_FOR_SYMBOLS_RANGE (0x20D0-0x20FF) - HEBREW_COMBINING_MARKS_RANGE (0x0591-0x05BD) - ARABIC_COMBINING_MARKS_RANGE (0x0610-0x061A) Added helper functions: - isCombiningDiacritic() - isCombiningCharacter() Updated files: - GraphemeUtils.kt (both isGraphemeExtender functions) - GraphemeBoundaryUtils.kt - GraphemeMetadata.kt 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../terminal/util/GraphemeBoundaryUtils.kt | 2 +- .../terminal/util/GraphemeMetadata.kt | 2 +- .../bossterm/terminal/util/GraphemeUtils.kt | 18 ++++++++--------- .../terminal/util/UnicodeConstants.kt | 20 +++++++++++++++++++ 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeBoundaryUtils.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeBoundaryUtils.kt index 92024100..15000ef4 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeBoundaryUtils.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeBoundaryUtils.kt @@ -80,7 +80,7 @@ object GraphemeBoundaryUtils { c.isLowSurrogate() || c.code == UnicodeConstants.ZWJ || UnicodeConstants.isVariationSelector(c) || - c.code in 0x0300..0x036F || // Combining diacritics + UnicodeConstants.isCombiningDiacritic(c.code) || GraphemeUtils.isGraphemeExtender(c) } } diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeMetadata.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeMetadata.kt index c128c18a..6b43cb97 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeMetadata.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeMetadata.kt @@ -129,7 +129,7 @@ class GraphemeMetadata private constructor( val c = text[i] // Check for surrogates, emoji, combining characters if (c.isHighSurrogate() || c.isLowSurrogate() || - c.code in 0x0300..0x036F || // Combining diacritics + UnicodeConstants.isCombiningDiacritic(c.code) || c.code == UnicodeConstants.ZWJ || UnicodeConstants.isVariationSelector(c) ) { diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt index 8c191709..f9314843 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt @@ -178,7 +178,7 @@ object GraphemeUtils { // Emoji are typically width 2 return when { baseWidth == 2 -> 2 - baseWidth == 1 && (hasVariationSelector || hasSkinTone) -> 2 // Emoji presentation + baseWidth == 1 -> 2 // Emoji presentation baseWidth <= 0 -> 0 else -> baseWidth } @@ -304,11 +304,11 @@ object GraphemeUtils { return when (c.code) { UnicodeConstants.ZWJ -> true // Zero-Width Joiner UnicodeConstants.VARIATION_SELECTOR_TEXT, UnicodeConstants.VARIATION_SELECTOR_EMOJI -> true // Variation selectors - in 0x0300..0x036F -> true // Combining diacritics + in UnicodeConstants.COMBINING_DIACRITICS_RANGE -> true // Combining diacritics in UnicodeConstants.SKIN_TONE_RANGE -> true // Skin tone modifiers (requires surrogate pair check) - in 0x20D0..0x20FF -> true // Combining marks for symbols - in 0x0591..0x05BD -> true // Hebrew combining marks - in 0x0610..0x061A -> true // Arabic combining marks + in UnicodeConstants.COMBINING_MARKS_FOR_SYMBOLS_RANGE -> true // Combining marks for symbols + in UnicodeConstants.HEBREW_COMBINING_MARKS_RANGE -> true // Hebrew combining marks + in UnicodeConstants.ARABIC_COMBINING_MARKS_RANGE -> true // Arabic combining marks else -> false } } @@ -321,11 +321,11 @@ object GraphemeUtils { return when (codePoint) { UnicodeConstants.ZWJ -> true // ZWJ UnicodeConstants.VARIATION_SELECTOR_TEXT, UnicodeConstants.VARIATION_SELECTOR_EMOJI -> true // Variation selectors - in 0x0300..0x036F -> true // Combining diacritics + in UnicodeConstants.COMBINING_DIACRITICS_RANGE -> true // Combining diacritics in UnicodeConstants.SKIN_TONE_RANGE -> true // Skin tone modifiers - in 0x20D0..0x20FF -> true // Combining marks for symbols - in 0x0591..0x05BD -> true // Hebrew combining marks - in 0x0610..0x061A -> true // Arabic combining marks + in UnicodeConstants.COMBINING_MARKS_FOR_SYMBOLS_RANGE -> true // Combining marks for symbols + in UnicodeConstants.HEBREW_COMBINING_MARKS_RANGE -> true // Hebrew combining marks + in UnicodeConstants.ARABIC_COMBINING_MARKS_RANGE -> true // Arabic combining marks else -> false } } diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/UnicodeConstants.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/UnicodeConstants.kt index 70e47e11..6f159eb0 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/UnicodeConstants.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/UnicodeConstants.kt @@ -39,6 +39,16 @@ object UnicodeConstants { /** Low surrogate range for Regional Indicators */ val REGIONAL_INDICATOR_LOW_SURROGATE_RANGE = 0xDDE6..0xDDFF + // === Combining Characters === + /** Combining Diacritical Marks (U+0300 to U+036F) - e.g., accents, umlauts */ + val COMBINING_DIACRITICS_RANGE = 0x0300..0x036F + /** Combining Diacritical Marks for Symbols (U+20D0 to U+20FF) */ + val COMBINING_MARKS_FOR_SYMBOLS_RANGE = 0x20D0..0x20FF + /** Hebrew combining marks (U+0591 to U+05BD) */ + val HEBREW_COMBINING_MARKS_RANGE = 0x0591..0x05BD + /** Arabic combining marks (U+0610 to U+061A) */ + val ARABIC_COMBINING_MARKS_RANGE = 0x0610..0x061A + // === Helper Functions === /** Check if code point is a variation selector (VS15 or VS16) */ @@ -65,4 +75,14 @@ object UnicodeConstants { /** Check if char code is a Regional Indicator low surrogate */ fun isRegionalIndicatorLowSurrogate(charCode: Int): Boolean = charCode in REGIONAL_INDICATOR_LOW_SURROGATE_RANGE + + /** Check if code point is a combining diacritical mark */ + fun isCombiningDiacritic(codePoint: Int): Boolean = codePoint in COMBINING_DIACRITICS_RANGE + + /** Check if code point is any combining character (diacritics, symbols, Hebrew, Arabic) */ + fun isCombiningCharacter(codePoint: Int): Boolean = + codePoint in COMBINING_DIACRITICS_RANGE || + codePoint in COMBINING_MARKS_FOR_SYMBOLS_RANGE || + codePoint in HEBREW_COMBINING_MARKS_RANGE || + codePoint in ARABIC_COMBINING_MARKS_RANGE } From 70f23fb9b4ad5c8ecc04d9c351b95bd50967424a Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 18:10:06 -0500 Subject: [PATCH 17/20] refactor: Remove unused isGraphemeExtender(Int) overload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the Char version is used in GraphemeBoundaryUtils.kt. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../bossterm/terminal/util/GraphemeUtils.kt | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt index f9314843..ea5e4232 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt @@ -313,23 +313,6 @@ object GraphemeUtils { } } - /** - * Checks if a code point is a grapheme extender. - * More accurate than the Char version for supplementary plane characters. - */ - fun isGraphemeExtender(codePoint: Int): Boolean { - return when (codePoint) { - UnicodeConstants.ZWJ -> true // ZWJ - UnicodeConstants.VARIATION_SELECTOR_TEXT, UnicodeConstants.VARIATION_SELECTOR_EMOJI -> true // Variation selectors - in UnicodeConstants.COMBINING_DIACRITICS_RANGE -> true // Combining diacritics - in UnicodeConstants.SKIN_TONE_RANGE -> true // Skin tone modifiers - in UnicodeConstants.COMBINING_MARKS_FOR_SYMBOLS_RANGE -> true // Combining marks for symbols - in UnicodeConstants.HEBREW_COMBINING_MARKS_RANGE -> true // Hebrew combining marks - in UnicodeConstants.ARABIC_COMBINING_MARKS_RANGE -> true // Arabic combining marks - else -> false - } - } - /** * Simple LRU cache implementation for grapheme width caching. */ From e1070dcffd71e1aefa921f319162d2241fe4b7c5 Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 18:13:49 -0500 Subject: [PATCH 18/20] refactor: Add emoji presentation constants to UnicodeConstants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Centralizes all emoji-related Unicode constants: Supplementary plane emoji blocks: - ENCLOSED_ALPHANUMERIC_SUPPLEMENT_RANGE - MISC_SYMBOLS_PICTOGRAPHS_RANGE - EMOTICONS_RANGE - TRANSPORT_MAP_SYMBOLS_RANGE - SUPPLEMENTAL_SYMBOLS_RANGE - SYMBOLS_PICTOGRAPHS_EXTENDED_A_RANGE - CHESS_SYMBOLS_RANGE BMP emoji constants (40+ individual code points) Helper functions: - isSupplementaryPlaneEmoji() - isBmpEmoji() Updated files: - GraphemeUtils.kt - simplified isEmojiPresentation() - GraphemeCluster.kt - now uses GraphemeUtils.isEmojiPresentation() - TerminalCanvasRenderer.kt - uses constant instead of magic number 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../bossterm/terminal/util/GraphemeCluster.kt | 16 +- .../bossterm/terminal/util/GraphemeUtils.kt | 62 +------- .../terminal/util/UnicodeConstants.kt | 138 ++++++++++++++++++ .../rendering/TerminalCanvasRenderer.kt | 2 +- 4 files changed, 143 insertions(+), 75 deletions(-) diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeCluster.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeCluster.kt index f37db481..6fa74b28 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeCluster.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeCluster.kt @@ -35,20 +35,8 @@ data class GraphemeCluster( val isEmoji: Boolean get() { if (codePoints.isEmpty()) return false - val first = codePoints[0] - return when { - // Emoji & Pictographs (U+1F300-U+1F9FF) - first in 0x1F300..0x1F9FF -> true - // Emoticons (U+1F600-U+1F64F) - first in 0x1F600..0x1F64F -> true - // Transport & Map Symbols (U+1F680-U+1F6FF) - first in 0x1F680..0x1F6FF -> true - // Supplemental Symbols (U+1F900-U+1F9FF) - first in 0x1F900..0x1F9FF -> true - // Misc Symbols with emoji presentation - hasVariationSelector(UnicodeConstants.VARIATION_SELECTOR_EMOJI) -> true - else -> false - } + return GraphemeUtils.isEmojiPresentation(codePoints[0]) || + hasVariationSelector(UnicodeConstants.VARIATION_SELECTOR_EMOJI) } /** diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt index ea5e4232..13f46e97 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/GraphemeUtils.kt @@ -231,66 +231,8 @@ object GraphemeUtils { * @return True if this character should render as 2 cells by default */ fun isEmojiPresentation(codePoint: Int): Boolean { - return when { - // === SUPPLEMENTARY PLANE EMOJI (always 2-cell) === - // Enclosed Alphanumeric Supplement (U+1F100-U+1F1FF) - includes regional indicators - codePoint in 0x1F100..0x1F1FF -> true - // Misc Symbols and Pictographs (U+1F300-U+1F5FF) - weather, food, animals, objects - codePoint in 0x1F300..0x1F5FF -> true - // Emoticons (U+1F600-U+1F64F) - smileys & people - codePoint in 0x1F600..0x1F64F -> true - // Transport and Map Symbols (U+1F680-U+1F6FF) - codePoint in 0x1F680..0x1F6FF -> true - // Supplemental Symbols and Pictographs (U+1F900-U+1F9FF) - codePoint in 0x1F900..0x1F9FF -> true - // Symbols and Pictographs Extended-A (U+1FA70-U+1FAFF) - codePoint in 0x1FA70..0x1FAFF -> true - // Chess Symbols (U+1FA00-U+1FA6F) - codePoint in 0x1FA00..0x1FA6F -> true - - // === BMP EMOJI that are unambiguously emoji (not used as text symbols) === - // Watch and hourglass - commonly rendered as emoji - codePoint == 0x231A || codePoint == 0x231B -> true - // Miscellaneous Symbols - only include clearly emoji ones - codePoint == 0x2614 || codePoint == 0x2615 -> true // ☔☕ Umbrella, coffee - codePoint in 0x2648..0x2653 -> true // Zodiac signs - codePoint == 0x267F -> true // ♿ Wheelchair - codePoint == 0x2693 -> true // ⚓ Anchor - codePoint == 0x26A1 -> true // ⚡ High voltage - codePoint == 0x26AA || codePoint == 0x26AB -> true // ⚪⚫ Circles - codePoint == 0x26BD || codePoint == 0x26BE -> true // ⚽⚾ Sports balls - codePoint == 0x26C4 || codePoint == 0x26C5 -> true // ⛄⛅ Snowman, sun/cloud - codePoint == 0x26CE -> true // ⛎ Ophiuchus - codePoint == 0x26D4 -> true // ⛔ No entry - codePoint == 0x26EA -> true // ⛪ Church - codePoint == 0x26F2 || codePoint == 0x26F3 -> true // ⛲⛳ Fountain, golf - codePoint == 0x26F5 -> true // ⛵ Sailboat - codePoint == 0x26FA -> true // ⛺ Tent - codePoint == 0x26FD -> true // ⛽ Fuel pump - // Dingbats - only clearly emoji ones - codePoint == 0x2705 -> true // ✅ Check mark button - codePoint == 0x2728 -> true // ✨ Sparkles - codePoint == 0x274C -> true // ❌ Cross mark - codePoint == 0x274E -> true // ❎ Cross mark button - codePoint in 0x2753..0x2755 -> true // ❓❔❕ Question/exclamation marks - codePoint == 0x2757 -> true // ❗ Exclamation mark - codePoint in 0x2795..0x2797 -> true // ➕➖➗ Math operators - codePoint == 0x27B0 -> true // ➰ Curly loop - codePoint == 0x27BF -> true // ➿ Double curly loop - // Arrows - only the clearly emoji ones - codePoint == 0x2934 || codePoint == 0x2935 -> true // ⤴⤵ Curved arrows - codePoint in 0x2B05..0x2B07 -> true // ⬅⬆⬇ Directional arrows - codePoint == 0x2B1B || codePoint == 0x2B1C -> true // ⬛⬜ Large squares - codePoint == 0x2B50 -> true // ⭐ Star - codePoint == 0x2B55 -> true // ⭕ Heavy large circle - // Japanese symbols - codePoint == 0x3030 -> true // 〰 Wavy dash - codePoint == 0x303D -> true // 〽 Part alternation mark - codePoint == 0x3297 -> true // ㊗ Circled Ideograph Congratulation - codePoint == 0x3299 -> true // ㊙ Circled Ideograph Secret - - else -> false - } + return UnicodeConstants.isSupplementaryPlaneEmoji(codePoint) || + UnicodeConstants.isBmpEmoji(codePoint) } /** diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/UnicodeConstants.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/UnicodeConstants.kt index 6f159eb0..93e1b20e 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/UnicodeConstants.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/UnicodeConstants.kt @@ -39,6 +39,108 @@ object UnicodeConstants { /** Low surrogate range for Regional Indicators */ val REGIONAL_INDICATOR_LOW_SURROGATE_RANGE = 0xDDE6..0xDDFF + // === Emoji Blocks (Supplementary Plane) === + /** Enclosed Alphanumeric Supplement - includes regional indicators */ + val ENCLOSED_ALPHANUMERIC_SUPPLEMENT_RANGE = 0x1F100..0x1F1FF + /** Misc Symbols and Pictographs - weather, food, animals, objects */ + val MISC_SYMBOLS_PICTOGRAPHS_RANGE = 0x1F300..0x1F5FF + /** Emoticons - smileys & people */ + val EMOTICONS_RANGE = 0x1F600..0x1F64F + /** Transport and Map Symbols */ + val TRANSPORT_MAP_SYMBOLS_RANGE = 0x1F680..0x1F6FF + /** Supplemental Symbols and Pictographs */ + val SUPPLEMENTAL_SYMBOLS_RANGE = 0x1F900..0x1F9FF + /** Symbols and Pictographs Extended-A */ + val SYMBOLS_PICTOGRAPHS_EXTENDED_A_RANGE = 0x1FA70..0x1FAFF + /** Chess Symbols */ + val CHESS_SYMBOLS_RANGE = 0x1FA00..0x1FA6F + + // === BMP Emoji (unambiguous emoji, not text symbols) === + /** Watch (U+231A) */ + const val WATCH = 0x231A + /** Hourglass (U+231B) */ + const val HOURGLASS = 0x231B + /** Umbrella with rain (U+2614) */ + const val UMBRELLA_RAIN = 0x2614 + /** Hot beverage (U+2615) */ + const val HOT_BEVERAGE = 0x2615 + /** Zodiac signs range */ + val ZODIAC_RANGE = 0x2648..0x2653 + /** Wheelchair (U+267F) */ + const val WHEELCHAIR = 0x267F + /** Anchor (U+2693) */ + const val ANCHOR = 0x2693 + /** High voltage (U+26A1) */ + const val HIGH_VOLTAGE = 0x26A1 + /** White circle (U+26AA) */ + const val WHITE_CIRCLE = 0x26AA + /** Black circle (U+26AB) */ + const val BLACK_CIRCLE = 0x26AB + /** Soccer ball (U+26BD) */ + const val SOCCER_BALL = 0x26BD + /** Baseball (U+26BE) */ + const val BASEBALL = 0x26BE + /** Snowman (U+26C4) */ + const val SNOWMAN = 0x26C4 + /** Sun behind cloud (U+26C5) */ + const val SUN_CLOUD = 0x26C5 + /** Ophiuchus (U+26CE) */ + const val OPHIUCHUS = 0x26CE + /** No entry (U+26D4) */ + const val NO_ENTRY = 0x26D4 + /** Church (U+26EA) */ + const val CHURCH = 0x26EA + /** Fountain (U+26F2) */ + const val FOUNTAIN = 0x26F2 + /** Golf (U+26F3) */ + const val GOLF = 0x26F3 + /** Sailboat (U+26F5) */ + const val SAILBOAT = 0x26F5 + /** Tent (U+26FA) */ + const val TENT = 0x26FA + /** Fuel pump (U+26FD) */ + const val FUEL_PUMP = 0x26FD + /** Check mark button (U+2705) */ + const val CHECK_MARK_BUTTON = 0x2705 + /** Sparkles (U+2728) */ + const val SPARKLES = 0x2728 + /** Cross mark (U+274C) */ + const val CROSS_MARK = 0x274C + /** Cross mark button (U+274E) */ + const val CROSS_MARK_BUTTON = 0x274E + /** Question/exclamation marks range */ + val QUESTION_EXCLAMATION_RANGE = 0x2753..0x2755 + /** Exclamation mark (U+2757) */ + const val EXCLAMATION_MARK = 0x2757 + /** Math operators emoji range (➕➖➗) */ + val MATH_OPERATORS_EMOJI_RANGE = 0x2795..0x2797 + /** Curly loop (U+27B0) */ + const val CURLY_LOOP = 0x27B0 + /** Double curly loop (U+27BF) */ + const val DOUBLE_CURLY_LOOP = 0x27BF + /** Curved arrow up (U+2934) */ + const val CURVED_ARROW_UP = 0x2934 + /** Curved arrow down (U+2935) */ + const val CURVED_ARROW_DOWN = 0x2935 + /** Directional arrows range (⬅⬆⬇) */ + val DIRECTIONAL_ARROWS_RANGE = 0x2B05..0x2B07 + /** Black large square (U+2B1B) */ + const val BLACK_LARGE_SQUARE = 0x2B1B + /** White large square (U+2B1C) */ + const val WHITE_LARGE_SQUARE = 0x2B1C + /** Star (U+2B50) */ + const val STAR = 0x2B50 + /** Heavy large circle (U+2B55) */ + const val HEAVY_CIRCLE = 0x2B55 + /** Wavy dash (U+3030) */ + const val WAVY_DASH = 0x3030 + /** Part alternation mark (U+303D) */ + const val PART_ALTERNATION = 0x303D + /** Circled Ideograph Congratulation (U+3297) */ + const val CIRCLED_CONGRATULATION = 0x3297 + /** Circled Ideograph Secret (U+3299) */ + const val CIRCLED_SECRET = 0x3299 + // === Combining Characters === /** Combining Diacritical Marks (U+0300 to U+036F) - e.g., accents, umlauts */ val COMBINING_DIACRITICS_RANGE = 0x0300..0x036F @@ -85,4 +187,40 @@ object UnicodeConstants { codePoint in COMBINING_MARKS_FOR_SYMBOLS_RANGE || codePoint in HEBREW_COMBINING_MARKS_RANGE || codePoint in ARABIC_COMBINING_MARKS_RANGE + + /** Check if code point is in supplementary plane emoji blocks (always 2-cell) */ + fun isSupplementaryPlaneEmoji(codePoint: Int): Boolean = + codePoint in ENCLOSED_ALPHANUMERIC_SUPPLEMENT_RANGE || + codePoint in MISC_SYMBOLS_PICTOGRAPHS_RANGE || + codePoint in EMOTICONS_RANGE || + codePoint in TRANSPORT_MAP_SYMBOLS_RANGE || + codePoint in SUPPLEMENTAL_SYMBOLS_RANGE || + codePoint in SYMBOLS_PICTOGRAPHS_EXTENDED_A_RANGE || + codePoint in CHESS_SYMBOLS_RANGE + + /** Check if code point is a BMP emoji (unambiguous, not text symbols) */ + fun isBmpEmoji(codePoint: Int): Boolean = when (codePoint) { + WATCH, HOURGLASS -> true + UMBRELLA_RAIN, HOT_BEVERAGE -> true + in ZODIAC_RANGE -> true + WHEELCHAIR, ANCHOR, HIGH_VOLTAGE -> true + WHITE_CIRCLE, BLACK_CIRCLE -> true + SOCCER_BALL, BASEBALL -> true + SNOWMAN, SUN_CLOUD -> true + OPHIUCHUS, NO_ENTRY, CHURCH -> true + FOUNTAIN, GOLF, SAILBOAT, TENT, FUEL_PUMP -> true + CHECK_MARK_BUTTON, SPARKLES -> true + CROSS_MARK, CROSS_MARK_BUTTON -> true + in QUESTION_EXCLAMATION_RANGE -> true + EXCLAMATION_MARK -> true + in MATH_OPERATORS_EMOJI_RANGE -> true + CURLY_LOOP, DOUBLE_CURLY_LOOP -> true + CURVED_ARROW_UP, CURVED_ARROW_DOWN -> true + in DIRECTIONAL_ARROWS_RANGE -> true + BLACK_LARGE_SQUARE, WHITE_LARGE_SQUARE -> true + STAR, HEAVY_CIRCLE -> true + WAVY_DASH, PART_ALTERNATION -> true + CIRCLED_CONGRATULATION, CIRCLED_SECRET -> true + else -> false + } } diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt index 6a09ba16..04cc86e4 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt @@ -152,7 +152,7 @@ fun analyzeCharacter( (hasVariationSelectorAtCol1 && charAtCol2 == CharUtils.DWC) || wcwidthResult - val isBaseDoubleWidth = if (actualCodePoint >= 0x1F100) true else isWcwidthDoubleWidth + val isBaseDoubleWidth = if (actualCodePoint >= UnicodeConstants.ENCLOSED_ALPHANUMERIC_SUPPLEMENT_RANGE.first) true else isWcwidthDoubleWidth // Check for variation selector - handle both DWC and non-DWC cases val nextCharOffset = if (isWcwidthDoubleWidth) 2 else 1 From abbc8f86f66118bf8b4adfb73b00bd2403036ea2 Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 18:21:58 -0500 Subject: [PATCH 19/20] refactor: Return exact column count from checkRegionalIndicatorSequence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed checkRegionalIndicatorSequence() to return the number of buffer columns occupied by a flag emoji instead of a boolean. This allows the caller to skip exactly the right number of columns without relying on fallback skip logic. Possible flag layouts handled: - [High1][Low1][High2][Low2] = 4 chars - [High1][Low1][DWC][High2][Low2] = 5 chars - [High1][Low1][DWC][High2][Low2][DWC] = 6 chars This is cleaner than the previous approach which skipped 4 chars and relied on subsequent loop iterations to handle remaining chars. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../rendering/TerminalCanvasRenderer.kt | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt index 04cc86e4..f4016a4f 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt @@ -263,7 +263,8 @@ object TerminalCanvasRenderer { // Handle Regional Indicator sequences (flag emoji) as a single unit // Flags like 🇺🇸 are two Regional Indicators that should render as one 2-cell glyph - if (checkRegionalIndicatorSequence(line, col, ctx.visibleCols)) { + val flagColCount = checkRegionalIndicatorSequence(line, col, ctx.visibleCols) + if (flagColCount > 0) { val x = kotlin.math.floor(visualCol * ctx.cellWidth) val y = kotlin.math.floor(row * ctx.cellHeight) @@ -294,12 +295,8 @@ object TerminalCanvasRenderer { ) } - // Skip all chars in the flag sequence (2 surrogate pairs + possible DWC markers) - // Use minOf to prevent index out of bounds if flag is at end of line - col += minOf(4, ctx.visibleCols - col) - while (col < ctx.visibleCols && line.charAt(col) == CharUtils.DWC) { - col++ // Skip any trailing DWC markers - } + // Skip all chars in the flag sequence using the exact count returned + col += flagColCount visualCol += 2 continue } @@ -498,7 +495,7 @@ object TerminalCanvasRenderer { val hasZWJ = cleanText.contains('\u200D') val hasSkinTone = checkFollowingSkinTone(line, col, snapshot.width) - val hasRegionalIndicator = checkRegionalIndicatorSequence(line, col, snapshot.width) + val hasRegionalIndicator = checkRegionalIndicatorSequence(line, col, snapshot.width) > 0 if (hasZWJ || hasSkinTone || hasRegionalIndicator) { val graphemes = ai.rever.bossterm.terminal.util.GraphemeUtils.segmentIntoGraphemes(cleanText) @@ -926,9 +923,15 @@ object TerminalCanvasRenderer { * Check if current position starts a Regional Indicator sequence (flag emoji). * Regional Indicators are surrogate pairs with high surrogate 0xD83C and low surrogate 0xDDE6-0xDDFF. * Two consecutive Regional Indicators form a flag (e.g., 🇺🇸 = U+1F1FA + U+1F1F8). + * + * @return Number of buffer columns the flag occupies (0 if not a flag sequence) + * Possible layouts: + * - [High1][Low1][High2][Low2] = 4 chars + * - [High1][Low1][DWC][High2][Low2] = 5 chars (DWC after first indicator) + * - [High1][Low1][DWC][High2][Low2][DWC] = 6 chars (DWC after both) */ - private fun checkRegionalIndicatorSequence(line: TerminalLine, col: Int, width: Int): Boolean { - if (col + 3 >= width) return false // Need at least 4 chars for 2 surrogate pairs + private fun checkRegionalIndicatorSequence(line: TerminalLine, col: Int, width: Int): Int { + if (col + 3 >= width) return 0 // Need at least 4 chars for 2 surrogate pairs val c1 = line.charAt(col) val c2 = line.charAt(col + 1) @@ -947,11 +950,17 @@ object TerminalCanvasRenderer { val c4 = line.charAt(nextCol + 1) if (UnicodeConstants.isRegionalIndicatorHighSurrogate(c3) && UnicodeConstants.isRegionalIndicatorLowSurrogate(c4.code)) { - return true + // Calculate total columns: position after second indicator's low surrogate + var endCol = nextCol + 2 + // Skip trailing DWC if present + if (endCol < width && line.charAt(endCol) == CharUtils.DWC) { + endCol++ + } + return endCol - col } } } - return false + return 0 } /** From 5a8d48a39c02ac6cdbac621d8c8bd51300e7d6bc Mon Sep 17 00:00:00 2001 From: Shivang Date: Tue, 23 Dec 2025 18:33:39 -0500 Subject: [PATCH 20/20] docs: Add KDoc for character analysis edge cases and width properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CharacterAnalysis: Document width property evaluation order (isWcwidthDoubleWidth -> isBaseDoubleWidth -> isDoubleWidth) - analyzeCharacter(): Document orphaned surrogate and DWC marker handling - isBmpEmoji(): Document selection criteria and intentionally excluded TUI symbols (play/pause, geometric shapes, arrows, box drawing) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../terminal/util/UnicodeConstants.kt | 26 ++++++++++++++++- .../rendering/TerminalCanvasRenderer.kt | 29 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/UnicodeConstants.kt b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/UnicodeConstants.kt index 93e1b20e..ed6bd705 100644 --- a/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/UnicodeConstants.kt +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/UnicodeConstants.kt @@ -198,7 +198,31 @@ object UnicodeConstants { codePoint in SYMBOLS_PICTOGRAPHS_EXTENDED_A_RANGE || codePoint in CHESS_SYMBOLS_RANGE - /** Check if code point is a BMP emoji (unambiguous, not text symbols) */ + /** + * Check if code point is a BMP emoji (Basic Multilingual Plane, U+0000 to U+FFFF). + * + * ## Selection Criteria + * This list includes ONLY characters that: + * 1. Have `Emoji_Presentation=Yes` in Unicode data (default emoji rendering) + * 2. Are UNAMBIGUOUS emoji - not commonly used as 1-cell text symbols in TUIs + * + * ## Intentionally EXCLUDED + * Many BMP symbols are NOT included even though they can render as emoji: + * - Play/pause/stop controls (U+23F8-U+23FA): Used as 1-cell TUI buttons + * - Geometric shapes (U+25A0-U+25FF): Used in progress bars, menus + * - Arrows (U+2190-U+21FF): Common text navigation symbols + * - Box drawing (U+2500-U+257F): TUI borders + * + * These characters render as 2-cell emoji ONLY when followed by VS16 (U+FE0F). + * Without VS16, they default to 1-cell text presentation. + * + * ## Reference + * Based on Unicode 15.1 emoji-data.txt `Emoji_Presentation=Yes` entries for BMP, + * filtered to exclude common TUI text symbols. See: + * https://unicode.org/Public/emoji/15.1/emoji-data.txt + * + * @return True if this character should render as 2 cells by default (without VS16) + */ fun isBmpEmoji(codePoint: Int): Boolean = when (codePoint) { WATCH, HOURGLASS -> true UMBRELLA_RAIN, HOT_BEVERAGE -> true diff --git a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt index f4016a4f..a02dad29 100644 --- a/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt +++ b/compose-ui/src/desktopMain/kotlin/ai/rever/bossterm/compose/rendering/TerminalCanvasRenderer.kt @@ -90,6 +90,25 @@ data class RenderingContext( /** * Result of analyzing a character for rendering purposes. * Encapsulates surrogate pair handling, double-width detection, and variation selector info. + * + * ## Width Properties (evaluated in order) + * + * @property isWcwidthDoubleWidth True if the character is double-width by any of: + * - `wcwidth()` returns 2 (CJK, fullwidth forms) + * - A DWC marker exists at col+1 (buffer-level marking) + * - A variation selector at col+1 has DWC at col+2 (emoji+VS layout) + * + * @property isBaseDoubleWidth True if: + * - Code point >= U+1F100 (supplementary plane, always 2-cell), OR + * - [isWcwidthDoubleWidth] is true + * This captures characters that are inherently double-width regardless of modifiers. + * + * @property isDoubleWidth True if: + * - [isBaseDoubleWidth] is true, OR + * - Character has a variation selector (emoji presentation) + * This is the final width determination used for rendering and cursor movement. + * + * @property visualWidth The actual cell count (1 or 2), derived from [isDoubleWidth]. */ data class CharacterAnalysis( val actualCodePoint: Int, @@ -111,6 +130,16 @@ data class CharacterAnalysis( * Analyze a character at the given column position for rendering. * Handles surrogate pairs, double-width detection, and variation selectors. * This is shared between renderBackgrounds() and renderText() to avoid duplication. + * + * ## Edge Cases + * - **Orphaned high surrogate**: If a high surrogate appears without a matching low surrogate + * (e.g., at line boundary), it renders as-is. The font will typically display U+FFFD + * (replacement character) or a placeholder glyph. This is intentional - the terminal + * buffer may legitimately contain orphaned surrogates from incomplete writes. + * + * - **DWC marker between surrogates**: Some buffer layouts place a DWC marker between + * high and low surrogates: [High][DWC][Low]. This is handled by checking col+2 when + * col+1 contains a DWC marker. */ fun analyzeCharacter( char: Char,