fix: add missing header declarations and editor scroll fixes

- mainwindow.h: add m_viewBtnGroup, m_btnReclass, m_btnRendered members,
  syncViewButtons() declaration, QButtonGroup/QPushButton includes,
  remove applyTabWidgetStyle() declaration
- editor.cpp: reset xOffset on applyDocument, clamp in restoreViewState
- test_editor.cpp: add horizontal scroll reset test
This commit is contained in:
IChooseYou
2026-02-20 13:22:23 -07:00
parent 3b1fe7ff35
commit 5fa1dd0ab4
3 changed files with 365 additions and 3 deletions

View File

@@ -808,6 +808,10 @@ void RcxEditor::applyDocument(const ComposeResult& result) {
int pixelWidth = fm.horizontalAdvance(QString(maxLen, QChar('0'))); int pixelWidth = fm.horizontalAdvance(QString(maxLen, QChar('0')));
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSCROLLWIDTH, m_sci->SendScintilla(QsciScintillaBase::SCI_SETSCROLLWIDTH,
(unsigned long)qMax(1, pixelWidth)); (unsigned long)qMax(1, pixelWidth));
// Reset horizontal scroll to 0. The controller's restoreViewState()
// will set it back to the (clamped) saved position afterward.
m_sci->SendScintilla(QsciScintillaBase::SCI_SETXOFFSET, (unsigned long)0);
} }
// Force full re-lex to fix stale syntax coloring after edits // Force full re-lex to fix stale syntax coloring after edits
@@ -1130,8 +1134,13 @@ void RcxEditor::restoreViewState(const ViewState& vs) {
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, (unsigned long)pos); m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, (unsigned long)pos);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETFIRSTVISIBLELINE, m_sci->SendScintilla(QsciScintillaBase::SCI_SETFIRSTVISIBLELINE,
(unsigned long)vs.scrollLine); (unsigned long)vs.scrollLine);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETXOFFSET, // Clamp xOffset so it doesn't exceed the current content width.
(unsigned long)vs.xOffset); // After a rename that shrinks content, the saved offset may be stale.
int scrollW = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_GETSCROLLWIDTH);
int vpW = m_sci->viewport() ? m_sci->viewport()->width() : 0;
int maxXOff = qMax(0, scrollW - vpW);
int xOff = qBound(0, vs.xOffset, maxXOff);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETXOFFSET, (unsigned long)xOff);
} }
const LineMeta* RcxEditor::metaForLine(int line) const { const LineMeta* RcxEditor::metaForLine(int line) const {

View File

@@ -12,6 +12,8 @@
#include <QTreeView> #include <QTreeView>
#include <QStandardItemModel> #include <QStandardItemModel>
#include <QMap> #include <QMap>
#include <QButtonGroup>
#include <QPushButton>
#include <Qsci/qsciscintilla.h> #include <Qsci/qsciscintilla.h>
namespace rcx { namespace rcx {
@@ -67,6 +69,9 @@ private:
QMdiArea* m_mdiArea; QMdiArea* m_mdiArea;
QLabel* m_statusLabel; QLabel* m_statusLabel;
QButtonGroup* m_viewBtnGroup = nullptr;
QPushButton* m_btnReclass = nullptr;
QPushButton* m_btnRendered = nullptr;
TitleBarWidget* m_titleBar = nullptr; TitleBarWidget* m_titleBar = nullptr;
QWidget* m_borderOverlay = nullptr; QWidget* m_borderOverlay = nullptr;
PluginManager m_pluginManager; PluginManager m_pluginManager;
@@ -114,8 +119,8 @@ private:
SplitPane createSplitPane(TabState& tab); SplitPane createSplitPane(TabState& tab);
void applyTheme(const Theme& theme); void applyTheme(const Theme& theme);
void applyTabWidgetStyle(QTabWidget* tw);
void styleTabCloseButtons(); void styleTabCloseButtons();
void syncViewButtons(ViewMode mode);
SplitPane* findPaneByTabWidget(QTabWidget* tw); SplitPane* findPaneByTabWidget(QTabWidget* tw);
SplitPane* findActiveSplitPane(); SplitPane* findActiveSplitPane();
RcxEditor* activePaneEditor(); RcxEditor* activePaneEditor();

View File

@@ -14,6 +14,12 @@
#include <QScreen> #include <QScreen>
#include <QMainWindow> #include <QMainWindow>
#include <QStatusBar> #include <QStatusBar>
#include <QPushButton>
#include <QButtonGroup>
#include <QLabel>
#include <QLayout>
#include <QHBoxLayout>
#include <QScrollBar>
#include <Qsci/qsciscintilla.h> #include <Qsci/qsciscintilla.h>
#include <Qsci/qsciscintillabase.h> #include <Qsci/qsciscintillabase.h>
#include "editor.h" #include "editor.h"
@@ -2048,11 +2054,353 @@ private slots:
m_editor->applyDocument(m_result); m_editor->applyDocument(m_result);
} }
// ── Test: status bar view toggle buttons (pixel-level) ──
void testStatusBarViewToggleButtons() {
// Mirror the production ViewTabButton from main.cpp
static constexpr int kAccentH = 2;
static constexpr int kPadLR = 12;
static constexpr int kPadBot = 4;
class VTB : public QPushButton {
public:
QColor colBg, colBgChecked, colBgHover, colBgPressed;
QColor colText, colTextMuted, colAccent;
explicit VTB(const QString& t, QWidget* p = nullptr) : QPushButton(t, p) {
setCheckable(true); setFlat(true); setContentsMargins(0,0,0,0);
setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Ignored);
}
QSize sizeHint() const override {
QFontMetrics fm(font());
return QSize(fm.horizontalAdvance(text()) + 2*kPadLR,
fm.height() + kAccentH + kPadBot);
}
protected:
void paintEvent(QPaintEvent*) override {
QPainter p(this);
QColor bg = colBg;
if (isDown()) bg = colBgPressed;
else if (underMouse()) bg = colBgHover;
else if (isChecked()) bg = colBgChecked;
p.fillRect(rect(), bg);
if (isChecked())
p.fillRect(0, 0, width(), kAccentH, colAccent);
p.setPen(isChecked() || underMouse() || isDown() ? colText : colTextMuted);
p.setFont(font());
QRect tr(kPadLR, kAccentH, width()-2*kPadLR, height()-kAccentH);
p.drawText(tr, Qt::AlignVCenter|Qt::AlignLeft, text());
}
void enterEvent(QEnterEvent*) override { update(); }
void leaveEvent(QEvent*) override { update(); }
};
QColor bg(30,30,30), bgAlt(45,45,48), hover(62,62,66);
QColor text(212,212,212), textMuted(128,128,128);
QColor accent("#b180d7");
QColor pressed = hover.darker(130);
auto setColors = [&](VTB* b) {
b->colBg = bg; b->colBgChecked = bgAlt; b->colBgHover = hover;
b->colBgPressed = pressed; b->colText = text;
b->colTextMuted = textMuted; b->colAccent = accent;
};
// Borderless status bar with manual layout (mirrors production FlatStatusBar)
class FSB : public QStatusBar {
public:
QWidget* tabRow = nullptr;
QLabel* label = nullptr;
FSB() { setSizeGripEnabled(false); }
protected:
void paintEvent(QPaintEvent*) override {
QPainter p(this); p.fillRect(rect(), palette().window());
}
void resizeEvent(QResizeEvent* e) override {
QStatusBar::resizeEvent(e);
doLayout();
}
void showEvent(QShowEvent* e) override {
QStatusBar::showEvent(e);
doLayout();
}
private:
void doLayout() {
if (!tabRow || !label) return;
int h = height(), tw = tabRow->sizeHint().width();
tabRow->setGeometry(0, 0, tw, h);
label->setGeometry(tw, 0, width() - tw, h);
}
};
QMainWindow win;
win.resize(600, 400);
QPalette pal; pal.setColor(QPalette::Window, bg);
win.setPalette(pal);
auto* sb = new FSB;
win.setStatusBar(sb);
sb->setPalette(pal);
sb->setAutoFillBackground(true);
if (win.layout()) {
win.layout()->setSpacing(0);
win.layout()->setContentsMargins(0,0,0,0);
}
auto* btnGroup = new QButtonGroup(&win);
btnGroup->setExclusive(true);
auto* btnR = new VTB("Reclass");
auto* btnC = new VTB("C/C++");
setColors(btnR); setColors(btnC);
btnR->setChecked(true);
btnGroup->addButton(btnR, 0);
btnGroup->addButton(btnC, 1);
auto* tabRow = new QWidget(sb);
auto* tabLay = new QHBoxLayout(tabRow);
tabLay->setContentsMargins(0,0,0,0);
tabLay->setSpacing(0);
tabLay->addWidget(btnR);
tabLay->addWidget(btnC);
auto* lbl = new QLabel("Ready", sb);
lbl->setContentsMargins(10,0,0,0);
sb->tabRow = tabRow;
sb->label = lbl;
win.show();
QVERIFY(QTest::qWaitForWindowExposed(&win));
QTest::qWait(100);
// ── Toggle logic ──
QVERIFY(btnR->isChecked());
QVERIFY(!btnC->isChecked());
QTest::mouseClick(btnC, Qt::LeftButton);
QVERIFY(btnC->isChecked());
QVERIFY(!btnR->isChecked());
QTest::mouseClick(btnR, Qt::LeftButton);
QVERIFY(btnR->isChecked());
QTest::qWait(50);
// ── Pixel: accent line on checked button at rows 0..(kAccentH-1) ──
QImage imgR = btnR->grab().toImage().convertToFormat(QImage::Format_ARGB32);
QVERIFY(imgR.height() >= kAccentH + 4);
// Every pixel in the top kAccentH rows (middle 80% width) must be accent
int x0 = imgR.width() / 10, x1 = imgR.width() * 9 / 10;
for (int y = 0; y < kAccentH; y++) {
for (int x = x0; x < x1; x++) {
QColor c(imgR.pixel(x, y));
QVERIFY2(qAbs(c.red() - accent.red()) < 10
&& qAbs(c.green() - accent.green()) < 10
&& qAbs(c.blue() - accent.blue()) < 10,
qPrintable(QString("Checked btn pixel(%1,%2)=%3 expected accent %4")
.arg(x).arg(y).arg(c.name(), accent.name())));
}
}
// Mid-height row must NOT be accent (accent doesn't bleed into body)
{
int midY = imgR.height() / 2;
QColor c(imgR.pixel(imgR.width()/2, midY));
QVERIFY2(qAbs(c.red() - accent.red()) > 15
|| qAbs(c.green() - accent.green()) > 15
|| qAbs(c.blue() - accent.blue()) > 15,
qPrintable(QString("Row %1 should be background, not accent: %2")
.arg(midY).arg(c.name())));
}
// ── Pixel: unchecked button has NO accent line ──
QImage imgC = btnC->grab().toImage().convertToFormat(QImage::Format_ARGB32);
for (int y = 0; y < kAccentH; y++) {
QColor c(imgC.pixel(imgC.width()/2, y));
QVERIFY2(qAbs(c.red() - accent.red()) > 15
|| qAbs(c.green() - accent.green()) > 15
|| qAbs(c.blue() - accent.blue()) > 15,
qPrintable(QString("Unchecked btn row %1 has accent: %2")
.arg(y).arg(c.name())));
}
// ── Pixel: zero gap between the two buttons ──
// Map to their shared parent (the tabRow container)
QWidget* container = btnR->parentWidget();
int rRight = btnR->mapTo(container, QPoint(btnR->width(), 0)).x();
int cLeft = btnC->mapTo(container, QPoint(0, 0)).x();
QVERIFY2(rRight == cLeft,
qPrintable(QString("Gap between buttons: btnR right=%1 btnC left=%2 gap=%3")
.arg(rRight).arg(cLeft).arg(cLeft - rRight)));
// ── Pressed color is darker than hover ──
QVERIFY2(pressed.lightness() < hover.lightness(),
qPrintable(QString("Pressed %1 should be darker than hover %2")
.arg(pressed.name(), hover.name())));
// ── Button starts at x=0 in status bar (no left padding) ──
QPoint btnTopLeft = tabRow->mapTo(sb, QPoint(0, 0));
QVERIFY2(btnTopLeft.x() == 0,
qPrintable(QString("Tab row left margin: x=%1, expected 0").arg(btnTopLeft.x())));
// ── Button starts at y=0 in status bar (no top padding) ──
QVERIFY2(btnTopLeft.y() == 0,
qPrintable(QString("Tab row top margin: y=%1, expected 0").arg(btnTopLeft.y())));
// ── Button takes full status bar height ──
QVERIFY2(btnR->height() == sb->height(),
qPrintable(QString("Button height=%1 sb height=%2")
.arg(btnR->height()).arg(sb->height())));
// ── Accent at y=0 in status bar pixel coordinates (grab status bar) ──
QImage sbImg = sb->grab().toImage().convertToFormat(QImage::Format_ARGB32);
{
QColor c(sbImg.pixel(btnR->width()/2, 0));
QVERIFY2(qAbs(c.red() - accent.red()) < 10
&& qAbs(c.green() - accent.green()) < 10
&& qAbs(c.blue() - accent.blue()) < 10,
qPrintable(QString("Status bar pixel(x,%1,0)=%2 expected accent %3")
.arg(btnR->width()/2).arg(c.name(), accent.name())));
}
qDebug() << QString("ViewTabButton: accent=%1 btnH=%2 sbH=%3 gap=%4 leftX=%5 topY=%6")
.arg(accent.name()).arg(btnR->height()).arg(sb->height())
.arg(cLeft - rRight).arg(btnTopLeft.x()).arg(btnTopLeft.y());
}
// ── Test: resize grip dots are equidistant from right and bottom window edges ── // ── Test: resize grip dots are equidistant from right and bottom window edges ──
// The grip is a direct child of the window positioned via move(), not inside // The grip is a direct child of the window positioned via move(), not inside
// the status bar layout. This test verifies the dot placement is symmetric // the status bar layout. This test verifies the dot placement is symmetric
// regardless of font, and runs the check at two different font sizes to prove // regardless of font, and runs the check at two different font sizes to prove
// font independence. // font independence.
// ── Test: horizontal scrollbar after long name rename ──
void testHScrollResetAfterNameShrink() {
// Use a dedicated narrow editor so content easily overflows the viewport
auto* editor = new RcxEditor();
editor->resize(200, 300);
editor->show();
QVERIFY(QTest::qWaitForWindowExposed(editor));
auto* sci = editor->scintilla();
auto* hbar = sci->horizontalScrollBar();
auto makeTree = [](const QString& fieldName) {
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.structTypeName = "MyStruct";
root.name = "s";
root.parentId = 0;
root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node f;
f.kind = NodeKind::Int32;
f.name = fieldName;
f.parentId = rootId;
f.offset = 0;
tree.addNode(f);
return tree;
};
BufferProvider prov(QByteArray(64, '\0'));
// ── Step 1: long name → wide content, scrollbar must appear ──
QString longName = QString(120, QChar('W'));
{
NodeTree tree = makeTree(longName);
ComposeResult cr = compose(tree, prov);
editor->applyDocument(cr);
QApplication::processEvents();
QTest::qWait(50);
}
int scrollW1 = (int)sci->SendScintilla(QsciScintillaBase::SCI_GETSCROLLWIDTH);
int viewW = sci->viewport()->width();
qDebug() << QString("Long name: scrollW=%1 vpW=%2 hbar.visible=%3 "
"hbar.max=%4 hbar.value=%5")
.arg(scrollW1).arg(viewW)
.arg(hbar->isVisible())
.arg(hbar->maximum()).arg(hbar->value());
QVERIFY2(scrollW1 > viewW,
qPrintable(QString("scrollW=%1 should exceed vpW=%2")
.arg(scrollW1).arg(viewW)));
// Scrollbar must be visible when content overflows
QVERIFY2(hbar->isVisible(),
"Horizontal scrollbar should be visible when content overflows");
QVERIFY2(hbar->maximum() > 0,
qPrintable(QString("Scrollbar max should be >0, got %1")
.arg(hbar->maximum())));
// Simulate user scrolled right
sci->SendScintilla(QsciScintillaBase::SCI_SETXOFFSET, (unsigned long)(scrollW1 / 2));
QApplication::processEvents();
QTest::qWait(20);
int xOff1 = (int)sci->SendScintilla(QsciScintillaBase::SCI_GETXOFFSET);
QVERIFY2(xOff1 > 0, "X offset should be non-zero after scrolling right");
// ── Step 2: short name → narrower content ──
{
NodeTree tree = makeTree("x");
ComposeResult cr = compose(tree, prov);
editor->applyDocument(cr);
QApplication::processEvents();
QTest::qWait(50);
}
int scrollW2 = (int)sci->SendScintilla(QsciScintillaBase::SCI_GETSCROLLWIDTH);
int xOff2 = (int)sci->SendScintilla(QsciScintillaBase::SCI_GETXOFFSET);
qDebug() << QString("Short name: scrollW=%1 xOff=%2 vpW=%3 hbar.visible=%4 "
"hbar.max=%5 hbar.value=%6")
.arg(scrollW2).arg(xOff2).arg(viewW)
.arg(hbar->isVisible())
.arg(hbar->maximum()).arg(hbar->value());
// Scroll width should have shrunk
QVERIFY2(scrollW2 < scrollW1,
qPrintable(QString("scrollW should shrink: was %1, now %2")
.arg(scrollW1).arg(scrollW2)));
// X offset must be clamped to max(0, scrollW - viewportW)
int maxValidXOff = qMax(0, scrollW2 - viewW);
QVERIFY2(xOff2 <= maxValidXOff,
qPrintable(QString("xOffset=%1 exceeds max valid=%2 (scrollW=%3 vpW=%4)")
.arg(xOff2).arg(maxValidXOff).arg(scrollW2).arg(viewW)));
// If content fits viewport entirely, offset must be 0
if (scrollW2 <= viewW) {
QCOMPARE(xOff2, 0);
}
// If content still overflows, scrollbar must still be visible
if (scrollW2 > viewW) {
QVERIFY2(hbar->isVisible(),
"Scrollbar should remain visible when content still overflows");
}
// ── Step 3: apply long name again → scrollbar must reappear ──
{
NodeTree tree = makeTree(longName);
ComposeResult cr = compose(tree, prov);
editor->applyDocument(cr);
QApplication::processEvents();
QTest::qWait(50);
}
int scrollW3 = (int)sci->SendScintilla(QsciScintillaBase::SCI_GETSCROLLWIDTH);
int xOff3 = (int)sci->SendScintilla(QsciScintillaBase::SCI_GETXOFFSET);
qDebug() << QString("Long again: scrollW=%1 xOff=%2 hbar.visible=%3 hbar.max=%4")
.arg(scrollW3).arg(xOff3)
.arg(hbar->isVisible()).arg(hbar->maximum());
QVERIFY2(scrollW3 > viewW,
qPrintable(QString("scrollW=%1 should exceed vpW=%2 after re-widen")
.arg(scrollW3).arg(viewW)));
QVERIFY2(hbar->isVisible(),
"Scrollbar must reappear after content widens again");
// After fresh apply with no prior scroll, xOffset should be 0
QCOMPARE(xOff3, 0);
delete editor;
}
void testResizeGripCornerSymmetry() { void testResizeGripCornerSymmetry() {
// Same constants as production ResizeGrip in main.cpp // Same constants as production ResizeGrip in main.cpp
static constexpr int kSize = 16; static constexpr int kSize = 16;