#include "ollie_ghost.h" #include "ollie_kate.h" #include #if KTEXTEDITOR_VERSION_MAJOR < 6 #include #include #include #endif #include #include #include #include #include #include #include // === GhostOverlay === GhostOverlay::GhostOverlay(QWidget *parent) : QWidget(parent) { setAttribute(Qt::WA_TransparentForMouseEvents); setAttribute(Qt::WA_TranslucentBackground); setAutoFillBackground(false); hide(); } void GhostOverlay::setLines(const QStringList &lines, const QFont &font) { m_lines = lines; m_font = font; if (m_lines.isEmpty()) { hide(); return; } QFontMetricsF fm(m_font); qreal maxWidth = 0; for (const QString &line : m_lines) { qreal w = fm.horizontalAdvance(line); if (w > maxWidth) maxWidth = w; } int height = qRound(fm.lineSpacing() * m_lines.size()); int width = qRound(maxWidth) + 4; setFixedSize(width, height); show(); raise(); update(); } void GhostOverlay::clear() { m_lines.clear(); hide(); } void GhostOverlay::paintEvent(QPaintEvent *event) { Q_UNUSED(event); if (m_lines.isEmpty()) return; QPainter painter(this); painter.setFont(m_font); QColor ghostColor = QApplication::palette().color(QPalette::Text); ghostColor.setAlphaF(0.4); painter.setPen(ghostColor); QFontMetricsF fm(m_font); qreal y = fm.ascent(); for (const QString &line : m_lines) { painter.drawText(QPointF(0, y), line); y += fm.lineSpacing(); } } // === OllieGhostProvider === OllieGhostProvider::OllieGhostProvider(KTextEditor::View *view, OllieKatePlugin *plugin, QObject *parent) : KTextEditor::InlineNoteProvider() // note: KDE's ctor takes no parent arg , m_view(view) , m_plugin(plugin) , m_debounce(new QTimer(this)) { // InlineNoteProvider inherits QObject but has no parent-accepting ctor QObject::setParent(parent); m_debounce->setSingleShot(true); m_debounce->setInterval(500); connect(m_debounce, &QTimer::timeout, this, &OllieGhostProvider::requestCompletion); connect(view, &KTextEditor::View::cursorPositionChanged, this, &OllieGhostProvider::onCursorPositionChanged); connect(view->document(), &KTextEditor::Document::textChanged, this, &OllieGhostProvider::onTextChanged); connect(view, &QObject::destroyed, this, [this]() { m_view = nullptr; }); #if KTEXTEDITOR_VERSION_MAJOR >= 6 view->registerInlineNoteProvider(this); #else // Use dynamic_cast instead of qobject_cast: during view construction, // qt_metacast() can access uninitialized KateViewConfig and crash. // dynamic_cast only needs the vtable which is valid once the C++ ctor runs. auto *noteIface = dynamic_cast(view); if (noteIface) noteIface->registerInlineNoteProvider(this); #endif } OllieGhostProvider::~OllieGhostProvider() { // m_overlay is parented to the view widget and may already be destroyed // by Qt's child cleanup before we get here — just null it out. m_overlay = nullptr; if (m_view) { #if KTEXTEDITOR_VERSION_MAJOR >= 6 m_view->unregisterInlineNoteProvider(this); #else auto *iface = dynamic_cast(m_view); if (iface) iface->unregisterInlineNoteProvider(this); #endif } } // --- InlineNoteProvider interface --- #if KTEXTEDITOR_VERSION_MAJOR >= 6 QList OllieGhostProvider::inlineNotes(int line) const #else QVector OllieGhostProvider::inlineNotes(int line) const #endif { if (m_suggestionLines.isEmpty() || m_suggestionStartLine < 0) return {}; // Only show inline note on the FIRST suggestion line if (line != m_suggestionStartLine) return {}; if (m_suggestionLines[0].isEmpty()) return {}; // Don't show ghost text if cursor was mid-line — the suggestion would // visually overlap or conflict with existing text after the cursor. int lineLen = m_view->document()->lineLength(line); if (m_suggestionStartCol < lineLen) return {}; return {lineLen}; } QSize OllieGhostProvider::inlineNoteSize(const KTextEditor::InlineNote ¬e) const { Q_UNUSED(note); if (m_suggestionLines.isEmpty()) return QSize(0, 0); QFont font = note.font(); QFontMetricsF fm(font); qreal width = fm.horizontalAdvance(m_suggestionLines[0]); return QSize(qRound(width), note.lineHeight()); } #if KTEXTEDITOR_VERSION_MAJOR >= 6 void OllieGhostProvider::paintInlineNote(const KTextEditor::InlineNote ¬e, QPainter &painter, Qt::LayoutDirection direction) const { Q_UNUSED(direction); #else void OllieGhostProvider::paintInlineNote(const KTextEditor::InlineNote ¬e, QPainter &painter) const { #endif if (m_suggestionLines.isEmpty()) return; QFont font = note.font(); painter.setFont(font); QColor ghostColor = QApplication::palette().color(QPalette::Text); ghostColor.setAlphaF(0.4); painter.setPen(ghostColor); QFontMetricsF fm(font); qreal y = fm.ascent(); painter.drawText(QPointF(0, y), m_suggestionLines[0]); } // --- Public API --- bool OllieGhostProvider::accept() { if (m_suggestionLines.isEmpty() || m_suggestionStartLine < 0) return false; auto *doc = m_view->document(); QString text = m_suggestionLines.join(QStringLiteral("\n")); // Insert at the trigger cursor position KTextEditor::Cursor insertPos(m_suggestionStartLine, m_suggestionStartCol); doc->insertText(insertPos, text); clearSuggestion(); return true; } void OllieGhostProvider::dismiss() { if (!m_suggestionLines.isEmpty()) { clearSuggestion(); } m_debounce->stop(); } void OllieGhostProvider::cycle(int direction) { if (direction > 0) { // Next: if we have a cached next, show it; otherwise request new if (m_historyIndex + 1 < m_history.size()) { m_historyIndex++; showSuggestionFromHistory(); } else { // Request a new completion (will append to history) requestCompletion(); } } else if (direction < 0) { // Previous: show earlier suggestion if available if (m_historyIndex > 0) { m_historyIndex--; showSuggestionFromHistory(); } } } void OllieGhostProvider::setEnabled(bool enabled) { m_enabled = enabled; if (!enabled) { m_debounce->stop(); dismiss(); } } bool OllieGhostProvider::hasSuggestion() const { return !m_suggestionLines.isEmpty(); } // --- Private slots --- void OllieGhostProvider::onCursorPositionChanged() { // Don't react to cursor changes while completion popup is active #if KTEXTEDITOR_VERSION_MAJOR >= 6 if (m_view->isCompletionActive()) return; #else auto *ccIface = dynamic_cast(m_view); if (ccIface && ccIface->isCompletionActive()) return; #endif if (hasSuggestion()) { KTextEditor::Cursor cur = m_view->cursorPosition(); if (cur != m_triggerCursor) { clearSuggestion(); } } if (m_enabled) m_debounce->start(); } void OllieGhostProvider::onTextChanged() { if (hasSuggestion()) { clearSuggestion(); } if (m_enabled) m_debounce->start(); } void OllieGhostProvider::requestCompletion() { if (m_requesting) return; auto *doc = m_view->document(); if (!doc || doc->url().toLocalFile().isEmpty()) return; KTextEditor::Cursor cursor = m_view->cursorPosition(); QString prefix = gatherPrefix(); QString suffix = gatherSuffix(); QString filePath = doc->url().toLocalFile(); QString cwd = QFileInfo(filePath).absolutePath(); m_triggerCursor = cursor; m_requesting = true; // Build context with optional backend/model override QString context; if (m_plugin) { QString backend = m_plugin->completionBackend(); QString model = m_plugin->completionModel(); if (!backend.isEmpty()) context += QStringLiteral("backend=") + backend + QStringLiteral("\n"); if (!model.isEmpty()) context += QStringLiteral("model=") + model + QStringLiteral("\n"); } // Build JSON request for ollie-9p rdwr complete QJsonObject request; request["file"] = filePath; request["prefix"] = prefix; request["suffix"] = suffix; if (!context.isEmpty()) { request["context"] = context; } QJsonDocument docJson(request); QString jsonStr = QString::fromUtf8(docJson.toJson(QJsonDocument::Compact)); // Run ollie-9p rdwr complete QProcess *process = new QProcess(this); process->setProgram("ollie-9p"); process->setArguments({"rdwr", "complete"}); connect(process, QOverload::of(&QProcess::finished), this, [this, process](int exitCode, QProcess::ExitStatus exitStatus) { process->deleteLater(); m_requesting = false; if (exitStatus != QProcess::NormalExit || exitCode != 0) { return; } QString result = QString::fromUtf8(process->readAllStandardOutput()); onCompletionReply(result); }); process->start(); process->write(jsonStr.toUtf8()); process->closeWriteChannel(); } void OllieGhostProvider::onCompletionReply(const QString &result) { if (result.trimmed().isEmpty()) return; if (m_view->cursorPosition() != m_triggerCursor) return; QStringList lines = result.split(QStringLiteral("\n")); // Append to history and advance index m_history.append(lines); m_historyIndex = m_history.size() - 1; m_suggestionStartLine = m_triggerCursor.line(); m_suggestionStartCol = m_triggerCursor.column(); m_suggestionLines = lines; // Notify Kate to repaint the first line (inline note) emit inlineNotesChanged(m_suggestionStartLine); // Show overlay for continuation lines only if they won't overlap // existing document content (i.e. cursor is at or near EOF). if (m_suggestionLines.size() > 1) { auto *doc = m_view->document(); int linesBelow = doc->lines() - 1 - m_suggestionStartLine; int continuationLines = m_suggestionLines.size() - 1; // Only show overlay if there's enough empty space below // (cursor line is within continuationLines of EOF) if (linesBelow <= continuationLines) { showOverlay(); } } } // --- Private helpers --- void OllieGhostProvider::showOverlay() { // Get the editor's internal widget (viewport) to parent the overlay QWidget *viewWidget = m_view->focusProxy(); if (!viewWidget) viewWidget = qobject_cast(m_view); if (!viewWidget) return; if (!m_overlay) { m_overlay = new GhostOverlay(viewWidget); connect(m_overlay, &QObject::destroyed, this, [this]() { m_overlay = nullptr; }); } // Gather continuation lines (skip first, it's the inline note) QStringList contLines = m_suggestionLines.mid(1); // Get font from the view's config #if KTEXTEDITOR_VERSION_MAJOR >= 6 QFont font = m_view->configValue(QStringLiteral("font")).value(); #else QFont font; auto *cfgIface = dynamic_cast(m_view); if (cfgIface) font = cfgIface->configValue(QStringLiteral("font")).value(); else font = m_view->font(); #endif m_overlay->setLines(contLines, font); // Position the overlay below the cursor line // Use cursorPositionCoordinates to get pixel position of cursor QPoint cursorPos = m_view->cursorPositionCoordinates(); QFontMetricsF fm(font); int lineHeight = qRound(fm.lineSpacing()); // Place overlay one line below the cursor m_overlay->move(cursorPos.x(), cursorPos.y() + lineHeight); } void OllieGhostProvider::hideOverlay() { if (m_overlay) { m_overlay->clear(); } } void OllieGhostProvider::showSuggestionFromHistory() { if (m_historyIndex < 0 || m_historyIndex >= m_history.size()) return; // Clear visual state without resetting history if (!m_suggestionLines.isEmpty()) { emit inlineNotesChanged(m_suggestionStartLine); hideOverlay(); } m_suggestionStartLine = m_triggerCursor.line(); m_suggestionStartCol = m_triggerCursor.column(); m_suggestionLines = m_history[m_historyIndex]; emit inlineNotesChanged(m_suggestionStartLine); if (m_suggestionLines.size() > 1) { auto *doc = m_view->document(); int linesBelow = doc->lines() - 1 - m_suggestionStartLine; int continuationLines = m_suggestionLines.size() - 1; if (linesBelow <= continuationLines) { showOverlay(); } } } void OllieGhostProvider::clearSuggestion() { if (m_suggestionLines.isEmpty()) return; m_suggestionLines.clear(); m_suggestionStartLine = -1; m_suggestionStartCol = -1; m_history.clear(); m_historyIndex = -1; emit inlineNotesChanged(m_triggerCursor.line()); hideOverlay(); } QString OllieGhostProvider::gatherPrefix() const { auto *doc = m_view->document(); KTextEditor::Cursor cursor = m_view->cursorPosition(); KTextEditor::Range prefixRange(KTextEditor::Cursor(0, 0), cursor); return doc->text(prefixRange); } QString OllieGhostProvider::gatherSuffix() const { auto *doc = m_view->document(); KTextEditor::Cursor cursor = m_view->cursorPosition(); KTextEditor::Cursor endCursor(doc->lines() - 1, doc->lineLength(doc->lines() - 1)); KTextEditor::Range suffixRange(cursor, endCursor); return doc->text(suffixRange); }