#include "EditorController.h" #include #include #include 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(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(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(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(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() : 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(); }