#include "ui/controls/input.h"

#include "core/deferred_call.h"
#include "core/input/key_chord.h"
#include "core/input/key_modifiers.h"
#include "core/input/key_symbols.h"
#include "core/text_clipboard.h"
#include "cursor-shape-v1-client-protocol.h"
#include "render/animation/animation.h"
#include "render/animation/animation_manager.h"
#include "render/core/color.h"
#include "render/core/render_styles.h"
#include "render/core/renderer.h"
#include "render/scene/glyph_node.h"
#include "render/scene/input_area.h"
#include "render/scene/rect_node.h"
#include "render/text/glyph_registry.h"
#include "ui/controls/label.h"
#include "ui/palette.h"
#include "ui/style.h"
#include "wayland/text_input_service.h"

#include <algorithm>
#include <array>
#include <cctype>
#include <cmath>
#include <functional>
#include <limits>
#include <memory>
#include <numbers>
#include <optional>
#include <string>
#include <wayland-client-protocol.h>

namespace {

  TextClipboard* g_clipboard = nullptr;
  Input::PasswordMaskStyle g_passwordMaskStyle = Input::PasswordMaskStyle::CircleFilled;
  std::function<bool(std::uint32_t, std::uint32_t)> g_validateKeyMatcher;

  std::optional<std::string> readClipboardText() {
    return g_clipboard != nullptr ? g_clipboard->clipboardText() : std::nullopt;
  }

  constexpr float kMinWidth = 48.0F;
  constexpr float kCursorWidth = 1.25F;
  constexpr float kCursorPadV = 4.0F;
  constexpr float kCursorMinHeight = 14.0F;
  constexpr float kCursorHeightRatio = 0.50F;
  constexpr float kCursorRevealPadding = 2.0F;
  constexpr float kClearGlyphScale = 0.85F;
  constexpr auto kCursorBlinkInterval = std::chrono::milliseconds(530);
  constexpr auto kCursorBlinkResumeDelay = std::chrono::milliseconds(650);
  constexpr float kTextInnerInset = 3.0F;
  constexpr float kPlaceholderAlpha = 0.68F;
  constexpr float kPrimaryPlaceholderAlpha = 0.50F;
  constexpr float kPasswordGlyphScale = 0.82F;
  constexpr auto kDoubleClickThreshold = std::chrono::milliseconds(400);
  constexpr float kDoubleClickDistance = 6.0F;
  constexpr std::size_t kUndoStackLimit = 100;
  // Keep in sync with cairo_text_renderer single-texture width clip (GL_MAX_TEXTURE_SIZE).
  constexpr float kMaxLabelRasterWidth = 8192.0F - 64.0F;
  constexpr auto kTypingUndoCoalesceWindow = std::chrono::milliseconds(1000);
  // Multiline mode: vertical text inset, wheel step in lines, and the stub
  // highlight width (in em) marking a selected line break.
  constexpr float kMultilinePadV = Style::spaceSm;
  constexpr int kWheelScrollLines = 3;
  constexpr float kSelectionLineBreakStubEm = 0.4F;
  constexpr float kLineYEpsilon = 0.5F;

  bool sameLineY(float a, float b) noexcept { return std::abs(a - b) < kLineYEpsilon; }

  float chromeScaleForControlHeight(float controlHeight) noexcept {
    return std::max(0.1F, controlHeight / Style::controlHeight);
  }

  bool isWordCodepoint(const std::string& text, std::size_t bytePos) {
    if (bytePos >= text.size()) {
      return false;
    }

    const auto lead = static_cast<unsigned char>(text[bytePos]);
    if ((lead & 0x80U) != 0) {
      return true;
    }
    return std::isalnum(lead) != 0 || lead == '_';
  }

  char32_t passwordMaskCodepointForIndex(std::size_t index) {
    if (g_passwordMaskStyle == Input::PasswordMaskStyle::CircleFilled) {
      return GlyphRegistry::lookup("circle-filled");
    }
    static const std::array<char32_t, 7> randomCodepoints = {
        GlyphRegistry::lookup("circle-filled"),        GlyphRegistry::lookup("pentagon-filled"),
        GlyphRegistry::lookup("michelin-star-filled"), GlyphRegistry::lookup("square-rounded-filled"),
        GlyphRegistry::lookup("guitar-pick-filled"),   GlyphRegistry::lookup("blob-filled"),
        GlyphRegistry::lookup("triangle-filled"),
    };
    return randomCodepoints[index % randomCodepoints.size()];
  }

  void layoutPasswordMaskGlyph(
      Renderer& renderer, GlyphNode& glyph, char32_t codepoint, float glyphSize, float cellX, float cellSize,
      float inputHeight
  ) {
    glyph.setCodepoint(codepoint);
    glyph.setFontSize(glyphSize);
    glyph.setHitTestVisible(false);
    const auto metrics = renderer.measureGlyph(codepoint, glyphSize);
    // Shared horizontal em grid; per-glyph ink center on a common row line.
    const float cellCenterX = cellX + cellSize * 0.5F;
    const float emCenterX = glyphSize * 0.5F;
    const float inkCenterY = (metrics.top + metrics.bottom) * 0.5F;
    const float rowCenterY = inputHeight * 0.5F;
    glyph.setPosition(cellCenterX - emCenterX, rowCenterY - inkCenterY);
    // The node origin is the glyph baseline, so the ink center is the pivot animated scale/rotation must use.
    glyph.setTransformOrigin((metrics.left + metrics.right) * 0.5F, inkCenterY);
  }

  Color resolved(ColorRole role, float alpha = 1.0F) { return colorForRole(role, alpha); }

  bool isUtf8ContinuationByte(unsigned char value) noexcept { return (value & 0xC0U) == 0x80U; }

  std::size_t clampToUtf8Start(const std::string& text, std::size_t pos) {
    pos = std::min(pos, text.size());
    while (pos > 0 && pos < text.size() && isUtf8ContinuationByte(static_cast<unsigned char>(text[pos]))) {
      --pos;
    }
    return pos;
  }

  std::size_t clampToUtf8End(const std::string& text, std::size_t pos) {
    pos = std::min(pos, text.size());
    while (pos < text.size() && isUtf8ContinuationByte(static_cast<unsigned char>(text[pos]))) {
      ++pos;
    }
    return pos;
  }

  std::int32_t byteOffsetToProtocolInt(std::size_t value) {
    return static_cast<std::int32_t>(
        std::min<std::size_t>(value, static_cast<std::size_t>(std::numeric_limits<std::int32_t>::max()))
    );
  }

} // namespace

Input::Input() {
  // 0: background
  auto bg = std::make_unique<RectNode>();
  m_background = static_cast<RectNode*>(addChild(std::move(bg)));

  // 1: text viewport clip layer
  auto textViewport = std::make_unique<Node>();
  textViewport->setClipChildren(true);
  textViewport->setHitTestVisible(false);
  m_textViewport = addChild(std::move(textViewport));

  // Text-layer children:
  // 0: selection highlight (rendered behind text)
  auto sel = std::make_unique<RectNode>();
  sel->setStyle(
      RoundedRectStyle{
          .fill = resolved(ColorRole::Primary),
          .fillMode = FillMode::Solid,
          .radius = 2.0F,
      }
  );
  sel->setOpacity(0.3F);
  sel->setVisible(false);
  m_selectionRect = static_cast<RectNode*>(m_textViewport->addChild(std::move(sel)));

  // 1: text
  auto label = std::make_unique<Label>();
  label->setFontSize(m_fontSize);
  label->setMaxLines(1);
  label->setColor(colorSpecFromRole(ColorRole::OnSurface));
  m_label = static_cast<Label*>(m_textViewport->addChild(std::move(label)));

  // 2: cursor
  auto cursor = std::make_unique<RectNode>();
  cursor->setStyle(
      RoundedRectStyle{
          .fill = resolved(ColorRole::Primary),
          .fillMode = FillMode::Solid,
          .radius = 1.0F,
      }
  );
  cursor->setVisible(false);
  m_cursor = static_cast<RectNode*>(m_textViewport->addChild(std::move(cursor)));

  // Full-field input area.
  auto area = std::make_unique<InputArea>();
  area->setFocusable(true);
  area->setTextInputClient(this);
  area->setCursorShape(WP_CURSOR_SHAPE_DEVICE_V1_SHAPE_TEXT);
  area->setOnEnter([this](const InputArea::PointerData& /*data*/) { applyVisualState(); });
  area->setOnLeave([this]() { applyVisualState(); });
  area->setOnFocusGain([this]() {
    revealCursor();
    startCursorBlink();
    applyVisualState();
    markLayoutDirty();
    // Focus gain is delivered before pointer press in the same dispatch pass.
    // Defer scroll-to-caret so click placement sees the blurred scroll offset (0).
    const std::weak_ptr<int> alive = m_aliveToken;
    DeferredCall::callLater([this, alive]() {
      if (alive.expired() || m_inputArea == nullptr || !m_inputArea->focused()) {
        return;
      }
      requestCaretUpdate();
    });
    if (m_onFocusGain) {
      m_onFocusGain();
    }
  });
  area->setOnFocusLoss([this]() {
    const bool removedPreedit = removePreeditText();
    stopCursorBlink();
    updateCursorVisibility();
    if (!m_multiline) {
      m_scrollOffset = 0.0F;
    }
    applyVisualState();
    markLayoutDirty();
    if (removedPreedit) {
      updateDisplayText();
      markTextContentChanged();
    }
    if (m_submitOnFocusLoss && m_onSubmit) {
      m_onSubmit(m_value);
    }
    if (m_onFocusLoss) {
      m_onFocusLoss();
    }
  });
  area->setOnPress([this](const InputArea::PointerData& data) {
    if (data.pressed) {
      resetUndoCoalescing();
      m_goalCaretX = -1.0F;
      const float textStartX = m_horizontalPadding + kTextInnerInset;
      const std::size_t offset = m_multiline
          ? pointToByteOffset(data.localX - textStartX, data.localY - kMultilinePadV + m_scrollOffsetY)
          : xToByteOffset(data.localX - textStartX + m_scrollOffset - m_contentLeadSlack);
      const auto now = std::chrono::steady_clock::now();
      const bool inMultiClickWindow = data.button == BTN_LEFT
          && m_hasLastPrimaryPress
          && now - m_lastPrimaryPressTime <= kDoubleClickThreshold
          && std::abs(data.localX - m_lastPrimaryPressX) <= kDoubleClickDistance
          && std::abs(data.localY - m_lastPrimaryPressY) <= kDoubleClickDistance;

      if (data.button == BTN_LEFT) {
        m_primaryClickCount = inMultiClickWindow ? (m_primaryClickCount >= 3 ? 1 : m_primaryClickCount + 1) : 1;
        m_lastPrimaryPressTime = now;
        m_lastPrimaryPressX = data.localX;
        m_lastPrimaryPressY = data.localY;
        m_hasLastPrimaryPress = true;
      } else {
        m_hasLastPrimaryPress = false;
        m_primaryClickCount = 0;
      }

      if (data.button == BTN_LEFT && m_primaryClickCount == 2) {
        m_pointerSelectGranularity = PointerSelectGranularity::Word;
        selectWordAtByteOffset(offset);
        m_pointerSelectPivotStart = selectionStart();
        m_pointerSelectPivotEnd = selectionEnd();
      } else if (data.button == BTN_LEFT && m_primaryClickCount == 3) {
        m_pointerSelectGranularity = PointerSelectGranularity::Line;
        selectLineAtByteOffset(offset);
        m_pointerSelectPivotStart = selectionStart();
        m_pointerSelectPivotEnd = selectionEnd();
      } else {
        m_pointerSelectGranularity = PointerSelectGranularity::Character;
        m_pointerSelectPivotStart = offset;
        m_pointerSelectPivotEnd = offset;
        m_cursorPos = offset;
        m_selectionAnchor = offset;
      }
      requestCaretUpdate();
      revealCursor();
      notifyTextInputStateChanged(TextInputChangeCause::Other);
    }
  });
  area->setOnMotion([this](const InputArea::PointerData& data) {
    if (m_inputArea != nullptr && m_inputArea->pressed()) {
      resetUndoCoalescing();
      m_goalCaretX = -1.0F;
      const float textStartX = m_horizontalPadding + kTextInnerInset;
      if (m_multiline) {
        // Drag near the top/bottom edge pans the viewport a line per motion event.
        const float heightPx = height() > 0.0F ? height() : m_controlHeight;
        const float edgePx = 12.0F;
        if (data.localY <= edgePx) {
          m_scrollOffsetY -= currentLineHeight();
        } else if (data.localY >= heightPx - edgePx) {
          m_scrollOffsetY += currentLineHeight();
        }
        clampScrollOffsetY();
        const std::size_t offset =
            pointToByteOffset(data.localX - textStartX, data.localY - kMultilinePadV + m_scrollOffsetY);
        extendPointerSelectionToByteOffset(offset);
        updateInteractiveGeometry();
        updateCursorVisibility();
        markPaintDirty();
        revealCursor();
        notifyTextInputStateChanged(TextInputChangeCause::Other);
        return;
      }
      const float widthPx = width() > 0.0F ? width() : kMinWidth;
      const float edgePx = std::max(12.0F, m_horizontalPadding);
      const float scrollNudge = std::max(4.0F, textViewportWidth() * 0.02F);
      bool handledByEdgeScroll = false;
      std::size_t offset = m_cursorPos;
      if (data.localX <= edgePx) {
        m_scrollOffset -= scrollNudge;
        offset = prevCharPos(m_value, m_cursorPos);
        handledByEdgeScroll = true;
      } else if (data.localX >= widthPx - edgePx) {
        m_scrollOffset += scrollNudge;
        offset = nextCharPos(m_value, m_cursorPos);
        handledByEdgeScroll = true;
      }
      clampScrollOffset();
      if (!handledByEdgeScroll) {
        offset = xToByteOffset(data.localX - textStartX + m_scrollOffset - m_contentLeadSlack);
      }
      extendPointerSelectionToByteOffset(offset);
      requestCaretUpdate();
      revealCursor();
      notifyTextInputStateChanged(TextInputChangeCause::Other);
    }
  });
  area->setOnAxisHandler([this](const InputArea::PointerData& data) {
    if (data.axis != WL_POINTER_AXIS_VERTICAL_SCROLL || m_inputArea == nullptr || !m_inputArea->focused()) {
      return false;
    }
    const float delta = data.scrollSteps();
    if (delta == 0.0F) {
      return false;
    }
    if (m_multiline) {
      // Pan the viewport without moving the caret.
      const float step = currentLineHeight() * static_cast<float>(kWheelScrollLines);
      const float previous = m_scrollOffsetY;
      m_scrollOffsetY += delta > 0.0F ? -step : step;
      clampScrollOffsetY();
      if (std::abs(m_scrollOffsetY - previous) < 0.001F) {
        return false;
      }
      updateInteractiveGeometry();
      markPaintDirty();
      return true;
    }
    resetUndoCoalescing();
    // Wheel should move caret through text, not pan viewport directly.
    constexpr int kWheelCaretStep = 1;
    if (delta > 0.0F) {
      for (int i = 0; i < kWheelCaretStep; ++i) {
        m_cursorPos = prevCharPos(m_value, m_cursorPos);
      }
    } else {
      for (int i = 0; i < kWheelCaretStep; ++i) {
        m_cursorPos = nextCharPos(m_value, m_cursorPos);
      }
    }
    m_selectionAnchor = m_cursorPos;
    requestCaretUpdate();
    revealCursor();
    notifyTextInputStateChanged(TextInputChangeCause::Other);
    return true;
  });
  area->setOnKeyDown([this](const InputArea::KeyData& k) { handleKey(k.sym, k.utf32, k.modifiers, k.preedit); });
  m_inputArea = static_cast<InputArea*>(addChild(std::move(area)));

  // Optional clear button, kept above the full-field input area for hit-testing.
  auto clearButtonArea = std::make_unique<InputArea>();
  clearButtonArea->setOnEnter([this](const InputArea::PointerData& /*data*/) { applyVisualState(); });
  clearButtonArea->setOnLeave([this]() { applyVisualState(); });
  clearButtonArea->setOnClick([this](const InputArea::PointerData& data) {
    if (data.button == BTN_LEFT) {
      clearFromButton();
    }
  });
  clearButtonArea->setVisible(false);
  m_clearButtonArea = static_cast<InputArea*>(addChild(std::move(clearButtonArea)));

  auto clearGlyph = std::make_unique<GlyphNode>();
  clearGlyph->setCodepoint(GlyphRegistry::lookup("close"));
  clearGlyph->setHitTestVisible(false);
  m_clearButtonGlyph = static_cast<GlyphNode*>(m_clearButtonArea->addChild(std::move(clearGlyph)));

  applyVisualState();
  m_paletteConn = paletteChanged().connect([this] {
    updateDisplayText();
    applyVisualState();
  });
  m_inputBordersConn = Style::inputBordersChanged().connect([this] { applyVisualState(); });
}

Input::~Input() {
  if (m_textInputService != nullptr) {
    m_textInputService->clearFocusedClient(this);
  }
}

void Input::setValue(std::string_view value) {
  m_value = std::string(value);
  m_cursorPos = m_value.size();
  m_selectionAnchor = m_cursorPos;
  m_preeditStart = 0;
  m_preeditLen = 0;
  m_goalCaretX = -1.0F;
  clearEditHistory();
  updateDisplayText();
  notifyTextInputStateChanged(TextInputChangeCause::Other);
  markTextContentChanged();
}

void Input::setPlaceholder(std::string_view placeholder) {
  m_placeholder = std::string(placeholder);
  if (m_value.empty()) {
    updateDisplayText();
    markLayoutDirty();
  }
}

void Input::setFontSize(float size) {
  m_fontSize = std::max(1.0F, size);
  if (m_label != nullptr) {
    m_label->setFontSize(m_fontSize);
  }
  markTextContentChanged();
}

void Input::setControlHeight(float height) {
  m_controlHeight = std::max(1.0F, height);
  markLayoutDirty();
}

void Input::setHorizontalPadding(float padding) {
  m_horizontalPadding = std::max(0.0F, padding);
  markLayoutDirty();
}

void Input::setClearButtonEnabled(bool enabled) {
  if (m_clearButtonEnabled == enabled) {
    return;
  }
  m_clearButtonEnabled = enabled;
  markLayoutDirty();
}

void Input::setPasswordMode(bool enabled) {
  // Mutually exclusive with multiline; multiline wins.
  if (enabled && m_multiline) {
    return;
  }
  if (m_passwordMode == enabled) {
    return;
  }
  m_passwordMode = enabled;
  if (m_passwordMode) {
    clearEditHistory();
  } else {
    syncPasswordGlyphNodes(0);
  }
  updateDisplayText();
  notifyTextInputStateChanged(TextInputChangeCause::Other);
  markTextContentChanged();
}

void Input::setMultiline(bool enabled) {
  if (m_multiline == enabled) {
    return;
  }
  m_multiline = enabled;
  if (enabled && m_passwordMode) {
    m_passwordMode = false;
    syncPasswordGlyphNodes(0);
  }
  if (m_label != nullptr) {
    m_label->setMaxLines(enabled ? 0 : 1);
    if (!enabled) {
      m_label->setMaxWidth(0.0F);
    }
  }
  if (!enabled) {
    syncSelectionLineRects(0);
    if (m_selectionRect != nullptr) {
      m_selectionRect->setVisible(false);
    }
  }
  m_scrollOffset = 0.0F;
  m_scrollOffsetY = 0.0F;
  m_goalCaretX = -1.0F;
  m_stopsBuiltForWidth = -1.0F;
  updateDisplayText();
  notifyTextInputStateChanged(TextInputChangeCause::Other);
  markTextContentChanged();
}

void Input::setInvalid(bool invalid) {
  if (m_invalid == invalid) {
    return;
  }
  m_invalid = invalid;
  applyVisualState();
  markPaintDirty();
}

void Input::setFrameVisible(bool visible) {
  if (m_frameVisible == visible) {
    return;
  }
  m_frameVisible = visible;
  applyVisualState();
  markPaintDirty();
}

void Input::setEmbeddedOnSolidPrimary(bool embedded) {
  if (m_embeddedOnSolidPrimary == embedded) {
    return;
  }
  m_embeddedOnSolidPrimary = embedded;
  applyVisualState();
  markPaintDirty();
}

void Input::setFontWeight(FontWeight fontWeight) {
  if (m_label != nullptr) {
    m_label->setFontWeight(fontWeight);
  }
  markTextContentChanged();
}

void Input::setMinLayoutWidth(float width) {
  const float next = std::max(0.0F, width);
  if (m_minLayoutWidth == next) {
    return;
  }
  m_minLayoutWidth = next;
  markLayoutDirty();
}

void Input::setTextAlign(TextAlign align) {
  if (m_textAlign == align) {
    return;
  }
  m_textAlign = align;
  markLayoutDirty();
}

void Input::setOnChange(std::function<void(const std::string&)> callback) { m_onChange = std::move(callback); }

void Input::setOnSubmit(std::function<void(const std::string&)> callback) { m_onSubmit = std::move(callback); }

void Input::setOnKeyEvent(std::function<bool(std::uint32_t, std::uint32_t)> callback) {
  m_onKeyEvent = std::move(callback);
}

void Input::setOnFocusLoss(std::function<void()> callback) { m_onFocusLoss = std::move(callback); }

void Input::setOnFocusGain(std::function<void()> callback) { m_onFocusGain = std::move(callback); }

void Input::setSubmitOnFocusLoss(bool enabled) { m_submitOnFocusLoss = enabled; }

void Input::setSubmitOnEnter(bool enabled) { m_submitOnEnter = enabled; }

void Input::setSurfaceOpacity(float opacity) {
  const float clamped = std::clamp(opacity, 0.0F, 1.0F);
  if (m_surfaceOpacity == clamped) {
    return;
  }
  m_surfaceOpacity = clamped;
  applyVisualState();
}

void Input::setFrameRadius(float radius) {
  const float clamped = std::max(0.0F, radius);
  if (m_frameRadius == clamped) {
    return;
  }
  m_frameRadius = clamped;
  applyVisualState();
}

void Input::setEnabled(bool enabled) {
  if (m_enabled == enabled) {
    return;
  }
  m_enabled = enabled;
  if (m_inputArea != nullptr) {
    m_inputArea->setEnabled(enabled);
  }
  if (m_clearButtonArea != nullptr) {
    m_clearButtonArea->setEnabled(enabled);
  }
  setOpacity(enabled ? 1.0F : 0.55F);
  applyVisualState();
}

void Input::setTextClipboard(TextClipboard* clipboard) noexcept { g_clipboard = clipboard; }

void Input::setValidateKeyMatcher(std::function<bool(std::uint32_t, std::uint32_t)> matcher) noexcept {
  g_validateKeyMatcher = std::move(matcher);
}

void Input::setPasswordMaskStyle(PasswordMaskStyle style) noexcept { g_passwordMaskStyle = style; }

void Input::selectAll() {
  resetUndoCoalescing();
  m_selectionAnchor = 0;
  m_cursorPos = m_value.size();
  updateInteractiveGeometry();
  notifyTextInputStateChanged(TextInputChangeCause::Other);
  markPaintDirty();
}

void Input::moveCaretLeft(bool shift) {
  resetUndoCoalescing();
  if (!shift && hasSelection()) {
    m_cursorPos = selectionStart();
    m_selectionAnchor = m_cursorPos;
  } else {
    m_cursorPos = prevCharPos(m_value, m_cursorPos);
    if (!shift) {
      m_selectionAnchor = m_cursorPos;
    }
  }
  requestCaretUpdate();
  revealCursor();
  notifyTextInputStateChanged(TextInputChangeCause::Other);
}

void Input::moveCaretRight(bool shift) {
  resetUndoCoalescing();
  if (!shift && hasSelection()) {
    m_cursorPos = selectionEnd();
    m_selectionAnchor = m_cursorPos;
  } else {
    m_cursorPos = nextCharPos(m_value, m_cursorPos);
    if (!shift) {
      m_selectionAnchor = m_cursorPos;
    }
  }
  requestCaretUpdate();
  revealCursor();
  notifyTextInputStateChanged(TextInputChangeCause::Other);
}

void Input::setLineEditingEnabled(bool enabled) { m_lineEditing = enabled; }

void Input::clearSelection() {
  resetUndoCoalescing();
  m_selectionAnchor = m_cursorPos;
  updateInteractiveGeometry();
  notifyTextInputStateChanged(TextInputChangeCause::Other);
  markPaintDirty();
}

TextInputState Input::textInputState() const {
  std::string surrounding = m_value;
  std::size_t cursor = clampToUtf8End(m_value, m_cursorPos);
  std::size_t anchor = clampToUtf8End(m_value, m_selectionAnchor);
  if (m_preeditLen > 0 && m_preeditStart <= surrounding.size()) {
    const std::size_t preeditStart = clampToUtf8End(surrounding, m_preeditStart);
    const std::size_t preeditLen = std::min(m_preeditLen, surrounding.size() - preeditStart);
    const std::size_t preeditEnd = preeditStart + preeditLen;
    surrounding.erase(preeditStart, preeditLen);
    const auto adjustOffset = [preeditStart, preeditEnd](std::size_t pos) {
      if (pos <= preeditStart) {
        return pos;
      }
      if (pos <= preeditEnd) {
        return preeditStart;
      }
      return pos - (preeditEnd - preeditStart);
    };
    cursor = adjustOffset(cursor);
    anchor = adjustOffset(anchor);
  }

  float cursorSceneX = 0.0F;
  float cursorSceneY = 0.0F;
  if (m_textViewport != nullptr) {
    Node::absolutePosition(m_textViewport, cursorSceneX, cursorSceneY);
  }
  if (m_cursor != nullptr) {
    cursorSceneX += m_cursor->x();
    cursorSceneY += m_cursor->y();
  }

  const bool password = m_passwordMode;
  const float cursorWidth = m_cursor != nullptr ? m_cursor->width() : 1.0F;
  const float cursorHeight = m_cursor != nullptr ? m_cursor->height() : m_controlHeight;
  return TextInputState{
      .surroundingText = password ? std::string{} : std::move(surrounding),
      .cursor = byteOffsetToProtocolInt(cursor),
      .anchor = byteOffsetToProtocolInt(anchor),
      .cursorRectX = static_cast<std::int32_t>(std::round(cursorSceneX)),
      .cursorRectY = static_cast<std::int32_t>(std::round(cursorSceneY)),
      .cursorRectWidth = static_cast<std::int32_t>(std::max(1.0F, std::round(cursorWidth))),
      .cursorRectHeight = static_cast<std::int32_t>(std::max(1.0F, std::round(cursorHeight))),
      .purpose = password ? TextInputPurpose::Password : TextInputPurpose::Normal,
      .sendSurroundingText = !password,
      .sensitiveData = password,
      .hiddenText = password,
      .preeditVisible = !password,
  };
}

void Input::textInputApplyEdit(const TextInputEdit& edit) {
  clampEditState();

  const bool permanentEdit = edit.hasDelete || edit.hasCommitText;
  if (permanentEdit) {
    pushUndoSnapshot(EditCoalesceKind::Discrete);
  }

  bool displayChanged = removePreeditText();
  bool permanentChanged = false;
  std::optional<std::string> permanentValue;

  if (edit.hasDelete) {
    permanentChanged = deleteSurroundingText(edit.deleteBeforeLength, edit.deleteAfterLength) || permanentChanged;
    displayChanged = displayChanged || permanentChanged;
  }

  if (edit.hasCommitText) {
    if (hasSelection()) {
      deleteSelection();
      permanentChanged = true;
      displayChanged = true;
    }
    if (!edit.commitText.empty()) {
      m_value.insert(m_cursorPos, edit.commitText);
      m_cursorPos += edit.commitText.size();
      m_selectionAnchor = m_cursorPos;
      permanentChanged = true;
      displayChanged = true;
    }
  }

  if (permanentChanged) {
    noteTypingEditEnd();
    permanentValue = m_value;
  }

  if (edit.hasPreedit) {
    if (hasSelection()) {
      deleteSelection();
      displayChanged = true;
    }
    if (!edit.preeditText.empty()) {
      m_preeditStart = m_cursorPos;
      m_preeditLen = edit.preeditText.size();
      m_value.insert(m_cursorPos, edit.preeditText);
      const std::size_t preeditEnd = m_preeditStart + m_preeditLen;
      auto cursorOffset =
          edit.preeditCursorBegin < 0 ? m_preeditLen : static_cast<std::size_t>(edit.preeditCursorBegin);
      auto anchorOffset = edit.preeditCursorEnd < 0 ? cursorOffset : static_cast<std::size_t>(edit.preeditCursorEnd);
      cursorOffset = clampToUtf8End(edit.preeditText, std::min(cursorOffset, m_preeditLen));
      anchorOffset = clampToUtf8End(edit.preeditText, std::min(anchorOffset, m_preeditLen));
      m_cursorPos = std::min(preeditEnd, m_preeditStart + cursorOffset);
      m_selectionAnchor = std::min(preeditEnd, m_preeditStart + anchorOffset);
      displayChanged = true;
    } else {
      m_preeditStart = 0;
      m_preeditLen = 0;
    }
  }

  if (displayChanged || edit.submit) {
    updateDisplayText();
    markTextContentChanged();
    updateInteractiveGeometry();
    revealCursor();
    markPaintDirty();
  }

  const std::optional<std::string> submitValue = edit.submit ? std::optional<std::string>{m_value} : std::nullopt;
  const auto onChange = m_onChange;
  const auto onSubmit = m_onSubmit;
  if (permanentValue.has_value() && onChange) {
    onChange(*permanentValue);
  }
  if (submitValue.has_value() && onSubmit) {
    onSubmit(*submitValue);
  }
}

void Input::textInputResetPreedit() {
  if (!removePreeditText()) {
    return;
  }
  updateDisplayText();
  markTextContentChanged();
  updateInteractiveGeometry();
  revealCursor();
  markPaintDirty();
}

void Input::textInputActivated(TextInputService& service) { m_textInputService = &service; }

void Input::textInputDeactivated(TextInputService& service) {
  if (m_textInputService == &service) {
    m_textInputService = nullptr;
  }
}

void Input::markTextContentChanged() {
  m_textMetricsDirty = true;
  markLayoutDirty();
}

void Input::rebuildCursorStopsFull(Renderer& renderer) {
  const bool showPasswordGlyphs = m_passwordMode && !m_value.empty();

  m_stopByte.clear();
  m_stopX.clear();
  m_stopRect.clear();
  m_stopByte.push_back(0);
  m_stopX.push_back(0.0F);
  m_stopsBuiltForWidth = textViewportWidth();

  if (m_value.empty()) {
    if (m_multiline) {
      m_stopRect.push_back(TextCursorStop{0.0F, 0.0F, renderer.fontRowExtent(m_fontSize, m_label->fontWeight())});
    }
    return;
  }

  const float passwordCellSize = showPasswordGlyphs ? std::round(m_fontSize * kPasswordGlyphScale) : 0.0F;
  std::size_t pos = 0;
  float maskX = 0.0F;
  while (pos < m_value.size()) {
    pos = nextCharPos(m_value, pos);
    m_stopByte.push_back(pos);
    if (showPasswordGlyphs) {
      maskX += passwordCellSize;
      m_stopX.push_back(maskX);
    }
  }
  if (m_multiline) {
    renderer.measureTextCursorStopsWrapped(
        m_value, m_fontSize, m_stopByte, m_stopsBuiltForWidth, m_stopRect, m_label->fontWeight()
    );
    if (m_stopRect.size() != m_stopByte.size()) {
      m_stopRect.assign(m_stopByte.size(), TextCursorStop{0.0F, 0.0F, m_fontSize});
    }
    m_stopX.resize(m_stopRect.size());
    for (std::size_t i = 0; i < m_stopRect.size(); ++i) {
      m_stopX[i] = m_stopRect[i].x;
    }
  } else if (!showPasswordGlyphs) {
    renderer.measureTextCursorStops(m_value, m_fontSize, m_stopByte, m_stopX, m_label->fontWeight());
    if (m_stopX.size() != m_stopByte.size()) {
      m_stopX.assign(m_stopByte.size(), 0.0F);
    }
  }
}

void Input::rebuildCursorStops(Renderer& renderer) {
  if (!m_textMetricsDirty) {
    return;
  }
  rebuildCursorStopsFull(renderer);
  m_textMetricsDirty = false;
}

void Input::recomputeContentLeadSlack(Renderer& renderer, float width, bool showClearButton) {
  m_contentLeadSlack = 0.0F;
  if (m_multiline || (m_textAlign != TextAlign::Center && !Style::rtl())) {
    return;
  }

  const float textInset = m_horizontalPadding + kTextInnerInset;
  const float rightInset = showClearButton ? clearButtonTextReserveWidth() : textInset;
  const float viewportWidth = std::max(0.0F, width - textInset - rightInset);
  float textExtent = 0.0F;
  const bool showPasswordGlyphs = m_passwordMode && !m_value.empty();
  if (showPasswordGlyphs) {
    const std::size_t charCount = !m_stopByte.empty() ? m_stopByte.size() - 1 : 0;
    const float passwordCellSize = std::round(m_fontSize * kPasswordGlyphScale);
    textExtent = static_cast<float>(charCount) * passwordCellSize;
  } else if (!m_value.empty() && !m_stopX.empty()) {
    textExtent = *std::ranges::max_element(m_stopX);
  } else if (m_value.empty() && !m_placeholder.empty()) {
    textExtent = renderer.measureText(m_placeholder, m_fontSize, m_label->fontWeight()).width;
  }
  if (viewportWidth > 0.0F && textExtent > 0.0F && textExtent + 0.5F < viewportWidth) {
    m_contentLeadSlack = m_textAlign == TextAlign::Center ? std::round((viewportWidth - textExtent) * 0.5F)
                                                          : std::max(0.0F, viewportWidth - textExtent);
  }
}

std::size_t Input::visibleLabelStartByte() const {
  if (m_stopX.empty()) {
    return 0;
  }
  const float viewStart = m_scrollOffset;
  std::size_t startByte = 0;
  for (std::size_t i = 0; i < m_stopByte.size(); ++i) {
    if (m_stopX[i] <= viewStart + 0.5F) {
      startByte = m_stopByte[i];
    } else {
      break;
    }
  }
  return startByte;
}

std::size_t Input::visibleLabelEndByte(float contentWidth, std::size_t startByte) const {
  if (m_stopX.empty()) {
    return m_value.size();
  }
  const float originX = stopXForByte(startByte);
  const float endX = originX + contentWidth;
  for (std::size_t i = 1; i < m_stopByte.size(); ++i) {
    if (m_stopX[i] >= endX - 0.5F) {
      return m_stopByte[i];
    }
  }
  return m_value.size();
}

void Input::updateLabelVisibleSlice(Renderer& renderer) {
  if (m_label == nullptr || m_value.empty() || (m_passwordMode && !m_value.empty())) {
    return;
  }

  std::size_t sliceStart = 0;
  std::size_t sliceEnd = m_value.size();
  if (!Style::rtl()) {
    const float viewportW = textViewportWidth();
    const float pad = std::max(viewportW, m_fontSize * 4.0F);
    const float sliceContentW = std::min(kMaxLabelRasterWidth, viewportW + pad * 2.0F);
    sliceStart = visibleLabelStartByte();
    if (sliceStart > 0) {
      sliceStart = prevCharPos(m_value, sliceStart + 1);
    }
    sliceEnd = visibleLabelEndByte(sliceContentW, sliceStart);
  }
  if (sliceEnd <= sliceStart) {
    return;
  }

  const std::string slice = m_value.substr(sliceStart, sliceEnd - sliceStart);
  m_labelSliceOriginX = Style::rtl() ? 0.0F : stopXForByte(sliceStart);
  m_labelVisibleStartByte = sliceStart;
  if (slice != m_labelVisibleSlice) {
    m_labelVisibleSlice = slice;
    m_label->setText(m_labelVisibleSlice);
    m_label->measure(renderer);
    const float h = m_controlHeight;
    m_cachedLabelY = std::round((h - m_label->height()) * 0.5F);
  }
}

void Input::syncLabelScrollPosition() {
  if (m_label == nullptr || (m_passwordMode && !m_value.empty())) {
    return;
  }
  if (m_multiline) {
    m_label->setPosition(0.0F, m_cachedLabelY - m_scrollOffsetY);
    return;
  }
  if (m_value.empty()) {
    m_label->setPosition(-m_scrollOffset + m_contentLeadSlack, m_cachedLabelY);
    return;
  }
  m_label->setPosition(m_labelSliceOriginX - m_scrollOffset + m_contentLeadSlack, m_cachedLabelY);
}

void Input::doLayout(Renderer& renderer) {
  const float minFromHint = m_minLayoutWidth > 0.0F ? m_minLayoutWidth : 0.0F;
  const float wBase = width() > 0.0F ? width() : (minFromHint > 0.0F ? minFromHint : kMinWidth);
  const float w = std::max(wBase, minFromHint);
  // Multiline keeps whatever height layout assigned (explicit or flex-grown)
  // instead of forcing the single-line control height.
  const float h = m_multiline && height() > 0.0F ? height() : m_controlHeight;
  setSize(w, h);
  const bool showClearButton = clearButtonVisible();

  const bool showPasswordGlyphs = m_passwordMode && !m_value.empty();
  m_label->setVisible(!showPasswordGlyphs);

  // Wrapped stops depend on the viewport width, not just the text.
  if (m_multiline && m_stopsBuiltForWidth != textViewportWidth()) {
    m_textMetricsDirty = true;
  }
  rebuildCursorStops(renderer);
  recomputeContentLeadSlack(renderer, w, showClearButton);

  if (m_inputArea != nullptr && m_inputArea->focused()) {
    if (m_multiline) {
      ensureCursorVisibleY();
    } else {
      ensureCursorVisible();
    }
  } else if (m_multiline) {
    clampScrollOffsetY();
  } else {
    m_scrollOffset = 0.0F;
  }

  if (m_multiline) {
    m_label->setMaxWidth(textViewportWidth());
    m_label->measure(renderer);
    m_cachedLabelY = kMultilinePadV;
  } else if (!showPasswordGlyphs) {
    if (m_value.empty() && !m_placeholder.empty()) {
      m_label->measure(renderer);
      m_cachedLabelY = std::round((h - m_label->height()) * 0.5F);
    } else if (!m_value.empty()) {
      updateLabelVisibleSlice(renderer);
    }
  }

  std::size_t charCount = 0;
  if (showPasswordGlyphs) {
    charCount = !m_stopByte.empty() ? m_stopByte.size() - 1 : 0;
  }
  float passwordGlyphSize = 0.0F;
  const float passwordCellSize = showPasswordGlyphs ? std::round(m_fontSize * kPasswordGlyphScale) : 0.0F;
  if (showPasswordGlyphs) {
    passwordGlyphSize = m_fontSize * kPasswordGlyphScale;
  }

  if (showPasswordGlyphs) {
    syncPasswordGlyphNodes(charCount);
    float gx = m_contentLeadSlack - m_scrollOffset;
    for (std::size_t i = 0; i < m_passwordGlyphs.size(); ++i) {
      auto* glyph = m_passwordGlyphs[i];
      layoutPasswordMaskGlyph(
          renderer, *glyph, passwordMaskCodepointForIndex(i), passwordGlyphSize, gx, passwordCellSize, h
      );
      glyph->setVisible(true);
      gx += passwordCellSize;
    }
  } else {
    syncPasswordGlyphNodes(0);
    syncLabelScrollPosition();
  }

  m_background->setPosition(0.0F, 0.0F);
  m_background->setFrameSize(w, h);

  if (m_textViewport != nullptr) {
    const float textInset = m_horizontalPadding + kTextInnerInset;
    const float rightInset = showClearButton ? clearButtonTextReserveWidth() : textInset;
    const float viewportW = std::max(0.0F, w - textInset - rightInset);
    m_textViewport->setPosition(textInset, 0.0F);
    m_textViewport->setSize(viewportW, h);
  }

  m_inputArea->setPosition(0.0F, 0.0F);
  m_inputArea->setFrameSize(w, h);

  if (m_clearButtonArea != nullptr && m_clearButtonGlyph != nullptr) {
    const float buttonSize = clearButtonHitWidth();
    m_clearButtonArea->setVisible(showClearButton);
    m_clearButtonArea->setPosition(std::max(0.0F, w - buttonSize), 0.0F);
    m_clearButtonArea->setFrameSize(buttonSize, h);

    const float clearGlyphSize = std::round(m_fontSize * kClearGlyphScale);
    const auto metrics = renderer.measureGlyph(m_clearButtonGlyph->codepoint(), clearGlyphSize);
    const float glyphCenterX = (metrics.left + metrics.right) * 0.5F;
    const float glyphInkCenter = (metrics.top + metrics.bottom) * 0.5F;
    m_clearButtonGlyph->setFontSize(clearGlyphSize);
    m_clearButtonGlyph->setPosition(buttonSize * 0.5F - glyphCenterX, h * 0.5F - glyphInkCenter);
  }

  updateInteractiveGeometry();
  applyVisualState();
  updateCursorVisibility();
}

void Input::handleKey(std::uint32_t sym, std::uint32_t utf32, std::uint32_t modifiers, bool preedit) {
  // onSubmit/onKeyEvent below can synchronously destroy this Input (e.g. closing a
  // dialog that owns it). Bail before touching members if that happens.
  const std::weak_ptr<int> alive = m_aliveToken;
  clampEditState();

  const bool validateMatch = g_validateKeyMatcher && g_validateKeyMatcher(sym, modifiers);
  const bool shift = (modifiers & KeyMod::Shift) != 0;
  const bool ctrl = (modifiers & KeyMod::Ctrl) != 0;
  const bool plainPrintableText = isPlainPrintableKey(utf32, modifiers, preedit);

  // A printable key that doubles as a keybind chord (Space is bound to Validate)
  // is text while the field is focused, not an activation to hand to the panel.
  if (m_onKeyEvent && !(plainPrintableText && validateMatch)) {
    if (m_onKeyEvent(sym, modifiers)) {
      return;
    }
    if (alive.expired()) {
      return;
    }
  }
  const bool undoShortcut = ctrl && !shift && (sym == 'z' || sym == 'Z');
  const bool redoShortcut = (ctrl && (sym == 'y' || sym == 'Y')) || (ctrl && shift && (sym == 'z' || sym == 'Z'));
  const bool clearShortcut = ctrl && !shift && (sym == 'u' || sym == 'U');

  const bool alt = (modifiers & KeyMod::Alt) != 0;
  const bool lineEditCaretToEnd = m_lineEditing && ctrl && !shift && (sym == 'e' || sym == 'E');
  const bool lineEditCaretBack = m_lineEditing && ctrl && !shift && (sym == 'b' || sym == 'B');
  const bool lineEditCaretForward = m_lineEditing && ctrl && !shift && (sym == 'f' || sym == 'F');
  const bool lineEditWordBack = m_lineEditing && alt && (sym == 'b' || sym == 'B');
  const bool lineEditWordForward = m_lineEditing && alt && (sym == 'f' || sym == 'F');
  const bool lineEditDeleteWordBack = m_lineEditing && ctrl && (sym == 'w' || sym == 'W');
  const bool lineEditDeleteToEnd = m_lineEditing && ctrl && !shift && (sym == 'k' || sym == 'K');
  const bool lineEditDeleteWordForward = m_lineEditing && alt && (sym == 'd' || sym == 'D');
  const bool lineEditShortcut = lineEditCaretToEnd
      || lineEditCaretBack
      || lineEditCaretForward
      || lineEditWordBack
      || lineEditWordForward
      || lineEditDeleteWordBack
      || lineEditDeleteToEnd
      || lineEditDeleteWordForward;

  const bool verticalNav = m_multiline
      && (KeySymbol::isUp(sym) || KeySymbol::isDown(sym) || KeySymbol::isPageUp(sym) || KeySymbol::isPageDown(sym));

  // Any non-vertical key breaks an Up/Down run's sticky column.
  if (!verticalNav) {
    m_goalCaretX = -1.0F;
  }

  // Ignore non-text keys that aren't handled below. Ctrl chords may still have utf32 == 0.
  if (utf32 == 0 && !preedit && !ctrl) {
    const bool navigationOrEdit = KeySymbol::isBackspace(sym)
        || KeySymbol::isDelete(sym)
        || KeySymbol::isLeft(sym)
        || KeySymbol::isRight(sym)
        || KeySymbol::isHome(sym)
        || KeySymbol::isEnd(sym)
        || KeySymbol::isInsert(sym)
        || verticalNav
        || undoShortcut
        || redoShortcut
        || clearShortcut
        || lineEditShortcut;
    if (!navigationOrEdit && !validateMatch) {
      return;
    }
  }

  bool changed = false;

  // Remove previous preedit text before processing
  changed = removePreeditText();

  const bool copyShortcut = ctrl && (KeySymbol::isInsert(sym) || sym == 'c' || sym == 'C');
  const bool cutShortcut =
      (ctrl && (sym == 'x' || sym == 'X')) || (!ctrl && shift && KeySymbol::isDelete(sym) && hasSelection());
  const bool pasteShortcut = (ctrl && (sym == 'v' || sym == 'V')) || (!ctrl && shift && KeySymbol::isInsert(sym));

  if (undoShortcut) {
    if (undoEdit()) {
      return;
    }
    if (changed) {
      updateDisplayText();
      markTextContentChanged();
      revealCursor();
      notifyTextInputStateChanged(TextInputChangeCause::Other);
      if (!preedit && m_onChange) {
        m_onChange(m_value);
      }
    }
    return;
  }
  if (redoShortcut) {
    if (redoEdit()) {
      return;
    }
    if (changed) {
      updateDisplayText();
      markTextContentChanged();
      revealCursor();
      notifyTextInputStateChanged(TextInputChangeCause::Other);
      if (!preedit && m_onChange) {
        m_onChange(m_value);
      }
    }
    return;
  }
  if (clearShortcut) {
    resetUndoCoalescing();
    if (m_lineEditing) {
      if (m_cursorPos > 0) {
        pushUndoSnapshot(EditCoalesceKind::Discrete);
        m_value.erase(0, m_cursorPos);
        m_cursorPos = 0;
        m_selectionAnchor = 0;
        changed = true;
      }
    } else {
      if (!m_value.empty() || hasSelection()) {
        pushUndoSnapshot(EditCoalesceKind::Discrete);
        m_value.clear();
        m_cursorPos = 0;
        m_selectionAnchor = 0;
        changed = true;
      }
    }
  } else if (ctrl && (sym == 'a' || sym == 'A')) {
    resetUndoCoalescing();
    if (m_lineEditing) {
      m_cursorPos = 0;
      m_selectionAnchor = 0;
    } else {
      m_selectionAnchor = 0;
      m_cursorPos = m_value.size();
    }
  } else if (lineEditCaretToEnd) {
    resetUndoCoalescing();
    m_cursorPos = m_value.size();
    m_selectionAnchor = m_cursorPos;
  } else if (lineEditCaretBack) {
    resetUndoCoalescing();
    if (hasSelection()) {
      m_cursorPos = selectionStart();
    } else {
      m_cursorPos = prevCharPos(m_value, m_cursorPos);
    }
    m_selectionAnchor = m_cursorPos;
  } else if (lineEditCaretForward) {
    resetUndoCoalescing();
    if (hasSelection()) {
      m_cursorPos = selectionEnd();
    } else {
      m_cursorPos = nextCharPos(m_value, m_cursorPos);
    }
    m_selectionAnchor = m_cursorPos;
  } else if (lineEditWordBack) {
    resetUndoCoalescing();
    m_cursorPos = previousWordStartForByteOffset(m_cursorPos);
    m_selectionAnchor = m_cursorPos;
  } else if (lineEditWordForward) {
    resetUndoCoalescing();
    m_cursorPos = nextWordEndForByteOffset(m_cursorPos);
    m_selectionAnchor = m_cursorPos;
  } else if (lineEditDeleteWordBack) {
    if (hasSelection()) {
      pushUndoSnapshot(EditCoalesceKind::Discrete);
      deleteSelection();
      changed = true;
    } else {
      const std::size_t prev = previousWordStartForByteOffset(m_cursorPos);
      if (prev != m_cursorPos) {
        pushUndoSnapshot(EditCoalesceKind::Discrete);
        m_value.erase(prev, m_cursorPos - prev);
        m_cursorPos = prev;
        m_selectionAnchor = prev;
        changed = true;
      }
    }
  } else if (lineEditDeleteToEnd) {
    if (m_cursorPos < m_value.size()) {
      pushUndoSnapshot(EditCoalesceKind::Discrete);
      m_value.erase(m_cursorPos);
      m_selectionAnchor = m_cursorPos;
      changed = true;
    }
  } else if (lineEditDeleteWordForward) {
    const std::size_t end = nextWordEndForByteOffset(m_cursorPos);
    if (end != m_cursorPos) {
      pushUndoSnapshot(EditCoalesceKind::Discrete);
      m_value.erase(m_cursorPos, end - m_cursorPos);
      m_selectionAnchor = m_cursorPos;
      changed = true;
    }
  } else if (copyShortcut) {
    if (g_clipboard != nullptr && hasSelection() && !m_passwordMode) {
      g_clipboard->setClipboardText(m_value.substr(selectionStart(), selectionEnd() - selectionStart()));
    }
  } else if (cutShortcut) {
    if (g_clipboard != nullptr && hasSelection()) {
      pushUndoSnapshot(EditCoalesceKind::Discrete);
      if (!m_passwordMode) {
        g_clipboard->setClipboardText(m_value.substr(selectionStart(), selectionEnd() - selectionStart()));
      }
      deleteSelection();
      changed = true;
    }
  } else if (pasteShortcut) {
    if (g_clipboard != nullptr) {
      if (auto text = readClipboardText(); text.has_value()) {
        pushUndoSnapshot(EditCoalesceKind::Discrete);
        if (hasSelection()) {
          deleteSelection();
        }
        m_value.insert(m_cursorPos, *text);
        m_cursorPos += text->size();
        m_selectionAnchor = m_cursorPos;
        changed = true;
      }
    }
  } else if (KeySymbol::isBackspace(sym)) {
    if (hasSelection()) {
      pushUndoSnapshot(EditCoalesceKind::Discrete);
      deleteSelection();
      changed = true;
    } else if (m_cursorPos > 0) {
      pushUndoSnapshot(EditCoalesceKind::Discrete);
      const std::size_t prev = ctrl ? previousWordStartForByteOffset(m_cursorPos) : prevCharPos(m_value, m_cursorPos);
      m_value.erase(prev, m_cursorPos - prev);
      m_cursorPos = prev;
      m_selectionAnchor = prev;
      changed = true;
    }
  } else if (KeySymbol::isDelete(sym)) {
    if (hasSelection()) {
      pushUndoSnapshot(EditCoalesceKind::Discrete);
      deleteSelection();
      changed = true;
    } else if (m_cursorPos < m_value.size()) {
      pushUndoSnapshot(EditCoalesceKind::Discrete);
      const std::size_t next = ctrl ? nextWordEndForByteOffset(m_cursorPos) : nextCharPos(m_value, m_cursorPos);
      m_value.erase(m_cursorPos, next - m_cursorPos);
      changed = true;
    }
  } else if (KeySymbol::isLeft(sym)) {
    resetUndoCoalescing();
    if (!shift && hasSelection()) {
      // Collapse to start of selection
      m_cursorPos = selectionStart();
      m_selectionAnchor = m_cursorPos;
    } else {
      m_cursorPos = ctrl ? previousWordStartForByteOffset(m_cursorPos) : prevCharPos(m_value, m_cursorPos);
      if (!shift) {
        m_selectionAnchor = m_cursorPos;
      }
    }
  } else if (KeySymbol::isRight(sym)) {
    resetUndoCoalescing();
    if (!shift && hasSelection()) {
      // Collapse to end of selection
      m_cursorPos = selectionEnd();
      m_selectionAnchor = m_cursorPos;
    } else {
      m_cursorPos = ctrl ? nextWordStartForByteOffset(m_cursorPos) : nextCharPos(m_value, m_cursorPos);
      if (!shift) {
        m_selectionAnchor = m_cursorPos;
      }
    }
  } else if (verticalNav) {
    resetUndoCoalescing();
    const bool up = KeySymbol::isUp(sym) || KeySymbol::isPageUp(sym);
    int lineDelta = up ? -1 : 1;
    if (KeySymbol::isPageUp(sym) || KeySymbol::isPageDown(sym)) {
      const int pageLines = std::max(1, static_cast<int>(std::floor(textViewportHeight() / currentLineHeight())));
      lineDelta *= pageLines;
    }
    if (!shift && hasSelection()) {
      m_cursorPos = up ? selectionStart() : selectionEnd();
      m_selectionAnchor = m_cursorPos;
      m_goalCaretX = -1.0F;
    } else {
      m_cursorPos = byteForVerticalMove(m_cursorPos, lineDelta);
      if (!shift) {
        m_selectionAnchor = m_cursorPos;
      }
    }
  } else if (m_multiline && KeySymbol::isEnter(sym) && !preedit) {
    if (ctrl || (m_submitOnEnter && !shift)) {
      if (m_onSubmit) {
        m_onSubmit(m_value);
        if (alive.expired()) {
          return;
        }
      }
    } else {
      pushUndoSnapshot(hasSelection() ? EditCoalesceKind::Discrete : EditCoalesceKind::Typing);
      if (hasSelection()) {
        deleteSelection();
      }
      m_value.insert(m_cursorPos, 1, '\n');
      ++m_cursorPos;
      m_selectionAnchor = m_cursorPos;
      changed = true;
      noteTypingEditEnd();
    }
  } else if (KeySymbol::isHome(sym)) {
    resetUndoCoalescing();
    m_cursorPos = m_multiline && !ctrl ? lineStartForByte(m_cursorPos) : 0;
    if (!shift) {
      m_selectionAnchor = m_cursorPos;
    }
  } else if (KeySymbol::isEnd(sym)) {
    resetUndoCoalescing();
    m_cursorPos = m_multiline && !ctrl ? lineEndForByte(m_cursorPos) : m_value.size();
    if (!shift) {
      m_selectionAnchor = m_cursorPos;
    }
  } else if (validateMatch && !plainPrintableText) {
    if (m_onSubmit) {
      m_onSubmit(m_value);
      if (alive.expired()) {
        return;
      }
    }
  } else if (utf32 >= 0x20U && utf32 != 0x7FU) {
    // Printable character (skip DEL = 0x7F)
    if (!preedit) {
      pushUndoSnapshot(hasSelection() ? EditCoalesceKind::Discrete : EditCoalesceKind::Typing);
    }
    if (hasSelection()) {
      deleteSelection();
    }
    const auto bytes = utf32ToUtf8(utf32);
    m_value.insert(m_cursorPos, bytes);
    if (preedit) {
      m_preeditStart = m_cursorPos;
      m_preeditLen = bytes.size();
    }
    m_cursorPos += bytes.size();
    m_selectionAnchor = m_cursorPos;
    changed = true;
    if (!preedit) {
      noteTypingEditEnd();
    }
  }

  updateDisplayText();
  if (changed) {
    markTextContentChanged();
    revealCursor();
    notifyTextInputStateChanged(TextInputChangeCause::Other);
    if (!preedit && m_onChange) {
      m_onChange(m_value);
    }
  } else {
    requestCaretUpdate();
    revealCursor();
    notifyTextInputStateChanged(TextInputChangeCause::Other);
  }
}

void Input::notifyTextInputStateChanged(TextInputChangeCause cause) {
  if (m_textInputService == nullptr) {
    return;
  }
  m_textInputService->notifyClientStateChanged(this, cause);
}

bool Input::removePreeditText() {
  if (m_preeditLen == 0) {
    m_preeditStart = 0;
    m_preeditLen = 0;
    return false;
  }

  if (m_preeditStart > m_value.size()) {
    m_preeditStart = 0;
    m_preeditLen = 0;
    clampEditState();
    return false;
  }

  m_preeditStart = clampToUtf8End(m_value, m_preeditStart);
  const std::size_t len = std::min(m_preeditLen, m_value.size() - m_preeditStart);
  if (len == 0) {
    m_preeditStart = 0;
    m_preeditLen = 0;
    clampEditState();
    return false;
  }

  m_value.erase(m_preeditStart, len);
  m_cursorPos = m_preeditStart;
  m_selectionAnchor = m_cursorPos;
  m_preeditStart = 0;
  m_preeditLen = 0;
  return true;
}

bool Input::deleteSurroundingText(std::uint32_t beforeLength, std::uint32_t afterLength) {
  clampEditState();

  const bool hadSelection = hasSelection();
  const std::size_t baseStart = selectionStart();
  const std::size_t baseEnd = selectionEnd();
  const std::size_t selectionLength = baseEnd - baseStart;
  const std::size_t rawStart = beforeLength > baseStart ? 0 : baseStart - beforeLength;
  const std::size_t start = clampToUtf8Start(m_value, rawStart);
  const std::size_t beforeEnd = clampToUtf8Start(m_value, baseStart);
  const std::size_t afterStart = clampToUtf8End(m_value, baseEnd);
  const std::size_t rawEnd = std::min(m_value.size(), baseEnd + static_cast<std::size_t>(afterLength));
  const std::size_t end = clampToUtf8End(m_value, rawEnd);
  if (start >= beforeEnd && afterStart >= end) {
    return false;
  }
  if (afterStart < end) {
    m_value.erase(afterStart, end - afterStart);
  }
  if (start < beforeEnd) {
    m_value.erase(start, beforeEnd - start);
  }
  m_cursorPos = start;
  m_selectionAnchor = hadSelection ? m_cursorPos + selectionLength : start;
  m_selectionAnchor = std::min(m_selectionAnchor, m_value.size());
  return true;
}

void Input::applyVisualState() {
  const bool focused = m_inputArea != nullptr && m_inputArea->focused();
  const bool clearButtonHovered = m_clearButtonArea != nullptr && m_clearButtonArea->hovered();
  const bool inputHovered = (m_inputArea != nullptr && m_inputArea->hovered()) || clearButtonHovered;
  const bool readOnly = isReadOnlyVisual();
  const float chromeScale = chromeScaleForControlHeight(m_controlHeight);

  if (m_frameVisible) {
    m_background->setVisible(true);
    const Color fill = focused ? resolved(ColorRole::Surface, m_surfaceOpacity)
                               : resolved(ColorRole::SurfaceVariant, m_surfaceOpacity);
    const Color border = m_invalid
        ? resolved(ColorRole::Error)
        : (focused ? resolveColorSpec(focusRingColorSpec())
                   : (inputHovered ? resolved(ColorRole::Hover) : resolved(ColorRole::Outline)));

    float resolvedBorderWidth = 0.0F;
    if (focused) {
      resolvedBorderWidth = Style::focusRingWidth;
    } else if (Style::inputBordersEnabled()) {
      resolvedBorderWidth = Style::borderWidth;
    }

    m_background->setStyle(
        RoundedRectStyle{
            .fill = fill,
            .border = border,
            .fillMode = FillMode::Solid,
            .radius = Style::scaledRadius(m_frameRadius, chromeScale),
            .softness = 1.0F,
            .borderWidth = resolvedBorderWidth,
        }
    );
  } else if (m_background != nullptr) {
    m_background->setVisible(false);
  }

  if (m_embeddedOnSolidPrimary && !m_frameVisible) {
    auto selectionStyleEmb = m_selectionRect->style();
    selectionStyleEmb.fill = resolved(ColorRole::Surface, 0.4F);
    selectionStyleEmb.fillMode = FillMode::Solid;
    selectionStyleEmb.radius = 2.0F;
    m_selectionRect->setStyle(selectionStyleEmb);
    for (auto* rect : m_selectionLineRects) {
      rect->setStyle(selectionStyleEmb);
    }

    auto cursorStyleEmb = m_cursor->style();
    cursorStyleEmb.fill = resolved(ColorRole::Surface);
    cursorStyleEmb.fillMode = FillMode::Solid;
    cursorStyleEmb.radius = 1.0F;
    m_cursor->setStyle(cursorStyleEmb);

    const bool showingPlaceholder = m_value.empty() && !m_placeholder.empty();
    if (m_invalid) {
      m_label->setColor(colorSpecFromRole(ColorRole::Error));
    } else if (showingPlaceholder) {
      m_label->setColor(colorSpecFromRole(ColorRole::OnPrimary, kPrimaryPlaceholderAlpha));
    } else if (readOnly) {
      m_label->setColor(colorSpecFromRole(ColorRole::OnPrimary, 0.65F));
    } else {
      m_label->setColor(colorSpecFromRole(ColorRole::OnPrimary));
    }
    const Color passwordGlyphEmb = m_invalid
        ? resolved(ColorRole::Error)
        : (showingPlaceholder ? resolved(ColorRole::OnPrimary, kPrimaryPlaceholderAlpha)
                              : (readOnly ? resolved(ColorRole::OnPrimary, 0.65F) : resolved(ColorRole::OnPrimary)));
    for (auto* glyph : m_passwordGlyphs) {
      glyph->setColor(passwordGlyphEmb);
    }
    if (m_clearButtonGlyph != nullptr) {
      m_clearButtonGlyph->setColor(resolved(ColorRole::OnPrimary, clearButtonHovered ? 1.0F : 0.72F));
    }
    return;
  }

  auto selectionStyle = m_selectionRect->style();
  selectionStyle.fill = resolved(ColorRole::Primary);
  selectionStyle.fillMode = FillMode::Solid;
  selectionStyle.radius = 2.0F;
  m_selectionRect->setStyle(selectionStyle);
  for (auto* rect : m_selectionLineRects) {
    rect->setStyle(selectionStyle);
  }

  auto cursorStyle = m_cursor->style();
  cursorStyle.fill = resolved(ColorRole::Primary);
  cursorStyle.fillMode = FillMode::Solid;
  cursorStyle.radius = 1.0F;
  m_cursor->setStyle(cursorStyle);

  const bool showingPlaceholder = m_value.empty() && !m_placeholder.empty();
  const ColorSpec textColor = m_invalid
      ? colorSpecFromRole(ColorRole::Error)
      : (showingPlaceholder
             ? colorSpecFromRole(ColorRole::OnSurfaceVariant, kPlaceholderAlpha)
             : (readOnly ? colorSpecFromRole(ColorRole::OnSurfaceVariant) : colorSpecFromRole(ColorRole::OnSurface)));
  m_label->setColor(textColor);
  const Color passwordGlyphColor = resolveColorSpec(textColor);
  for (auto* glyph : m_passwordGlyphs) {
    glyph->setColor(passwordGlyphColor);
  }
  if (m_clearButtonGlyph != nullptr) {
    m_clearButtonGlyph->setColor(resolved(clearButtonHovered ? ColorRole::OnSurface : ColorRole::OnSurfaceVariant));
  }
}

void Input::updateDisplayText() {
  if (m_value.empty() && !m_placeholder.empty()) {
    m_labelVisibleSlice.clear();
    m_label->setText(m_placeholder);
  } else if (m_passwordMode) {
    m_labelVisibleSlice.clear();
    m_label->setText(std::string{});
  } else {
    m_labelVisibleSlice.clear();
    m_label->setText(m_value);
  }
}

void Input::requestCaretUpdate() {
  if (m_multiline) {
    ensureCursorVisibleY();
  }
  updateInteractiveGeometry();
  updateCursorVisibility();
  markPaintDirty();
}

bool Input::isReadOnlyVisual() const noexcept {
  return m_inputArea != nullptr && (!m_inputArea->focusable() || !m_inputArea->enabled());
}

void Input::clearFromButton() {
  if (m_value.empty()) {
    return;
  }
  pushUndoSnapshot(EditCoalesceKind::Discrete);
  m_value.clear();
  m_cursorPos = 0;
  m_selectionAnchor = 0;
  m_preeditStart = 0;
  m_preeditLen = 0;
  m_scrollOffset = 0.0F;
  m_scrollOffsetY = 0.0F;
  m_goalCaretX = -1.0F;
  updateDisplayText();
  if (m_clearButtonArea != nullptr) {
    m_clearButtonArea->setVisible(false);
  }
  updateInteractiveGeometry();
  revealCursor();
  applyVisualState();
  markTextContentChanged();
  markPaintDirty();
  notifyTextInputStateChanged(TextInputChangeCause::Other);
  if (m_onChange) {
    m_onChange(m_value);
  }
}

void Input::updateInteractiveGeometry() {
  if (m_cursor == nullptr || m_selectionRect == nullptr) {
    return;
  }

  if (m_multiline) {
    // Vertical scroll is maintained by the callers (ensureCursorVisibleY /
    // wheel / drag); this only places the nodes for the current offset.
    syncLabelScrollPosition();

    TextCursorStop stop{0.0F, 0.0F, currentLineHeight()};
    if (multilineStopsValid()) {
      stop = m_stopRect[stopIndexForByte(m_cursorPos)];
    }
    m_cursor->setPosition(stop.x, stop.y + kMultilinePadV - m_scrollOffsetY);
    m_cursor->setFrameSize(kCursorWidth, std::max(1.0F, stop.height));

    m_selectionRect->setVisible(false);
    updateMultilineSelection();
    return;
  }

  const float previousScrollOffset = m_scrollOffset;
  const bool shouldRevealCursor =
      m_inputArea != nullptr && (m_inputArea->focused() || m_inputArea->pressed() || hasSelection());
  if (shouldRevealCursor) {
    ensureCursorVisible();
  } else {
    m_scrollOffset = 0.0F;
  }
  if (std::abs(m_scrollOffset - previousScrollOffset) > 0.001F) {
    if (Style::rtl()) {
      syncLabelScrollPosition();
      markPaintDirty();
    } else {
      std::size_t sliceStart = visibleLabelStartByte();
      if (sliceStart > 0) {
        sliceStart = prevCharPos(m_value, sliceStart + 1);
      }
      if (!m_value.empty() && sliceStart != m_labelVisibleStartByte) {
        markLayoutDirty();
      } else {
        syncLabelScrollPosition();
        markPaintDirty();
      }
    }
  }

  const float controlHeight = height() > 0.0F ? height() : m_controlHeight;
  const float maxCursorHeight = std::max(0.0F, controlHeight - kCursorPadV * 2.0F);
  const float cursorHeight =
      std::clamp(controlHeight * kCursorHeightRatio, std::min(kCursorMinHeight, maxCursorHeight), maxCursorHeight);
  const float cursorY = std::round((controlHeight - cursorHeight) * 0.5F);
  const float cursorX = stopXForByte(m_cursorPos) - m_scrollOffset + m_contentLeadSlack;
  m_cursor->setPosition(cursorX, cursorY);
  m_cursor->setFrameSize(kCursorWidth, cursorHeight);

  if (hasSelection()) {
    const float selX0 = stopXForByte(selectionStart()) - m_scrollOffset + m_contentLeadSlack;
    const float selX1 = stopXForByte(selectionEnd()) - m_scrollOffset + m_contentLeadSlack;
    m_selectionRect->setPosition(std::min(selX0, selX1), cursorY);
    m_selectionRect->setFrameSize(std::abs(selX1 - selX0), cursorHeight);
    m_selectionRect->setVisible(true);
  } else {
    m_selectionRect->setVisible(false);
  }
}

void Input::ensureCursorVisible() {
  if (m_value.empty() || m_stopX.empty()) {
    m_scrollOffset = 0.0F;
    return;
  }

  const float viewportWidth = textViewportWidth();
  if (viewportWidth <= 0.0F) {
    m_scrollOffset = 0.0F;
    return;
  }

  const float cursorContentX = stopXForByte(m_cursorPos);
  const float revealPad = std::max(kCursorRevealPadding, kTextInnerInset);
  const float slack = m_contentLeadSlack;
  const float cursorVx = cursorContentX - m_scrollOffset + slack;
  const float leftEdge = revealPad;
  const float rightEdge = viewportWidth - revealPad - kCursorWidth;

  if (cursorVx < leftEdge) {
    m_scrollOffset = cursorContentX + slack - leftEdge;
  } else if (cursorVx > rightEdge) {
    m_scrollOffset = cursorContentX + slack - rightEdge;
  }

  clampScrollOffset();
}

void Input::clampScrollOffset() {
  if (m_value.empty() || m_stopX.empty()) {
    m_scrollOffset = 0.0F;
    return;
  }
  const float textExtent = *std::ranges::max_element(m_stopX);
  const float maxOffset = std::max(0.0F, textExtent - textViewportWidth() + kCursorWidth + kCursorRevealPadding);
  m_scrollOffset = std::clamp(m_scrollOffset, 0.0F, maxOffset);
}

bool Input::multilineStopsValid() const noexcept {
  return !m_stopRect.empty() && m_stopRect.size() == m_stopByte.size();
}

std::size_t Input::stopIndexForByte(std::size_t bytePos) const {
  if (m_stopByte.empty()) {
    return 0;
  }
  const auto it = std::ranges::lower_bound(m_stopByte, bytePos);
  if (it == m_stopByte.end()) {
    return m_stopByte.size() - 1;
  }
  return static_cast<std::size_t>(it - m_stopByte.begin());
}

float Input::contentTextHeight() const noexcept {
  if (m_stopRect.empty()) {
    return 0.0F;
  }
  return m_stopRect.back().y + m_stopRect.back().height;
}

float Input::textViewportHeight() const noexcept {
  const float h = height() > 0.0F ? height() : m_controlHeight;
  return std::max(0.0F, h - kMultilinePadV * 2.0F);
}

float Input::currentLineHeight() const noexcept {
  if (multilineStopsValid()) {
    return std::max(1.0F, m_stopRect[stopIndexForByte(m_cursorPos)].height);
  }
  return std::max(1.0F, m_fontSize * 1.4F);
}

void Input::ensureCursorVisibleY() {
  if (!multilineStopsValid()) {
    m_scrollOffsetY = 0.0F;
    return;
  }
  const float viewH = textViewportHeight();
  if (viewH <= 0.0F) {
    m_scrollOffsetY = 0.0F;
    return;
  }
  const TextCursorStop& stop = m_stopRect[stopIndexForByte(m_cursorPos)];
  if (stop.y < m_scrollOffsetY) {
    m_scrollOffsetY = stop.y;
  } else if (stop.y + stop.height > m_scrollOffsetY + viewH) {
    m_scrollOffsetY = stop.y + stop.height - viewH;
  }
  clampScrollOffsetY();
}

void Input::clampScrollOffsetY() {
  const float maxOffset = std::max(0.0F, contentTextHeight() - textViewportHeight());
  m_scrollOffsetY = std::clamp(m_scrollOffsetY, 0.0F, maxOffset);
}

void Input::clampEditState() {
  m_cursorPos = clampToUtf8End(m_value, m_cursorPos);
  m_selectionAnchor = clampToUtf8End(m_value, m_selectionAnchor);

  if (m_preeditLen == 0) {
    m_preeditStart = 0;
    return;
  }

  if (m_preeditStart > m_value.size()) {
    m_preeditStart = 0;
    m_preeditLen = 0;
    return;
  }

  m_preeditStart = clampToUtf8End(m_value, m_preeditStart);
  m_preeditLen = std::min(m_preeditLen, m_value.size() - m_preeditStart);
  if (m_preeditLen == 0) {
    m_preeditStart = 0;
  }
}

LayoutSize Input::doMeasure(Renderer& renderer, const LayoutConstraints& constraints) {
  const float minFromHint = m_minLayoutWidth > 0.0F ? m_minLayoutWidth : 0.0F;
  float assignW = width() > 0.0F ? width() : (minFromHint > 0.0F ? minFromHint : kMinWidth);
  if (constraints.hasExactWidth()) {
    assignW = std::max(constraints.maxWidth, minFromHint);
  }
  float assignH = m_multiline && height() > 0.0F ? height() : m_controlHeight;
  if (m_multiline && constraints.hasExactHeight()) {
    assignH = constraints.maxHeight;
  }
  setSize(assignW, assignH);
  if (m_textMetricsDirty || m_stopByte.empty()) {
    rebuildCursorStops(renderer);
  }
  const float w = std::max(width(), minFromHint);
  return constraints.constrain(LayoutSize{.width = w, .height = assignH});
}

void Input::updateCursorVisibility() {
  const bool focused = m_inputArea != nullptr && m_inputArea->focused();
  m_cursor->setVisible(focused && m_cursorBlinkVisible);
}

void Input::revealCursor() {
  m_cursorBlinkVisible = true;
  updateCursorVisibility();
  markPaintDirty();
  if (m_inputArea != nullptr && m_inputArea->focused()) {
    m_cursorBlinkTimer.start(kCursorBlinkResumeDelay, [this]() { startCursorBlink(); });
  } else {
    m_cursorBlinkTimer.stop();
  }
}

void Input::startCursorBlink() {
  m_cursorBlinkTimer.startRepeating(kCursorBlinkInterval, [this]() {
    if (m_inputArea == nullptr || !m_inputArea->focused()) {
      stopCursorBlink();
      return;
    }
    m_cursorBlinkVisible = !m_cursorBlinkVisible;
    updateCursorVisibility();
    markPaintDirty();
  });
}

void Input::stopCursorBlink() {
  m_cursorBlinkTimer.stop();
  m_cursorBlinkVisible = false;
}

void Input::selectWordAtByteOffset(std::size_t offset) {
  const std::size_t start = wordStartForByteOffset(offset);
  const std::size_t end = wordEndForByteOffset(offset);
  m_selectionAnchor = start;
  m_cursorPos = end;
}

void Input::selectLineAtByteOffset(std::size_t offset) {
  if (m_passwordMode || !m_multiline) {
    m_selectionAnchor = 0;
    m_cursorPos = m_value.size();
    return;
  }

  std::size_t start = 0;
  std::size_t end = m_value.size();
  if (multilineStopsValid()) {
    start = lineStartForByte(offset);
    end = lineEndForByte(offset);
  } else {
    // Layout stops not ready yet — fall back to hard newline bounds.
    const std::size_t pos = std::min(offset, m_value.size());
    const auto left = m_value.rfind('\n', pos == 0 ? 0 : pos - 1);
    start = left == std::string::npos ? 0 : left + 1;
    const auto right = m_value.find('\n', pos);
    end = right == std::string::npos ? m_value.size() : right;
  }
  m_selectionAnchor = start;
  m_cursorPos = end;
}

void Input::extendPointerSelectionToByteOffset(std::size_t offset) {
  if (m_pointerSelectGranularity == PointerSelectGranularity::Character) {
    m_cursorPos = offset;
    return;
  }

  std::size_t unitStart = offset;
  std::size_t unitEnd = offset;
  if (m_pointerSelectGranularity == PointerSelectGranularity::Word) {
    unitStart = wordStartForByteOffset(offset);
    unitEnd = wordEndForByteOffset(offset);
  } else {
    if (m_passwordMode || !m_multiline) {
      unitStart = 0;
      unitEnd = m_value.size();
    } else if (multilineStopsValid()) {
      unitStart = lineStartForByte(offset);
      unitEnd = lineEndForByte(offset);
    } else {
      const std::size_t pos = std::min(offset, m_value.size());
      const auto left = m_value.rfind('\n', pos == 0 ? 0 : pos - 1);
      unitStart = left == std::string::npos ? 0 : left + 1;
      const auto right = m_value.find('\n', pos);
      unitEnd = right == std::string::npos ? m_value.size() : right;
    }
  }

  m_selectionAnchor = std::min(m_pointerSelectPivotStart, unitStart);
  m_cursorPos = std::max(m_pointerSelectPivotEnd, unitEnd);
}

std::size_t Input::wordStartForByteOffset(std::size_t offset) const {
  if (m_value.empty()) {
    return 0;
  }

  if (m_passwordMode) {
    return 0;
  }

  std::size_t pos = std::min(offset, m_value.size());
  if (pos == m_value.size() && pos > 0) {
    pos = prevCharPos(m_value, pos);
  }

  if (!isWordCodepoint(m_value, pos)) {
    return pos;
  }

  while (pos > 0) {
    const std::size_t prev = prevCharPos(m_value, pos);
    if (prev == pos || !isWordCodepoint(m_value, prev)) {
      break;
    }
    pos = prev;
  }
  return pos;
}

std::size_t Input::wordEndForByteOffset(std::size_t offset) const {
  if (m_value.empty()) {
    return 0;
  }

  if (m_passwordMode) {
    return m_value.size();
  }

  std::size_t pos = std::min(offset, m_value.size());
  if (pos == m_value.size() && pos > 0) {
    pos = prevCharPos(m_value, pos);
  }

  if (!isWordCodepoint(m_value, pos)) {
    return nextCharPos(m_value, pos);
  }

  std::size_t end = nextCharPos(m_value, pos);
  while (end < m_value.size() && isWordCodepoint(m_value, end)) {
    end = nextCharPos(m_value, end);
  }
  return end;
}

std::size_t Input::previousWordStartForByteOffset(std::size_t offset) const {
  if (m_value.empty()) {
    return 0;
  }

  if (m_passwordMode) {
    return 0;
  }

  std::size_t pos = std::min(offset, m_value.size());
  while (pos > 0) {
    const std::size_t prev = prevCharPos(m_value, pos);
    if (prev == pos || isWordCodepoint(m_value, prev)) {
      break;
    }
    pos = prev;
  }
  while (pos > 0) {
    const std::size_t prev = prevCharPos(m_value, pos);
    if (prev == pos || !isWordCodepoint(m_value, prev)) {
      break;
    }
    pos = prev;
  }
  return pos;
}

std::size_t Input::nextWordStartForByteOffset(std::size_t offset) const {
  if (m_value.empty()) {
    return 0;
  }

  if (m_passwordMode) {
    return m_value.size();
  }

  std::size_t pos = std::min(offset, m_value.size());
  if (pos < m_value.size() && isWordCodepoint(m_value, pos)) {
    while (pos < m_value.size() && isWordCodepoint(m_value, pos)) {
      pos = nextCharPos(m_value, pos);
    }
  }
  while (pos < m_value.size() && !isWordCodepoint(m_value, pos)) {
    pos = nextCharPos(m_value, pos);
  }
  return pos;
}

std::size_t Input::nextWordEndForByteOffset(std::size_t offset) const {
  if (m_value.empty()) {
    return 0;
  }

  if (m_passwordMode) {
    return m_value.size();
  }

  std::size_t pos = std::min(offset, m_value.size());
  while (pos < m_value.size() && !isWordCodepoint(m_value, pos)) {
    pos = nextCharPos(m_value, pos);
  }
  while (pos < m_value.size() && isWordCodepoint(m_value, pos)) {
    pos = nextCharPos(m_value, pos);
  }
  return pos;
}

void Input::syncPasswordGlyphNodes(std::size_t count) {
  if (m_textViewport == nullptr) {
    return;
  }
  while (m_passwordGlyphs.size() > count) {
    auto* node = m_passwordGlyphs.back();
    (void)m_textViewport->removeChild(node);
    m_passwordGlyphs.pop_back();
  }
  while (m_passwordGlyphs.size() < count) {
    auto glyph = std::make_unique<GlyphNode>();
    auto* glyphPtr = static_cast<GlyphNode*>(m_textViewport->insertChildAt(2, std::move(glyph)));
    m_passwordGlyphs.push_back(glyphPtr);
    // Animate the glyph entrance
    animatePasswordGlyphIn(*glyphPtr);
  }
}

void Input::animatePasswordGlyphIn(GlyphNode& glyph) {
  auto* animations = animationManager();
  if (animations == nullptr) {
    return;
  }

  constexpr float kStartScale = 0.15F;
  // Fraction of the animation over which the glyph fades in.
  constexpr float kFadeInFraction = 0.35F;
  // Three alternating swings (right, left, right), starting and ending at zero rotation.
  constexpr float kWobbleSwings = 3.0F;
  constexpr float kWobbleAmplitude = 0.45F; // radians, ~26 degrees

  glyph.setOpacity(0.0F);
  glyph.setScale(kStartScale);
  glyph.setRotation(0.0F);

  auto* glyphPtr = &glyph;
  // Driven linearly so each animated property carries its own curve. The owner is the glyph node, so a
  // backspace that destroys it cancels this animation.
  animations->animate(
      0.0F, 1.0F, static_cast<float>(Style::animNormal), Easing::Linear,
      [glyphPtr](float t) {
        const float scaleEase = applyEasing(Easing::EaseOutCubic, t);
        glyphPtr->setScale(kStartScale + (1.0F - kStartScale) * scaleEase);
        glyphPtr->setOpacity(applyEasing(Easing::EaseOutCubic, std::min(1.0F, t / kFadeInFraction)));

        // Rotation stays on even for the rotationally symmetric circle mask: a non-zero rotation is what makes
        // the glyph renderer skip pixel snapping, which would otherwise jump the glyph a whole pixel per frame.
        const float wobbleDecay = applyEasing(Easing::EaseOutCubic, 1.0F - t);
        const float wobble = kWobbleAmplitude * std::sin(t * kWobbleSwings * std::numbers::pi_v<float>) * wobbleDecay;
        glyphPtr->setRotation(wobble);
      },
      [glyphPtr]() {
        glyphPtr->setOpacity(1.0F);
        glyphPtr->setScale(1.0F);
        glyphPtr->setRotation(0.0F);
      },
      glyphPtr
  );
}

float Input::textViewportWidth() const noexcept {
  const float w = width() > 0.0F ? width() : kMinWidth;
  const float textInset = m_horizontalPadding + kTextInnerInset;
  const float rightInset = clearButtonVisible() ? clearButtonTextReserveWidth() : textInset;
  return std::max(0.0F, w - textInset - rightInset);
}

bool Input::clearButtonVisible() const noexcept { return m_clearButtonEnabled && !m_value.empty() && !m_multiline; }

float Input::clearButtonHitWidth() const noexcept {
  if (!clearButtonVisible()) {
    return 0.0F;
  }
  const float h = height() > 0.0F ? height() : m_controlHeight;
  return std::max(0.0F, h);
}

float Input::clearButtonTextReserveWidth() const noexcept {
  if (!clearButtonVisible()) {
    return 0.0F;
  }
  const float clearGlyphSize = std::round(m_fontSize * kClearGlyphScale);
  return clearButtonHitWidth() * 0.5F + clearGlyphSize * 0.5F + kTextInnerInset;
}

bool Input::hasSelection() const noexcept { return selectionStart() != selectionEnd(); }

std::size_t Input::selectionStart() const noexcept {
  return std::min({m_selectionAnchor, m_cursorPos, m_value.size()});
}

std::size_t Input::selectionEnd() const noexcept {
  return std::min(std::max(m_selectionAnchor, m_cursorPos), m_value.size());
}

Input::EditSnapshot Input::currentEditSnapshot() const {
  return EditSnapshot{
      .value = m_value,
      .cursorPos = clampToUtf8End(m_value, m_cursorPos),
      .selectionAnchor = clampToUtf8End(m_value, m_selectionAnchor),
  };
}

void Input::deleteSelection() {
  clampEditState();

  const std::size_t start = selectionStart();
  const std::size_t end = selectionEnd();
  m_value.erase(start, end - start);
  m_cursorPos = start;
  m_selectionAnchor = start;
}

void Input::clearEditHistory() {
  m_undoStack.clear();
  m_redoStack.clear();
  resetUndoCoalescing();
}

void Input::resetUndoCoalescing() {
  m_lastEditCoalesceKind = EditCoalesceKind::None;
  m_lastUndoRecordTime = {};
  m_typingCoalesceCursorPos = m_cursorPos;
}

void Input::pushUndoSnapshot(EditCoalesceKind kind) {
  // Password mode keeps no edit history so the plaintext is never retained in a
  // snapshot and can't be restored (e.g. Ctrl+Z after clearing the field).
  if (m_passwordMode) {
    resetUndoCoalescing();
    return;
  }
  if (kind == EditCoalesceKind::None) {
    resetUndoCoalescing();
    return;
  }

  const auto now = std::chrono::steady_clock::now();
  if (kind == EditCoalesceKind::Typing
      && m_lastEditCoalesceKind == EditCoalesceKind::Typing
      && m_cursorPos == m_typingCoalesceCursorPos
      && !hasSelection()
      && !m_undoStack.empty()
      && now - m_lastUndoRecordTime <= kTypingUndoCoalesceWindow) {
    m_redoStack.clear();
    m_lastUndoRecordTime = now;
    return;
  }

  const EditSnapshot snapshot = currentEditSnapshot();
  if (m_undoStack.empty() || !(m_undoStack.back() == snapshot)) {
    m_undoStack.push_back(snapshot);
    if (m_undoStack.size() > kUndoStackLimit) {
      m_undoStack.erase(m_undoStack.begin());
    }
  }
  m_redoStack.clear();
  m_lastEditCoalesceKind = kind;
  m_lastUndoRecordTime = now;
  m_typingCoalesceCursorPos = m_cursorPos;
}

void Input::noteTypingEditEnd() { m_typingCoalesceCursorPos = m_cursorPos; }

bool Input::undoEdit() { return !m_passwordMode && restoreFromHistory(m_undoStack, m_redoStack); }

bool Input::redoEdit() { return !m_passwordMode && restoreFromHistory(m_redoStack, m_undoStack); }

bool Input::restoreFromHistory(std::vector<EditSnapshot>& source, std::vector<EditSnapshot>& target) {
  if (source.empty()) {
    resetUndoCoalescing();
    return false;
  }

  const EditSnapshot current = currentEditSnapshot();
  const EditSnapshot snapshot = source.back();
  source.pop_back();
  if (target.empty() || !(target.back() == current)) {
    target.push_back(current);
    if (target.size() > kUndoStackLimit) {
      target.erase(target.begin());
    }
  }
  restoreEditSnapshot(snapshot);
  resetUndoCoalescing();
  return true;
}

void Input::restoreEditSnapshot(const EditSnapshot& snapshot) {
  const std::string previousValue = m_value;
  m_value = snapshot.value;
  m_cursorPos = std::min(snapshot.cursorPos, m_value.size());
  m_selectionAnchor = std::min(snapshot.selectionAnchor, m_value.size());
  m_preeditStart = 0;
  m_preeditLen = 0;
  updateDisplayText();
  updateInteractiveGeometry();
  revealCursor();
  applyVisualState();
  markTextContentChanged();
  markPaintDirty();
  notifyTextInputStateChanged(TextInputChangeCause::Other);
  if (m_value != previousValue && m_onChange) {
    m_onChange(m_value);
  }
}

std::size_t Input::xToByteOffset(float localX) const {
  if (m_stopX.empty()) {
    return 0;
  }
  std::size_t best = 0;
  float bestDistance = std::abs(m_stopX.front() - localX);
  for (std::size_t i = 1; i < m_stopX.size(); ++i) {
    const float distance = std::abs(m_stopX[i] - localX);
    if (distance < bestDistance) {
      best = i;
      bestDistance = distance;
    }
  }
  return m_stopByte[best];
}

std::size_t Input::pointToByteOffset(float localX, float localY) const {
  if (!multilineStopsValid()) {
    return 0;
  }

  // Find the visual line containing localY (clamped to first/last line).
  std::size_t lineFirst = 0;
  std::size_t lineLast = 0;
  bool found = false;
  for (std::size_t i = 0; i < m_stopRect.size();) {
    std::size_t j = i;
    while (j + 1 < m_stopRect.size() && sameLineY(m_stopRect[j + 1].y, m_stopRect[i].y)) {
      ++j;
    }
    lineFirst = i;
    lineLast = j;
    const float top = m_stopRect[i].y;
    const float bottom = top + m_stopRect[i].height;
    if (localY < bottom || j + 1 >= m_stopRect.size()) {
      found = localY >= top || i == 0;
      break;
    }
    i = j + 1;
  }
  (void)found;

  // Nearest caret boundary by x within the line.
  std::size_t best = lineFirst;
  float bestDist = std::numeric_limits<float>::max();
  for (std::size_t i = lineFirst; i <= lineLast; ++i) {
    const float dist = std::abs(m_stopRect[i].x - localX);
    if (dist < bestDist) {
      bestDist = dist;
      best = i;
    }
  }

  // Clicking past the end of a soft-wrapped line targets the wrap boundary
  // (one past the line's last char start) rather than before the last char.
  if (best == lineLast && lineLast + 1 < m_stopRect.size() && localX > m_stopRect[lineLast].x) {
    const std::size_t byte = m_stopByte[lineLast];
    if (byte < m_value.size() && m_value[byte] != '\n') {
      return m_stopByte[lineLast + 1];
    }
  }
  return m_stopByte[best];
}

std::size_t Input::lineStartForByte(std::size_t bytePos) const {
  if (!multilineStopsValid()) {
    return 0;
  }
  std::size_t idx = stopIndexForByte(bytePos);
  while (idx > 0 && sameLineY(m_stopRect[idx - 1].y, m_stopRect[idx].y)) {
    --idx;
  }
  return m_stopByte[idx];
}

std::size_t Input::lineEndForByte(std::size_t bytePos) const {
  if (!multilineStopsValid()) {
    return m_value.size();
  }
  std::size_t idx = stopIndexForByte(bytePos);
  while (idx + 1 < m_stopRect.size() && sameLineY(m_stopRect[idx + 1].y, m_stopRect[idx].y)) {
    ++idx;
  }
  const std::size_t byte = m_stopByte[idx];
  if (byte >= m_value.size() || m_value[byte] == '\n') {
    // End-of-text stop, or the caret slot just before the newline: already the
    // visual end of this line.
    return byte;
  }
  // Soft-wrapped line: the end position is the wrap boundary one char further
  // (the caret renders at the next line's start — no cursor affinity).
  return idx + 1 < m_stopByte.size() ? m_stopByte[idx + 1] : byte;
}

std::size_t Input::byteForVerticalMove(std::size_t from, int lineDelta) {
  if (!multilineStopsValid() || lineDelta == 0) {
    return from;
  }
  const std::size_t fromIdx = stopIndexForByte(from);
  if (m_goalCaretX < 0.0F) {
    m_goalCaretX = m_stopRect[fromIdx].x;
  }

  // Distinct line tops, in stop order, plus the index of the current line.
  std::vector<float> lineYs;
  std::size_t currentLine = 0;
  for (std::size_t i = 0; i < m_stopRect.size(); ++i) {
    if (lineYs.empty() || !sameLineY(lineYs.back(), m_stopRect[i].y)) {
      lineYs.push_back(m_stopRect[i].y);
    }
    if (i == fromIdx) {
      currentLine = lineYs.size() - 1;
    }
  }

  const std::ptrdiff_t target = static_cast<std::ptrdiff_t>(currentLine) + lineDelta;
  if (target < 0) {
    return 0;
  }
  if (target >= static_cast<std::ptrdiff_t>(lineYs.size())) {
    return m_value.size();
  }

  const float targetY = lineYs[static_cast<std::size_t>(target)];
  std::size_t best = fromIdx;
  float bestDist = std::numeric_limits<float>::max();
  for (std::size_t i = 0; i < m_stopRect.size(); ++i) {
    if (!sameLineY(m_stopRect[i].y, targetY)) {
      continue;
    }
    const float dist = std::abs(m_stopRect[i].x - m_goalCaretX);
    if (dist < bestDist) {
      bestDist = dist;
      best = i;
    }
  }
  return m_stopByte[best];
}

void Input::updateMultilineSelection() {
  if (!hasSelection() || !multilineStopsValid()) {
    syncSelectionLineRects(0);
    return;
  }

  struct LineSpan {
    float x0 = 0.0F;
    float x1 = 0.0F;
    float y = 0.0F;
    float h = 0.0F;
  };
  std::vector<LineSpan> lines;
  const std::size_t i0 = stopIndexForByte(selectionStart());
  const std::size_t i1 = std::min(stopIndexForByte(selectionEnd()), m_stopRect.size() - 1);
  const float stub = m_fontSize * kSelectionLineBreakStubEm;
  for (std::size_t i = i0; i < i1; ++i) {
    const TextCursorStop& a = m_stopRect[i];
    const TextCursorStop& b = m_stopRect[i + 1];
    // A char whose next boundary sits on another line ends its line; highlight
    // it with a short stub (its true advance is not in the stop data).
    const float boundaryX = sameLineY(a.y, b.y) ? b.x : a.x + stub;
    const float spanStart = std::min(a.x, boundaryX);
    const float spanEnd = std::max(a.x, boundaryX);
    if (!lines.empty() && sameLineY(lines.back().y, a.y)) {
      lines.back().x0 = std::min(lines.back().x0, spanStart);
      lines.back().x1 = std::max(lines.back().x1, spanEnd);
      lines.back().h = std::max(lines.back().h, a.height);
    } else {
      lines.push_back(LineSpan{spanStart, spanEnd, a.y, a.height});
    }
  }

  syncSelectionLineRects(lines.size());
  for (std::size_t i = 0; i < lines.size(); ++i) {
    const LineSpan& span = lines[i];
    RectNode* rect = m_selectionLineRects[i];
    rect->setPosition(span.x0, span.y + kMultilinePadV - m_scrollOffsetY);
    rect->setFrameSize(std::max(1.0F, span.x1 - span.x0), std::max(1.0F, span.h));
    rect->setVisible(true);
  }
}

void Input::syncSelectionLineRects(std::size_t count) {
  if (m_textViewport == nullptr) {
    return;
  }
  while (m_selectionLineRects.size() > count) {
    auto* node = m_selectionLineRects.back();
    (void)m_textViewport->removeChild(node);
    m_selectionLineRects.pop_back();
  }
  while (m_selectionLineRects.size() < count) {
    auto rect = std::make_unique<RectNode>();
    rect->setStyle(m_selectionRect != nullptr ? m_selectionRect->style() : RoundedRectStyle{});
    rect->setOpacity(0.3F);
    rect->setVisible(false);
    auto* rectPtr = static_cast<RectNode*>(m_textViewport->insertChildAt(0, std::move(rect)));
    m_selectionLineRects.push_back(rectPtr);
  }
}

float Input::stopXForByte(std::size_t bytePos) const {
  for (std::size_t i = 0; i < m_stopByte.size(); ++i) {
    if (m_stopByte[i] == bytePos) {
      return m_stopX[i];
    }
  }
  return m_stopX.empty() ? 0.0F : m_stopX.back();
}

std::size_t Input::nextCharPos(const std::string& s, std::size_t pos) {
  if (pos >= s.size()) {
    return s.size();
  }
  ++pos;
  while (pos < s.size() && (static_cast<unsigned char>(s[pos]) & 0xC0U) == 0x80U) {
    ++pos;
  }
  return pos;
}

std::size_t Input::prevCharPos(const std::string& s, std::size_t pos) {
  pos = std::min(pos, s.size());
  if (pos == 0) {
    return 0;
  }
  --pos;
  while (pos > 0 && (static_cast<unsigned char>(s[pos]) & 0xC0U) == 0x80U) {
    --pos;
  }
  return pos;
}

std::string Input::utf32ToUtf8(std::uint32_t cp) {
  std::string result;
  if (cp < 0x80U) {
    result += static_cast<char>(cp);
  } else if (cp < 0x800U) {
    result += static_cast<char>(0xC0U | (cp >> 6U));
    result += static_cast<char>(0x80U | (cp & 0x3FU));
  } else if (cp < 0x10000U) {
    result += static_cast<char>(0xE0U | (cp >> 12U));
    result += static_cast<char>(0x80U | ((cp >> 6U) & 0x3FU));
    result += static_cast<char>(0x80U | (cp & 0x3FU));
  } else {
    result += static_cast<char>(0xF0U | (cp >> 18U));
    result += static_cast<char>(0x80U | ((cp >> 12U) & 0x3FU));
    result += static_cast<char>(0x80U | ((cp >> 6U) & 0x3FU));
    result += static_cast<char>(0x80U | (cp & 0x3FU));
  }
  return result;
}
