Something went wrong. Try again.
The agentic engineering control plane for the posthuman future
Something went wrong. Try again.
17 kB · 541 lines
C++
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542#include "EditorController.h"
#include <QFontDatabase>#include <QQuickTextDocument>
#include <ranges>
EditorController::EditorController(QObject* parent) : QObject(parent), m_model(new EditorModel(this)), m_wheelGlideTimer(new QTimer(this)){ // Scroll momentum tick. The glide arithmetic and the period live in the // shared xeno::scroll header, so the editor and the terminal pane glide at // the same rate with the same decay. m_wheelGlideTimer->setSingleShot(false); m_wheelGlideTimer->setInterval(xeno::scroll::kMomentumTickMs); connect(m_wheelGlideTimer, &QTimer::timeout, this, &EditorController::stepWheelGlide);
// Every signal QML consumes is forwarded one to one, so no lambda and no // property bookkeeping is needed. connect(m_model, &EditorModel::editorFontChanged, this, &EditorController::editorFontChanged); connect(m_model, &EditorModel::wrapChanged, this, &EditorController::wrapChanged); connect(m_model, &EditorModel::highlightEnabledChanged, this, &EditorController::highlightEnabledChanged); connect(m_model, &EditorModel::statusLineChanged, this, &EditorController::statusLineChanged); connect(m_model, &EditorModel::documentChanged, this, &EditorController::documentChanged); connect(m_model, &EditorModel::windowChanged, this, &EditorController::windowChanged); connect(m_model, &EditorModel::storeChanged, this, &EditorController::storeChanged); connect(m_model, &EditorModel::viewportTopPxChanged, this, &EditorController::viewportTopPxChanged); connect(m_model, &EditorModel::caretOutsideWindowChanged, this, &EditorController::caretOutsideWindowChanged); connect(m_model, &EditorModel::cursorRequested, this, &EditorController::cursorRequested); connect(m_model, &EditorModel::selectionRequested, this, &EditorController::selectionRequested); connect(m_model, &EditorModel::loaded, this, &EditorController::loaded); connect(m_model, &EditorModel::undoAvailableChanged, this, &EditorController::undoAvailableChanged); connect(m_model, &EditorModel::dirtyChanged, this, &EditorController::dirtyChanged); connect(m_model, &EditorModel::noteRequested, this, &EditorController::notePosted); connect(m_model, &EditorModel::conflictSaveRequested, this, [this]() { // A refused save points at the work: the first conflict comes into view. m_model->revealConflict(m_conflictIndex); emit conflictSaveRequested(); }); // The index lives here, so the clamp runs before the count is announced: a // binding that reads both sees a consistent pair. connect(m_model, &EditorModel::conflictsChanged, this, [this]() { const int count = conflictCount(); const int clamped = count <= 0 ? 0 : qBound(0, m_conflictIndex, count - 1); if (clamped != m_conflictIndex) { m_conflictIndex = clamped; emit conflictIndexChanged(); } emit conflictsChanged(); }); // The match set describes the store's text, so a store change rebuilds it // while a query is live. The rebuild must not move the caret or the // selection: only an index that no longer names a match is dropped. connect(m_model, &EditorModel::storeChanged, this, &EditorController::rerunSearch); // A load or reload replaces the whole text, so the match set is rebuilt the // same way. connect(m_model, &EditorModel::loaded, this, &EditorController::rerunSearch);}
void EditorController::setSettings(XenoSettings* settings){ if (m_settings == settings) { return; } if (m_settings != nullptr) { disconnect(m_settings, nullptr, this, nullptr); } m_settings = settings; if (m_settings != nullptr) { // The font spec follows the terminal's family and weight, and the editor's // own size; the line height is its own fact. connect(m_settings, &XenoSettings::fontFamilyChanged, this, &EditorController::applyFontSettings); connect(m_settings, &XenoSettings::fontWeightChanged, this, &EditorController::applyFontSettings); connect(m_settings, &XenoSettings::fontSizeChanged, this, &EditorController::applyFontSettings); connect(m_settings, &XenoSettings::editorFontSizeChanged, this, &EditorController::applyFontSettings); connect(m_settings, &XenoSettings::editorLineHeightChanged, this, &EditorController::applyLineHeight); connect(m_settings, &XenoSettings::editorWrapChanged, this, &EditorController::applyWrap); } emit settingsChanged(); // Apply immediately, so a controller handed its settings after opening a // file still shows the configured editor face. applyFontSettings(); applyLineHeight(); applyWrap();}
void EditorController::applyFontSettings(){ if (m_settings == nullptr) { return; } // The same recipe as TerminalQuickItem::syncTerminalFont: the platform's // fixed font when no family is chosen, the family otherwise, the editor's // size when set (else the terminal's), and the shared weight. QFont font; if (m_settings->fontFamily().isEmpty()) { font = QFontDatabase::systemFont(QFontDatabase::FixedFont); } else { font = QFont(m_settings->fontFamily()); } if (m_settings->editorFontSize() > 0.0) { font.setPointSizeF(m_settings->editorFontSize()); } else if (m_settings->fontSize() > 0.0) { font.setPointSizeF(m_settings->fontSize()); } if (m_settings->fontWeight() > 0) { font.setWeight(static_cast<QFont::Weight>(m_settings->fontWeight())); } font.setStyleHint(QFont::Monospace); m_model->setFont(font);}
void EditorController::applyLineHeight(){ m_model->setLineHeightFactor(m_settings != nullptr ? m_settings->editorLineHeight() : 1.0);}
void EditorController::applyWrap(){ m_model->setWrap(m_settings != nullptr && m_settings->editorWrap());}
int EditorController::conflictCount() const{ return int(m_model->conflicts().size());}
void EditorController::setConflictIndex(int index){ const int count = conflictCount(); const int clamped = count <= 0 ? 0 : qBound(0, index, count - 1); if (clamped == m_conflictIndex) { return; } m_conflictIndex = clamped; emit conflictIndexChanged(); // The card the user asked for must be on screen, so the viewport follows the // index rather than only the caret. m_model->revealConflict(m_conflictIndex);}
void EditorController::nextConflict(){ const int count = conflictCount(); if (count <= 0) { return; } setConflictIndex((m_conflictIndex + 1) % count); m_model->revealConflict(m_conflictIndex);}
void EditorController::previousConflict(){ const int count = conflictCount(); if (count <= 0) { return; } setConflictIndex((m_conflictIndex + count - 1) % count); m_model->revealConflict(m_conflictIndex);}
QVariantMap EditorController::conflictRange(int index) const{ return m_model->conflictRange(index);}
int EditorController::documentPositionForOffset(int offset) const{ return m_model->documentPositionForOffset(offset);}
void EditorController::acceptConflict(int index){ m_model->resolveConflict(index, EditorModel::ConflictResolution::Disk);}
void EditorController::rejectConflict(int index){ m_model->resolveConflict(index, EditorModel::ConflictResolution::Local);}
void EditorController::acceptAllConflicts(){ m_model->acceptAllConflicts();}
void EditorController::rejectAllConflicts(){ m_model->rejectAllConflicts();}
void EditorController::revealConflict(int index){ m_model->revealConflict(index);}
EditorController::~EditorController() = default;
void EditorController::attach(QObject* textDocument){ // The one QtQuick call in the editor: the model works on a QTextDocument, and // only the view knows how to hand one over. auto* quickDocument = qobject_cast<QQuickTextDocument*>(textDocument); if (quickDocument == nullptr) { m_model->reportAttachFailure( QStringLiteral("attach failed: the argument is not a text document")); return; } m_model->attachDocument(quickDocument->textDocument());}
bool EditorController::openFile(const QString& path){ return m_model->openFile(path);}
bool EditorController::save(){ return m_model->save();}
void EditorController::detach(){ m_model->detachDocument();}
bool EditorController::loadSpanTable(const QString& path){ return m_model->loadSpanTable(path);}
void EditorController::setViewport(qreal topPx, qreal viewportHeightPx){ m_model->setViewport(topPx, viewportHeightPx);}
void EditorController::setWrapWidth(qreal px){ m_model->setWrapWidth(px);}
void EditorController::wheelScroll(const QPoint& angleDelta, const QPoint& pixelDelta, Qt::KeyboardModifiers modifiers){ // The line size in the event's pixel space, and the shared conversion from // a raw wheel event to lines (positive = scroll up). float lines = xeno::scroll::linesFromWheelDelta( angleDelta, pixelDelta, static_cast<float>(m_model->lineHeightPx())); if ((modifiers & Qt::ControlModifier) != 0) { // Historic behavior, shared with the terminal: ctrl flips the direction. lines = -lines; } if (lines == 0.0F) { return; } if (pixelDelta.y() != 0) { // Trackpad: this event's own delta lands NOW, at pixel resolution, and an // impulse on top carries the viewport on after the finger lifts, so a // stream of events both tracks the finger and builds momentum. The terminal // does the same (TerminalQuickItem applies the delta, then adds impulse). // Adding impulse alone moved the viewport only by the glide: 35% of the // accumulated delta per tick, decaying, with each tick's sub-line remainder // dropped when the glide stopped. A slow drag therefore scrolled almost // nothing and a flick arrived late as an animation that overshot the finger. applyWheelLines(lines); m_wheelGlide.impulse(lines); m_wheelGlideTimer->start(); return; } // Classic wheel notch: a detented, fixed-distance input. Step it exactly // and cancel any glide in flight; coasting after a notch is wrong. m_wheelGlide.cancel(); m_wheelGlideTimer->stop(); applyWheelLines(lines);}
void EditorController::applyWheelLines(float lines){ // Positive lines scroll up = smaller contentY. The model owns the clamp, so // the value is handed over as it stands. const qreal deltaPx = -static_cast<qreal>(lines) * m_model->lineHeightPx(); m_model->setViewport(m_model->viewportTopPx() + deltaPx, m_model->viewportHeightPx());}
void EditorController::stepWheelGlide(){ const float lines = m_wheelGlide.step(); if (!m_wheelGlide.active()) { m_wheelGlideTimer->stop(); } if (lines != 0.0F) { applyWheelLines(lines); }}
void EditorController::requestCursor(int line, int column){ m_model->requestCursor(line, column);}
void EditorController::requestSelection(int anchorLine, int caretLine){ m_model->requestSelection(anchorLine, caretLine);}
void EditorController::extendSelectionTo(int fileLine, int column){ m_model->extendSelectionTo(fileLine, column);}
void EditorController::selectAll(){ m_model->selectAll();}
void EditorController::replaceSelection(const QString& text){ m_model->replaceSelection(text);}
void EditorController::undo(){ m_model->undo();}
void EditorController::redo(){ m_model->redo();}
QVariantMap EditorController::filePositionForDocument(int position) const{ return m_model->filePositionForDocument(position);}
void EditorController::noteCursorPosition(int position){ m_model->noteCursorPosition(position);}
void EditorController::noteSelection(int start, int end){ m_model->noteSelection(start, end);}
bool EditorController::selectionClamped() const{ return m_model->selectionClamped();}
bool EditorController::windowCoversSelection() const{ return m_model->windowCoversSelection();}
bool EditorController::caretInWindow() const{ return m_model->caretInWindow();}
bool EditorController::hasSelection() const{ return m_model->hasSelection();}
int EditorController::caretLine() const{ return m_model->caretLine();}
int EditorController::caretColumn() const{ return m_model->caretColumn();}
int EditorController::anchorLine() const{ return m_model->anchorLine();}
int EditorController::anchorColumn() const{ return m_model->anchorColumn();}
int EditorController::caretStoreOffset() const{ const LineStore& store = m_model->store(); if (!store.isLoaded()) { return 0; } // A FILE position is the store line's own offset plus the column in it. return store.lineOffset(m_model->caretLine()) + m_model->caretColumn();}
int EditorController::matchAtOrAfterCaret() const{ if (m_searchMatches.isEmpty()) { return -1; } const int caretOffset = caretStoreOffset(); for (const int index : std::views::iota(0, int(m_searchMatches.size()))) { if (m_searchMatches.at(index) >= caretOffset) { return index; } } // Every match sits behind the caret, so the walk wraps to the first. return 0;}
int EditorController::matchBeforeCaret() const{ if (m_searchMatches.isEmpty()) { return -1; } const int caretOffset = caretStoreOffset(); // The matches are in file order, so the last one that precedes the caret is // the answer; remember it rather than scanning with a reverse view. int result = -1; for (const int index : std::views::iota(0, int(m_searchMatches.size()))) { if (m_searchMatches.at(index) < caretOffset) { result = index; } } // No match precedes the caret, so the walk wraps to the last. return result < 0 ? int(m_searchMatches.size()) - 1 : result;}
void EditorController::selectMatch(int index){ if (index < 0 || index >= int(m_searchMatches.size())) { return; } // A match is exactly the needle's own length, so the selection is the hit. m_model->selectStoreRange(m_searchMatches.at(index), int(m_searchQuery.size()));}
void EditorController::rerunSearch(){ // No live query: a document change rebuilds nothing. if (m_searchQuery.trimmed().isEmpty()) { return; } m_searchMatches = m_model->findMatches(m_searchQuery); // A rebuild must not move the caret or the selection, so an index that no // longer names a match is dropped rather than re-seeded from the caret. if (m_currentMatch >= int(m_searchMatches.size())) { m_currentMatch = -1; } emit searchStateChanged();}
void EditorController::startSearch(const QString& query){ m_searchQuery = query; // A blank needle searches for nothing: findMatches() already rejects the // empty one, and a whitespace-only one is dropped here because a space is a // real character to the scanner. m_searchMatches = query.trimmed().isEmpty() ? QList<int>() : m_model->findMatches(query); if (m_searchMatches.isEmpty()) { m_currentMatch = -1; } else { // Seed on the first hit at or after the caret, so a search starts where the // user is looking and wraps only when every match is behind the caret. m_currentMatch = matchAtOrAfterCaret(); selectMatch(m_currentMatch); } emit searchStateChanged();}
void EditorController::searchNext(){ if (m_searchMatches.isEmpty()) { return; } // A selection dropped by a rebuild re-seeds forward from the caret; a live // one steps with wrap-around. m_currentMatch = m_currentMatch < 0 ? matchAtOrAfterCaret() : (m_currentMatch + 1) % int(m_searchMatches.size()); selectMatch(m_currentMatch); emit searchStateChanged();}
void EditorController::searchPrevious(){ if (m_searchMatches.isEmpty()) { return; } m_currentMatch = m_currentMatch < 0 ? matchBeforeCaret() : (m_currentMatch + int(m_searchMatches.size()) - 1) % int(m_searchMatches.size()); selectMatch(m_currentMatch); emit searchStateChanged();}
void EditorController::clearSearch(){ m_searchQuery.clear(); m_searchMatches.clear(); m_currentMatch = -1; emit searchStateChanged();}
void EditorController::setSearchBarOpen(bool open){ if (m_searchBarOpen == open) { return; } m_searchBarOpen = open; emit searchStateChanged();}