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..143b932b --- /dev/null +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/ColumnConversionUtils.kt @@ -0,0 +1,196 @@ +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 { + + /** + * 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) + */ + 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 column conversion and rendering. + * + * 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) + */ + 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 (UnicodeConstants.isVariationSelector(char)) { + return SkipResult(true, 1) + } + + // Skip ZWJ (U+200D) + if (char.code == UnicodeConstants.ZWJ) { + 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 (UnicodeConstants.isSkinToneModifier(codePoint)) { + return SkipResult(true, 2) + } + } + } + + // Skip gender symbols only when preceded by ZWJ (part of ZWJ sequences) + if (UnicodeConstants.isGenderSymbol(char.code)) { + if (col > 0 && line.charAt(col - 1).code == UnicodeConstants.ZWJ) { + 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 + * 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 skipResult = shouldSkipChar(line, col, width) + if (skipResult.shouldSkip) { + col += skipResult.colsToAdvance + 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 skipResult = shouldSkipChar(line, col, width) + if (skipResult.shouldSkip) { + col += skipResult.colsToAdvance + 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) || + UnicodeConstants.isVariationSelector(nextChar) || + nextChar.code == UnicodeConstants.ZWJ || + UnicodeConstants.isGenderSymbol(nextChar.code)) { + 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 (UnicodeConstants.isSkinToneModifier(cp)) { + nextCol += 2 + continue + } + } + } + break + } + } + + return 1 // Default: single width + } +} 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..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 @@ -78,9 +78,9 @@ 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 in 0x0300..0x036F || // Combining diacritics + c.code == UnicodeConstants.ZWJ || + UnicodeConstants.isVariationSelector(c) || + UnicodeConstants.isCombiningDiacritic(c.code) || 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..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(0xFE0F) -> true - else -> false - } + return GraphemeUtils.isEmojiPresentation(codePoints[0]) || + hasVariationSelector(UnicodeConstants.VARIATION_SELECTOR_EMOJI) } /** @@ -58,7 +46,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 +54,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..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,9 +129,9 @@ 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 - c.code == 0x200D || // ZWJ - c.code == 0xFE0E || c.code == 0xFE0F // Variation selectors + UnicodeConstants.isCombiningDiacritic(c.code) || + 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 293345ae..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 @@ -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 @@ -147,16 +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) + // Two consecutive Regional Indicators form a flag (e.g., πŸ‡ΊπŸ‡Έ = U+1F1FA + U+1F1F8) + 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) { @@ -167,12 +178,18 @@ 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 } } + // 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 +214,27 @@ object GraphemeUtils { return totalWidth } + /** + * Checks if a code point should render as emoji (2 cells width) by default. + * + * This covers: + * - Supplementary plane emoji (U+1F000+) which are always 2-cell wide + * - 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. + * + * 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 + */ + fun isEmojiPresentation(codePoint: Int): Boolean { + return UnicodeConstants.isSupplementaryPlaneEmoji(codePoint) || + UnicodeConstants.isBmpEmoji(codePoint) + } + /** * Checks if a character is a grapheme extender (ZWJ, variation selector, skin tone, combining). * Used for incremental grapheme boundary detection in streaming scenarios. @@ -206,30 +244,13 @@ object GraphemeUtils { */ fun isGraphemeExtender(c: Char): Boolean { return when (c.code) { - 0x200D -> true // Zero-Width Joiner - 0xFE0E, 0xFE0F -> true // Variation selectors - in 0x0300..0x036F -> true // Combining diacritics - in 0x1F3FB..0x1F3FF -> 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 - else -> false - } - } - - /** - * 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) { - 0x200D -> true // ZWJ - 0xFE0E, 0xFE0F -> true // Variation selectors - in 0x0300..0x036F -> true // Combining diacritics - in 0x1F3FB..0x1F3FF -> 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 + UnicodeConstants.ZWJ -> true // Zero-Width Joiner + 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 (requires surrogate pair check) + 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 new file mode 100644 index 00000000..ed6bd705 --- /dev/null +++ b/bossterm-core-mpp/src/jvmMain/kotlin/ai/rever/bossterm/terminal/util/UnicodeConstants.kt @@ -0,0 +1,250 @@ +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 + + // === 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 + /** 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) */ + 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 + + /** 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 + + /** 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 (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 + 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 f3749b0b..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 @@ -22,6 +22,8 @@ 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.util.UnicodeConstants import ai.rever.bossterm.terminal.TextStyle as BossTextStyle import org.jetbrains.skia.FontMgr @@ -85,6 +87,140 @@ 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. + * + * ## 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, + 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. + * + * ## 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, + 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 && UnicodeConstants.isVariationSelector(charAtCol1) + val isWcwidthDoubleWidth = charAtCol1 == CharUtils.DWC || + (hasVariationSelectorAtCol1 && charAtCol2 == CharUtils.DWC) || + wcwidthResult + + 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 + val nextChar = if (col + nextCharOffset < width) line.charAt(col + nextCharOffset) else null + val hasVariationSelector = (nextChar != null && UnicodeConstants.isVariationSelector(nextChar)) || + 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 + // 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, + lowSurrogate = lowSurrogate, + charTextToRender = charTextToRender, + isWcwidthDoubleWidth = isWcwidthDoubleWidth, + isBaseDoubleWidth = isBaseDoubleWidth, + hasVariationSelector = hasVariationSelector, + isEmojiWithVariationSelector = isEmojiWithVariationSelector, + isDoubleWidth = isDoubleWidth, + visualWidth = visualWidth, + isCursiveOrMath = isCursiveOrMath, + isTechnicalSymbol = isTechnicalSymbol, + isEmojiOrWideSymbol = isEmojiOrWideSymbol + ) +} + +/** + * 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. @@ -94,20 +230,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 @@ -118,8 +256,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) { @@ -127,27 +266,81 @@ 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 - if (char == CharUtils.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 == UnicodeConstants.ZWJ) { col++ + while (col < ctx.visibleCols && line.charAt(col) != CharUtils.DWC) { + 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 + // Flags like πŸ‡ΊπŸ‡Έ are two Regional Indicators that should render as one 2-cell glyph + 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) + + // 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 using the exact count returned + col += flagColCount + visualCol += 2 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) + // 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 - 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 +352,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 + analysis.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,21 +371,24 @@ object TerminalCanvasRenderer { ) } - // Skip next column if double-width - if (isWcwidthDoubleWidth) { - col++ - } - + // Advance buffer position (must match renderText col advancement) col++ + 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 += analysis.visualWidth } } } /** * 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>() @@ -330,12 +524,13 @@ object TerminalCanvasRenderer { val hasZWJ = cleanText.contains('\u200D') val hasSkinTone = checkFollowingSkinTone(line, col, snapshot.width) + val hasRegionalIndicator = checkRegionalIndicatorSequence(line, col, snapshot.width) > 0 - 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 @@ -349,55 +544,18 @@ object TerminalCanvasRenderer { val x = visualCol * ctx.cellWidth val y = row * ctx.cellHeight + val lineIndex = row - ctx.scrollOffset - // 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 cached analysis from renderBackgrounds, or compute if not found + val analysis = analysisCache[lineIndex to col] + ?: 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) { - in 0x2600..0x26FF -> true - in 0x1F100..0x1F1FF -> true - in 0x1F300..0x1F9FF -> true - in 0x1F600..0x1F64F -> true - in 0x1F680..0x1F6FF -> true - else -> false - } - - val isDoubleWidth = if (actualCodePoint >= 0x1F100) true else isWcwidthDoubleWidth - - // Check for variation selector - 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 isEmojiWithVariationSelector = isEmojiOrWideSymbol && - nextChar != null && (nextChar.code == 0xFE0F || nextChar.code == 0xFE0E) // Skip standalone variation selectors - if ((char.code == 0xFE0F || char.code == 0xFE0E) && !isEmojiOrWideSymbol) { + if (UnicodeConstants.isVariationSelector(char) && !analysis.isEmojiOrWideSymbol) { col++ continue } @@ -426,7 +584,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() && @@ -449,23 +607,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() @@ -542,6 +700,12 @@ 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 @@ -562,7 +726,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) } @@ -579,22 +749,112 @@ 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) + ) + } + } + } + } + + /** + * Convert buffer column to visual column. + * Delegates to shared ColumnConversionUtils. + */ + fun bufferColToVisualCol(line: TerminalLine, bufferCol: Int, width: Int): Int = + ColumnConversionUtils.bufferColToVisualCol(line, bufferCol, width) + + /** + * Convert visual column to buffer column. + * Delegates to shared ColumnConversionUtils. + */ + 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. + * 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 && + !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 (UnicodeConstants.isSkinToneModifier(cp)) { + startCol-- + continue + } } } + // Check if preceded by ZWJ (part of sequence) + if (startCol > 0 && line.charAt(startCol - 1).code == UnicodeConstants.ZWJ) { + 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 || + UnicodeConstants.isVariationSelector(nextChar) || + nextChar.code == UnicodeConstants.ZWJ || + 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 (UnicodeConstants.isSkinToneModifier(cp)) { + endCol += 2 + continue + } + } + } + // Check for gender symbol after ZWJ + if (line.charAt(endCol).code == UnicodeConstants.ZWJ && + UnicodeConstants.isGenderSymbol(nextChar.code)) { + endCol++ + continue + } + break + } + + return Pair(startCol, endCol) } /** @@ -675,8 +935,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 } @@ -686,6 +948,50 @@ 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). + * + * @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): 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) + + // 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) { + nextCol++ + } + if (nextCol + 1 < width) { + val c3 = line.charAt(nextCol) + val c4 = line.charAt(nextCol + 1) + if (UnicodeConstants.isRegionalIndicatorHighSurrogate(c3) && + UnicodeConstants.isRegionalIndicatorLowSurrogate(c4.code)) { + // 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 0 + } + /** * Render a ZWJ sequence (emoji family, skin tones, etc.). * Returns (columns skipped in buffer, visual width consumed). @@ -825,8 +1131,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) { @@ -862,7 +1177,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 && + UnicodeConstants.isVariationSelector(nextChar)) { + "$charTextToRender$nextChar" + } else { + charTextToRender + } + val measurement = ctx.textMeasurer.measure(textToRender, textStyle) val glyphWidth = measurement.size.width.toFloat() val allocatedWidth = ctx.cellWidth * 2 @@ -871,7 +1193,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 ) @@ -881,7 +1203,7 @@ object TerminalCanvasRenderer { val centeringOffset = emptySpace / 2f drawText( textMeasurer = ctx.textMeasurer, - text = charTextToRender, + text = textToRender, topLeft = Offset(x + centeringOffset, y), style = textStyle ) @@ -897,23 +1219,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 ) } 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..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) } @@ -356,14 +358,25 @@ 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 + // 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) + if (line != null) { + TerminalCanvasRenderer.visualColToBufferCol(line, visualCol, snapshot.width) + } else visualCol + } else visualCol + selectionEnd = Pair(bufferCol, bufferRow) } display.requestImmediateRedraw() @@ -924,6 +937,7 @@ fun ProperTerminal( selectionEnd = null } isDragging = false + cachedDragSnapshot = null // Clear cached snapshot // Ensure focus is on terminal canvas after click focusRequester.requestFocus() } @@ -936,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) { @@ -955,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) { @@ -1054,23 +1070,46 @@ 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 + // 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) + 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 + // 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) + 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 @@ -1145,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