478 lines
13 KiB
C++
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 ¬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<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);
|
|
} |