This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-kde/kate/ollie_ghost.cpp

478 lines
13 KiB
C++

#include "ollie_ghost.h"
#include "ollie_kate.h"
#include <ktexteditor_version.h>
#if KTEXTEDITOR_VERSION_MAJOR < 6
#include <KTextEditor/InlineNoteInterface>
#include <KTextEditor/CodeCompletionInterface>
#include <KTextEditor/ConfigInterface>
#endif
#include <QApplication>
#include <QFileInfo>
#include <QPaintEvent>
#include <QScrollBar>
#include <QProcess>
#include <QJsonDocument>
#include <QJsonObject>
// === 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<KTextEditor::InlineNoteInterface *>(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<KTextEditor::InlineNoteInterface *>(m_view);
if (iface)
iface->unregisterInlineNoteProvider(this);
#endif
}
}
// --- InlineNoteProvider interface ---
#if KTEXTEDITOR_VERSION_MAJOR >= 6
QList<int> OllieGhostProvider::inlineNotes(int line) const
#else
QVector<int> 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 {};
int lineLen = m_view->document()->lineLength(line);
int col = m_suggestionStartCol;
if (col < lineLen)
col = lineLen;
return {col};
}
QSize OllieGhostProvider::inlineNoteSize(const KTextEditor::InlineNote &note) 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 &note, QPainter &painter, Qt::LayoutDirection direction) const
{
Q_UNUSED(direction);
#else
void OllieGhostProvider::paintInlineNote(const KTextEditor::InlineNote &note, 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<KTextEditor::CodeCompletionInterface *>(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<int, QProcess::ExitStatus>::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 (if multi-line)
if (m_suggestionLines.size() > 1) {
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<QWidget *>(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<QFont>();
#else
QFont font;
auto *cfgIface = dynamic_cast<KTextEditor::ConfigInterface *>(m_view);
if (cfgIface)
font = cfgIface->configValue(QStringLiteral("font")).value<QFont>();
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) {
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);
}