Something went wrong. Try again.
The agentic engineering control plane for the posthuman future
Something went wrong. Try again.
65 kB · 1665 lines
C++
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666#include "TerminalRenderer.h"
#include "GlyphStrip.h"
#include <ghostty/vt.h>
#include <QColor>#include <QDateTime>#include <QElapsedTimer>#include <QFile>#include <QFont>#include <QFontMetricsF>#include <QGlyphRun>#include <QImage>#include <QMatrix4x4>#include <QPainter>#include <QRawFont>#include <QSet>#include <QTextCharFormat>#include <QTextLayout>#include <QtGlobal>
#include <cstdio>
#include <rhi/qrhi.h>#include <rhi/qshader.h>
#include <algorithm>#include <array>#include <cmath>#include <cstddef>#include <cstdint>#include <cstring>#include <map>#include <print>#include <ranges>#include <span>#include <utility>
namespace {
// Monotonic wall-clock stamp (ms since boot) so perf lines can be diffed to// expose stalls where NO render runs (a blocked GUI thread emits nothing).qint64 perfStampMs() { return QDateTime::currentMSecsSinceEpoch();}
// PERF_DEBUG env: log per-frame timings to stderr. Read once at startup.const bool perfDebug = qEnvironmentVariableIsSet("PERF_DEBUG");
// Convert an ARGB uint32 to a normalized RGBA float.inline void argbToRgba(uint32_t argb, std::span<float, 4> out) { out[0] = static_cast<float>((argb >> 16) & 0xFFu) / 255.0f; out[1] = static_cast<float>((argb >> 8) & 0xFFu) / 255.0f; out[2] = static_cast<float>(argb & 0xFFu) / 255.0f; out[3] = static_cast<float>((argb >> 24) & 0xFFu) / 255.0f;}
// std140 layout of the shared uniform block:// mat4 u_projection; int u_useTexture; float _pad0..2;// mat4 = 64 bytes, then a 16-byte block for the int + 3 float padding.// NOLINTBEGIN(cppcoreguidelines-avoid-c-arrays,modernize-avoid-c-arrays)// Fixed GPU std140 layout: C arrays keep the byte layout explicit and the// static_assert below pins it; std::array would add .data() noise for no gain.struct UniformBlock { float m[16]; int useTexture; float pad[3];};// NOLINTEND(cppcoreguidelines-avoid-c-arrays,modernize-avoid-c-arrays)static_assert(sizeof(UniformBlock) == 80, "UniformBlock must be 80 bytes");
// Grow a dynamic vertex buffer so its byte capacity covers neededBytes.// QRhi defers the native release of the replaced buffer until the command// buffers referencing it complete, so destroying it here is safe.void growVertexBuffer(QRhi* rhi, QRhiBuffer*& vbo, quint32 neededBytes) { const quint32 oldSize = (vbo != nullptr) ? vbo->size() : 0u; quint32 newBytes = oldSize * 2u; newBytes = std::max(newBytes, neededBytes); QRhiBuffer* replacement = rhi->newBuffer(QRhiBuffer::Dynamic, QRhiBuffer::VertexBuffer, newBytes); replacement->create(); if (vbo != nullptr) { vbo->destroy(); delete vbo; } vbo = replacement;}
} // namespace
TerminalRenderer::TerminalRenderer() = default;
TerminalRenderer::~TerminalRenderer() { clearColorStrips(); clearAtlas(); if (m_whiteTexture != nullptr) { m_whiteTexture->destroy(); delete m_whiteTexture; } if (m_sampler != nullptr) { m_sampler->destroy(); delete m_sampler; } if (m_srb != nullptr) { m_srb->destroy(); delete m_srb; } if (m_pipeline != nullptr) { m_pipeline->destroy(); delete m_pipeline; } if (m_vbo != nullptr) { m_vbo->destroy(); delete m_vbo; } if (m_solidUbo != nullptr) { m_solidUbo->destroy(); delete m_solidUbo; } if (m_texturedUbo != nullptr) { m_texturedUbo->destroy(); delete m_texturedUbo; } if (m_imageVbo != nullptr) { m_imageVbo->destroy(); delete m_imageVbo; } for (auto& [key, it] : m_imageTextures) { (void)key; if (it.tex != nullptr) { it.tex->destroy(); delete it.tex; } if (it.srb != nullptr) { it.srb->destroy(); delete it.srb; } } m_imageTextures.clear(); // m_rhi, m_colorTexture and m_renderTarget are owned by the QQuickRhiItem.}
void TerminalRenderer::setFont(const QFont& font) { if (m_fontSet && m_font == font) { return; } m_font = font; m_fontSet = true; // Metrics depend on the font; recompute on next use. m_cellW = 0.0f; m_cellH = 0.0f; // Every atlas tile and color strip was rasterized with the old font: // re-place and re-upload them on the next pass. clearAtlas(); clearColorStrips(); // Shape cache holds QTextLine objects whose layout was built with the // old font; drop so the next frame rebuilds with the new one. clearShapeCache(); // Cached row draw metadata was computed with the old font's metrics; // drop so every row is rebuilt (regardless of the frame's dirty flags). m_rowGeometry.clear();}
void TerminalRenderer::resize(int width, int height) { m_width = width; m_height = height;}
void TerminalRenderer::setTopPadding(float pad) { m_topPadding = pad; }
void TerminalRenderer::setScrollFraction(float px) { m_scrollFractionPx = px;}
void TerminalRenderer::setHoverSpan(int row, int startCol, int endCol) { if (startCol > endCol) { row = -1; } m_hoverRow = row; m_hoverStartCol = startCol; m_hoverEndCol = endCol;}
void TerminalRenderer::setSearchSpans( const std::vector<SearchSpan>& spans) { m_searchSpans = spans;}
void TerminalRenderer::setCopySelectionSpan(int startRow, int startCol, int endRow, int endCol) { m_copySelStartRow = startRow; m_copySelStartCol = startCol; m_copySelEndRow = endRow; m_copySelEndCol = endCol;}
void TerminalRenderer::setCopyCursor(int row, int col) { m_copyCursorRow = row; m_copyCursorCol = col;}
void TerminalRenderer::ensureFontMetrics() { if (m_cellW > 0.0f && m_cellH > 0.0f) { return; } const QFontMetricsF fm(m_font); m_cellW = static_cast<float>(fm.horizontalAdvance(QStringLiteral("M"))); if (m_cellW <= 0.0f) { m_cellW = static_cast<float>(fm.averageCharWidth()); } if (m_cellW <= 0.0f) { m_cellW = 8.0f; }
// Ghostty font/Metrics.zig calc(): the cell height is the rounded line // height (ascent + descent + line_gap), and the baseline is the rounded // distance from the cell bottom that centers the face in the cell. This is // the model our grid sizes and glyph placement share. const double faceDescent = fm.descent(); const double lineGap = fm.lineSpacing() - fm.height(); const double faceHeight = fm.lineSpacing(); const double halfGap = lineGap / 2.0; const double faceBaselineFromBottom = halfGap + faceDescent; m_cellH = static_cast<float>(std::round(faceHeight)); if (m_cellH <= 0.0f) { m_cellH = 16.0f; } const double cellBaselineFromBottom = std::round(faceBaselineFromBottom - ((static_cast<double>(m_cellH) - faceHeight) / 2.0)); // Baseline from the top of the cell. m_ascent = static_cast<float>(static_cast<double>(m_cellH) - cellBaselineFromBottom); if (m_ascent <= 0.0f || m_ascent >= m_cellH) { m_ascent = static_cast<float>(static_cast<double>(m_cellH) - faceDescent); }
// Underline metrics. fm.underlinePos() is the distance from the baseline to // the top of the underline (device pixels, since m_font is device-scaled). const auto underlinePos = static_cast<float>(fm.underlinePos()); m_underlinePos = std::clamp(underlinePos, 0.0f, std::max(0.0f, m_cellH - 1.0f)); m_underlineThickness = std::max(1.0f, static_cast<float>(fm.lineWidth()));}
void TerminalRenderer::ensureWhiteTexture() { if (m_whiteTexture != nullptr) { return; } m_whiteImage = QImage(1, 1, QImage::Format_RGBA8888); m_whiteImage.fill(Qt::white); m_whiteTexture = m_rhi->newTexture(QRhiTexture::RGBA8, QSize(1, 1), 1, QRhiTexture::UsedAsTransferSource); m_whiteTexture->create();}
QShader TerminalRenderer::loadShader(const QString& name) { QFile f(name); if (!f.open(QIODevice::ReadOnly)) { qWarning("TerminalRenderer: failed to open shader resource %s", qPrintable(name)); return {}; } return QShader::fromSerialized(f.readAll());}
// Shader resource bindings for a sampled-texture pass: binding 0 is the// texture, binding 1 is the shared (textured) uniform block. The gradient// pass 1 (solid quads) reuses m_srb with the white placeholder texture: the// shader multiplies white by the vertex color, which reproduces the solid// quad exactly and needs no separate solid UBO binding in any SRB.QRhiShaderResourceBindings* TerminalRenderer::createShaderResourceBindings( QRhiTexture* tex) { QRhiShaderResourceBindings* srb = m_rhi->newShaderResourceBindings(); srb->setBindings({ QRhiShaderResourceBinding::sampledTexture( 0, QRhiShaderResourceBinding::FragmentStage, tex, m_sampler), QRhiShaderResourceBinding::uniformBuffer( 1, QRhiShaderResourceBinding::VertexStage | QRhiShaderResourceBinding::FragmentStage, m_texturedUbo), }); srb->create(); return srb;}
bool TerminalRenderer::buildPipeline() { QShader vs = loadShader(QStringLiteral(":/shaders/terminal.vert.qsb")); QShader fs = loadShader(QStringLiteral(":/shaders/terminal.frag.qsb")); if (!vs.isValid() || !fs.isValid()) { qWarning("TerminalRenderer: failed to load terminal shaders"); return false; }
QRhiGraphicsPipeline* ps = m_rhi->newGraphicsPipeline(); QRhiShaderStage vsStage(QRhiShaderStage::Vertex, vs); QRhiShaderStage fsStage(QRhiShaderStage::Fragment, fs); ps->setShaderStages({vsStage, fsStage});
QRhiVertexInputLayout inputLayout; inputLayout.setBindings( {QRhiVertexInputBinding(static_cast<quint32>(sizeof(Vertex)))}); inputLayout.setAttributes({ {0, 0, QRhiVertexInputAttribute::Float2, static_cast<quint32>(offsetof(Vertex, x))}, {0, 1, QRhiVertexInputAttribute::Float4, static_cast<quint32>(offsetof(Vertex, r))}, {0, 2, QRhiVertexInputAttribute::Float2, static_cast<quint32>(offsetof(Vertex, u))}, }); ps->setVertexInputLayout(inputLayout);
// The pipeline records a binding LAYOUT, not specific resources; m_srb has // the same (sampledTexture 0, uniformBuffer 1) layout as every draw-time // SRB so it anchors the layout here. ps->setShaderResourceBindings(m_srb); ps->setRenderPassDescriptor(m_renderTarget->renderPassDescriptor()); ps->setSampleCount(m_colorTexture->sampleCount()); ps->setCullMode(QRhiGraphicsPipeline::None);
// Glyph textures are premultiplied RGBA, so blend with src=One. The same // blend serves the solid pass (opaque quads: src alpha is 1, so One and // SrcAlpha agree there). QRhiGraphicsPipeline::TargetBlend blend; blend.enable = true; blend.srcColor = QRhiGraphicsPipeline::One; blend.dstColor = QRhiGraphicsPipeline::OneMinusSrcAlpha; blend.srcAlpha = QRhiGraphicsPipeline::One; blend.dstAlpha = QRhiGraphicsPipeline::OneMinusSrcAlpha; ps->setTargetBlends({blend});
if (!ps->create()) { qWarning("TerminalRenderer: failed to create graphics pipeline"); ps->destroy(); delete ps; return false; } m_pipeline = ps; return true;}
void TerminalRenderer::initialize(QRhi* rhi, QRhiTexture* colorTexture, QRhiRenderTarget* renderTarget) { if (m_initialized) { return; } m_rhi = rhi; m_colorTexture = colorTexture; m_renderTarget = renderTarget;
// One shared vertex buffer holding both passes in one dynamic update: // solid geometry in [0, solidVertexCount) and textured geometry after. // Start with a generous cap (full screen of background quads + cursor + // a frame's worth of per-glyph quads); render() grows the buffer on demand // if a pass ever exceeds it. constexpr quint32 kMaxBgQuads = 256 * 256; constexpr quint32 kMaxGlyphQuads = 24000; constexpr quint32 kVertCap = (kMaxBgQuads + 4u + kMaxGlyphQuads) * 6u; const quint32 kVboSize = kVertCap * sizeof(Vertex); m_vbo = m_rhi->newBuffer(QRhiBuffer::Dynamic, QRhiBuffer::VertexBuffer, kVboSize); m_vbo->create();
// Kitty image vertex buffer: a dynamic scratch buffer re-uploaded every // frame when there are visible images. Start with room for 64 quads. constexpr quint32 kImageVertCap = 64u * 6u; m_imageVbo = m_rhi->newBuffer(QRhiBuffer::Dynamic, QRhiBuffer::VertexBuffer, kImageVertCap * sizeof(Vertex)); m_imageVbo->create();
// Two uniform buffers (one per pass) so u_useTexture can differ between the // solid and textured passes without dynamic-offset alignment concerns. // All SRBs bind m_texturedUbo (see createShaderResourceBindings); the solid // pass still gets the solid UBO filled with useTexture=0 and renders // through m_srb (white texture), which the shader multiplies out to the // exact vertex color. const auto kUboSize = static_cast<quint32>(sizeof(UniformBlock)); m_solidUbo = m_rhi->newBuffer(QRhiBuffer::Dynamic, QRhiBuffer::UniformBuffer, kUboSize); m_solidUbo->create(); m_texturedUbo = m_rhi->newBuffer(QRhiBuffer::Dynamic, QRhiBuffer::UniformBuffer, kUboSize); m_texturedUbo->create();
m_sampler = m_rhi->newSampler(QRhiSampler::Linear, QRhiSampler::Linear, QRhiSampler::None, QRhiSampler::ClampToEdge, QRhiSampler::ClampToEdge); m_sampler->create();
ensureWhiteTexture();
m_srb = createShaderResourceBindings(m_whiteTexture); // m_atlasSrb is created lazily with the first atlas texture (packGlyph).
if (!buildPipeline()) { return; }
m_initialized = true;}
void TerminalRenderer::drawSolidQuad(std::vector<Vertex>& out, float x0, float y0, float x1, float y1, uint32_t argb) { std::array<float, 4> c{}; argbToRgba(argb, c); const float u = 0.0f; const float v = 0.0f; // Six push_backs beats insert(end, ptr, ptr+6): insert may allocate and // copy when the vector grows; push_back invalidates neither iterators // (we have none) nor the .data() pointer we haven't taken yet. out.push_back({x0, y0, c[0], c[1], c[2], c[3], u, v}); out.push_back({x1, y0, c[0], c[1], c[2], c[3], u, v}); out.push_back({x0, y1, c[0], c[1], c[2], c[3], u, v}); out.push_back({x0, y1, c[0], c[1], c[2], c[3], u, v}); out.push_back({x1, y0, c[0], c[1], c[2], c[3], u, v}); out.push_back({x1, y1, c[0], c[1], c[2], c[3], u, v});}
void TerminalRenderer::drawTexturedQuad(std::vector<Vertex>& out, float x0, float y0, float x1, float y1, uint32_t argb, float u0, float v0, float u1, float v1) { std::array<float, 4> c{}; argbToRgba(argb, c); out.push_back({x0, y0, c[0], c[1], c[2], c[3], u0, v0}); out.push_back({x1, y0, c[0], c[1], c[2], c[3], u1, v0}); out.push_back({x0, y1, c[0], c[1], c[2], c[3], u0, v1}); out.push_back({x0, y1, c[0], c[1], c[2], c[3], u0, v1}); out.push_back({x1, y0, c[0], c[1], c[2], c[3], u1, v0}); out.push_back({x1, y1, c[0], c[1], c[2], c[3], u1, v1});}
uint32_t TerminalRenderer::runFg(const QTextCharFormat& format, const TerminalFrame* frame) { if (format.hasProperty(QTextFormat::ForegroundBrush)) { return static_cast<uint32_t>(format.foreground().color().rgba()); } return frame->defaultFg;}
void TerminalRenderer::clearShapeCache() { // unique_ptr<QTextLayout> owns the QTextLine's address, so destroying // the entries is sufficient. A new first miss will allocate fresh // layouts on the next render. m_shapeCache.clear();}
QString TerminalRenderer::formatSignature(const QTextCharFormat& format, const TerminalFrame* frame) { // Only flags that influence QTextLayout shaping need be in the key; // underline draws on top and is not consulted by the shaper. const uint32_t fg = runFg(format, frame); const int weight = format.fontWeight(); // "FG|weight|italic" -> ~24 bytes; the segment dominates the key. return QString::asprintf("%08x|%d|%d", fg, weight, static_cast<int>(format.fontItalic()));}
TerminalRenderer::Shaped TerminalRenderer::buildShape( const QString& segment, const QTextCharFormat& format) const { Shaped shaped; shaped.layout = std::make_unique<QTextLayout>(segment, m_font); QTextOption opt; opt.setUseDesignMetrics(true); // Do not force emoji-presentation codepoints onto a color emoji font. A // terminal font that patches these (Nerd Fonts) must win for the symbols // it covers; genuine emoji absent from the base font still fall back. opt.setFlags(opt.flags() | QTextOption::DisableEmojiParsing); shaped.layout->setTextOption(opt); QTextLayout::FormatRange fr; fr.start = 0; fr.length = static_cast<int>(segment.length()); // Decorations are solid quads now; keep QTextLayout::draw (the color-strip // fallback) from painting its own underline into the strip texture. QTextCharFormat shapedFormat = format; shapedFormat.setFontUnderline(false); fr.format = shapedFormat; shaped.layout->setFormats({fr}); shaped.layout->beginLayout(); shaped.line = shaped.layout->createLine(); // Width budget far beyond the grid so a run never wraps onto a second // layout line this code never draws. shaped.line.setLineWidth(1e6); shaped.layout->endLayout(); // Baseline in strip space, matching GlyphStrip::rasterizeLine: pinned at // max(minBaseline, line ascent) so tall glyphs are never clipped. shaped.baseline = std::max(m_ascent, static_cast<float>(shaped.line.ascent())); return shaped;}
// Place (or fetch) a glyph tile in the atlas. `key` is built by the caller// (family|style|px|glyphIndex); `font` carries the same family/style/px so// ensureGlyph can rasterize on a miss. Returns false when the glyph cannot// be resolved as a gray coverage tile (color emoji, embedded bitmap) so the// caller falls back to a whole-run color strip.bool TerminalRenderer::ensureGlyph(const QString& key, uint32_t glyphIndex, const QFont& font, CacheEntry& out) { auto it = m_cachedGlyphs.constFind(key); if (it != m_cachedGlyphs.constEnd()) { out = it.value(); return true; }
GlyphStrip::CoverageTile tile; if (!GlyphStrip::coverageTile(font.family(), font.styleName(), static_cast<qreal>(font.pixelSize()), glyphIndex, tile)) { return false; } if (tile.image.isNull()) { return false; } const QImage::Format fmt = tile.image.format(); if (fmt != QImage::Format_RGBA8888 && fmt != QImage::Format_RGBA8888_Premultiplied) { return false; } const int w = tile.image.width(); const int h = tile.image.height(); if (w <= 0 || h <= 0 || w > kAtlasSize - 2 || h > kAtlasSize - 2) { return false; }
int texIndex = 0; const QPoint dst = packGlyph(w, h, &texIndex);
const int slot = static_cast<int>(m_glyphs.size()); AtlasGlyph ag; ag.image = tile.image; ag.dst = dst; ag.texIndex = texIndex; ag.slot = slot; m_glyphs.push_back(ag);
CacheEntry e; e.slot = slot; e.glyphW = w; e.glyphH = h; e.left = tile.left; e.top = tile.top; m_cachedGlyphs.insert(key, e); out = e; return true;}
QPoint TerminalRenderer::packGlyph(int w, int h, int* texIndex) { // Lazily create the first atlas texture (and its SRB). auto makeTexture = [this](int& outTex) { QRhiTexture* tex = m_rhi->newTexture(QRhiTexture::RGBA8, QSize(kAtlasSize, kAtlasSize), 1, QRhiTexture::UsedAsTransferSource); tex->create(); m_atlasTextures.push_back(tex); outTex = static_cast<int>(m_atlasTextures.size()) - 1; m_packX = 1; m_packY = 1; m_packRowH = 0; if (m_atlasSrb == nullptr) { m_atlasSrb = createShaderResourceBindings(tex); } }; if (m_atlasTextures.empty()) { makeTexture(m_packTex); }
// Shelf/row pack. On a horizontal overflow bump to the next shelf row; if // that row cannot hold the tile vertically, allocate a fresh texture and // restart from its top-left. for (;;) { if (m_packX + w > kAtlasSize - 1) { m_packY += m_packRowH + 1; m_packX = 1; m_packRowH = 0; } if (m_packY + h > kAtlasSize - 1) { int newTex = 0; makeTexture(newTex); m_packTex = newTex; continue; } break; }
const QPoint dst(m_packX, m_packY); m_packRowH = std::max(m_packRowH, h); m_packX += w + 1; *texIndex = m_packTex; return dst;}
TerminalRenderer::ColorStrip* TerminalRenderer::rasterizeColorRun( const TerminalRow::StyleRun& run, const TerminalFrame* frame, const TerminalRow& row, QRhiResourceUpdateBatch* updates) { const QString segment = row.text.mid(run.start, run.end - run.start); if (segment.isEmpty()) { return nullptr; } auto it = m_colorStrips.find(segment); if (it != m_colorStrips.end()) { return it.value(); }
Shaped shaped = buildShape(segment, run.format); if (!shaped.line.isValid()) { return nullptr; } auto* cs = new ColorStrip(); const QImage image = GlyphStrip::rasterizeLine( *shaped.layout, shaped.line, runFg(run.format, frame), m_ascent, &cs->baseline); if (image.isNull() || image.width() <= 0 || image.height() <= 0) { delete cs; return nullptr; } cs->tex = m_rhi->newTexture(QRhiTexture::RGBA8, image.size(), 1, QRhiTexture::UsedAsTransferSource); cs->tex->create(); cs->srb = createShaderResourceBindings(cs->tex); cs->image = image; cs->uploaded = true; cs->startCell = run.startCell; cs->texW = image.width(); cs->texH = image.height(); updates->uploadTexture(cs->tex, image); cs->image = QImage(); m_colorStrips.insert(segment, cs); return cs;}
void TerminalRenderer::clearColorStrips() { for (ColorStrip* cs : std::as_const(m_colorStrips)) { if (cs->tex != nullptr) { cs->tex->destroy(); delete cs->tex; } if (cs->srb != nullptr) { cs->srb->destroy(); delete cs->srb; } delete cs; } m_colorStrips.clear();}
void TerminalRenderer::clearAtlas() { for (QRhiTexture* tex : std::as_const(m_atlasTextures)) { if (tex != nullptr) { tex->destroy(); delete tex; } } m_atlasTextures.clear(); if (m_atlasSrb != nullptr) { m_atlasSrb->destroy(); delete m_atlasSrb; m_atlasSrb = nullptr; } for (auto& kv : m_extraAtlasSrbs) { if (kv.second != nullptr) { kv.second->destroy(); delete kv.second; } } m_extraAtlasSrbs.clear(); m_glyphs.clear(); m_cachedGlyphs.clear(); m_packX = 1; m_packY = 1; m_packRowH = 0; m_packTex = 0;}
// SRB for the atlas texture at texIndex. Texture 0 uses the shared// m_atlasSrb; higher indices get persistent per-texture SRBs because cached// textDraws reference them across frames (the old per-frame create/destroy// would leave dangling pointers on clean rows).QRhiShaderResourceBindings* TerminalRenderer::atlasSrbFor(int texIndex) { if (texIndex == 0) { return m_atlasSrb; } auto it = m_extraAtlasSrbs.find(texIndex); if (it != m_extraAtlasSrbs.end()) { return it->second; } QRhiShaderResourceBindings* srb = createShaderResourceBindings(m_atlasTextures[static_cast<size_t>( texIndex)]); m_extraAtlasSrbs.emplace(texIndex, srb); return srb;}
// Rebuild one row's vertices into m_scratchSolid/m_scratchText, upload them// to the row's fixed VBO slots, and update geom (counts, textDraws,// stripKeys). Returns false when the row's text exceeds its slot// (overflow); the caller then falls back to a full rebuild.bool TerminalRenderer::rebuildRowGeometry(const TerminalRow& row, RowGeometry& geom, const TerminalFrame* frame, QRhiResourceUpdateBatch* updates, RenderStats& stats) { geom.textDraws.clear(); geom.stripKeys.clear(); geom.solidCount = 0; geom.textCount = 0; geom.decorCount = 0; geom.built = false;
const quint32 rowSolidBase = static_cast<quint32>(row.rowIndex) * m_solidSlotVerts; const quint32 rowTextBase = (static_cast<quint32>(m_layoutRows) * m_solidSlotVerts) + (static_cast<quint32>(row.rowIndex) * m_textSlotVerts);
// Backgrounds: solid quads into the row's solid slot. m_scratchSolid.clear(); for (const TerminalRow::BgCell& cell : row.backgrounds) { const float x0 = static_cast<float>(cell.x) * m_cellW; const float y0 = contentY(static_cast<float>(cell.y) * m_cellH); drawSolidQuad(m_scratchSolid, x0, y0, x0 + m_cellW, y0 + m_cellH, cell.bg); } // Block-cursor inversion: paint the cursor cell's background with the // cursor color regardless of any SGR/selection background (the cursor // quad would have covered it opaquely before), so the glyph below draws // on a uniform cursor-colored field. if (m_invertCursorCell && row.rowIndex == m_invertY) { const float x0 = static_cast<float>(m_invertX) * m_cellW; const float y0 = contentY(static_cast<float>(m_invertY) * m_cellH); drawSolidQuad(m_scratchSolid, x0, y0, x0 + m_cellW, y0 + m_cellH, m_invertBg); } geom.solidCount = static_cast<quint32>(m_scratchSolid.size()); if (geom.solidCount > m_solidSlotVerts) { // Pathological: more background cells than the row's slot holds, which is // one quad per cell plus the cursor's inverted cell. Uploading anyway would // run past the slot and leave a stray cursor-coloured quad in the next // row's slot, so signal overflow and let the caller rebuild every row. return false; } if (geom.solidCount > 0) { updates->updateDynamicBuffer(m_vbo, rowSolidBase * sizeof(Vertex), geom.solidCount * sizeof(Vertex), m_scratchSolid.data()); }
// Text: per-glyph atlas quads tinted by the run's opaque foreground, or a // whole-run color strip when any glyph in the run cannot be resolved as a // gray tile. auto pushDraw = [&geom](QRhiShaderResourceBindings* srb, quint32 first, quint32 count) { if (!geom.textDraws.empty() && geom.textDraws.back().srb == srb && geom.textDraws.back().firstVertex + geom.textDraws.back().count == first) { geom.textDraws.back().count += count; return; } geom.textDraws.push_back({srb, first, count}); };
const float rowTop = contentY(static_cast<float>(row.rowIndex) * m_cellH); m_scratchText.clear(); for (const TerminalRow::StyleRun& run : row.runs) { const QString segment = row.text.mid(run.start, run.end - run.start); if (segment.isEmpty()) { continue; } // Per-row geometry parameters replicated from // GlyphStrip::rasterizeLine so atlas quads land on identical pixels. const float runX0 = std::round(static_cast<float>(run.startCell) * m_cellW);
// Shape cache: identical (segment + format signature) hits on every // frame while the row is unchanged. miss -> shape once and remember // the atlas slot for every glyph. hit -> skip Qt shaper and Qt // QGlyphRun containers entirely and read slots from cache. // std::unordered_map is used because ShapeCacheEntry owns a // unique_ptr and therefore is non-copyable; QHash would need it // copyable. try_emplace preserves a stable reference even on // rehash since it constructs the value in place. QElapsedTimer keyTimer; if (perfDebug) { keyTimer.start(); } const QString cacheKey = segment + QLatin1Char('|') + formatSignature(run.format, frame); if (perfDebug) { stats.keyNs += keyTimer.nsecsElapsed(); } auto em = m_shapeCache.try_emplace(cacheKey); ShapeCacheEntry& entry = em.first->second; if (em.second) { // First miss for this key: shape once, place every glyph. ++stats.shapeMisses; QElapsedTimer shapeTimer; if (perfDebug) { shapeTimer.start(); } Shaped shaped = buildShape(segment, run.format); if (perfDebug) { stats.shapeNs += shapeTimer.nsecsElapsed(); } if (!shaped.line.isValid()) { entry.built = true; continue; } entry.layout = std::move(shaped.layout); entry.line = shaped.line; entry.baseline = shaped.baseline;
const QList<QGlyphRun> runs = shaped.line.glyphRuns(); bool needsColorFallback = runs.isEmpty(); int totalGlyphs = 0; for (const QGlyphRun& gr : runs) { totalGlyphs += gr.glyphIndexes().size(); } if (!needsColorFallback) { entry.glyphIds.reserve(totalGlyphs); entry.glyphPositions.reserve(totalGlyphs); entry.glyphSlots.reserve(static_cast<size_t>(totalGlyphs)); entry.glyphMetrics.reserve(static_cast<size_t>(totalGlyphs)); for (const QGlyphRun& gr : runs) { const QRawFont raw = gr.rawFont(); const QList<quint32> glyphs = gr.glyphIndexes(); const QList<QPointF> positions = gr.positions(); if (positions.size() != glyphs.size()) { needsColorFallback = true; break; } const int px = static_cast<int>(std::lround(raw.pixelSize())); QFont glyphFont; glyphFont.setFamily(raw.familyName()); glyphFont.setStyleName(raw.styleName()); glyphFont.setPixelSize(px); for (const auto [glyph, position] : std::views::zip(glyphs, positions)) { const QString key = raw.familyName() + QLatin1Char('|') + raw.styleName() + QLatin1Char('|') + QString::number(px) + QLatin1Char('|') + QString::number(glyph); CacheEntry atlasEntry; QElapsedTimer ensureTimer; if (perfDebug) { ensureTimer.start(); } const bool ok = ensureGlyph(key, glyph, glyphFont, atlasEntry); if (perfDebug) { stats.ensureNs += ensureTimer.nsecsElapsed(); } if (!ok) { needsColorFallback = true; break; } entry.glyphIds.append(glyph); entry.glyphPositions.append(position); entry.glyphSlots.push_back(atlasEntry.slot); entry.glyphMetrics.push_back(atlasEntry); ++stats.glyphsPlaced; } if (needsColorFallback) { // Drop per-glyph metrics: we are about to fall back to a // whole-run color strip; the slot map is unused. entry.glyphIds.clear(); entry.glyphPositions.clear(); entry.glyphSlots.clear(); entry.glyphMetrics.clear(); break; } } } entry.needsColorFallback = needsColorFallback; entry.built = true; } else { ++stats.shapeHits; } ShapeCacheEntry* cached = &entry;
if (cached->needsColorFallback) { ColorStrip* strip = rasterizeColorRun(run, frame, row, updates); if (strip == nullptr) { continue; } // Block-cursor inversion: retint the strip when its ink lies inside // the cursor cell (strips are per-run; single-cell strips only). const uint32_t stripTint = (m_invertCursorCell && row.rowIndex == m_invertY && run.startCell == m_invertX && run.end - run.start <= 1) ? m_invertFg : 0xFFFFFFFFu; geom.stripKeys.push_back(segment); ++stats.strips; const float stripX0 = runX0; const float stripY0 = std::round(rowTop + m_ascent - strip->baseline); const auto first = static_cast<quint32>(m_scratchText.size()); drawTexturedQuad(m_scratchText, stripX0, stripY0, stripX0 + static_cast<float>(strip->texW), stripY0 + static_cast<float>(strip->texH), stripTint, 0.0f, 0.0f, 1.0f, 1.0f); pushDraw(strip->srb, first, 6u); continue; }
// All glyphs resolved: emit gray atlas quads tinted by the opaque run // foreground using the cached metrics. Tiles are premultiplied white // coverage (r=g=b=a), so an opaque fg quad color × coverage yields // exactly fg × alpha. // Block-cursor inversion: a run can span several cells, so decide per // glyph: a glyph whose ink column lies inside the cursor cell is tinted // with the terminal background color so its strokes read as holes in // the cursor-colored field. const bool runInvertedRow = m_invertCursorCell && row.rowIndex == m_invertY; const float invertCellX1 = runInvertedRow ? (static_cast<float>(m_invertX) + 1) * m_cellW : 0.0f; const uint32_t runFgArgb = runFg(run.format, frame) | 0xFF000000u; const float baselineInStrip = cached->baseline; const float runY0 = std::round(rowTop + m_ascent - baselineInStrip); const float naturalLeft = static_cast<float>(cached->line.naturalTextRect().left()); const auto ks = static_cast<float>(kAtlasSize); QElapsedTimer placeTimer; if (perfDebug) { placeTimer.start(); } for (const auto& [slot, gm, origin] : std::views::zip(cached->glyphSlots, cached->glyphMetrics, cached->glyphPositions)) { if (slot < 0 || std::cmp_greater_equal(slot, m_glyphs.size())) { continue; } const AtlasGlyph& atlasGlyph = m_glyphs[static_cast<size_t>(slot)]; const float qx0 = std::round( runX0 + static_cast<float>(origin.x()) - naturalLeft + static_cast<float>(gm.left)); const float qy0 = std::round( runY0 + static_cast<float>(origin.y()) - static_cast<float>(gm.top)); const float qx1 = qx0 + static_cast<float>(gm.glyphW); const float qy1 = qy0 + static_cast<float>(gm.glyphH); const float u0 = static_cast<float>(atlasGlyph.dst.x()) / ks; const float v0 = static_cast<float>(atlasGlyph.dst.y()) / ks; const float u1 = (static_cast<float>(atlasGlyph.dst.x()) + static_cast<float>(gm.glyphW)) / ks; const float v1 = (static_cast<float>(atlasGlyph.dst.y()) + static_cast<float>(gm.glyphH)) / ks; const auto first = static_cast<quint32>(m_scratchText.size()); const float glyphMidX = (qx0 + qx1) * 0.5f; const uint32_t quadArgb = runInvertedRow && glyphMidX >= invertCellX1 - m_cellW && glyphMidX < invertCellX1 ? m_invertFg : runFgArgb; drawTexturedQuad(m_scratchText, qx0, qy0, qx1, qy1, quadArgb, u0, v0, u1, v1); pushDraw(atlasSrbFor(atlasGlyph.texIndex), first, 6u); } if (perfDebug) { stats.placeNs += placeTimer.nsecsElapsed(); } }
geom.textCount = static_cast<quint32>(m_scratchText.size());
// Decorations (SGR 4 text and OSC 8 links, plus SGR 9/53 and the non- // single underline styles). Runs tile the row (every cell contributes a // run), so the next run's start cell is this run's end cell; walking // backwards means one pass with no lookahead. The quads live at the tail // of the row's text slot and are drawn by the solid pass. const float underlineY = rowTop + m_ascent + m_underlinePos; const float strikeY = rowTop + m_ascent + (m_underlinePos * 0.5f); const float overlineY = rowTop; int endCell = frame->cols; for (const TerminalRow::StyleRun& run : row.runs | std::views::reverse) { const float x0 = std::round(static_cast<float>(run.startCell) * m_cellW); const float x1 = std::round(static_cast<float>(endCell) * m_cellW); const uint32_t fg = runFg(run.format, frame); if (run.format.fontUnderline()) { // Single underline (SGR 4:1 / OSC 8 link). drawSolidQuad(m_scratchText, x0, underlineY, x1, underlineY + m_underlineThickness, fg); } switch (run.underlineStyle) { case GHOSTTY_SGR_UNDERLINE_DOUBLE: // Two thin strokes at 1/3 and 2/3 of the underline position. drawSolidQuad(m_scratchText, x0, underlineY - m_underlineThickness, x1, underlineY, fg); drawSolidQuad(m_scratchText, x0, underlineY + m_underlineThickness, x1, underlineY + (2.0f * m_underlineThickness), fg); break; case GHOSTTY_SGR_UNDERLINE_CURLY: // Approximation: a stepped zigzag of small solid quads, one segment // per cell (4 px each), alternating between two y bands. Cost: 4 // extra quads (24 verts) per cell. { const float seg = m_cellW; const float half = m_underlineThickness; for (float sx = x0; sx < x1 - 0.5f; sx += seg) { const float sxe = std::min(sx + seg, x1); const float yUp = underlineY - (half * 0.5f); const float yDn = underlineY + (half * 0.5f); drawSolidQuad(m_scratchText, sx, yUp, sxe, yUp + half, fg); drawSolidQuad(m_scratchText, sx, yDn, sxe, yDn + half, fg); } } break; case GHOSTTY_SGR_UNDERLINE_DOTTED: // Approximation: 2 px on, cellW/4 px off along the span. Cost: // ~4 extra quads per cell. { const float period = std::max(3.0f, m_cellW / 4.0f); for (float sx = x0; sx < x1 - 0.5f; sx += period) { drawSolidQuad(m_scratchText, sx, underlineY, std::min(sx + 2.0f, x1), underlineY + m_underlineThickness, fg); } } break; case GHOSTTY_SGR_UNDERLINE_DASHED: // Approximation: cellW on, cellW/2 off. Cost: ~2 extra quads per // cell. { const float period = std::max(4.0f, m_cellW * 1.5f); for (float sx = x0; sx < x1 - 0.5f; sx += period) { drawSolidQuad(m_scratchText, sx, underlineY, std::min(sx + m_cellW, x1), underlineY + m_underlineThickness, fg); } } break; default: break; } if (run.strike) { drawSolidQuad(m_scratchText, x0, strikeY, x1, strikeY + m_underlineThickness, fg); } if (run.overline) { drawSolidQuad(m_scratchText, x0, overlineY, x1, overlineY + m_underlineThickness, fg); } endCell = run.startCell; } geom.decorCount = static_cast<quint32>(m_scratchText.size()) - geom.textCount;
if (geom.textCount + geom.decorCount > m_textSlotVerts) { // Pathological combining-mark cluster: the row's text exceeded its // fixed slot. Signal overflow; the caller rebuilds every row. return false; } if (geom.textCount + geom.decorCount > 0) { updates->updateDynamicBuffer( m_vbo, rowTextBase * sizeof(Vertex), (geom.textCount + geom.decorCount) * sizeof(Vertex), m_scratchText.data()); } geom.built = true; return true;}
void TerminalRenderer::render(QRhiCommandBuffer* cb, const TerminalFrame* frame) { if (!m_initialized || m_width <= 0 || m_height <= 0 || frame == nullptr) { return; }
QElapsedTimer rasterTimer; if (perfDebug) { rasterTimer.start(); }
ensureFontMetrics();
// Orthographic projection, y-down, top-left origin, in device pixels. // Map x in [0,w] -> NDC [-1,1] and y in [0,h] -> NDC [1,-1] (y flipped). // Translation lives in the LAST COLUMN (row 0..2 of col 3), NOT the bottom // row. A bottom-row value feeds the W component and causes a perspective // divide that warps the quad (the "vanishing point" artifact). const auto w = static_cast<float>(m_width); const auto h = static_cast<float>(m_height); QMatrix4x4 proj; proj.setToIdentity(); proj(0, 0) = 2.0f / w; proj(1, 1) = -2.0f / h; proj(0, 3) = -1.0f; proj(1, 3) = 1.0f;
// Collect all uploads into a single batch committed at beginPass (resource // updates cannot be issued inside a pass). QRhiResourceUpdateBatch* updates = m_rhi->nextResourceUpdateBatch();
// Fixed-slot VBO layout: recompute when the grid size changes. Each row // owns a solid slot (one bg quad per cell, plus one more for the block // cursor's inverted cell) and a text slot (up to kTextGlyphsPerCell quads // per cell); the overlay region after them holds selection + cursor + // scrollbar and is re-uploaded every frame. The +36 slack covers the worst // case: selection quads (6 verts per row) plus one hollow-block cursor (24 // verts), one hovered-link underline and one search-match quad (6 verts // each). if (frame->cols != m_layoutCols || frame->rows != m_layoutRows) { m_layoutCols = frame->cols; m_layoutRows = frame->rows; m_solidSlotVerts = 6u * (static_cast<quint32>(frame->cols) + 1u); m_textSlotVerts = 6u * static_cast<quint32>(frame->cols) * kTextGlyphsPerCell; m_overlayBase = static_cast<quint32>(frame->rows) * (m_solidSlotVerts + m_textSlotVerts); m_overlayMax = (6u * static_cast<quint32>(frame->rows)) + 36u; const quint32 totalVerts = m_overlayBase + m_overlayMax; if (totalVerts * sizeof(Vertex) > m_vbo->size()) { growVertexBuffer(m_rhi, m_vbo, totalVerts * sizeof(Vertex)); } } m_rowGeometry.resize(frame->rowData.size());
// Block-cursor inversion state, computed once per frame BEFORE the // dirty-row rebuild loop: when a visible, on-phase block cursor covers a // cell, that cell is drawn inverted (cursor-color background, glyph // tinted with the terminal background color) by rebuildRowGeometry, and // the overlay pass below skips its opaque block quad for that cell. const bool cursorOn = frame->cursorVisible && (!frame->cursorBlinking || frame->cursorBlinkPhaseOn); const bool invertActive = cursorOn && frame->cursorStyle == 1 && frame->cursorY >= 0 && frame->cursorY < static_cast<int>(frame->rows) && frame->cursorX >= 0 && frame->cursorX < frame->cols; const bool invertChanged = invertActive != m_invertCursorCell || (invertActive && (frame->cursorX != m_invertX || frame->cursorY != m_invertY || m_invertBg != (frame->cursorColor | 0xFF000000u))); const int prevInvertY = m_invertCursorCell ? m_invertY : -1; if (invertActive) { m_invertX = frame->cursorX; m_invertY = frame->cursorY; m_invertBg = frame->cursorColor | 0xFF000000u; m_invertFg = frame->defaultBg | 0xFF000000u; } m_invertCursorCell = invertActive;
// Rebuild geometry only for dirty rows (or rows whose cached geometry is // invalid, e.g. after a font change or a grid resize). Clean rows' bytes // stay in the VBO untouched. size_t rebuiltRows = 0; RenderStats stats; bool overflow = false; for (const auto& [r, rowData, geom] : std::views::zip( std::views::iota(0uz), frame->rowData, m_rowGeometry)) { const bool dirty = r < frame->dirtyRows.size() && frame->dirtyRows[r] != 0; // Force a rebuild when the inversion state changed this frame: the // previously inverted row must revert and the newly inverted row must // invert. The dirty-row flags only track content, not the cursor. const size_t prevInvertRow = prevInvertY >= 0 ? static_cast<size_t>(prevInvertY) : std::numeric_limits<size_t>::max(); const size_t invertRow = m_invertCursorCell ? static_cast<size_t>(m_invertY) : std::numeric_limits<size_t>::max(); const bool involved = invertChanged && (r == invertRow || r == prevInvertRow); if (dirty || !geom.built || involved) { if (!rebuildRowGeometry(rowData, geom, frame, updates, stats)) { overflow = true; } else { ++rebuiltRows; } } } if (overflow) { // A row's text exceeded its slot (pathological combining marks). Rare: // rebuild every row from the persisted frame and re-upload all slots. for (const auto& [r, row] : std::views::zip(std::views::iota(0uz), m_rowGeometry)) { row.built = false; if (rebuildRowGeometry(frame->rowData[r], row, frame, updates, stats)) { ++rebuiltRows; } } }
// Pass-1 solid geometry: per-row background quads (drawn from the row's // fixed slot) plus the overlay (selection + cursor + scrollbar), rebuilt // and uploaded every frame because none of these are dirty-tracked. struct SolidDraw { quint32 firstVertex; quint32 count; }; std::vector<SolidDraw> solidDraws; m_overlayVerts.clear(); for (const auto& [r, g, row] : std::views::zip(std::views::iota(0uz), m_rowGeometry, frame->rowData)) { if (g.solidCount > 0) { solidDraws.push_back( {static_cast<quint32>(r) * m_solidSlotVerts, g.solidCount}); } // Decorations live at the tail of the row's text slot but draw with the // solid pipeline (color-only quads, no texture sample). if (g.decorCount > 0) { const quint32 textBase = (static_cast<quint32>(m_layoutRows) * m_solidSlotVerts) + (static_cast<quint32>(r) * m_textSlotVerts); solidDraws.push_back({textBase + g.textCount, g.decorCount}); } // Selection: one translucent blue quad spanning the selected cell range. if (row.selectionStart >= 0 && row.selectionEnd >= row.selectionStart) { const float selX0 = static_cast<float>(row.selectionStart) * m_cellW; const float selX1 = static_cast<float>(row.selectionEnd + 1) * m_cellW; const float selY0 = contentY(static_cast<float>(row.rowIndex) * m_cellH); drawSolidQuad(m_overlayVerts, selX0, selY0, selX1, selY0 + m_cellH, 0x402B6FFFu); } // Search matches: one translucent quad over each VISIBLE match's cell // span, drawn under the text so glyphs stay readable. The current match // takes the EXACT selection color (same alpha, same RGB) so it reads as // strongly as a text selection; the other matches take the same hue at // half intensity, which keeps the stepper's "N of M" visually anchored // without competing with the match the user is on. Same overlay // machinery as selection/hover. for (const SearchSpan& span : m_searchSpans) { if (span.row != row.rowIndex || span.startCol > span.endCol || frame->cols <= 0) { continue; } const int searchStart = std::clamp(span.startCol, 0, static_cast<int>(frame->cols) - 1); const int searchEnd = std::clamp(span.endCol, searchStart, static_cast<int>(frame->cols) - 1); const float sX0 = static_cast<float>(searchStart) * m_cellW; const float sX1 = static_cast<float>(searchEnd + 1) * m_cellW; const float sY0 = contentY(static_cast<float>(row.rowIndex) * m_cellH); drawSolidQuad(m_overlayVerts, sX0, sY0, sX1, sY0 + m_cellH, span.current ? 0x402B6FFFu : 0x4015377Fu); } // Copy-mode selection: one quad per visible row between the selection's // first and last viewport rows (both clamped to the viewport by the // item). Endpoints use their own column ranges; middle rows span the // full width. Biolume at ~25% alpha, under the search highlight. if (m_copySelStartRow >= 0 && m_copySelEndRow >= m_copySelStartRow && row.rowIndex >= m_copySelStartRow && row.rowIndex <= m_copySelEndRow && frame->cols > 0) { const bool inMiddle = row.rowIndex > m_copySelStartRow && row.rowIndex < m_copySelEndRow; int c0 = 0; int c1 = static_cast<int>(frame->cols) - 1; if (row.rowIndex == m_copySelStartRow) { c0 = m_copySelStartCol; } if (row.rowIndex == m_copySelEndRow) { c1 = m_copySelEndCol; } if (inMiddle || c0 <= c1) { const int selStart = std::clamp(c0, 0, static_cast<int>(frame->cols) - 1); const int selEnd = std::clamp(c1, selStart, static_cast<int>(frame->cols) - 1); const float cX0 = static_cast<float>(selStart) * m_cellW; const float cX1 = static_cast<float>(selEnd + 1) * m_cellW; const float cY0 = contentY(static_cast<float>(row.rowIndex) * m_cellH); drawSolidQuad(m_overlayVerts, cX0, cY0, cX1, cY0 + m_cellH, 0x40130108u); } } // Copy-mode keyboard cursor: a hollow block over the cursor cell in // the brand accent (molecular acid), so it reads apart from both the // search highlight and the live terminal cursor. if (m_copyCursorRow == row.rowIndex && frame->cols > 0) { const int cc = std::clamp(m_copyCursorCol, 0, static_cast<int>(frame->cols) - 1); const float uX0 = static_cast<float>(cc) * m_cellW; const float uX1 = uX0 + m_cellW; const float uY0 = contentY(static_cast<float>(row.rowIndex) * m_cellH); const float uY1 = uY0 + m_cellH; constexpr float kThick = 2.0f; constexpr uint32_t kCursorColor = 0xFF1F4C05u; // molecular acid drawSolidQuad(m_overlayVerts, uX0, uY0, uX1, uY0 + kThick, kCursorColor); drawSolidQuad(m_overlayVerts, uX0, uY1 - kThick, uX1, uY1, kCursorColor); drawSolidQuad(m_overlayVerts, uX0, uY0, uX0 + kThick, uY1, kCursorColor); drawSolidQuad(m_overlayVerts, uX1 - kThick, uY0, uX1, uY1, kCursorColor); } // Hovered link: one underline quad in the default foreground color. if (m_hoverRow == row.rowIndex && m_hoverStartCol <= m_hoverEndCol && frame->cols > 0) { const int hoverStart = std::clamp(m_hoverStartCol, 0, static_cast<int>(frame->cols) - 1); const int hoverEnd = std::clamp(m_hoverEndCol, hoverStart, static_cast<int>(frame->cols) - 1); const float hoverX0 = static_cast<float>(hoverStart) * m_cellW; const float hoverX1 = static_cast<float>(hoverEnd + 1) * m_cellW; const float hoverY = contentY(static_cast<float>(row.rowIndex) * m_cellH) + m_ascent + m_underlinePos; drawSolidQuad(m_overlayVerts, hoverX0, hoverY, hoverX1, hoverY + m_underlineThickness, frame->defaultFg); } } if (cursorOn) { // computed before the dirty-row rebuild loop above const float cx = static_cast<float>(frame->cursorX) * m_cellW; const float cy = contentY(static_cast<float>(frame->cursorY) * m_cellH); switch (frame->cursorStyle) { case 0: // bar: left 2px vertical bar drawSolidQuad(m_overlayVerts, cx, cy, cx + 2.0f, cy + m_cellH, frame->cursorColor); break; case 1: // block: filled cell rect // Inverted cell: rebuildRowGeometry already painted the cursor // background and re-tinted the glyph, so an opaque quad here would // hide the glyph. Nothing to draw for this cell. if (!m_invertCursorCell) { drawSolidQuad(m_overlayVerts, cx, cy, cx + m_cellW, cy + m_cellH, frame->cursorColor); } break; case 2: // underline: bottom 2px bar drawSolidQuad(m_overlayVerts, cx, cy + m_cellH - 2.0f, cx + m_cellW, cy + m_cellH, frame->cursorColor); break; case 3: { // hollow block: outline made of 4 thin quads const float t = 2.0f; drawSolidQuad(m_overlayVerts, cx, cy, cx + m_cellW, cy + t, frame->cursorColor); drawSolidQuad(m_overlayVerts, cx, cy + m_cellH - t, cx + m_cellW, cy + m_cellH, frame->cursorColor); drawSolidQuad(m_overlayVerts, cx, cy, cx + t, cy + m_cellH, frame->cursorColor); drawSolidQuad(m_overlayVerts, cx + m_cellW - t, cy, cx + m_cellW, cy + m_cellH, frame->cursorColor); break; } default: break; } }
// The scrollback scrollbar is drawn by QML (a real QScrollBar over the // pane, synced to the scroll state via TerminalQuickItem's // scroll* properties); the GL overlay no longer reserves or draws one.
const auto overlayCount = static_cast<quint32>(m_overlayVerts.size()); if (overlayCount > 0) { updates->updateDynamicBuffer(m_vbo, m_overlayBase * sizeof(Vertex), overlayCount * sizeof(Vertex), m_overlayVerts.data()); }
// Pass-2 text geometry: coalesce cached per-row text draws into contiguous // (srb, range) runs with absolute VBO offsets. Cross-row coalescing rarely // triggers (slots are fixed-size) but is harmless. std::vector<TextDraw> textDraws; auto pushDraw = [&textDraws](QRhiShaderResourceBindings* srb, quint32 first, quint32 count) { if (!textDraws.empty() && textDraws.back().srb == srb && textDraws.back().firstVertex + textDraws.back().count == first) { textDraws.back().count += count; return; } textDraws.push_back({srb, first, count}); }; for (const auto& [r, g] : std::views::zip(std::views::iota(0uz), m_rowGeometry)) { const quint32 textBase = (static_cast<quint32>(m_layoutRows) * m_solidSlotVerts) + (static_cast<quint32>(r) * m_textSlotVerts); for (const TextDraw& td : g.textDraws) { pushDraw(td.srb, textBase + td.firstVertex, td.count); } }
// --- Kitty images: ensure textures (upload new generations, evict oldest // when over cap). The QImage wraps frame image pixels, which live until // render() returns; beginPass (which commits the upload batch) happens // later in this same call, so the borrow is safe. for (const TerminalFrame::ImagePlacement& p : frame->images) { if (m_imageTextures.contains(p.generation)) { continue; } if (p.imgW == 0 || p.imgH == 0 || p.pixels.size() < static_cast<int>(p.imgW * p.imgH * 4)) { continue; } QRhiTexture* tex = m_rhi->newTexture( QRhiTexture::RGBA8, QSize(static_cast<int>(p.imgW), static_cast<int>(p.imgH)), 1, QRhiTexture::UsedAsTransferSource); tex->create(); const QImage img(reinterpret_cast<const uchar*>(p.pixels.constData()), // NOLINT(cppcoreguidelines-pro-type-reinterpret-cast) static_cast<int>(p.imgW), static_cast<int>(p.imgH), static_cast<qsizetype>(p.imgW) * 4, QImage::Format_RGBA8888); updates->uploadTexture(tex, img); ImageTex it; it.tex = tex; it.srb = createShaderResourceBindings(tex); it.size = QSize(static_cast<int>(p.imgW), static_cast<int>(p.imgH)); m_imageTextures.emplace(p.generation, it); } while (m_imageTextures.size() > kMaxImageTextures) { auto oldest = std::ranges::min_element( m_imageTextures, [](const auto& a, const auto& b) { return a.first < b.first; }); ImageTex& it = oldest->second; if (it.tex != nullptr) { it.tex->destroy(); delete it.tex; } if (it.srb != nullptr) { it.srb->destroy(); delete it.srb; } m_imageTextures.erase(oldest); }
if (perfDebug) { std::println(stderr, "perf: t={} raster {}ms rows={} glyphs={} strips={} hits={} misses={} key={}us shape={}us ensure={}us place={}us", static_cast<long long>(perfStampMs()), static_cast<long long>(rasterTimer.elapsed()), rebuiltRows, stats.glyphsPlaced, stats.strips, stats.shapeHits, stats.shapeMisses, static_cast<long long>(stats.keyNs / 1000), static_cast<long long>(stats.shapeNs / 1000), static_cast<long long>(stats.ensureNs / 1000), static_cast<long long>(stats.placeNs / 1000)); }
// Upload NEW atlas tiles (those whose image has not been committed yet). // Uploading nulls the image so each tile uploads exactly once. New tiles // are always created in the frame they are placed, so this only runs when // a row was rebuilt this frame. if (stats.glyphsPlaced > 0) { int tilesUploaded = 0; QElapsedTimer uploadTimer; if (perfDebug) { uploadTimer.start(); } for (AtlasGlyph& ag : m_glyphs) { if (ag.image.isNull()) { continue; } QRhiTextureSubresourceUploadDescription sub(ag.image); sub.setDestinationTopLeft(ag.dst); QRhiTextureUploadEntry entry(0, 0, sub); QRhiTextureUploadDescription desc(entry); // Bounds check: the carrying texture must exist for this tile's index. if (ag.texIndex >= 0 && std::cmp_less(ag.texIndex, m_atlasTextures.size())) { updates->uploadTexture(m_atlasTextures[static_cast<size_t>(ag.texIndex)], desc); ++tilesUploaded; } ag.image = QImage(); } m_atlasUploadedThisFrame = (tilesUploaded > 0); if (perfDebug) { std::println(stderr, "perf: t={} upload {}us tiles={}", static_cast<long long>(perfStampMs()), static_cast<long long>(uploadTimer.nsecsElapsed() / 1000), tilesUploaded); } }
// Reap color strips not referenced by any cached row geometry; destroy // their GPU objects. A strip referenced by a clean row's cached textDraws // must not be reaped, so the union is built from all rows' stripKeys. QSet<QString> usedStrips; for (const RowGeometry& g : m_rowGeometry) { for (const QString& k : g.stripKeys) { usedStrips.insert(k); } } for (auto it = m_colorStrips.begin(); it != m_colorStrips.end();) { if (usedStrips.contains(it.key())) { ++it; continue; } ColorStrip* cs = it.value(); if (cs->tex != nullptr) { cs->tex->destroy(); delete cs->tex; } if (cs->srb != nullptr) { cs->srb->destroy(); delete cs->srb; } delete cs; it = m_colorStrips.erase(it); }
// --- Kitty images: build quads for visible placements, split into the // three z-layers (below-bg, below-text, above-text). Sorted ascending by // z in collectKittyImages. Each draw records (srb, firstVertex, count) // against the shared m_imageVbo. struct ImageDraw { QRhiShaderResourceBindings* srb; quint32 firstVertex; quint32 count; }; std::vector<ImageDraw> imageDraws; m_imageVerts.clear(); // Layer draw boundaries into imageDraws: [0,belowBg), [belowBg,belowText), // [belowText, end). Empty when no images are visible. std::pair<size_t, size_t> belowBgRange{0, 0}; std::pair<size_t, size_t> belowTextRange{0, 0}; std::pair<size_t, size_t> aboveTextRange{0, 0}; constexpr int32_t kBgLimit = INT32_MIN / 2; size_t bgEnd = 0; size_t textEnd = 0; for (const auto& [i, p] : std::views::zip(std::views::iota(0uz), frame->images)) { if (bgEnd == 0 && p.z >= kBgLimit) { bgEnd = i; } if (p.z >= 0) { textEnd = i; break; } } auto appendImageQuads = [&](const auto& imagesSub) { for (const auto& p : imagesSub) { auto it = m_imageTextures.find(p.generation); if (it == m_imageTextures.end()) { continue; } const float x0 = static_cast<float>(p.viewportCol) * m_cellW; const float y0 = contentY(static_cast<float>(p.viewportRow) * m_cellH); const float x1 = x0 + static_cast<float>(p.destW); const float y1 = y0 + static_cast<float>(p.destH); const float u0 = static_cast<float>(p.srcX) / static_cast<float>(p.imgW); const float v0 = static_cast<float>(p.srcY) / static_cast<float>(p.imgH); const float u1 = static_cast<float>(p.srcX + p.srcW) / static_cast<float>(p.imgW); const float v1 = static_cast<float>(p.srcY + p.srcH) / static_cast<float>(p.imgH); drawTexturedQuad(m_imageVerts, x0, y0, x1, y1, 0xFFFFFFFFu, u0, v0, u1, v1); imageDraws.push_back( {it->second.srb, static_cast<quint32>(m_imageVerts.size() - 6u), 6u}); } }; if (!frame->images.empty()) { // below-bg: z < INT32_MIN/2 appendImageQuads(std::span(frame->images).first(bgEnd)); const size_t belowBgDrawEnd = imageDraws.size(); // below-text: INT32_MIN/2 <= z < 0 appendImageQuads(std::span(frame->images).subspan(bgEnd, textEnd - bgEnd)); const size_t belowTextDrawEnd = imageDraws.size(); // above-text: z >= 0 appendImageQuads(std::span(frame->images).subspan(textEnd)); if (!m_imageVerts.empty()) { const auto imageVerts = static_cast<quint32>(m_imageVerts.size()); if (imageVerts * sizeof(Vertex) > m_imageVbo->size()) { growVertexBuffer(m_rhi, m_imageVbo, imageVerts * sizeof(Vertex)); } updates->updateDynamicBuffer(m_imageVbo, 0, imageVerts * sizeof(Vertex), m_imageVerts.data()); } // Record the three layer boundaries for the pass sequence below. belowBgRange = {0, belowBgDrawEnd}; belowTextRange = {belowBgDrawEnd, belowTextDrawEnd}; aboveTextRange = {belowTextDrawEnd, imageDraws.size()}; }
// Solid UBO: u_useTexture = 0. UniformBlock solidBlock{}; std::memcpy(solidBlock.m, proj.constData(), 64); solidBlock.useTexture = 0; updates->updateDynamicBuffer(m_solidUbo, 0, static_cast<quint32>(sizeof(UniformBlock)), &solidBlock); // Textured UBO: u_useTexture = 1. UniformBlock texturedBlock{}; std::memcpy(texturedBlock.m, proj.constData(), 64); texturedBlock.useTexture = 1; updates->updateDynamicBuffer(m_texturedUbo, 0, static_cast<quint32>(sizeof(UniformBlock)), &texturedBlock); // Upload the white placeholder texture once. if (!m_whiteUploaded) { updates->uploadTexture(m_whiteTexture, m_whiteImage); m_whiteUploaded = true; }
const QColor clearColor = QColor::fromRgba(frame->defaultBg); cb->beginPass(m_renderTarget, clearColor, QRhiDepthStencilClearValue(1.0f, 0u), updates);
cb->setGraphicsPipeline(m_pipeline); cb->setViewport(QRhiViewport(0.0f, 0.0f, w, h));
// Kitty images below the background: drawn first, under the solid pass. if (!m_imageVerts.empty() && belowBgRange.first < belowBgRange.second) { const QRhiCommandBuffer::VertexInput imgBinding(m_imageVbo, 0); for (const ImageDraw& d : std::span(imageDraws).subspan(belowBgRange.first, belowBgRange.second - belowBgRange.first)) { cb->setShaderResources(d.srb); cb->setVertexInput(0, 1, &imgBinding); cb->draw(d.count, 1, d.firstVertex); } }
// Pass 1: solid quads (per-row backgrounds, then the overlay). const QRhiCommandBuffer::VertexInput solidBinding(m_vbo, 0); for (const SolidDraw& sd : solidDraws) { cb->setShaderResources(m_srb); cb->setVertexInput(0, 1, &solidBinding); cb->draw(sd.count, 1, sd.firstVertex); } if (overlayCount > 0) { cb->setShaderResources(m_srb); cb->setVertexInput(0, 1, &solidBinding); cb->draw(overlayCount, 1, m_overlayBase); }
// Kitty images below the text (above the background): drawn after the // solid pass so the background shows through, before the glyphs. if (!m_imageVerts.empty() && belowTextRange.first < belowTextRange.second) { const QRhiCommandBuffer::VertexInput imgBinding(m_imageVbo, 0); for (const ImageDraw& d : std::span(imageDraws).subspan(belowTextRange.first, belowTextRange.second - belowTextRange.first)) { cb->setShaderResources(d.srb); cb->setVertexInput(0, 1, &imgBinding); cb->draw(d.count, 1, d.firstVertex); } }
// Pass 2: textured glyph geometry, one draw per contiguous (srb, range). if (!textDraws.empty()) { const QRhiCommandBuffer::VertexInput vboBinding(m_vbo, 0); for (const TextDraw& td : textDraws) { cb->setShaderResources(td.srb); cb->setVertexInput(0, 1, &vboBinding); cb->draw(td.count, 1, td.firstVertex); } }
// Kitty images above the text: drawn last, on top of everything. if (!m_imageVerts.empty() && aboveTextRange.first < aboveTextRange.second) { const QRhiCommandBuffer::VertexInput imgBinding(m_imageVbo, 0); for (const ImageDraw& d : std::span(imageDraws).subspan(aboveTextRange.first, aboveTextRange.second - aboveTextRange.first)) { cb->setShaderResources(d.srb); cb->setVertexInput(0, 1, &imgBinding); cb->draw(d.count, 1, d.firstVertex); } }
cb->endPass();}