#include "configuration.h" #if HAS_SCREEN #include "MeshService.h" #include "NodeDB.h" #include "Power.h" #include "RTC.h" #include "draw/NodeListRenderer.h" #include "graphics/ScreenFonts.h" #include "graphics/SharedUIDisplay.h" #include "graphics/TFTColorRegions.h" #include "graphics/TFTPalette.h" #include "graphics/draw/UIRenderer.h" #include "main.h" #include "meshtastic/config.pb.h" #include "modules/ExternalNotificationModule.h" #include #include #include namespace graphics { ScreenResolution determineScreenResolution(int16_t screenheight, int16_t screenwidth) { #ifdef FORCE_LOW_RES return ScreenResolution::Low; #else // Unit C6L and other ultra low res screens if (screenwidth <= 64 || screenheight <= 48) { return ScreenResolution::UltraLow; } #ifdef DISPLAY_FORCE_SMALL_FONTS if (screenwidth <= 160 && screenheight <= 80) { return ScreenResolution::Low; } #endif // Standard OLED screens if (screenwidth > 128 && screenheight <= 64) { return ScreenResolution::Low; } // High Resolutions screens like T114, TDeck, TLora Pager, etc if (screenwidth > 128) { return ScreenResolution::High; } // Default to low resolution return ScreenResolution::Low; #endif } void decomposeTime(uint32_t rtc_sec, int &hour, int &minute, int &second) { hour = 0; minute = 0; second = 0; if (rtc_sec == 0) return; uint32_t hms = (rtc_sec % SEC_PER_DAY + SEC_PER_DAY) % SEC_PER_DAY; hour = hms / SEC_PER_HOUR; minute = (hms % SEC_PER_HOUR) / SEC_PER_MIN; second = hms % SEC_PER_MIN; } // === Shared External State === bool hasUnreadMessage = false; ScreenResolution currentResolution = ScreenResolution::Low; // === Internal State === bool isBoltVisibleShared = true; uint32_t lastBlinkShared = 0; bool isMailIconVisible = true; uint32_t lastMailBlink = 0; static inline bool useClockHeaderAccentTheme(uint32_t themeId) { return themeId == ThemeID::Pink || themeId == ThemeID::Creamsicle || themeId == ThemeID::MeshtasticGreen || themeId == ThemeID::ClassicRed || themeId == ThemeID::MonochromeWhite; } // ********************************* // * Rounded Header when inverted * // ********************************* void drawRoundedHighlight(OLEDDisplay *display, int16_t x, int16_t y, int16_t w, int16_t h, int16_t r) { // Draw the center and side rectangles display->fillRect(x + r, y, w - 2 * r, h); // center bar display->fillRect(x, y + r, r, h - 2 * r); // left edge display->fillRect(x + w - r, y + r, r, h - 2 * r); // right edge // Draw the rounded corners using filled circles display->fillCircle(x + r + 1, y + r, r); // top-left display->fillCircle(x + w - r - 1, y + r, r); // top-right display->fillCircle(x + r + 1, y + h - r - 1, r); // bottom-left display->fillCircle(x + w - r - 1, y + h - r - 1, r); // bottom-right } // ************************* // * Common Header Drawing * // ************************* void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const char *titleStr, bool force_no_invert, bool show_date, bool transparent_background, bool use_title_color_override, uint16_t title_color_override) { constexpr int HEADER_OFFSET_Y = 1; y += HEADER_OFFSET_Y; display->setFont(FONT_SMALL); display->setTextAlignment(TEXT_ALIGN_LEFT); const int xOffset = 4; const int highlightHeight = FONT_HEIGHT_SMALL - 1; const bool isInverted = (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_INVERTED); const bool isBold = config.display.heading_bold; const int screenW = display->getWidth(); const int screenH = display->getHeight(); const int headerHeight = highlightHeight + 2; const uint16_t headerColorForRoles = getThemeHeaderBg(); // Color TFT headers use a fixed dark background + white glyphs. // Keep legacy inverted bitmap behavior only for monochrome displays. const bool useInvertedHeaderStyle = (isInverted && !force_no_invert && !isTFTColoringEnabled() && !transparent_background); #if GRAPHICS_TFT_COLORING_ENABLED int statusLeftEndX = 0; int statusRightStartX = screenW; const bool isClockHeader = transparent_background && show_date && (!titleStr || titleStr[0] == '\0'); const auto activeThemeId = getActiveTheme().id; const bool useClockHeaderAccent = isClockHeader && useClockHeaderAccentTheme(activeThemeId); #endif { const uint16_t headerColor = getThemeHeaderBg(); const uint16_t headerTextColor = getThemeHeaderText(); const uint16_t headerTitleColorForRole = use_title_color_override ? title_color_override : headerTextColor; uint16_t headerStatusColor = getThemeHeaderStatus(); #if GRAPHICS_TFT_COLORING_ENABLED // Clock frame uses transparent header + date + empty title. // For accent clock themes (Pink/Creamsicle + classic monochrome), tint // status items (battery outline, %, date, mail icon) to the header accent. if (useClockHeaderAccent) { headerStatusColor = getThemeHeaderBg(); } if (transparent_background) { // Transparent clock headers should inherit whatever body off-color is // already active under the header (important for light/inverted themes). const uint16_t transparentBgColor = resolveTFTOffColorAt(0, headerHeight + 1, getThemeBodyBg()); // Intentionally skip the HeaderBackground region, as small screens draw the clock in the unused space in this region, // and the transparent call was erasing segments from the clock setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, transparentBgColor); setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, transparentBgColor); } else if (useInvertedHeaderStyle) { setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, headerColor, TFTPalette::Black, 0, 0, screenW, headerHeight); setTFTColorRole(TFTColorRole::HeaderTitle, headerColor, headerTitleColorForRole); setTFTColorRole(TFTColorRole::HeaderStatus, headerColor, headerStatusColor); } else { setAndRegisterTFTColorRole(TFTColorRole::HeaderBackground, TFTPalette::Black, headerColor, 0, 0, screenW, headerHeight); setTFTColorRole(TFTColorRole::HeaderTitle, headerTitleColorForRole, headerColor); setTFTColorRole(TFTColorRole::HeaderStatus, headerStatusColor, headerColor); } #endif // === Inverted Header Background === if (useInvertedHeaderStyle) { display->setColor(BLACK); display->fillRect(0, 0, screenW, headerHeight); display->setColor(WHITE); drawRoundedHighlight(display, x, y, screenW, highlightHeight, 2); display->setColor(BLACK); } else { display->setColor(BLACK); display->fillRect(0, 0, screenW, headerHeight); // Keep the legacy white separator for monochrome displays only when header background is visible. #if !GRAPHICS_TFT_COLORING_ENABLED if (!transparent_background) { display->setColor(WHITE); if (currentResolution == ScreenResolution::High) { display->drawLine(0, 20, screenW, 20); } else { display->drawLine(0, 14, screenW, 14); } } #endif } if (transparent_background) { display->setColor(WHITE); } #if GRAPHICS_TFT_COLORING_ENABLED // TFT role coloring expects foreground glyph bits to be "set". display->setColor(WHITE); #endif // === Screen Title === const char *headerTitle = titleStr ? titleStr : ""; const int titleWidth = UIRenderer::measureStringWithEmotes(display, headerTitle); const int titleX = (SCREEN_WIDTH - titleWidth) / 2; #if GRAPHICS_TFT_COLORING_ENABLED const int titleRegionWidth = titleWidth + (config.display.heading_bold ? 3 : 2); registerTFTColorRegion(TFTColorRole::HeaderTitle, titleX - 1, y, titleRegionWidth, FONT_HEIGHT_SMALL); #endif UIRenderer::drawStringWithEmotes(display, titleX, y, headerTitle, FONT_HEIGHT_SMALL, 1, config.display.heading_bold); } display->setTextAlignment(TEXT_ALIGN_LEFT); // === Battery State === int chargePercent = powerStatus->getBatteryChargePercent(); bool isCharging = powerStatus->getIsCharging(); bool usbPowered = powerStatus->getHasUSB(); if (chargePercent >= 100) { isCharging = false; } if (chargePercent == 101) { usbPowered = true; // Forcing this flag on for the express purpose that some devices have no concept of having a USB cable // plugged in } uint32_t now = millis(); #ifndef USE_EINK if (isCharging && now - lastBlinkShared > 500) { isBoltVisibleShared = !isBoltVisibleShared; lastBlinkShared = now; } #endif bool useHorizontalBattery = (currentResolution == ScreenResolution::High && screenW >= screenH); const int textY = y + (highlightHeight - FONT_HEIGHT_SMALL) / 2; bool hasBatteryFillRegion = false; int16_t batteryFillRegionX = 0; int16_t batteryFillRegionY = 0; int16_t batteryFillRegionW = 0; int16_t batteryFillRegionH = 0; #if GRAPHICS_TFT_COLORING_ENABLED uint16_t batteryFillColor = getThemeBatteryFillColor(chargePercent); if (useClockHeaderAccent) { batteryFillColor = getThemeHeaderBg(); } #endif int batteryX = 1; int batteryY = HEADER_OFFSET_Y + 1; #if !defined(OLED_TINY) // === Battery Icons === if (usbPowered && !isCharging) { // This is a basic check to determine USB Powered is flagged but not charging batteryX += 1; batteryY += 2; if (currentResolution == ScreenResolution::High) { display->drawXbm(batteryX, batteryY, 19, 12, imgUSB_HighResolution); batteryX += 20; // Icon + 1 pixel } else { display->drawXbm(batteryX, batteryY, 10, 8, imgUSB); batteryX += 11; // Icon + 1 pixel } } else { if (useHorizontalBattery) { batteryX += 1; batteryY += 2; display->drawXbm(batteryX, batteryY, 9, 13, batteryBitmap_h_bottom); display->drawXbm(batteryX + 9, batteryY, 9, 13, batteryBitmap_h_top); if (isCharging && isBoltVisibleShared) display->drawXbm(batteryX + 4, batteryY, 9, 13, lightning_bolt_h); else { display->drawLine(batteryX + 5, batteryY, batteryX + 10, batteryY); display->drawLine(batteryX + 5, batteryY + 12, batteryX + 10, batteryY + 12); int fillWidth = 14 * chargePercent / 100; display->fillRect(batteryX + 1, batteryY + 1, fillWidth, 11); #if GRAPHICS_TFT_COLORING_ENABLED if (fillWidth > 0) { hasBatteryFillRegion = true; batteryFillRegionX = batteryX + 1; batteryFillRegionY = batteryY + 1; batteryFillRegionW = fillWidth; batteryFillRegionH = 11; } #endif } batteryX += 18; // Icon + 2 pixels } else { #ifdef USE_EINK batteryY += 2; #endif display->drawXbm(batteryX, batteryY, 7, 11, batteryBitmap_v); if (isCharging && isBoltVisibleShared) display->drawXbm(batteryX + 1, batteryY + 3, 5, 5, lightning_bolt_v); else { display->drawXbm(batteryX - 1, batteryY + 4, 8, 3, batteryBitmap_sidegaps_v); int fillHeight = 8 * chargePercent / 100; int fillY = batteryY - fillHeight; display->fillRect(batteryX + 1, fillY + 10, 5, fillHeight); #if GRAPHICS_TFT_COLORING_ENABLED if (fillHeight > 0) { hasBatteryFillRegion = true; batteryFillRegionX = batteryX + 1; batteryFillRegionY = fillY + 10; batteryFillRegionW = 5; batteryFillRegionH = fillHeight; } #endif } batteryX += 9; // Icon + 2 pixels } } #if GRAPHICS_TFT_COLORING_ENABLED statusLeftEndX = batteryX + 2; #endif if (chargePercent != 101) { // === Battery % Display === char chargeStr[4]; snprintf(chargeStr, sizeof(chargeStr), "%d", chargePercent); int chargeNumWidth = display->getStringWidth(chargeStr); const int percentWidth = display->getStringWidth("%"); const int percentX = batteryX + chargeNumWidth - 1; display->drawString(batteryX, textY, chargeStr); display->drawString(percentX, textY, "%"); #if GRAPHICS_TFT_COLORING_ENABLED statusLeftEndX = percentX + percentWidth + 2; #endif if (isBold) { display->drawString(batteryX + 1, textY, chargeStr); display->drawString(percentX + 1, textY, "%"); #if GRAPHICS_TFT_COLORING_ENABLED statusLeftEndX = percentX + percentWidth + 3; #endif } } // === Time and Right-aligned Icons === uint32_t rtc_sec = getValidTime(RTCQuality::RTCQualityDevice, true); char timeStr[10] = "--:--"; // Fallback display int timeStrWidth = display->getStringWidth("12:34"); // Default alignment int timeX = screenW - xOffset - timeStrWidth + 4; if (rtc_sec > 0) { // === Build Time String === int hour, minute, second; graphics::decomposeTime(rtc_sec, hour, minute, second); snprintf(timeStr, sizeof(timeStr), "%d:%02d", hour, minute); // === Build Date String === char datetimeStr[25]; UIRenderer::formatDateTime(datetimeStr, sizeof(datetimeStr), rtc_sec, display, false); char dateLine[40]; if (currentResolution == ScreenResolution::High) { snprintf(dateLine, sizeof(dateLine), "%s", datetimeStr); } else { if (hasUnreadMessage) { snprintf(dateLine, sizeof(dateLine), "%s", &datetimeStr[5]); } else { snprintf(dateLine, sizeof(dateLine), "%s", &datetimeStr[2]); } } if (config.display.use_12h_clock) { bool isPM = hour >= 12; hour %= 12; if (hour == 0) hour = 12; snprintf(timeStr, sizeof(timeStr), "%d:%02d%s", hour, minute, isPM ? "p" : "a"); } if (show_date) { timeStrWidth = display->getStringWidth(dateLine); } else { timeStrWidth = display->getStringWidth(timeStr); } timeX = screenW - xOffset - timeStrWidth + 3; #if GRAPHICS_TFT_COLORING_ENABLED statusRightStartX = timeX - (useHorizontalBattery ? 22 : 16); #endif // === Show Mail or Mute Icon to the Left of Time === int iconRightEdge = timeX - 2; bool showMail = false; #ifndef USE_EINK if (hasUnreadMessage) { if (now - lastMailBlink > 500) { isMailIconVisible = !isMailIconVisible; lastMailBlink = now; } showMail = isMailIconVisible; } #else if (hasUnreadMessage) { showMail = true; } #endif if (showMail) { if (useHorizontalBattery) { int iconW = 16, iconH = 12; int iconX = iconRightEdge - iconW; int iconY = textY + (FONT_HEIGHT_SMALL - iconH) / 2 - 1; if (useInvertedHeaderStyle) { display->setColor(WHITE); display->fillRect(iconX - 1, iconY - 1, iconW + 3, iconH + 2); display->setColor(BLACK); } else { display->setColor(BLACK); display->fillRect(iconX - 1, iconY - 1, iconW + 3, iconH + 2); display->setColor(WHITE); } display->drawRect(iconX, iconY, iconW + 1, iconH); display->drawLine(iconX, iconY, iconX + iconW / 2, iconY + iconH - 4); display->drawLine(iconX + iconW, iconY, iconX + iconW / 2, iconY + iconH - 4); } else { int iconX = iconRightEdge - (mail_width - 2); int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2; if (useInvertedHeaderStyle) { display->setColor(WHITE); display->fillRect(iconX - 1, iconY - 1, mail_width + 2, mail_height + 2); display->setColor(BLACK); } else { display->setColor(BLACK); display->fillRect(iconX - 1, iconY - 1, mail_width + 2, mail_height + 2); display->setColor(WHITE); } display->drawXbm(iconX, iconY, mail_width, mail_height, mail); } } else if (externalNotificationModule->getMute()) { if (currentResolution == ScreenResolution::High) { int iconX = iconRightEdge - mute_symbol_big_width; int iconY = textY + (FONT_HEIGHT_SMALL - mute_symbol_big_height) / 2; if (useInvertedHeaderStyle) { display->setColor(WHITE); display->fillRect(iconX - 1, iconY - 1, mute_symbol_big_width + 2, mute_symbol_big_height + 2); display->setColor(BLACK); } else { display->setColor(BLACK); display->fillRect(iconX - 1, iconY - 1, mute_symbol_big_width + 2, mute_symbol_big_height + 2); display->setColor(WHITE); } display->drawXbm(iconX, iconY, mute_symbol_big_width, mute_symbol_big_height, mute_symbol_big); } else { int iconX = iconRightEdge - mute_symbol_width; int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2; if (useInvertedHeaderStyle) { display->setColor(WHITE); display->fillRect(iconX - 1, iconY - 1, mute_symbol_width + 2, mute_symbol_height + 2); display->setColor(BLACK); } else { display->setColor(BLACK); display->fillRect(iconX - 1, iconY - 1, mute_symbol_width + 2, mute_symbol_height + 2); display->setColor(WHITE); } display->drawXbm(iconX, iconY, mute_symbol_width, mute_symbol_height, mute_symbol); } } if (show_date) { // === Draw Date === display->drawString(timeX, textY, dateLine); if (isBold) display->drawString(timeX - 1, textY, dateLine); } else { // === Draw Time === display->drawString(timeX, textY, timeStr); if (isBold) display->drawString(timeX - 1, textY, timeStr); } } else { // === No Time Available: Mail/Mute Icon Moves to Far Right === int iconRightEdge = screenW - xOffset; #if GRAPHICS_TFT_COLORING_ENABLED statusRightStartX = screenW - (useHorizontalBattery ? 22 : 12); #endif bool showMail = false; #ifndef USE_EINK if (hasUnreadMessage) { if (now - lastMailBlink > 500) { isMailIconVisible = !isMailIconVisible; lastMailBlink = now; } showMail = isMailIconVisible; } #else if (hasUnreadMessage) { showMail = true; } #endif if (showMail) { if (useHorizontalBattery) { int iconW = 16, iconH = 12; int iconX = iconRightEdge - iconW; int iconY = textY + (FONT_HEIGHT_SMALL - iconH) / 2 - 1; display->drawRect(iconX, iconY, iconW + 1, iconH); display->drawLine(iconX, iconY, iconX + iconW / 2, iconY + iconH - 4); display->drawLine(iconX + iconW, iconY, iconX + iconW / 2, iconY + iconH - 4); } else { int iconX = iconRightEdge - mail_width; int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2; display->drawXbm(iconX, iconY, mail_width, mail_height, mail); } } else if (externalNotificationModule->getMute()) { if (currentResolution == ScreenResolution::High) { int iconX = iconRightEdge - mute_symbol_big_width; int iconY = textY + (FONT_HEIGHT_SMALL - mute_symbol_big_height) / 2; display->drawXbm(iconX, iconY, mute_symbol_big_width, mute_symbol_big_height, mute_symbol_big); } else { int iconX = iconRightEdge - mute_symbol_width; int iconY = textY + (FONT_HEIGHT_SMALL - mail_height) / 2; display->drawXbm(iconX, iconY, mute_symbol_width, mute_symbol_height, mute_symbol); } } } #endif #if GRAPHICS_TFT_COLORING_ENABLED registerTFTColorRegion(TFTColorRole::HeaderStatus, 0, 0, statusLeftEndX, headerHeight); if (statusRightStartX < screenW) { registerTFTColorRegion(TFTColorRole::HeaderStatus, statusRightStartX, 0, screenW - statusRightStartX, headerHeight); } if (hasBatteryFillRegion) { registerTFTColorRegionDirect(batteryFillRegionX, batteryFillRegionY, batteryFillRegionW, batteryFillRegionH, batteryFillColor, headerColorForRoles); } #endif display->setColor(WHITE); // Reset for other UI } const int *getTextPositions(OLEDDisplay *display) { static int textPositions[7]; // Static array that persists beyond function scope if (currentResolution == ScreenResolution::High) { textPositions[0] = textZeroLine; textPositions[1] = textFirstLine_medium; textPositions[2] = textSecondLine_medium; textPositions[3] = textThirdLine_medium; textPositions[4] = textFourthLine_medium; textPositions[5] = textFifthLine_medium; textPositions[6] = textSixthLine_medium; } else { textPositions[0] = textZeroLine; textPositions[1] = textFirstLine; textPositions[2] = textSecondLine; textPositions[3] = textThirdLine; textPositions[4] = textFourthLine; textPositions[5] = textFifthLine; textPositions[6] = textSixthLine; } return textPositions; } // ************************* // * Common Footer Drawing * // ************************* void drawCommonFooter(OLEDDisplay *display, int16_t x, int16_t y) { if (!isAPIConnected(service->api_state)) return; const int scale = (currentResolution == ScreenResolution::High) ? 2 : 1; const int footerY = SCREEN_HEIGHT - (1 * scale) - (connection_icon_height * scale); const int footerH = (connection_icon_height * scale) + (2 * scale); const int iconX = 0; const int iconY = SCREEN_HEIGHT - (connection_icon_height * scale); const int iconW = connection_icon_width * scale; const int iconH = connection_icon_height * scale; #if GRAPHICS_TFT_COLORING_ENABLED // Only tint the link glyph itself on TFT; keep the footer background black. setAndRegisterTFTColorRole(TFTColorRole::ConnectionIcon, TFTPalette::Blue, TFTPalette::Black, iconX, iconY, iconW, iconH); #endif display->setColor(BLACK); #if GRAPHICS_TFT_COLORING_ENABLED display->fillRect(0, footerY, SCREEN_WIDTH, footerH); #else display->fillRect(0, footerY, connection_icon_width + 1, footerH); #endif display->setColor(WHITE); if (currentResolution == ScreenResolution::High) { const int bytesPerRow = (connection_icon_width + 7) / 8; for (int yy = 0; yy < connection_icon_height; ++yy) { const uint8_t *rowPtr = connection_icon + yy * bytesPerRow; for (int xx = 0; xx < connection_icon_width; ++xx) { const uint8_t byteVal = pgm_read_byte(rowPtr + (xx >> 3)); const uint8_t bitMask = 1U << (xx & 7); // XBM is LSB-first if (byteVal & bitMask) { display->fillRect(iconX + xx * scale, iconY + yy * scale, scale, scale); } } } } else { display->drawXbm(iconX, iconY, connection_icon_width, connection_icon_height, connection_icon); } } bool isAllowedPunctuation(char c) { switch (c) { case '.': case ',': case '!': case '?': case ';': case ':': case '-': case '_': case '(': case ')': case '[': case ']': case '{': case '}': case '\'': case '"': case '@': case '#': case '$': case '/': case '\\': case '&': case '+': case '=': case '%': case '~': case '^': case ' ': return true; default: return false; } } static inline size_t utf8CodePointLength(unsigned char lead) { if ((lead & 0x80) == 0x00) { return 1; } if ((lead & 0xE0) == 0xC0) { return 2; } if ((lead & 0xF0) == 0xE0) { return 3; } if ((lead & 0xF8) == 0xF0) { return 4; } return 1; } std::string sanitizeString(const std::string &input) { static constexpr char kReplacementChar = static_cast(0xBF); // Inverted question mark in ISO-8859-1. std::string output; output.reserve(input.size()); bool inReplacement = false; const size_t inputSize = input.size(); size_t i = 0; while (i < inputSize) { const unsigned char byte0 = static_cast(input[i]); char normalized = '\0'; size_t consumed = 0; if (byte0 < 0x80) { normalized = static_cast(byte0); consumed = 1; } else if ((i + 2) < inputSize && byte0 == 0xE2 && static_cast(input[i + 1]) == 0x80) { // Smart punctuation: ' ' \" \" - - switch (static_cast(input[i + 2])) { case 0x98: case 0x99: normalized = '\''; consumed = 3; break; case 0x9C: case 0x9D: normalized = '\"'; consumed = 3; break; case 0x93: case 0x94: normalized = '-'; consumed = 3; break; default: break; } } else if ((i + 1) < inputSize && byte0 == 0xC2 && static_cast(input[i + 1]) == 0xA0) { // Non-breaking space. normalized = ' '; consumed = 2; } if (consumed == 0) { size_t seqLen = utf8CodePointLength(byte0); if (seqLen > (inputSize - i)) { seqLen = 1; } if (!inReplacement) { output.push_back(kReplacementChar); inReplacement = true; } i += seqLen; continue; } const unsigned char normalizedUc = static_cast(normalized); if (std::isalnum(normalizedUc) || isAllowedPunctuation(normalized)) { output.push_back(normalized); inReplacement = false; } else if (!inReplacement) { output.push_back(kReplacementChar); inReplacement = true; } i += consumed; } return output; } } // namespace graphics #endif