572 lines
16 KiB
C++
572 lines
16 KiB
C++
#include "CodeEditor.h"
|
||
#include "LineNumberArea.h"
|
||
|
||
#include "core/Settings.h"
|
||
#include "highlighter/HighlighterFactory.h"
|
||
|
||
#include <QPainter>
|
||
#include <QTextBlock>
|
||
#include <QPaintEvent>
|
||
#include <QResizeEvent>
|
||
#include <QKeyEvent>
|
||
#include <QScrollBar>
|
||
#include <QFile>
|
||
#include <QTextStream>
|
||
#include <QFileInfo>
|
||
#include <QFileDialog>
|
||
#include <QMessageBox>
|
||
|
||
CodeEditor::CodeEditor(Settings *settings, QWidget *parent)
|
||
: QPlainTextEdit(parent)
|
||
, m_settings(settings)
|
||
{
|
||
m_lineNumberArea = new LineNumberArea(this);
|
||
setupEditor();
|
||
|
||
connect(this, &CodeEditor::blockCountChanged,
|
||
this, &CodeEditor::updateLineNumberAreaWidth);
|
||
|
||
connect(this, &CodeEditor::updateRequest,
|
||
this, &CodeEditor::updateLineNumberArea);
|
||
|
||
connect(this, &CodeEditor::cursorPositionChanged,
|
||
this, &CodeEditor::highlightCurrentLine);
|
||
|
||
updateLineNumberAreaWidth(0);
|
||
highlightCurrentLine();
|
||
}
|
||
|
||
CodeEditor::~CodeEditor() = default;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Setup
|
||
// ---------------------------------------------------------------------------
|
||
void CodeEditor::setupEditor()
|
||
{
|
||
applySettings();
|
||
setLineWrapMode(QPlainTextEdit::NoWrap);
|
||
}
|
||
|
||
void CodeEditor::applySettings()
|
||
{
|
||
setFont(m_settings->editorFont());
|
||
|
||
const int tabStop = m_settings->tabSize();
|
||
// Set tab stop width in pixels using font metrics
|
||
QFontMetrics fm(m_settings->editorFont());
|
||
setTabStopDistance(static_cast<qreal>(tabStop) * fm.horizontalAdvance(' '));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// File I/O
|
||
// ---------------------------------------------------------------------------
|
||
void CodeEditor::loadFile(const QString &filePath)
|
||
{
|
||
QFile file(filePath);
|
||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text))
|
||
{
|
||
QMessageBox::warning(this, tr("Open File"),
|
||
tr("Cannot open file:\n%1").arg(filePath));
|
||
return;
|
||
}
|
||
|
||
m_filePath = filePath;
|
||
|
||
QTextStream in(&file);
|
||
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
|
||
in.setEncoding(QStringConverter::Utf8);
|
||
#else
|
||
in.setCodec("UTF-8");
|
||
#endif
|
||
|
||
setPlainText(in.readAll());
|
||
document()->setModified(false);
|
||
|
||
installHighlighter(filePath);
|
||
}
|
||
|
||
QString CodeEditor::filePath() const
|
||
{
|
||
return m_filePath;
|
||
}
|
||
|
||
bool CodeEditor::save()
|
||
{
|
||
if (m_filePath.isEmpty())
|
||
{
|
||
return saveAs();
|
||
}
|
||
|
||
return writeToFile(m_filePath);
|
||
}
|
||
|
||
bool CodeEditor::saveAs()
|
||
{
|
||
const QString path = QFileDialog::getSaveFileName(
|
||
this,
|
||
tr("Speichern unter"),
|
||
m_filePath
|
||
);
|
||
|
||
if (path.isEmpty())
|
||
{
|
||
return false;
|
||
}
|
||
|
||
m_filePath = path;
|
||
installHighlighter(m_filePath);
|
||
return writeToFile(m_filePath);
|
||
}
|
||
|
||
bool CodeEditor::writeToFile(const QString &filePath)
|
||
{
|
||
QFile file(filePath);
|
||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text))
|
||
{
|
||
QMessageBox::warning(this, tr("Speichern"),
|
||
tr("Datei konnte nicht gespeichert werden:\n%1").arg(filePath));
|
||
return false;
|
||
}
|
||
|
||
QTextStream out(&file);
|
||
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
|
||
out.setEncoding(QStringConverter::Utf8);
|
||
#else
|
||
out.setCodec("UTF-8");
|
||
#endif
|
||
|
||
out << toPlainText();
|
||
document()->setModified(false);
|
||
emit fileSaved(filePath);
|
||
return true;
|
||
}
|
||
|
||
void CodeEditor::installHighlighter(const QString &filePath)
|
||
{
|
||
// Remove old highlighter first
|
||
delete m_highlighter;
|
||
m_highlighter = nullptr;
|
||
|
||
m_highlighter = HighlighterFactory::createForFile(filePath, document());
|
||
}
|
||
|
||
bool CodeEditor::isModified() const
|
||
{
|
||
return document()->isModified();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Line number area
|
||
// ---------------------------------------------------------------------------
|
||
int CodeEditor::lineNumberAreaWidth() const
|
||
{
|
||
int digits = 1;
|
||
int max = qMax(1, blockCount());
|
||
while (max >= 10)
|
||
{
|
||
max /= 10;
|
||
++digits;
|
||
}
|
||
|
||
const int padding = 8;
|
||
return fontMetrics().horizontalAdvance('9') * digits + padding * 2;
|
||
}
|
||
|
||
void CodeEditor::updateLineNumberAreaWidth(int /*newBlockCount*/)
|
||
{
|
||
setViewportMargins(lineNumberAreaWidth(), 0, 0, 0);
|
||
}
|
||
|
||
void CodeEditor::updateLineNumberArea(const QRect &rect, int dy)
|
||
{
|
||
if (dy != 0)
|
||
{
|
||
m_lineNumberArea->scroll(0, dy);
|
||
}
|
||
else
|
||
{
|
||
m_lineNumberArea->update(0, rect.y(), m_lineNumberArea->width(), rect.height());
|
||
}
|
||
|
||
if (rect.contains(viewport()->rect()))
|
||
{
|
||
updateLineNumberAreaWidth(0);
|
||
}
|
||
}
|
||
|
||
void CodeEditor::paintEvent(QPaintEvent *event)
|
||
{
|
||
// Zuerst den normalen Editor-Inhalt zeichnen
|
||
QPlainTextEdit::paintEvent(event);
|
||
|
||
// Danach die Einrück-Führungslinien darüber legen
|
||
const int tabSize = m_settings->tabSize();
|
||
if (tabSize <= 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
QPainter painter(viewport());
|
||
|
||
// Farbe: subtil, passt zu Hell- und Dunkeltheme
|
||
QColor guideColor = palette().color(QPalette::Text);
|
||
guideColor.setAlpha(30);
|
||
painter.setPen(QPen(guideColor, 1, Qt::SolidLine));
|
||
|
||
const QFontMetrics fm(font());
|
||
const int spaceWidth = fm.horizontalAdvance(' ');
|
||
const int tabPixels = tabSize * spaceWidth;
|
||
|
||
if (tabPixels <= 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// X-Startposition des Textes direkt aus dem Layout des ersten Blocks holen.
|
||
// Das ist der einzige zuverlässige Weg — Qt berücksichtigt intern Margins,
|
||
// Gutter und Frame-Abstände die sich nicht sauber manuell nachrechnen lassen.
|
||
int textOriginX = 0;
|
||
{
|
||
QTextBlock firstBlock = firstVisibleBlock();
|
||
if (!firstBlock.isValid())
|
||
{
|
||
firstBlock = document()->begin();
|
||
}
|
||
if (firstBlock.isValid())
|
||
{
|
||
const QRectF blockRect = blockBoundingGeometry(firstBlock)
|
||
.translated(contentOffset());
|
||
// Position des ersten Zeichens im Layout
|
||
const QTextLayout *layout = firstBlock.layout();
|
||
if (layout && layout->lineCount() > 0)
|
||
{
|
||
textOriginX = static_cast<int>(blockRect.left()
|
||
+ layout->lineAt(0).position().x());
|
||
}
|
||
else
|
||
{
|
||
textOriginX = static_cast<int>(blockRect.left());
|
||
}
|
||
}
|
||
}
|
||
|
||
// Horizontalen Scroll-Offset berücksichtigen
|
||
const int scrollX = horizontalScrollBar()->value();
|
||
|
||
// Sichtbaren Zeilenbereich bestimmen
|
||
QTextBlock block = firstVisibleBlock();
|
||
const int bottom = event->rect().bottom();
|
||
|
||
while (block.isValid())
|
||
{
|
||
const QRectF blockRect = blockBoundingGeometry(block).translated(contentOffset());
|
||
|
||
if (blockRect.top() > bottom)
|
||
{
|
||
break;
|
||
}
|
||
|
||
if (block.isVisible())
|
||
{
|
||
const QString text = block.text();
|
||
|
||
// Einrückungstiefe der Zeile zählen (Leerzeichen / Tabs)
|
||
int indentSpaces = 0;
|
||
for (const QChar &ch : text)
|
||
{
|
||
if (ch == ' ')
|
||
{
|
||
++indentSpaces;
|
||
}
|
||
else if (ch == '\t')
|
||
{
|
||
// Tab auffüllen auf nächsten Tab-Stop
|
||
indentSpaces = ((indentSpaces / tabSize) + 1) * tabSize;
|
||
}
|
||
else
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
|
||
const int indentStops = indentSpaces / tabSize;
|
||
|
||
// Für jeden Einrückungslevel eine vertikale Linie zeichnen
|
||
for (int stop = 1; stop <= indentStops; ++stop)
|
||
{
|
||
const int xPixel = textOriginX + stop * tabPixels - scrollX;
|
||
|
||
// Nur im sichtbaren Bereich zeichnen
|
||
if (xPixel < lineNumberAreaWidth() || xPixel > viewport()->width())
|
||
{
|
||
continue;
|
||
}
|
||
|
||
const int y1 = static_cast<int>(blockRect.top());
|
||
const int y2 = static_cast<int>(blockRect.bottom());
|
||
|
||
painter.drawLine(xPixel, y1, xPixel, y2);
|
||
}
|
||
}
|
||
|
||
block = block.next();
|
||
}
|
||
}
|
||
|
||
|
||
void CodeEditor::resizeEvent(QResizeEvent *event)
|
||
{
|
||
QPlainTextEdit::resizeEvent(event);
|
||
|
||
const QRect cr = contentsRect();
|
||
m_lineNumberArea->setGeometry(
|
||
QRect(cr.left(), cr.top(), lineNumberAreaWidth(), cr.height())
|
||
);
|
||
}
|
||
|
||
void CodeEditor::lineNumberAreaPaintEvent(QPaintEvent *event)
|
||
{
|
||
QPainter painter(m_lineNumberArea);
|
||
|
||
// Background
|
||
const QColor bgColor = palette().color(QPalette::Window).darker(110);
|
||
painter.fillRect(event->rect(), bgColor);
|
||
|
||
const QColor lineNumColor = palette().color(QPalette::Mid);
|
||
const QColor activeColor = palette().color(QPalette::Text);
|
||
|
||
const int currentLine = textCursor().blockNumber();
|
||
|
||
QTextBlock block = firstVisibleBlock();
|
||
int blockNumber = block.blockNumber();
|
||
int top = static_cast<int>(blockBoundingGeometry(block).translated(contentOffset()).top());
|
||
int bottom = top + static_cast<int>(blockBoundingRect(block).height());
|
||
|
||
while (block.isValid() && top <= event->rect().bottom())
|
||
{
|
||
if (block.isVisible() && bottom >= event->rect().top())
|
||
{
|
||
const QString number = QString::number(blockNumber + 1);
|
||
painter.setPen(blockNumber == currentLine ? activeColor : lineNumColor);
|
||
painter.drawText(
|
||
0,
|
||
top,
|
||
m_lineNumberArea->width() - 4,
|
||
fontMetrics().height(),
|
||
Qt::AlignRight,
|
||
number
|
||
);
|
||
}
|
||
|
||
block = block.next();
|
||
top = bottom;
|
||
bottom = top + static_cast<int>(blockBoundingRect(block).height());
|
||
++blockNumber;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Current line highlight
|
||
// ---------------------------------------------------------------------------
|
||
void CodeEditor::highlightCurrentLine()
|
||
{
|
||
QList<QTextEdit::ExtraSelection> extraSelections;
|
||
|
||
if (!isReadOnly())
|
||
{
|
||
QTextEdit::ExtraSelection selection;
|
||
|
||
const QColor lineColor = palette().color(QPalette::AlternateBase);
|
||
selection.format.setBackground(lineColor);
|
||
selection.format.setProperty(QTextFormat::FullWidthSelection, true);
|
||
selection.cursor = textCursor();
|
||
selection.cursor.clearSelection();
|
||
|
||
extraSelections.append(selection);
|
||
}
|
||
|
||
setExtraSelections(extraSelections);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Key handling – Tab/Shift+Tab, Smart-Backspace, Auto-Indent
|
||
// ---------------------------------------------------------------------------
|
||
void CodeEditor::keyPressEvent(QKeyEvent *event)
|
||
{
|
||
const int tabSize = m_settings->tabSize();
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Shift+Tab: Einrückung zurückziehen
|
||
// -----------------------------------------------------------------------
|
||
if (event->key() == Qt::Key_Backtab ||
|
||
(event->key() == Qt::Key_Tab && event->modifiers() & Qt::ShiftModifier))
|
||
{
|
||
QTextCursor cursor = textCursor();
|
||
|
||
QTextBlock startBlock = document()->findBlock(cursor.selectionStart());
|
||
QTextBlock endBlock = document()->findBlock(cursor.selectionEnd());
|
||
|
||
cursor.beginEditBlock();
|
||
for (QTextBlock b = startBlock; b != endBlock.next(); b = b.next())
|
||
{
|
||
const QString lineText = b.text();
|
||
int toRemove = 0;
|
||
|
||
if (m_settings->useSpacesForTabs())
|
||
{
|
||
// Bis zu tabSize führende Leerzeichen entfernen
|
||
for (int i = 0; i < tabSize && i < lineText.length(); ++i)
|
||
{
|
||
if (lineText[i] == ' ')
|
||
{
|
||
++toRemove;
|
||
}
|
||
else
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Einen führenden Tab entfernen
|
||
if (!lineText.isEmpty() && lineText[0] == '\t')
|
||
{
|
||
toRemove = 1;
|
||
}
|
||
}
|
||
|
||
if (toRemove > 0)
|
||
{
|
||
QTextCursor lineCursor(b);
|
||
lineCursor.movePosition(QTextCursor::StartOfBlock);
|
||
lineCursor.movePosition(QTextCursor::Right,
|
||
QTextCursor::KeepAnchor,
|
||
toRemove);
|
||
lineCursor.removeSelectedText();
|
||
}
|
||
}
|
||
cursor.endEditBlock();
|
||
return;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Tab: Einrücken (Leerzeichen oder echter Tab)
|
||
// -----------------------------------------------------------------------
|
||
if (event->key() == Qt::Key_Tab)
|
||
{
|
||
QTextCursor cursor = textCursor();
|
||
|
||
if (cursor.hasSelection())
|
||
{
|
||
// Mehrere Zeilen einrücken
|
||
QTextBlock startBlock = document()->findBlock(cursor.selectionStart());
|
||
QTextBlock endBlock = document()->findBlock(cursor.selectionEnd());
|
||
|
||
cursor.beginEditBlock();
|
||
for (QTextBlock b = startBlock; b != endBlock.next(); b = b.next())
|
||
{
|
||
QTextCursor lineCursor(b);
|
||
lineCursor.movePosition(QTextCursor::StartOfBlock);
|
||
if (m_settings->useSpacesForTabs())
|
||
{
|
||
lineCursor.insertText(QString(tabSize, ' '));
|
||
}
|
||
else
|
||
{
|
||
lineCursor.insertText("\t");
|
||
}
|
||
}
|
||
cursor.endEditBlock();
|
||
}
|
||
else
|
||
{
|
||
if (m_settings->useSpacesForTabs())
|
||
{
|
||
// Zum nächsten Tab-Stop auffüllen
|
||
const int col = cursor.columnNumber();
|
||
const int spacesNeeded = tabSize - (col % tabSize);
|
||
cursor.insertText(QString(spacesNeeded, ' '));
|
||
}
|
||
else
|
||
{
|
||
cursor.insertText("\t");
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Smart Backspace: springt zur vorherigen Einrückungsstufe
|
||
// -----------------------------------------------------------------------
|
||
if (event->key() == Qt::Key_Backspace
|
||
&& !textCursor().hasSelection()
|
||
&& m_settings->useSpacesForTabs())
|
||
{
|
||
QTextCursor cursor = textCursor();
|
||
const int col = cursor.columnNumber();
|
||
|
||
if (col > 0)
|
||
{
|
||
// Prüfen ob links vom Cursor nur Leerzeichen bis Zeilenbeginn stehen
|
||
const QString lineText = cursor.block().text();
|
||
const QString leftOfCursor = lineText.left(col);
|
||
const bool onlySpaces = leftOfCursor.trimmed().isEmpty();
|
||
|
||
if (onlySpaces && col > 0)
|
||
{
|
||
// Zur vorherigen Tab-Stop-Position springen
|
||
const int targetCol = ((col - 1) / tabSize) * tabSize;
|
||
const int toDelete = col - targetCol;
|
||
|
||
cursor.movePosition(QTextCursor::Left,
|
||
QTextCursor::KeepAnchor,
|
||
toDelete);
|
||
cursor.removeSelectedText();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Enter / Return: Auto-Indent
|
||
// -----------------------------------------------------------------------
|
||
if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter)
|
||
{
|
||
QTextCursor cursor = textCursor();
|
||
const QString currentLine = cursor.block().text();
|
||
|
||
// Führende Leerzeichen der aktuellen Zeile zählen
|
||
int leadingSpaces = 0;
|
||
for (const QChar &ch : currentLine)
|
||
{
|
||
if (ch == ' ')
|
||
{
|
||
++leadingSpaces;
|
||
}
|
||
else if (ch == '\t')
|
||
{
|
||
leadingSpaces += tabSize;
|
||
}
|
||
else
|
||
{
|
||
break;
|
||
}
|
||
}
|
||
|
||
QPlainTextEdit::keyPressEvent(event);
|
||
|
||
if (leadingSpaces > 0)
|
||
{
|
||
const QString indent = m_settings->useSpacesForTabs()
|
||
? QString(leadingSpaces, ' ')
|
||
: QString(leadingSpaces / tabSize, '\t');
|
||
textCursor().insertText(indent);
|
||
}
|
||
return;
|
||
}
|
||
|
||
QPlainTextEdit::keyPressEvent(event);
|
||
}
|
||
|