js8call/JS8_Main/PillRenderer.cpp
2026-03-25 07:10:18 -05:00

422 lines
16 KiB
C++

/**
* @file PillRenderer.cpp
* @brief Implementation of PillRenderer — pill painting, tooltips,
* and letter-spacing.
*/
#include "PillRenderer.h"
#include "DirectedMessageHighlighter.h"
#include "TransmitTextEdit.h"
#include <QAbstractTextDocumentLayout>
#include <QHelpEvent>
#include <QPainter>
#include <QScrollBar>
#include <QSignalBlocker>
#include <QTextBlock>
#include <QTextCharFormat>
#include <QTextCursor>
#include <QTextEdit>
#include <QTextFragment>
#include <QTextLayout>
#include <QToolTip>
// ---------------------------------------------------------------------------
// Construction
// ---------------------------------------------------------------------------
PillRenderer::PillRenderer(TransmitTextEdit *host, QObject *parent)
: QObject(parent), m_host(host) {
m_highlighter = new DirectedMessageHighlighter(host->document());
m_defaultDocumentMargin = host->document()->documentMargin();
m_defaultBlockFormat = host->document()->firstBlock().blockFormat();
{
QSignalBlocker blocker(host->document());
host->beginInternalDocumentMutation();
// Margin >= PillHPad so the first pill's left padding doesn't clip.
host->document()->setDocumentMargin(PillRenderer::PillHPad);
// Line height 140% gives pills vertical breathing room.
QTextCursor cursor(host->document());
cursor.select(QTextCursor::Document);
QTextBlockFormat blockFmt;
blockFmt.setLineHeight(140, QTextBlockFormat::ProportionalHeight);
cursor.mergeBlockFormat(blockFmt);
host->endInternalDocumentMutation();
}
}
// ---------------------------------------------------------------------------
// Pill painting
// ---------------------------------------------------------------------------
void PillRenderer::paintPills(QPaintEvent * /*event*/) {
if (!m_enabled)
return;
QPainter painter(m_host->viewport());
painter.setRenderHint(QPainter::Antialiasing, true);
painter.setPen(Qt::NoPen);
auto *doc = m_host->document();
auto *docLayout = doc->documentLayout();
QPointF offset(-m_host->horizontalScrollBar()->value(),
-m_host->verticalScrollBar()->value());
QFontMetricsF fm(doc->defaultFont());
qreal capH = fm.capHeight();
QTextBlock block = doc->firstBlock();
while (block.isValid()) {
auto *layout = block.layout();
if (layout) {
QRectF blockRect = docLayout->blockBoundingRect(block);
blockRect.translate(offset);
// Group pill rectangles by pill ID within each visual line.
struct PillInfo {
QColor color;
QRectF rect;
int lineIndex = 0;
};
QHash<quint64, PillInfo> pillGroups;
auto pillFragmentKey = [](int groupId, int lineIndex) {
quint64 pillId = static_cast<quint64>(
static_cast<quint32>(groupId));
return (pillId << 32) | static_cast<quint32>(lineIndex);
};
for (const auto &range : layout->formats()) {
auto colorVar = range.format.property(
DirectedMessageHighlighter::PillColorProperty);
if (!colorVar.isValid())
continue;
auto groupVar = range.format.property(
DirectedMessageHighlighter::PillGroupProperty);
int groupId = groupVar.isValid() ? groupVar.toInt() : -1;
QColor pillColor = colorVar.value<QColor>();
int rangeStart = range.start;
int rangeLen = range.length;
for (int li = 0; li < layout->lineCount(); ++li) {
QTextLine line = layout->lineAt(li);
if (rangeStart >= line.textStart() + line.textLength())
continue;
if (rangeStart + rangeLen <= line.textStart())
break;
int lineRangeStart = qMax(rangeStart, line.textStart());
int lineRangeEnd = qMin(
rangeStart + rangeLen,
line.textStart() + line.textLength());
qreal x1 =
qRound(line.cursorToX(lineRangeStart) * 2.0) / 2.0;
qreal x2 =
qRound(line.cursorToX(lineRangeEnd) * 2.0) / 2.0;
QRectF lineRect = line.rect();
// Bias vertical centering toward cap-height; 0.35 blends
// between tight cap-height and full line-height.
qreal baseline = lineRect.y() + line.ascent();
qreal extra = (lineRect.height() - capH) * 0.35;
qreal textTop = baseline - capH - extra;
qreal textH = capH + 2.0 * extra;
QRectF rangeRect(blockRect.x() + qMin(x1, x2),
blockRect.y() + textTop, qAbs(x2 - x1),
textH);
quint64 fragmentKey =
pillFragmentKey(groupId >= 0 ? groupId : rangeStart,
li);
if (pillGroups.contains(fragmentKey)) {
pillGroups[fragmentKey].rect =
pillGroups[fragmentKey].rect.united(rangeRect);
} else {
pillGroups[fragmentKey] = {pillColor, rangeRect, li};
}
}
}
// Add padding around each pill fragment and keep overlap handling
// scoped to the visual line where it is drawn.
struct DrawnPill {
QRectF baseRect;
QRectF rect;
QColor color;
};
QHash<int, QList<DrawnPill>> pillsByLine;
for (auto it = pillGroups.constBegin(); it != pillGroups.constEnd();
++it) {
DrawnPill pill;
pill.baseRect = it.value().rect;
pill.rect = pill.baseRect.adjusted(
-PillRenderer::PillHPad,
-PillRenderer::PillVPad,
PillRenderer::PillHPad,
PillRenderer::PillVPad);
pill.color = it.value().color;
pillsByLine[it.value().lineIndex].append(pill);
}
QList<int> lineIndexes = pillsByLine.keys();
std::sort(lineIndexes.begin(), lineIndexes.end());
for (int lineIndex : lineIndexes) {
auto &pills = pillsByLine[lineIndex];
std::sort(pills.begin(), pills.end(),
[](const DrawnPill &a, const DrawnPill &b) {
return a.rect.left() < b.rect.left();
});
// Shrink overlapping padding symmetrically, clamped to
// PillMinHPad, only among pills that share this visual line.
for (int i = 0; i + 1 < pills.size(); ++i) {
qreal overlap =
pills[i].rect.right() - pills[i + 1].rect.left() +
PillRenderer::PillMinGap;
if (overlap > 0) {
qreal shrink = overlap / 2.0;
pills[i].rect.setRight(
qMax(pills[i].rect.right() - shrink,
pills[i].baseRect.right() +
PillRenderer::PillMinHPad));
pills[i + 1].rect.setLeft(
qMin(pills[i + 1].rect.left() + shrink,
pills[i + 1].baseRect.left() -
PillRenderer::PillMinHPad));
}
}
for (auto &pill : pills) {
if (pill.rect.left() < 1.0)
pill.rect.setLeft(1.0);
}
for (const auto &pill : pills) {
qreal halfHeight = pill.rect.height() / 2.0;
qreal r = qMin(PillRenderer::PillMaxRadius, halfHeight);
r = qMax(r, PillRenderer::PillMinRadius);
painter.setBrush(pill.color);
painter.drawRoundedRect(pill.rect, r, r);
}
}
}
block = block.next();
}
}
// ---------------------------------------------------------------------------
// Tooltip handling
// ---------------------------------------------------------------------------
bool PillRenderer::handleTooltip(QHelpEvent *helpEvent) {
if (!m_enabled)
return false;
auto *doc = m_host->document();
QPointF pos = helpEvent->pos();
int charPos = doc->documentLayout()->hitTest(
pos + QPointF(m_host->horizontalScrollBar()->value(),
m_host->verticalScrollBar()->value()),
Qt::ExactHit);
if (charPos >= 0) {
QTextBlock block = doc->findBlock(charPos);
if (block.isValid() && block.layout()) {
int posInBlock = charPos - block.position();
for (const auto &range : block.layout()->formats()) {
if (posInBlock >= range.start &&
posInBlock < range.start + range.length) {
QString tip = range.format.toolTip();
if (!tip.isEmpty()) {
QToolTip::showText(helpEvent->globalPos(), tip,
m_host);
return true;
}
}
}
}
}
QToolTip::hideText();
return false;
}
// ---------------------------------------------------------------------------
// Highlighting + letter-spacing
// ---------------------------------------------------------------------------
void PillRenderer::applyPillHighlighting() {
refreshPillState();
}
// ---------------------------------------------------------------------------
// Enable / disable
// ---------------------------------------------------------------------------
void PillRenderer::setEnabled(bool enabled) {
if (m_enabled == enabled)
return;
m_enabled = enabled;
m_highlighter->setEnabled(enabled);
m_host->highlight();
}
void PillRenderer::setPillColors(const PillColorScheme &scheme) {
m_highlighter->setPillColors(scheme);
}
void PillRenderer::setSelectedCallsign(const QString &callsign) {
if (callsign.compare(m_selectedCallsign, Qt::CaseInsensitive) == 0)
return;
m_selectedCallsign = callsign.toUpper();
m_highlighter->setSelectedCallsign(callsign);
m_host->highlight();
}
void PillRenderer::refreshPillState() {
auto *doc = m_host->document();
if (!doc)
return;
Q_ASSERT(m_host->isInternalDocumentMutationActive());
doc->setDocumentMargin(m_enabled ? PillRenderer::PillHPad
: m_defaultDocumentMargin);
applyLineHeight(m_enabled);
m_highlighter->rehighlight();
clearPillLetterSpacing();
if (m_enabled)
applyPillLetterSpacing();
m_host->viewport()->update();
}
void PillRenderer::applyLineHeight(bool enabled) const {
QTextBlock block = m_host->document()->firstBlock();
while (block.isValid()) {
QTextBlockFormat blockFmt = block.blockFormat();
const int targetHeight =
enabled ? 140 : m_defaultBlockFormat.lineHeight();
const auto targetType = enabled ? QTextBlockFormat::ProportionalHeight
: m_defaultBlockFormat.lineHeightType();
if (blockFmt.lineHeight() != targetHeight ||
blockFmt.lineHeightType() != targetType) {
blockFmt.setLineHeight(targetHeight, targetType);
QTextCursor cursor(block);
cursor.setBlockFormat(blockFmt);
}
block = block.next();
}
}
void PillRenderer::clearPillLetterSpacing() const {
auto *doc = m_host->document();
const qreal interPillMargin =
2.0 * PillRenderer::PillHPad + PillRenderer::PillMinGap;
const auto isPillSpacing = [interPillMargin](const QTextCharFormat &fmt) {
if (fmt.fontLetterSpacingType() != QFont::AbsoluteSpacing)
return false;
return qFuzzyCompare(fmt.fontLetterSpacing() + 1.0,
PillRenderer::PillHPad + 1.0) ||
qFuzzyCompare(fmt.fontLetterSpacing() + 1.0,
interPillMargin + 1.0);
};
const QFont defaultFont = doc->defaultFont();
QTextBlock block = doc->firstBlock();
while (block.isValid()) {
for (QTextBlock::iterator it = block.begin(); !it.atEnd(); ++it) {
const QTextFragment fragment = it.fragment();
if (!fragment.isValid())
continue;
const QTextCharFormat currentFmt = fragment.charFormat();
if (!isPillSpacing(currentFmt))
continue;
QTextCursor cursor(doc);
cursor.setPosition(fragment.position());
cursor.setPosition(fragment.position() + fragment.length(),
QTextCursor::KeepAnchor);
QTextCharFormat resetFmt;
resetFmt.setFontLetterSpacing(defaultFont.letterSpacing());
resetFmt.setFontLetterSpacingType(defaultFont.letterSpacingType());
cursor.mergeCharFormat(resetFmt);
}
block = block.next();
}
}
void PillRenderer::applyPillLetterSpacing() const {
auto *doc = m_host->document();
const qreal pillMargin = PillRenderer::PillHPad;
const qreal interPillMargin =
2.0 * PillRenderer::PillHPad + PillRenderer::PillMinGap;
struct SpaceInfo {
int absPos;
qreal spacing;
};
QList<SpaceInfo> spacesToAdjust;
QTextBlock block = doc->firstBlock();
while (block.isValid()) {
QTextLayout *layout = block.layout();
if (layout) {
QHash<int, int> groupEnds;
QSet<int> pillStarts;
for (const auto &range : layout->formats()) {
auto colorVar = range.format.property(
DirectedMessageHighlighter::PillColorProperty);
if (!colorVar.isValid())
continue;
auto groupVar = range.format.property(
DirectedMessageHighlighter::PillGroupProperty);
int groupId = groupVar.isValid() ? groupVar.toInt() : -1;
int end = range.start + range.length;
if (!groupEnds.contains(groupId) || end > groupEnds[groupId])
groupEnds[groupId] = end;
pillStarts.insert(range.start);
}
const QString text = block.text();
for (auto it = groupEnds.constBegin(); it != groupEnds.constEnd();
++it) {
const int end = it.value();
if (end < text.length() && text.at(end) == QLatin1Char(' ')) {
const bool nextIsPill = pillStarts.contains(end + 1);
const qreal spacing =
nextIsPill ? interPillMargin : pillMargin;
spacesToAdjust.append({block.position() + end, spacing});
}
}
}
block = block.next();
}
for (const auto &si : spacesToAdjust) {
QTextCursor cursor(doc);
cursor.setPosition(si.absPos);
cursor.movePosition(QTextCursor::NextCharacter,
QTextCursor::KeepAnchor);
QTextCharFormat fmt;
fmt.setFontLetterSpacing(si.spacing);
fmt.setFontLetterSpacingType(QFont::AbsoluteSpacing);
cursor.mergeCharFormat(fmt);
}
}