diff --git a/ghosteel.pro b/ghosteel.pro index 97d7110..117dcdd 100644 --- a/ghosteel.pro +++ b/ghosteel.pro @@ -60,11 +60,13 @@ HEADERS += \ src/ghosttyvt.h \ src/glrenderer.h \ src/glyphatlas.h \ + src/ipcmessage.h \ src/keymapping.h \ src/kittyimagedecoder.h \ src/ptymanager.h \ src/scrollencryptor.h \ src/sessionmanager.h \ + src/sessionstore.h \ src/settings.h \ src/terminalview.h \ src/textutil.h @@ -74,14 +76,23 @@ SOURCES += \ src/ghosteeladapter.cpp \ src/ghosttyvt.cpp \ src/glrenderer.cpp \ + src/glrenderer_geometry.cpp \ + src/glrenderer_kitty.cpp \ + src/glrenderer_pipeline.cpp \ + src/glrenderer_shaders.cpp \ src/glyphatlas.cpp \ src/keymapping.cpp \ src/kittyimagedecoder.cpp \ src/ptymanager.cpp \ src/scrollencryptor.cpp \ src/sessionmanager.cpp \ + src/sessionstore.cpp \ src/settings.cpp \ + src/singleinstance.cpp \ src/terminalview.cpp \ + src/terminalview_links.cpp \ + src/terminalview_search.cpp \ + src/terminalview_touch.cpp \ src/textutil.cpp DISTFILES += qml/ghosteel.qml \ diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml index 643890c..8198e27 100644 --- a/qml/pages/SettingsPage.qml +++ b/qml/pages/SettingsPage.qml @@ -121,8 +121,8 @@ Page { id: fontSlider width: parent.width label: qsTr("Default font size") - minimumValue: 6 - maximumValue: 32 + minimumValue: Settings.minFontSize + maximumValue: Settings.maxFontSize stepSize: 1 value: Settings.fontSize valueText: { diff --git a/qml/pages/TerminalPage.qml b/qml/pages/TerminalPage.qml index 7968c6f..6ef6d0d 100644 --- a/qml/pages/TerminalPage.qml +++ b/qml/pages/TerminalPage.qml @@ -713,7 +713,7 @@ Page { function onZoomRequested(delta) { if (!terminal) return SessionManager.setActiveSessionFontSize( - Math.max(6, Math.min(32, terminal.fontSize + delta)), false) + Math.max(Settings.minFontSize, Math.min(Settings.maxFontSize, terminal.fontSize + delta)), false) } function onPinchingChanged(pinching) { @@ -1158,7 +1158,7 @@ Page { Rectangle { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter - width: parent.width * Math.max(0, Math.min(1, ((terminal ? terminal.fontSize : 6) - 6) / (32 - 6))) + width: parent.width * Math.max(0, Math.min(1, ((terminal ? terminal.fontSize : Settings.minFontSize) - Settings.minFontSize) / (Settings.maxFontSize - Settings.minFontSize))) height: parent.height radius: height / 2 color: Theme.highlightColor @@ -1255,9 +1255,9 @@ Page { var dir = keyDef.id === "prevSession" ? -1 : 1 switchSession(dir) } else if (keyDef.id === "zoomIn") { - SessionManager.setActiveSessionFontSize(Math.min(32, terminal.fontSize + 1), false) + SessionManager.setActiveSessionFontSize(Math.min(Settings.maxFontSize, terminal.fontSize + 1), false) } else if (keyDef.id === "zoomOut") { - SessionManager.setActiveSessionFontSize(Math.max(6, terminal.fontSize - 1), false) + SessionManager.setActiveSessionFontSize(Math.max(Settings.minFontSize, terminal.fontSize - 1), false) } } diff --git a/src/glrenderer.cpp b/src/glrenderer.cpp index a8ab006..b57d279 100644 --- a/src/glrenderer.cpp +++ b/src/glrenderer.cpp @@ -18,236 +18,6 @@ #include #include -// GLSL ES 1.00 shaders — textured cell quads with per-cell fg/bg colors -// Cursor blink and style are handled in the fragment shader via uniforms to avoid -// rebuilding vertex data on every blink toggle or cursor style change. -static const char *vertexShaderSource = - "attribute vec2 position;\n" - "attribute vec2 texcoord;\n" - "attribute vec4 fg_color;\n" - "attribute vec4 bg_color;\n" - "attribute float deco_type;\n" - "uniform mat4 u_matrix;\n" - "varying vec2 v_texcoord;\n" - "varying vec2 v_cell;\n" - "varying vec4 v_fg_color;\n" - "varying vec4 v_bg_color;\n" - "varying float v_deco;\n" - "void main() {\n" - " gl_Position = u_matrix * vec4(position, 0.0, 1.0);\n" - " v_texcoord = texcoord;\n" - " v_cell = position;\n" - " v_fg_color = fg_color;\n" - " v_bg_color = bg_color;\n" - " v_deco = deco_type;\n" - "}\n"; - -static const char *fragmentShaderSource = - "precision mediump float;\n" - "varying vec2 v_texcoord;\n" - "varying vec2 v_cell;\n" - "varying vec4 v_fg_color;\n" - "varying vec4 v_bg_color;\n" - "varying float v_deco;\n" - "uniform sampler2D u_atlas;\n" - "uniform vec2 u_cursorPos;\n" - "uniform vec2 u_cellSize;\n" - "uniform float u_cursorBlink;\n" - "uniform float u_cursorStyle;\n" - "uniform float u_topPadding;\n" - "void main() {\n" - " vec4 fg = v_fg_color;\n" - " vec4 bg = v_bg_color;\n" - " vec2 adj_cell = v_cell - vec2(0.0, u_topPadding);\n" - " if (u_cursorBlink > 0.5 && u_cellSize.x > 0.0) {\n" - " vec2 cellCoord = floor(adj_cell / u_cellSize);\n" - " if (cellCoord == u_cursorPos) {\n" - " if (u_cursorStyle < 1.5) {\n" - " fg = v_bg_color;\n" - " bg = v_fg_color;\n" - " } else if (u_cursorStyle < 2.5) {\n" - " float cx = adj_cell.x - cellCoord.x * u_cellSize.x;\n" - " if (cx < 2.0) {\n" - " gl_FragColor = v_fg_color;\n" - " return;\n" - " }\n" - " } else if (u_cursorStyle < 3.5) {\n" - " float cy = adj_cell.y - cellCoord.y * u_cellSize.y;\n" - " if (cy > u_cellSize.y - 2.0) {\n" - " gl_FragColor = v_fg_color;\n" - " return;\n" - " }\n" - " } else {\n" - " float cx = adj_cell.x - cellCoord.x * u_cellSize.x;\n" - " float cy = adj_cell.y - cellCoord.y * u_cellSize.y;\n" - " if (cx < 1.0 || cx > u_cellSize.x - 1.0 ||\n" - " cy < 1.0 || cy > u_cellSize.y - 1.0) {\n" - " gl_FragColor = v_fg_color;\n" - " return;\n" - " }\n" - " }\n" - " }\n" - " }\n" - " float glyph_alpha = texture2D(u_atlas, v_texcoord).a;\n" - " vec4 color = mix(bg, fg, glyph_alpha);\n" - " // Text decorations: v_deco encodes type (0=none, 1=underline, 2=strikethrough)\n" - " if (v_deco > 0.5 && u_cellSize.x > 0.0) {\n" - " float cy = adj_cell.y - floor(adj_cell.y / u_cellSize.y) * u_cellSize.y;\n" - " if (v_deco < 1.5) {\n" - " // Underline: 2px at bottom of cell\n" - " if (cy > u_cellSize.y - 2.0)\n" - " color = vec4(fg.rgb, 1.0);\n" - " } else {\n" - " // Strikethrough: 2px at middle of cell\n" - " if (cy > u_cellSize.y * 0.5 - 1.0 && cy < u_cellSize.y * 0.5 + 1.0)\n" - " color = vec4(fg.rgb, 1.0);\n" - " }\n" - " }\n" - " gl_FragColor = color;\n" - "}\n"; - -// GLSL ES 1.00 flat-color shaders — solid-color rects for selection, search, link overlays -static const char *flatVertexShaderSource = - "attribute vec2 position;\n" - "attribute vec4 color;\n" - "uniform mat4 u_matrix;\n" - "varying vec4 v_color;\n" - "void main() {\n" - " gl_Position = u_matrix * vec4(position, 0.0, 1.0);\n" - " v_color = color;\n" - "}\n"; - -static const char *flatFragmentShaderSource = - "precision mediump float;\n" - "varying vec4 v_color;\n" - "void main() {\n" - " gl_FragColor = v_color;\n" - "}\n"; - -// GLSL ES 1.00 magnifier shaders — textured rounded-rect with SDF clip -static const char *magVertexShaderSource = - "attribute vec2 position;\n" - "attribute vec2 texcoord;\n" - "uniform mat4 u_matrix;\n" - "varying vec2 v_texcoord;\n" - "varying vec2 v_pos;\n" - "void main() {\n" - " gl_Position = u_matrix * vec4(position, 0.0, 1.0);\n" - " v_texcoord = texcoord;\n" - " v_pos = position;\n" - "}\n"; - -static const char *magFragmentShaderSource = - "precision mediump float;\n" - "varying vec2 v_texcoord;\n" - "varying vec2 v_pos;\n" - "uniform sampler2D u_sceneTex;\n" - "uniform vec4 u_destRect;\n" - "uniform vec4 u_srcRect;\n" - "uniform vec2 u_srcTexSize;\n" - "uniform float u_cornerRadius;\n" - "uniform vec4 u_borderColor;\n" - "uniform float u_borderWidth;\n" - "\n" - "float roundedRectSDF(vec2 p, vec2 center, vec2 halfSize, float radius) {\n" - " vec2 d = abs(p - center) - halfSize + radius;\n" - " return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)) - radius;\n" - "}\n" - "\n" - "void main() {\n" - " vec2 center = u_destRect.xy + u_destRect.zw * 0.5;\n" - " vec2 halfSize = u_destRect.zw * 0.5;\n" - " float dist = roundedRectSDF(v_pos, center, halfSize, u_cornerRadius);\n" - " if (dist > 1.5) discard;\n" - " vec2 srcUV = (u_srcRect.xy + v_texcoord * u_srcRect.zw) / u_srcTexSize;\n" - " vec4 texColor = texture2D(u_sceneTex, srcUV);\n" - " float edgeAlpha = smoothstep(0.0, 1.5, -dist);\n" - " float borderMask = smoothstep(u_borderWidth, u_borderWidth - 1.5, -dist);\n" - " vec4 color = mix(texColor, u_borderColor, borderMask);\n" - " gl_FragColor = vec4(color.rgb * edgeAlpha, color.a * edgeAlpha);\n" - "}\n"; - -// Simple ES 2.0 blit shader — textured quad for pipeline FBO → Qt FBO copy -static const char *blitVertexShaderSource = - "attribute vec2 position;\n" - "attribute vec2 texcoord;\n" - "uniform mat4 u_matrix;\n" - "varying vec2 v_texcoord;\n" - "void main() {\n" - " gl_Position = u_matrix * vec4(position, 0.0, 1.0);\n" - " v_texcoord = texcoord;\n" - "}\n"; - -static const char *blitFragmentShaderSource = - "precision mediump float;\n" - "varying vec2 v_texcoord;\n" - "uniform sampler2D u_texture;\n" - "void main() {\n" - " gl_FragColor = texture2D(u_texture, v_texcoord);\n" - "}\n"; - -// --- Phase 5B: ES 3.0 post-processing shaders --- - -// Full-screen triangle vertex shader (ES 3.0, no VBO needed — gl_VertexID trick) -static const char *postVertexShaderSource = - "#version 300 es\n" - "void main() {\n" - " vec4 position;\n" - " position.x = (gl_VertexID == 2) ? 3.0 : -1.0;\n" - " position.y = (gl_VertexID == 0) ? -3.0 : 1.0;\n" - " position.z = 1.0;\n" - " position.w = 1.0;\n" - " gl_Position = position;\n" - "}\n"; - -// Ghostty-compatible shadertoy prefix for ES 3.0 (individual uniforms, no UBO) -static const char *shadertoyPrefixES300 = - "#version 300 es\n" - "precision mediump float;\n" - "\n" - "uniform vec3 iResolution;\n" - "uniform float iTime;\n" - "uniform float iTimeDelta;\n" - "uniform float iFrameRate;\n" - "uniform int iFrame;\n" - "uniform float iChannelTime[4];\n" - "uniform vec3 iChannelResolution[4];\n" - "uniform vec4 iMouse;\n" - "uniform vec4 iDate;\n" - "uniform float iSampleRate;\n" - "uniform vec4 iCurrentCursor;\n" - "uniform vec4 iPreviousCursor;\n" - "uniform vec4 iCurrentCursorColor;\n" - "uniform vec4 iPreviousCursorColor;\n" - "uniform int iCurrentCursorStyle;\n" - "uniform int iPreviousCursorStyle;\n" - "uniform int iCursorVisible;\n" - "uniform float iTimeCursorChange;\n" - "uniform float iTimeFocus;\n" - "uniform int iFocus;\n" - "uniform vec3 iPalette[256];\n" - "uniform vec3 iBackgroundColor;\n" - "uniform vec3 iForegroundColor;\n" - "uniform vec3 iCursorColor;\n" - "uniform vec3 iCursorText;\n" - "uniform vec3 iSelectionForegroundColor;\n" - "uniform vec3 iSelectionBackgroundColor;\n" - "\n" - "uniform sampler2D iChannel0;\n" - "\n" - "out vec4 _fragColor;\n" - "\n" - "#define texture2D texture\n" - "\n" - "#define CURSORSTYLE_BLOCK 0\n" - "#define CURSORSTYLE_BLOCK_HOLLOW 1\n" - "#define CURSORSTYLE_BAR 2\n" - "#define CURSORSTYLE_UNDERLINE 3\n" - "#define CURSORSTYLE_LOCK 4\n" - "\n" - "void mainImage( out vec4 fragColor, in vec2 fragCoord );\n" - "void main() { mainImage (_fragColor, gl_FragCoord.xy); }\n"; - // --- GLRenderer implementation --- GLRenderer::GLRenderer(QQuickItem *parent) @@ -418,1071 +188,7 @@ void GLRenderer::Renderer::initialize() << "GL_VERSION:" << (const char*)glGetString(GL_VERSION) << "GL_RENDERER:" << (const char*)glGetString(GL_RENDERER) << "ES300:" << m_es300 - << "postShaderActive:" << m_postShaderActive; -} - -void GLRenderer::Renderer::createShaders() -{ - m_program = new QOpenGLShaderProgram; - if (!m_program->addShaderFromSourceCode(QOpenGLShader::Vertex, vertexShaderSource)) { - qWarning() << "GLRenderer: vertex shader compilation failed:" << m_program->log(); - delete m_program; - m_program = nullptr; - return; - } - if (!m_program->addShaderFromSourceCode(QOpenGLShader::Fragment, fragmentShaderSource)) { - qWarning() << "GLRenderer: fragment shader compilation failed:" << m_program->log(); - delete m_program; - m_program = nullptr; - return; - } - if (!m_program->link()) { - qWarning() << "GLRenderer: shader linking failed:" << m_program->log(); - delete m_program; - m_program = nullptr; - return; - } - - m_matrixUniform = m_program->uniformLocation("u_matrix"); - m_atlasUniform = m_program->uniformLocation("u_atlas"); - m_cursorPosUniform = m_program->uniformLocation("u_cursorPos"); - m_cellSizeUniform = m_program->uniformLocation("u_cellSize"); - m_cursorBlinkUniform = m_program->uniformLocation("u_cursorBlink"); - m_cursorStyleUniform = m_program->uniformLocation("u_cursorStyle"); - m_topPaddingUniform = m_program->uniformLocation("u_topPadding"); - m_positionAttr = m_program->attributeLocation("position"); - m_texcoordAttr = m_program->attributeLocation("texcoord"); - m_fgColorAttr = m_program->attributeLocation("fg_color"); - m_bgColorAttr = m_program->attributeLocation("bg_color"); - m_decoAttr = m_program->attributeLocation("deco_type"); -} - -void GLRenderer::Renderer::createVBO() -{ - // Create VBO (will be populated in render() when dirty) - m_vbo = QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - m_vbo.create(); - m_vbo.setUsagePattern(QOpenGLBuffer::DynamicDraw); -} - -void GLRenderer::Renderer::createFlatShaders() -{ - m_flatProgram = new QOpenGLShaderProgram; - if (!m_flatProgram->addShaderFromSourceCode(QOpenGLShader::Vertex, flatVertexShaderSource)) { - qWarning() << "GLRenderer: flat vertex shader compilation failed:" << m_flatProgram->log(); - delete m_flatProgram; - m_flatProgram = nullptr; - return; - } - if (!m_flatProgram->addShaderFromSourceCode(QOpenGLShader::Fragment, flatFragmentShaderSource)) { - qWarning() << "GLRenderer: flat fragment shader compilation failed:" << m_flatProgram->log(); - delete m_flatProgram; - m_flatProgram = nullptr; - return; - } - if (!m_flatProgram->link()) { - qWarning() << "GLRenderer: flat shader linking failed:" << m_flatProgram->log(); - delete m_flatProgram; - m_flatProgram = nullptr; - return; - } - - m_flatMatrixUniform = m_flatProgram->uniformLocation("u_matrix"); - m_flatPositionAttr = m_flatProgram->attributeLocation("position"); - m_flatColorAttr = m_flatProgram->attributeLocation("color"); -} - -void GLRenderer::Renderer::createFlatVBO() -{ - m_flatVbo = QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); - m_flatVbo.create(); - m_flatVbo.setUsagePattern(QOpenGLBuffer::DynamicDraw); -} - -void GLRenderer::Renderer::createMagShaders() -{ - m_magProgram = new QOpenGLShaderProgram; - if (!m_magProgram->addShaderFromSourceCode(QOpenGLShader::Vertex, magVertexShaderSource)) { - qWarning() << "GLRenderer: magnifier vertex shader compilation failed:" << m_magProgram->log(); - delete m_magProgram; - m_magProgram = nullptr; - return; - } - if (!m_magProgram->addShaderFromSourceCode(QOpenGLShader::Fragment, magFragmentShaderSource)) { - qWarning() << "GLRenderer: magnifier fragment shader compilation failed:" << m_magProgram->log(); - delete m_magProgram; - m_magProgram = nullptr; - return; - } - if (!m_magProgram->link()) { - qWarning() << "GLRenderer: magnifier shader linking failed:" << m_magProgram->log(); - delete m_magProgram; - m_magProgram = nullptr; - return; - } - - m_magMatrixUniform = m_magProgram->uniformLocation("u_matrix"); - m_magTexUniform = m_magProgram->uniformLocation("u_sceneTex"); - m_magDestRectUniform = m_magProgram->uniformLocation("u_destRect"); - m_magSrcRectUniform = m_magProgram->uniformLocation("u_srcRect"); - m_magSrcTexSizeUniform = m_magProgram->uniformLocation("u_srcTexSize"); - m_magCornerRadiusUniform = m_magProgram->uniformLocation("u_cornerRadius"); - m_magBorderColorUniform = m_magProgram->uniformLocation("u_borderColor"); - m_magBorderWidthUniform = m_magProgram->uniformLocation("u_borderWidth"); - m_magPositionAttr = m_magProgram->attributeLocation("position"); - m_magTexcoordAttr = m_magProgram->attributeLocation("texcoord"); -} - -void GLRenderer::Renderer::createBlitShader() -{ - m_blitProgram = new QOpenGLShaderProgram; - if (!m_blitProgram->addShaderFromSourceCode(QOpenGLShader::Vertex, blitVertexShaderSource)) { - qWarning() << "GLRenderer: blit vertex shader compilation failed:" << m_blitProgram->log(); - delete m_blitProgram; - m_blitProgram = nullptr; - return; - } - if (!m_blitProgram->addShaderFromSourceCode(QOpenGLShader::Fragment, blitFragmentShaderSource)) { - qWarning() << "GLRenderer: blit fragment shader compilation failed:" << m_blitProgram->log(); - delete m_blitProgram; - m_blitProgram = nullptr; - return; - } - if (!m_blitProgram->link()) { - qWarning() << "GLRenderer: blit shader linking failed:" << m_blitProgram->log(); - delete m_blitProgram; - m_blitProgram = nullptr; - return; - } - - m_blitMatrixUniform = m_blitProgram->uniformLocation("u_matrix"); - m_blitTexUniform = m_blitProgram->uniformLocation("u_texture"); - m_blitPositionAttr = m_blitProgram->attributeLocation("position"); - m_blitTexcoordAttr = m_blitProgram->attributeLocation("texcoord"); -} - -void GLRenderer::Renderer::blitPipelineToFbo(QOpenGLFramebufferObject *fbo) -{ - if (!m_blitProgram || !m_blitProgram->isLinked() || !m_pipelineTex) - return; - - int w = fbo->width(); - int h = fbo->height(); - - QMatrix4x4 proj; - proj.ortho(0, w, 0, h, -1, 1); - - // Full-screen quad: 2 triangles, 6 vertices, each pos2+texcoord2 = 4 floats - float blitVerts[] = { - 0.0f, 0.0f, 0.0f, 0.0f, - static_cast(w), 0.0f, 1.0f, 0.0f, - static_cast(w), static_cast(h), 1.0f, 1.0f, - 0.0f, 0.0f, 0.0f, 0.0f, - static_cast(w), static_cast(h), 1.0f, 1.0f, - 0.0f, static_cast(h), 0.0f, 1.0f, - }; - - glBindFramebuffer(GL_FRAMEBUFFER, fbo->handle()); - glViewport(0, 0, w, h); - glDisable(GL_BLEND); - - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, m_pipelineTex); - - m_blitProgram->bind(); - m_blitProgram->setUniformValue(m_blitMatrixUniform, proj); - m_blitProgram->setUniformValue(m_blitTexUniform, 0); - - const int stride = 4 * sizeof(float); - glVertexAttribPointer(m_blitPositionAttr, 2, GL_FLOAT, GL_FALSE, stride, blitVerts); - glVertexAttribPointer(m_blitTexcoordAttr, 2, GL_FLOAT, GL_FALSE, stride, blitVerts + 2); - glEnableVertexAttribArray(m_blitPositionAttr); - glEnableVertexAttribArray(m_blitTexcoordAttr); - glDrawArrays(GL_TRIANGLES, 0, 6); - glDisableVertexAttribArray(m_blitPositionAttr); - glDisableVertexAttribArray(m_blitTexcoordAttr); - - glBindTexture(GL_TEXTURE_2D, 0); - m_blitProgram->release(); -} - -void GLRenderer::Renderer::createKittyShaders() -{ - // Load shader from Qt resource — kitty_image.glsl has //! vertex and //! fragment sections - QFile shaderFile(QStringLiteral(":/shaders/kitty_image.glsl")); - if (!shaderFile.open(QIODevice::ReadOnly)) { - qWarning() << "Failed to open kitty_image.glsl"; - return; - } - QByteArray shaderSrc = shaderFile.readAll(); - shaderFile.close(); - - int vertIdx = shaderSrc.indexOf("//! vertex"); - int fragIdx = shaderSrc.indexOf("//! fragment"); - if (vertIdx < 0 || fragIdx < 0) { - qWarning() << "kitty_image.glsl missing vertex/fragment markers"; - return; - } - - QByteArray vertSrc = shaderSrc.mid(vertIdx, fragIdx - vertIdx); - QByteArray fragSrc = shaderSrc.mid(fragIdx); - - m_kittyProgram = new QOpenGLShaderProgram; - m_kittyProgram->addShaderFromSourceCode(QOpenGLShader::Vertex, vertSrc); - m_kittyProgram->addShaderFromSourceCode(QOpenGLShader::Fragment, fragSrc); - if (!m_kittyProgram->link()) { - qWarning() << "Kitty image shader link failed:" << m_kittyProgram->log(); - delete m_kittyProgram; - m_kittyProgram = nullptr; - return; - } - - m_kittyMatrixUniform = m_kittyProgram->uniformLocation("u_matrix"); - m_kittyTexUniform = m_kittyProgram->uniformLocation("u_image"); - m_kittyPositionAttr = m_kittyProgram->attributeLocation("position"); - m_kittyTexcoordAttr = m_kittyProgram->attributeLocation("texcoord"); -} - -void GLRenderer::Renderer::appendCircle(float cx, float cy, float radius, - float r, float g, float b, float a, int segments) -{ - const float step = 2.0f * static_cast(M_PI) / segments; - for (int i = 0; i < segments; ++i) { - float angle0 = step * i; - float angle1 = step * (i + 1); - // Center vertex - m_flatVertices << cx << cy << r << g << b << a; - // Perimeter vertex i - m_flatVertices << (cx + radius * cosf(angle0)) - << (cy + radius * sinf(angle0)) - << r << g << b << a; - // Perimeter vertex i+1 - m_flatVertices << (cx + radius * cosf(angle1)) - << (cy + radius * sinf(angle1)) - << r << g << b << a; - } -} - -void GLRenderer::Renderer::buildMagnifierVertices(int fboW, int fboH) -{ - m_magVertices.clear(); - m_magVertexCount = 0; - - if (!m_magnifierVisible || !m_selecting || m_selStart == m_selEnd) - return; - - QPointF fingerPos = m_magnifierFingerPos; - - // Source region: area around finger to zoom into - int srcW = TerminalView::MagnifierWidth / TerminalView::MagnifierZoom; // 90 - int srcH = TerminalView::MagnifierHeight / TerminalView::MagnifierZoom; // 50 - int srcX = static_cast(fingerPos.x()) - srcW / 2; - int srcY = static_cast(fingerPos.y()) - srcH / 2; - - // Clamp source to FBO bounds - if (fboW > srcW && fboH > srcH) { - srcX = qBound(0, srcX, fboW - srcW); - srcY = qBound(0, srcY, fboH - srcH); - } else { - srcX = qMax(0, srcX); - srcY = qMax(0, srcY); - } - - // Destination: above finger, centered horizontally - int destX = static_cast(fingerPos.x()) - TerminalView::MagnifierWidth / 2; - int destY = static_cast(fingerPos.y()) - TerminalView::MagnifierHeight - TerminalView::MagnifierOffset; - - // Flip below finger when near top edge - if (destY < 0) - destY = static_cast(fingerPos.y()) + TerminalView::MagnifierOffset; - destX = qBound(0, destX, fboW - TerminalView::MagnifierWidth); - destY = qBound(0, destY, fboH - TerminalView::MagnifierHeight); - - float dx0 = static_cast(destX); - float dy0 = static_cast(destY); - float dx1 = static_cast(destX + TerminalView::MagnifierWidth); - float dy1 = static_cast(destY + TerminalView::MagnifierHeight); - - // UV coords: [0,1] maps the full magnifier quad; the shader maps this to - // the source rectangle within m_pipelineTex via u_srcRect / u_srcTexSize. - float u0 = 0.0f; - float v0 = 0.0f; - float u1 = 1.0f; - float v1 = 1.0f; - - // 2 triangles (6 vertices), each: pos2 + texcoord2 = 4 floats - // Triangle 1: top-left, top-right, bottom-right - m_magVertices << dx0 << dy0 << u0 << v0; - m_magVertices << dx1 << dy0 << u1 << v0; - m_magVertices << dx1 << dy1 << u1 << v1; - // Triangle 2: top-left, bottom-right, bottom-left - m_magVertices << dx0 << dy0 << u0 << v0; - m_magVertices << dx1 << dy1 << u1 << v1; - m_magVertices << dx0 << dy1 << u0 << v1; - - m_magVertexCount = 6; -} - -// --- Phase 5B: ES 3.0 detection --- - -void GLRenderer::Renderer::detectES300() -{ - QOpenGLContext *ctx = QOpenGLContext::currentContext(); - if (!ctx) - return; - - QSurfaceFormat fmt = ctx->format(); - qDebug() << "GLRenderer: GL context: major=" << fmt.majorVersion() - << "minor=" << fmt.minorVersion() - << "ES=" << ctx->isOpenGLES() - << "GL_VERSION:" << (const char*)glGetString(GL_VERSION) - << "GL_RENDERER:" << (const char*)glGetString(GL_RENDERER); - - // Don't trust QSurfaceFormat version — SailfishOS libhybris often reports ES 2.0 - // even when the driver supports ES 3.0+. Just try compiling and see. - const char *testFragSrc = - "#version 300 es\n" - "precision mediump float;\n" - "out vec4 _out;\n" - "void main() { _out = vec4(1.0); }\n"; - - QOpenGLShaderProgram testProg; - bool ok = testProg.addShaderFromSourceCode(QOpenGLShader::Fragment, testFragSrc); - if (!ok) { - qDebug() << "GLRenderer: ES 3.0 probe failed — shader pipeline disabled"; - m_es300 = false; - return; - } - - m_es300 = true; - qDebug() << "GLRenderer: ES 3.0 confirmed (probe shader compiled)"; - // Marshal to the GUI thread — Settings is main-thread-only (settings.h:11-12) - QMetaObject::invokeMethod(Settings::instance(), - "setShaderPipelineAvailable", Qt::QueuedConnection, Q_ARG(bool, true)); -} - -// --- Phase 5B: Pipeline FBO (render-to-texture) --- - -void GLRenderer::Renderer::createPipelineFbo(int w, int h) -{ - if (m_pipelineFbo && m_pipelineTexW == w && m_pipelineTexH == h) - return; - - destroyPipelineFbo(); - - glGenTextures(1, &m_pipelineTex); - glBindTexture(GL_TEXTURE_2D, m_pipelineTex); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); - glBindTexture(GL_TEXTURE_2D, 0); - - // Create FBO with color attachment only (no depth/stencil — not needed for 2D terminal rendering) - glGenFramebuffers(1, &m_pipelineFbo); - glBindFramebuffer(GL_FRAMEBUFFER, m_pipelineFbo); - glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_pipelineTex, 0); - - GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); - if (status != GL_FRAMEBUFFER_COMPLETE) { - qWarning() << "GLRenderer: pipeline FBO incomplete, status=" << status; - destroyPipelineFbo(); - } else { - m_pipelineTexW = w; - m_pipelineTexH = h; - qDebug() << "GLRenderer: pipeline FBO created" << w << "x" << h; - } - - glBindFramebuffer(GL_FRAMEBUFFER, 0); -} - -void GLRenderer::Renderer::destroyPipelineFbo() -{ - if (m_pipelineFbo) { - glDeleteFramebuffers(1, &m_pipelineFbo); - m_pipelineFbo = 0; - } - if (m_pipelineTex) { - glDeleteTextures(1, &m_pipelineTex); - m_pipelineTex = 0; - } - m_pipelineTexW = 0; - m_pipelineTexH = 0; -} - -void GLRenderer::Renderer::createPingPongFbo(int w, int h) -{ - if (m_pingPongFbo && m_pingPongTexW == w && m_pingPongTexH == h) - return; - - destroyPingPongFbo(); - - glGenTextures(1, &m_pingPongTex); - glBindTexture(GL_TEXTURE_2D, m_pingPongTex); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); - glBindTexture(GL_TEXTURE_2D, 0); - - glGenFramebuffers(1, &m_pingPongFbo); - glBindFramebuffer(GL_FRAMEBUFFER, m_pingPongFbo); - glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_pingPongTex, 0); - - GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); - if (status != GL_FRAMEBUFFER_COMPLETE) { - qWarning() << "GLRenderer: ping-pong FBO incomplete, status=" << status; - destroyPingPongFbo(); - } else { - m_pingPongTexW = w; - m_pingPongTexH = h; - qDebug() << "GLRenderer: ping-pong FBO created" << w << "x" << h; - } - - glBindFramebuffer(GL_FRAMEBUFFER, 0); -} - -void GLRenderer::Renderer::destroyPingPongFbo() -{ - if (m_pingPongFbo) { - glDeleteFramebuffers(1, &m_pingPongFbo); - m_pingPongFbo = 0; - } - if (m_pingPongTex) { - glDeleteTextures(1, &m_pingPongTex); - m_pingPongTex = 0; - } - m_pingPongTexW = 0; - m_pingPongTexH = 0; -} - -void GLRenderer::Renderer::runPostProcessPass(PostShader &shader, GLuint inputTex, GLuint outputFbo, int w, int h) -{ - glBindFramebuffer(GL_FRAMEBUFFER, outputFbo); - glViewport(0, 0, w, h); - glDisable(GL_BLEND); - - glActiveTexture(GL_TEXTURE0); - glBindTexture(GL_TEXTURE_2D, inputTex); - - shader.program->bind(); - uploadPostShaderUniforms(shader, w, h); - - glDrawArrays(GL_TRIANGLES, 0, 3); - - shader.program->release(); - glBindTexture(GL_TEXTURE_2D, 0); -} - -// --- Phase 5B: Post shader loading & compilation --- - -void GLRenderer::Renderer::createPostShaders() -{ - // Compile full-screen triangle vertex shader - m_postShader.program = new QOpenGLShaderProgram; - if (!m_postShader.program->addShaderFromSourceCode(QOpenGLShader::Vertex, postVertexShaderSource)) { - qWarning() << "GLRenderer: post vertex shader compilation failed:" << m_postShader.program->log(); - delete m_postShader.program; - m_postShader.program = nullptr; - return; - } - - // Compile vertex shader. Fragment shader is loaded on demand via loadPostShader(). - qDebug() << "GLRenderer: post shader infrastructure ready (ES 3.0, vertex shader compiled)"; -} - -void GLRenderer::Renderer::loadPostShader(const QString &path) -{ - if (!m_es300) { - qWarning() << "GLRenderer: cannot load post shader — ES 3.0 not available"; - return; - } - - QFile file(path); - if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - qWarning() << "GLRenderer: cannot open post shader file:" << path; - m_postShaderActive = false; - return; - } - QByteArray userShaderSrc = file.readAll(); - file.close(); - - if (m_postShader.program) { - delete m_postShader.program; - m_postShader.program = nullptr; - m_postShaderActive = false; - } - - m_postShader.program = new QOpenGLShaderProgram; - - if (!m_postShader.program->addShaderFromSourceCode(QOpenGLShader::Vertex, postVertexShaderSource)) { - qWarning() << "GLRenderer: post vertex shader failed:" << m_postShader.program->log(); - delete m_postShader.program; - m_postShader.program = nullptr; - m_postShaderActive = false; - return; - } - - QByteArray fragSrc = QByteArray(shadertoyPrefixES300) + "\n" + userShaderSrc; - if (!m_postShader.program->addShaderFromSourceCode(QOpenGLShader::Fragment, fragSrc.constData())) { - qWarning() << "GLRenderer: post fragment shader compilation failed:" << m_postShader.program->log(); - delete m_postShader.program; - m_postShader.program = nullptr; - m_postShaderActive = false; - return; - } - - if (!m_postShader.program->link()) { - qWarning() << "GLRenderer: post shader linking failed:" << m_postShader.program->log(); - delete m_postShader.program; - m_postShader.program = nullptr; - m_postShaderActive = false; - return; - } - - m_postShader.loc.iResolution = m_postShader.program->uniformLocation("iResolution"); - m_postShader.loc.iTime = m_postShader.program->uniformLocation("iTime"); - m_postShader.loc.iTimeDelta = m_postShader.program->uniformLocation("iTimeDelta"); - m_postShader.loc.iFrameRate = m_postShader.program->uniformLocation("iFrameRate"); - m_postShader.loc.iFrame = m_postShader.program->uniformLocation("iFrame"); - m_postShader.loc.iChannelTime = m_postShader.program->uniformLocation("iChannelTime[0]"); - m_postShader.loc.iChannelResolution = m_postShader.program->uniformLocation("iChannelResolution[0]"); - m_postShader.loc.iMouse = m_postShader.program->uniformLocation("iMouse"); - m_postShader.loc.iDate = m_postShader.program->uniformLocation("iDate"); - m_postShader.loc.iSampleRate = m_postShader.program->uniformLocation("iSampleRate"); - m_postShader.loc.iCurrentCursor = m_postShader.program->uniformLocation("iCurrentCursor"); - m_postShader.loc.iPreviousCursor = m_postShader.program->uniformLocation("iPreviousCursor"); - m_postShader.loc.iCurrentCursorColor = m_postShader.program->uniformLocation("iCurrentCursorColor"); - m_postShader.loc.iPreviousCursorColor = m_postShader.program->uniformLocation("iPreviousCursorColor"); - m_postShader.loc.iCurrentCursorStyle = m_postShader.program->uniformLocation("iCurrentCursorStyle"); - m_postShader.loc.iPreviousCursorStyle = m_postShader.program->uniformLocation("iPreviousCursorStyle"); - m_postShader.loc.iCursorVisible = m_postShader.program->uniformLocation("iCursorVisible"); - m_postShader.loc.iTimeCursorChange = m_postShader.program->uniformLocation("iTimeCursorChange"); - m_postShader.loc.iTimeFocus = m_postShader.program->uniformLocation("iTimeFocus"); - m_postShader.loc.iFocus = m_postShader.program->uniformLocation("iFocus"); - m_postShader.loc.iPalette = m_postShader.program->uniformLocation("iPalette[0]"); - m_postShader.loc.iBackgroundColor = m_postShader.program->uniformLocation("iBackgroundColor"); - m_postShader.loc.iForegroundColor = m_postShader.program->uniformLocation("iForegroundColor"); - m_postShader.loc.iCursorColor = m_postShader.program->uniformLocation("iCursorColor"); - m_postShader.loc.iCursorText = m_postShader.program->uniformLocation("iCursorText"); - m_postShader.loc.iSelectionForegroundColor = m_postShader.program->uniformLocation("iSelectionForegroundColor"); - m_postShader.loc.iSelectionBackgroundColor = m_postShader.program->uniformLocation("iSelectionBackgroundColor"); - m_postShader.loc.iChannel0 = m_postShader.program->uniformLocation("iChannel0"); - - m_postShaderActive = true; - qDebug() << "GLRenderer: post shader loaded from" << path; -} - -void GLRenderer::Renderer::uploadPostShaderUniforms(PostShader &shader, int fboW, int fboH) -{ - const PostShaderUniforms &loc = shader.loc; - - if (loc.iResolution >= 0) - shader.program->setUniformValue(loc.iResolution, - static_cast(fboW), static_cast(fboH), 1.0f); - if (loc.iTime >= 0) - shader.program->setUniformValue(loc.iTime, m_postTime); - if (loc.iTimeDelta >= 0) - shader.program->setUniformValue(loc.iTimeDelta, m_postTimeDelta); - if (loc.iFrameRate >= 0) - shader.program->setUniformValue(loc.iFrameRate, m_postFrameRate); - if (loc.iFrame >= 0) - shader.program->setUniformValue(loc.iFrame, static_cast(m_postFrame)); - if (loc.iChannelTime >= 0) { - float chTime[4] = {m_postTime, 0.0f, 0.0f, 0.0f}; - glUniform1fv(loc.iChannelTime, 4, chTime); - } - if (loc.iChannelResolution >= 0) { - float chRes[12] = { - static_cast(fboW), static_cast(fboH), 1.0f, - 0, 0, 0, 0, 0, 0, 0, 0, 0 - }; - glUniform3fv(loc.iChannelResolution, 4, chRes); - } - if (loc.iMouse >= 0) - shader.program->setUniformValue(loc.iMouse, 0.0f, 0.0f, 0.0f, 0.0f); - if (loc.iDate >= 0) { - QDateTime now = QDateTime::currentDateTime(); - float secs = now.time().hour() * 3600.0f + now.time().minute() * 60.0f - + now.time().second() + now.time().msec() / 1000.0f; - shader.program->setUniformValue(loc.iDate, - static_cast(now.date().year()), - static_cast(now.date().month()), - static_cast(now.date().day()), - secs); - } - if (loc.iSampleRate >= 0) - shader.program->setUniformValue(loc.iSampleRate, 0.0f); - - // Cursor uniforms — Ghostty shader expects (x, y_top, w, h) - // In our Y-up ortho, cy = row*cellHeight + topPadding is the bottom edge. - // Add cellHeight to get the top edge (higher Y in Y-up = top of cell). - if (loc.iCurrentCursor >= 0) { - float cx = m_cursorX * m_cellWidth; - float cy = m_cursorY * m_cellHeight + m_topPadding + static_cast(m_cellHeight); - shader.program->setUniformValue(loc.iCurrentCursor, - cx, cy, - static_cast(m_cellWidth), - static_cast(m_cellHeight)); - } - if (loc.iPreviousCursor >= 0) { - float px = m_prevCursorX * m_cellWidth; - float py = m_prevCursorY * m_cellHeight + m_topPadding + static_cast(m_cellHeight); - shader.program->setUniformValue(loc.iPreviousCursor, - px, py, - static_cast(m_cellWidth), - static_cast(m_cellHeight)); - } - if (loc.iCurrentCursorColor >= 0) { - if (m_postCursorColorHasValue) - shader.program->setUniformValue(loc.iCurrentCursorColor, - m_postCursorR, m_postCursorG, m_postCursorB, 1.0f); - else - shader.program->setUniformValue(loc.iCurrentCursorColor, - m_postFgR, m_postFgG, m_postFgB, 1.0f); - } - if (loc.iPreviousCursorColor >= 0) - shader.program->setUniformValue(loc.iPreviousCursorColor, 0.0f, 0.0f, 0.0f, 0.0f); - if (loc.iCurrentCursorStyle >= 0) - shader.program->setUniformValue(loc.iCurrentCursorStyle, m_cursorStyle); - if (loc.iPreviousCursorStyle >= 0) - shader.program->setUniformValue(loc.iPreviousCursorStyle, 0); - if (loc.iCursorVisible >= 0) - shader.program->setUniformValue(loc.iCursorVisible, m_cursorVisible ? 1 : 0); - if (loc.iTimeCursorChange >= 0) - shader.program->setUniformValue(loc.iTimeCursorChange, m_cursorChangeTime); - if (loc.iTimeFocus >= 0) - shader.program->setUniformValue(loc.iTimeFocus, 0.0f); - if (loc.iFocus >= 0) - shader.program->setUniformValue(loc.iFocus, 1); - - // Palette (256 colors, vec3 each = 768 floats) - if (loc.iPalette >= 0) - glUniform3fv(loc.iPalette, 256, m_postPaletteData); - - if (loc.iBackgroundColor >= 0) - shader.program->setUniformValue(loc.iBackgroundColor, - m_postBgR, m_postBgG, m_postBgB); - if (loc.iForegroundColor >= 0) - shader.program->setUniformValue(loc.iForegroundColor, - m_postFgR, m_postFgG, m_postFgB); - if (loc.iCursorColor >= 0) { - if (m_postCursorColorHasValue) - shader.program->setUniformValue(loc.iCursorColor, - m_postCursorR, m_postCursorG, m_postCursorB); - else - shader.program->setUniformValue(loc.iCursorColor, - m_postFgR, m_postFgG, m_postFgB); - } - if (loc.iCursorText >= 0) - shader.program->setUniformValue(loc.iCursorText, - m_postBgR, m_postBgG, m_postBgB); - - // Selection colors — use reasonable defaults (not exposed by render state API) - if (loc.iSelectionForegroundColor >= 0) - shader.program->setUniformValue(loc.iSelectionForegroundColor, 0.0f, 0.0f, 0.0f); - if (loc.iSelectionBackgroundColor >= 0) - shader.program->setUniformValue(loc.iSelectionBackgroundColor, 0.4f, 0.6f, 1.0f); - - // iChannel0 = pipeline texture on unit 0 - if (loc.iChannel0 >= 0) - shader.program->setUniformValue(loc.iChannel0, 0); -} - -void GLRenderer::Renderer::buildOverlayVertices(int fboW, int fboH) -{ - m_flatVertices.clear(); - m_flatVertexCount = 0; - - // --- Selection highlights --- - if (m_selecting && m_selStart != m_selEnd) { - float a = m_selectionHighlightColor.alphaF(); - float r = m_selectionHighlightColor.redF() * a; - float g = m_selectionHighlightColor.greenF() * a; - float b = m_selectionHighlightColor.blueF() * a; - - int sr = qBound(0, static_cast(m_selStart.y() - m_topPadding) / m_cellHeight, m_rows - 1); - int sc = qBound(0, static_cast(m_selStart.x()) / m_cellWidth, m_cols - 1); - int er = qBound(0, static_cast(m_selEnd.y() - m_topPadding) / m_cellHeight, m_rows - 1); - int ec = qBound(0, static_cast(m_selEnd.x()) / m_cellWidth, m_cols - 1); - - if (sr > er || (sr == er && sc > ec)) { - qSwap(sr, er); - qSwap(sc, ec); - } - - for (int row = sr; row <= er; ++row) { - float y = row * m_cellHeight + m_topPadding; - float x0 = (row == sr) ? sc * m_cellWidth : 0; - float x1 = (row == er) ? (ec + 1) * m_cellWidth : m_cols * m_cellWidth; - float y1 = y + m_cellHeight; - - // 2 triangles = 6 vertices, each: pos(2) + color(4) = 6 floats - // Triangle 1: top-left, top-right, bottom-right - m_flatVertices << x0 << y << r << g << b << a; - m_flatVertices << x1 << y << r << g << b << a; - m_flatVertices << x1 << y1 << r << g << b << a; - // Triangle 2: top-left, bottom-right, bottom-left - m_flatVertices << x0 << y << r << g << b << a; - m_flatVertices << x1 << y1 << r << g << b << a; - m_flatVertices << x0 << y1 << r << g << b << a; - } - } - - // --- Search highlights --- - if (m_searchActive && !m_searchMatches.isEmpty()) { - int scrollOffset = m_scrollOffset; - int visibleStartRow = scrollOffset; - int visibleEndRow = scrollOffset + m_rows; - - int startIdx = 0; - for (int i = 0; i < m_searchMatches.size(); ++i) { - if (m_searchMatches[i].row >= visibleStartRow) { - startIdx = i; - break; - } - if (m_searchMatches[i].row > visibleEndRow) { - startIdx = m_searchMatches.size(); - break; - } - } - - for (int i = startIdx; i < m_searchMatches.size(); ++i) { - const auto &match = m_searchMatches[i]; - if (match.row > visibleEndRow) break; - - int viewportRow = match.row - scrollOffset; - if (viewportRow < 0 || viewportRow >= m_rows) continue; - - float y = viewportRow * m_cellHeight + m_topPadding; - float x = match.cellCol * m_cellWidth; - float w = match.cellWidth * m_cellWidth; - - if (x + w > m_cols * m_cellWidth) - w = m_cols * m_cellWidth - x; - - float x1 = x + w; - float y1 = y + m_cellHeight; - - QColor color = (i == m_currentMatchIndex) ? m_searchCurrentColor : m_searchHighlightColor; - float a = color.alphaF(); - float cr = color.redF() * a; - float cg = color.greenF() * a; - float cb = color.blueF() * a; - - // 2 triangles = 6 vertices - m_flatVertices << x << y << cr << cg << cb << a; - m_flatVertices << x1 << y << cr << cg << cb << a; - m_flatVertices << x1 << y1 << cr << cg << cb << a; - m_flatVertices << x << y << cr << cg << cb << a; - m_flatVertices << x1 << y1 << cr << cg << cb << a; - m_flatVertices << x << y1 << cr << cg << cb << a; - } - } - - // --- Link underlines --- - if (!m_linkSpans.isEmpty()) { - // Link color: QColor(100, 180, 255, 200) — premultiplied - float la = 200.0f / 255.0f; - float lr = (100.0f / 255.0f) * la; - float lg = (180.0f / 255.0f) * la; - float lb = (255.0f / 255.0f) * la; - - for (const auto &span : m_linkSpans) { - for (int r = span.startRow; r <= span.endRow; ++r) { - if (r < 0 || r >= m_rows) continue; - - int colStart = (r == span.startRow) ? span.startCol : 0; - int colEnd = (r == span.endRow) ? span.endCol : m_cols; - - // 2px-tall rect at bottom of cell - float y = r * m_cellHeight + m_topPadding + m_cellHeight - 2; - float x0 = colStart * m_cellWidth; - float x1 = colEnd * m_cellWidth; - float y1 = y + 2; - - // 2 triangles = 6 vertices - m_flatVertices << x0 << y << lr << lg << lb << la; - m_flatVertices << x1 << y << lr << lg << lb << la; - m_flatVertices << x1 << y1 << lr << lg << lb << la; - m_flatVertices << x0 << y << lr << lg << lb << la; - m_flatVertices << x1 << y1 << lr << lg << lb << la; - m_flatVertices << x0 << y1 << lr << lg << lb << la; - } - } - } - - // --- Shell exit overlay (full-screen semi-transparent rect) --- - if (m_shellExited) { - float a = m_shellExitOverlayColor.alphaF(); - float r = m_shellExitOverlayColor.redF() * a; - float g = m_shellExitOverlayColor.greenF() * a; - float b = m_shellExitOverlayColor.blueF() * a; - - float x0 = 0.0f, y0 = 0.0f; - float x1 = static_cast(fboW); - float y1 = static_cast(fboH); - - m_flatVertices << x0 << y0 << r << g << b << a; - m_flatVertices << x1 << y0 << r << g << b << a; - m_flatVertices << x1 << y1 << r << g << b << a; - m_flatVertices << x0 << y0 << r << g << b << a; - m_flatVertices << x1 << y1 << r << g << b << a; - m_flatVertices << x0 << y1 << r << g << b << a; - } - - // --- Selection handles (tessellated circles) --- - if (m_handlesVisible && m_selecting && !m_magnifierVisible && m_selStart != m_selEnd) { - int sr = qBound(0, static_cast(m_selStart.y() - m_topPadding) / m_cellHeight, m_rows - 1); - int sc = qBound(0, static_cast(m_selStart.x()) / m_cellWidth, m_cols - 1); - int er = qBound(0, static_cast(m_selEnd.y() - m_topPadding) / m_cellHeight, m_rows - 1); - int ec = qBound(0, static_cast(m_selEnd.x()) / m_cellWidth, m_cols - 1); - - if (sr > er || (sr == er && sc > ec)) { - qSwap(sr, er); - qSwap(sc, ec); - } - - // Border color (premultiplied) - float ba = m_selectionHandleBorderColor.alphaF(); - float br = m_selectionHandleBorderColor.redF() * ba; - float bg = m_selectionHandleBorderColor.greenF() * ba; - float bb = m_selectionHandleBorderColor.blueF() * ba; - - // Fill color (premultiplied) - float fa = m_selectionHandleColor.alphaF(); - float fr = m_selectionHandleColor.redF() * fa; - float fg = m_selectionHandleColor.greenF() * fa; - float fb = m_selectionHandleColor.blueF() * fa; - - // Start handle — border then fill - float sx = sc * m_cellWidth; - float sy = (sr + 1) * m_cellHeight + m_topPadding; - appendCircle(sx, sy, TerminalView::HandleRadius + 2, br, bg, bb, ba); - appendCircle(sx, sy, TerminalView::HandleRadius, fr, fg, fb, fa); - - // End handle — border then fill - float ex = (ec + 1) * m_cellWidth; - float ey = (er + 1) * m_cellHeight + m_topPadding; - appendCircle(ex, ey, TerminalView::HandleRadius + 2, br, bg, bb, ba); - appendCircle(ex, ey, TerminalView::HandleRadius, fr, fg, fb, fa); - } - - // --- Magnifier arrow (drawn after magnifier quad via separate draw call) --- - if (m_magnifierVisible && m_selecting && m_selStart != m_selEnd) { - // Compute dest rect to find arrow position (same logic as buildMagnifierVertices) - QPointF fingerPos = m_magnifierFingerPos; - int destX = static_cast(fingerPos.x()) - TerminalView::MagnifierWidth / 2; - int unclampedDestY = static_cast(fingerPos.y()) - TerminalView::MagnifierHeight - TerminalView::MagnifierOffset; - int destY = unclampedDestY; - if (destY < 0) - destY = static_cast(fingerPos.y()) + TerminalView::MagnifierOffset; - destX = qBound(0, destX, fboW - TerminalView::MagnifierWidth); - destY = qBound(0, destY, fboH - TerminalView::MagnifierHeight); - - float arrowCenterX = destX + TerminalView::MagnifierWidth / 2.0f; - // Arrow at bottom edge of magnifier on screen, pointing DOWN toward finger. - // Ortho is Y-up; Qt scene graph flips the FBO so low Y = top on screen. - // Bottom of magnifier on screen = high FBO Y = destY + MagnifierHeight. - float arrowBase = static_cast(destY + TerminalView::MagnifierHeight); - float arrowTip = arrowBase + 8.0f; - - // If magnifier was flipped below finger, arrow at top edge pointing UP - if (unclampedDestY < 0) { - arrowBase = static_cast(destY); - arrowTip = arrowBase - 8.0f; - } - - float a = m_magnifierBorderColor.alphaF(); - float r = m_magnifierBorderColor.redF() * a; - float g = m_magnifierBorderColor.greenF() * a; - float b = m_magnifierBorderColor.blueF() * a; - - // 3 vertices for arrow triangle - m_flatVertices << (arrowCenterX - 6.0f) << arrowBase << r << g << b << a; - m_flatVertices << arrowCenterX << arrowTip << r << g << b << a; - m_flatVertices << (arrowCenterX + 6.0f) << arrowBase << r << g << b << a; - } - - m_flatVertexCount = m_flatVertices.size() / 6; - if (m_flatVertexCount > 0) { - m_flatVbo.bind(); - m_flatVbo.allocate(m_flatVertices.constData(), - m_flatVertices.size() * sizeof(float)); - m_flatVbo.release(); - } -} - -void GLRenderer::Renderer::buildCellVertices(GhosttyRenderState state) -{ - m_cellVertices.clear(); - m_cellVertices.reserve(m_cols * m_rows * 6); - - float bgAlpha = m_bgOpacity; - float bgR = m_postBgR, bgG = m_postBgG, bgB = m_postBgB; - float fgR = m_postFgR, fgG = m_postFgG, fgB = m_postFgB; - - GhosttyRenderStateRowIterator iterator; - ghostty_render_state_row_iterator_new(nullptr, &iterator); - ghostty_render_state_get(state, GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR, &iterator); - - GhosttyRenderStateRowCells cells; - ghostty_render_state_row_cells_new(nullptr, &cells); - - int y = m_topPadding; - int rowIdx = 0; - while (ghostty_render_state_row_iterator_next(iterator)) { - ghostty_render_state_row_get(iterator, - GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, - &cells); - - int x = 0; - int colIdx = 0; - while (ghostty_render_state_row_cells_next(cells)) { - // === WIDE FLAG === - GhosttyCell rawCell = 0; - GhosttyCellWide wide = GHOSTTY_CELL_WIDE_NARROW; - if (ghostty_render_state_row_cells_get(cells, - GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW, &rawCell) == GHOSTTY_SUCCESS - && rawCell != 0) { - ghostty_cell_get(rawCell, GHOSTTY_CELL_DATA_WIDE, &wide); - } - - // === SPACER_TAIL SKIP === - // The preceding WIDE_WIDE head already emitted a 2-cell quad covering - // this spacer's screen position (head advanced x by 2*cellWidth). - // Do NOT advance x — bare continue. - if (wide == GHOSTTY_CELL_WIDE_SPACER_TAIL) { - continue; - } - - GhosttyColorRgb cellBg; - float cBgR = bgR, cBgG = bgG, cBgB = bgB; - if (ghostty_render_state_row_cells_get( - cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR, - &cellBg) == GHOSTTY_SUCCESS) { - cBgR = cellBg.r / 255.0f; - cBgG = cellBg.g / 255.0f; - cBgB = cellBg.b / 255.0f; - } - - GhosttyColorRgb cellFg; - float cFgR = fgR, cFgG = fgG, cFgB = fgB; - if (ghostty_render_state_row_cells_get( - cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR, - &cellFg) == GHOSTTY_SUCCESS) { - cFgR = cellFg.r / 255.0f; - cFgG = cellFg.g / 255.0f; - cFgB = cellFg.b / 255.0f; - } - - GhosttyStyle cellStyle = GHOSTTY_INIT_SIZED(GhosttyStyle); - ghostty_render_state_row_cells_get( - cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE, - &cellStyle); - float deco = 0.0f; - if (cellStyle.underline > 0) deco = 1.0f; - else if (cellStyle.strikethrough) deco = 2.0f; - - float pFgR = cFgR, pFgG = cFgG, pFgB = cFgB, pFgA = 1.0f; - float pBgR = cBgR * bgAlpha, pBgG = cBgG * bgAlpha, pBgB = cBgB * bgAlpha, pBgA = bgAlpha; - - // === GLYPH LOOKUP (single-cp hot path vs cluster path) === - uint32_t graphemesLen = 0; - ghostty_render_state_row_cells_get( - cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN, - &graphemesLen); - - float u0 = 0, v0 = 0, u1 = 0, v1 = 0; - if (graphemesLen > 0 && graphemesLen <= 128) { - uint32_t buf[128]; - ghostty_render_state_row_cells_get( - cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF, - buf); - - // Single-codepoint hot path (ASCII/CJK) vs multi-cp cluster path. - const GlyphInfo &gi = (graphemesLen == 1) - ? m_atlas.glyph(buf[0], cellStyle.bold, cellStyle.italic) - : m_atlas.glyphCluster(buf, graphemesLen, cellStyle.bold, cellStyle.italic); - u0 = gi.u0; v0 = gi.v0; u1 = gi.u1; v1 = gi.v1; - } - - // === QUAD EMISSION — width from grid wide flag === - int cellSpan = (wide == GHOSTTY_CELL_WIDE_WIDE) ? 2 : 1; - float x0 = static_cast(x); - float y0 = static_cast(y); - float x1 = static_cast(x + cellSpan * m_cellWidth); - float y1 = static_cast(y + m_cellHeight); - - m_cellVertices.append({x0, y0, u0, v0, pFgR, pFgG, pFgB, pFgA, pBgR, pBgG, pBgB, pBgA, deco}); - m_cellVertices.append({x1, y0, u1, v0, pFgR, pFgG, pFgB, pFgA, pBgR, pBgG, pBgB, pBgA, deco}); - m_cellVertices.append({x1, y1, u1, v1, pFgR, pFgG, pFgB, pFgA, pBgR, pBgG, pBgB, pBgA, deco}); - m_cellVertices.append({x0, y0, u0, v0, pFgR, pFgG, pFgB, pFgA, pBgR, pBgG, pBgB, pBgA, deco}); - m_cellVertices.append({x1, y1, u1, v1, pFgR, pFgG, pFgB, pFgA, pBgR, pBgG, pBgB, pBgA, deco}); - m_cellVertices.append({x0, y1, u0, v1, pFgR, pFgG, pFgB, pFgA, pBgR, pBgG, pBgB, pBgA, deco}); - - x += cellSpan * m_cellWidth; - colIdx += cellSpan; - } - - y += m_cellHeight; - rowIdx++; - } - - ghostty_render_state_row_cells_free(cells); - ghostty_render_state_row_iterator_free(iterator); - - // Fill the cell-grid leftover (width % cellWidth, often ~1px) so the FBO - // is bg-filled edge-to-edge — without this the transparent clear-color - // strip shows as a gap when content slides during a session swipe. - // Texcoord (0,0) → atlas reserved transparent pixel → bg-only output - // (same as empty cells). Strips split on the grid boundary to avoid - // double-applying the premultiplied bg at the corner. - float sAlpha = m_bgOpacity; - float spBgR = m_postBgR * sAlpha, spBgG = m_postBgG * sAlpha, spBgB = m_postBgB * sAlpha, spBgA = sAlpha; - float spFgR = m_postFgR, spFgG = m_postFgG, spFgB = m_postFgB, spFgA = 1.0f; - - int gridW = m_cols * m_cellWidth; - int gridH = m_topPadding + m_rows * m_cellHeight; - - // Right strip: columns beyond the grid, full viewport height. - if (m_viewportWidth > gridW && m_cellWidth > 0) { - float x0 = static_cast(gridW), x1 = static_cast(m_viewportWidth); - float y0 = 0.0f, y1 = static_cast(m_viewportHeight); - m_cellVertices.append({x0, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - m_cellVertices.append({x1, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - m_cellVertices.append({x1, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - m_cellVertices.append({x0, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - m_cellVertices.append({x1, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - m_cellVertices.append({x0, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - } - - // Top strip: padding band above the grid (y in [0, m_topPadding]), grid width - // only — the right strip above already covers this band for x in [gridW, viewportWidth]. - if (m_topPadding > 0 && m_cellWidth > 0) { - float x0 = 0.0f, x1 = static_cast(gridW); - float y0 = 0.0f, y1 = static_cast(m_topPadding); - m_cellVertices.append({x0, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - m_cellVertices.append({x1, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - m_cellVertices.append({x1, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - m_cellVertices.append({x0, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - m_cellVertices.append({x1, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - m_cellVertices.append({x0, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - } - - // Bottom strip: rows beyond the grid, grid width only (the right strip covers the rest). - if (m_viewportHeight > gridH && m_cellHeight > 0) { - float x0 = 0.0f, x1 = static_cast(gridW); - float y0 = static_cast(gridH), y1 = static_cast(m_viewportHeight); - m_cellVertices.append({x0, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - m_cellVertices.append({x1, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - m_cellVertices.append({x1, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - m_cellVertices.append({x0, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - m_cellVertices.append({x1, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - m_cellVertices.append({x0, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); - } + << "postShaderActive:" << m_postShaderActive; } void GLRenderer::Renderer::rebuildVBO() @@ -1869,334 +575,6 @@ void GLRenderer::Renderer::renderMagnifier(const QMatrix4x4 &proj, int fboW, int } } -void GLRenderer::Renderer::snapshotKittyGraphics(GhosttyTerminal terminal, GhosttyVt *vt) -{ - m_kittyPlacements.clear(); - - if (!terminal || !vt) - return; - - if (!m_kittyGraphicsEnabled) { - // Feature disabled — queue all textures for deferred deletion. - // GL calls must happen on the render thread, not the GUI thread. - for (auto it = m_kittyTextures.constBegin(); it != m_kittyTextures.constEnd(); ++it) - m_kittyTexturesToDelete.append(it.value().texture); - m_kittyTextures.clear(); - return; - } - - m_kittyFrameCounter++; - - if (m_kittyFrameCounter % 60 == 0) - cleanupKittyCache(); - - // Snapshot placement data from the terminal (GUI thread — safe). - // The render thread will draw from this snapshot without touching - // ghostty_terminal_get, avoiding a data race with vtWrite. - GhosttyKittyGraphics graphics = nullptr; - ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS, &graphics); - if (!graphics) - return; - - for (int layerIdx = 0; layerIdx < 2; ++layerIdx) { - GhosttyKittyPlacementLayer layer = (layerIdx == 0) - ? GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_TEXT - : GHOSTTY_KITTY_PLACEMENT_LAYER_ABOVE_TEXT; - - GhosttyKittyGraphicsPlacementIterator iter = nullptr; - if (ghostty_kitty_graphics_placement_iterator_new(nullptr, &iter) != GHOSTTY_SUCCESS) - continue; - if (ghostty_kitty_graphics_get(graphics, - GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR, &iter) != GHOSTTY_SUCCESS) { - ghostty_kitty_graphics_placement_iterator_free(iter); - continue; - } - ghostty_kitty_graphics_placement_iterator_set(iter, - GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_LAYER, &layer); - - while (ghostty_kitty_graphics_placement_next(iter)) { - bool isVirtual = false; - ghostty_kitty_graphics_placement_get(iter, - GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IS_VIRTUAL, &isVirtual); - if (isVirtual) - continue; - - KittyPlacementSnapshot snap; - snap.layer = layer; - - ghostty_kitty_graphics_placement_get(iter, - GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID, &snap.imageId); - if (snap.imageId == 0) - continue; - - GhosttyKittyGraphicsImage image = ghostty_kitty_graphics_image(graphics, snap.imageId); - if (!image) { - // Image deleted from storage — snapshot will trigger cache eviction - snap.imageExists = false; - m_kittyPlacements.append(snap); - continue; - } - snap.imageExists = true; - - ghostty_kitty_graphics_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_WIDTH, &snap.imgW); - ghostty_kitty_graphics_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_HEIGHT, &snap.imgH); - if (snap.imgW == 0 || snap.imgH == 0) - continue; - - GhosttyKittyGraphicsPlacementRenderInfo info = GHOSTTY_INIT_SIZED(GhosttyKittyGraphicsPlacementRenderInfo); - if (ghostty_kitty_graphics_placement_render_info(iter, image, terminal, &info) != GHOSTTY_SUCCESS) - continue; - if (!info.viewport_visible) - continue; - snap.renderInfo = info; - - ghostty_kitty_graphics_placement_get(iter, - GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET, &snap.xOffset); - ghostty_kitty_graphics_placement_get(iter, - GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Y_OFFSET, &snap.yOffset); - - if (m_kittyTextures.contains(snap.imageId)) { - size_t checkPixelsLen = 0; - ghostty_kitty_graphics_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN, &checkPixelsLen); - if (checkPixelsLen != m_kittyTextures[snap.imageId].dataLen) { - // Image ID reused with different data — queue old texture for deletion - m_kittyTexturesToDelete.append(m_kittyTextures[snap.imageId].texture); - m_kittyTextures.remove(snap.imageId); - snap.needsUpload = true; - } - } else { - snap.needsUpload = true; - } - - if (snap.needsUpload) { - const uint8_t *pixels = nullptr; - size_t pixelsLen = 0; - ghostty_kitty_graphics_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR, &pixels); - ghostty_kitty_graphics_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN, &pixelsLen); - ghostty_kitty_graphics_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_FORMAT, &snap.format); - if (pixels && pixelsLen > 0) { - snap.pixelData = QByteArray(reinterpret_cast(pixels), - static_cast(pixelsLen)); - snap.dataLen = pixelsLen; - } else { - continue; // No pixel data available - } - } - - m_kittyPlacements.append(snap); - } - ghostty_kitty_graphics_placement_iterator_free(iter); - } -} - -void GLRenderer::Renderer::drainPendingKittyDeletions() -{ - if (m_kittyTexturesToDelete.isEmpty()) - return; - for (GLuint tex : m_kittyTexturesToDelete) - glDeleteTextures(1, &tex); - m_kittyTexturesToDelete.clear(); -} - -void GLRenderer::Renderer::drawKittyImageLayer(GhosttyKittyPlacementLayer layer, - const QMatrix4x4 &proj, int /* fboW */, int /* fboH */) -{ - if (!m_kittyProgram || !m_kittyProgram->isLinked()) - return; - - if (!m_kittyGraphicsEnabled) - return; - - bool hasAnyPlacement = false; - - for (const auto &snap : m_kittyPlacements) { - if (snap.layer != layer) - continue; - - // Image deleted from storage — evict from cache - if (!snap.imageExists) { - auto it = m_kittyTextures.find(snap.imageId); - if (it != m_kittyTextures.end()) { - m_kittyTexturesToDelete.append(it.value().texture); - m_kittyTextures.erase(it); - } - continue; - } - - uint32_t imgW = snap.imgW, imgH = snap.imgH; - if (imgW == 0 || imgH == 0) - continue; - - const auto &info = snap.renderInfo; - uint32_t xOffset = snap.xOffset, yOffset = snap.yOffset; - - if (snap.needsUpload && !m_kittyTextures.contains(snap.imageId)) { - const uint8_t *pixels = reinterpret_cast(snap.pixelData.constData()); - size_t pixelsLen = snap.dataLen; - - if (!pixels || pixelsLen == 0) - continue; - - GhosttyKittyImageFormat fmt = snap.format; - - GLenum glFmt = GL_RGBA; - bool convertedPixels = false; - if (fmt == GHOSTTY_KITTY_IMAGE_FORMAT_RGB) - glFmt = GL_RGB; - else if (fmt == GHOSTTY_KITTY_IMAGE_FORMAT_GRAY - || fmt == GHOSTTY_KITTY_IMAGE_FORMAT_GRAY_ALPHA) { - // Convert gray/gray-alpha to RGBA for GL upload - bool hasAlpha = (fmt == GHOSTTY_KITTY_IMAGE_FORMAT_GRAY_ALPHA); - size_t srcBpp = hasAlpha ? 2 : 1; - size_t convertedLen = static_cast(imgW) * static_cast(imgH) * 4; - uint8_t* rgba = static_cast(malloc(convertedLen)); - if (rgba) { - for (size_t i = 0; i < static_cast(imgW) * static_cast(imgH); ++i) { - uint8_t gray = pixels[i * srcBpp]; - uint8_t alpha = hasAlpha ? pixels[i * srcBpp + 1] : 255; - rgba[i * 4 + 0] = gray; - rgba[i * 4 + 1] = gray; - rgba[i * 4 + 2] = gray; - rgba[i * 4 + 3] = alpha; - } - pixels = rgba; - glFmt = GL_RGBA; - convertedPixels = true; - } else { - // Falling through would pass the undersized gray buffer to - // glTexImage2D (expects RGBA), causing a heap over-read. - qWarning() << "Kitty image: failed to allocate" << convertedLen - << "bytes for gray→RGBA conversion (image" - << snap.imageId << imgW << "x" << imgH << ")"; - continue; - } - } - - GLuint tex = 0; - glGenTextures(1, &tex); - glBindTexture(GL_TEXTURE_2D, tex); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - glTexImage2D(GL_TEXTURE_2D, 0, glFmt, imgW, imgH, 0, - glFmt, GL_UNSIGNED_BYTE, pixels); - glBindTexture(GL_TEXTURE_2D, 0); - if (convertedPixels) - free(const_cast(pixels)); - - KittyCachedTexture cached; - cached.texture = tex; - cached.width = imgW; - cached.height = imgH; - cached.lastSeenFrame = m_kittyFrameCounter; - cached.dataLen = pixelsLen; - m_kittyTextures.insert(snap.imageId, cached); - } else if (m_kittyTextures.contains(snap.imageId)) { - m_kittyTextures[snap.imageId].lastSeenFrame = m_kittyFrameCounter; - } - - if (!m_kittyTextures.contains(snap.imageId)) - continue; - - float destX = static_cast(info.viewport_col * m_cellWidth) + static_cast(xOffset); - float destY = m_topPadding + static_cast(info.viewport_row * m_cellHeight) + static_cast(yOffset); - float destW = static_cast(info.pixel_width); - float destH = static_cast(info.pixel_height); - - float uvX0 = static_cast(info.source_x) / static_cast(imgW); - float uvY0 = static_cast(info.source_y) / static_cast(imgH); - float uvX1 = static_cast(info.source_x + info.source_width) / static_cast(imgW); - float uvY1 = static_cast(info.source_y + info.source_height) / static_cast(imgH); - - // Clip partial visibility (negative viewport_row) - if (info.viewport_row < 0) { - int clippedPx = (-info.viewport_row) * m_cellHeight; - destY = m_topPadding; - destH -= clippedPx; - if (destH <= 0) - continue; - float uvScale = (uvY1 - uvY0) / static_cast(info.pixel_height); - uvY0 += uvScale * clippedPx; - } - - float x0 = destX, y0 = destY; - float x1 = destX + destW, y1 = destY + destH; - - float verts[24] = { - x0, y0, uvX0, uvY0, - x1, y0, uvX1, uvY0, - x1, y1, uvX1, uvY1, - x0, y0, uvX0, uvY0, - x1, y1, uvX1, uvY1, - x0, y1, uvX0, uvY1, - }; - - if (!hasAnyPlacement) { - m_kittyProgram->bind(); - m_kittyProgram->setUniformValue(m_kittyMatrixUniform, proj); - m_kittyProgram->setUniformValue(m_kittyTexUniform, 0); - glActiveTexture(GL_TEXTURE0); - glEnableVertexAttribArray(m_kittyPositionAttr); - glEnableVertexAttribArray(m_kittyTexcoordAttr); - hasAnyPlacement = true; - } - - glBindTexture(GL_TEXTURE_2D, m_kittyTextures[snap.imageId].texture); - - glVertexAttribPointer(m_kittyPositionAttr, 2, GL_FLOAT, GL_FALSE, - 4 * sizeof(float), verts); - glVertexAttribPointer(m_kittyTexcoordAttr, 2, GL_FLOAT, GL_FALSE, - 4 * sizeof(float), verts + 2); - - glDrawArrays(GL_TRIANGLES, 0, 6); - } - - if (hasAnyPlacement) { - glDisableVertexAttribArray(m_kittyPositionAttr); - glDisableVertexAttribArray(m_kittyTexcoordAttr); - glBindTexture(GL_TEXTURE_2D, 0); - m_kittyProgram->release(); - } -} - -void GLRenderer::Renderer::cleanupKittyCache() -{ - if (m_kittyTextures.isEmpty()) - return; - - QList toRemove; - for (auto it = m_kittyTextures.constBegin(); it != m_kittyTextures.constEnd(); ++it) { - if (m_kittyFrameCounter - it.value().lastSeenFrame > KITTY_EVICTION_FRAMES) - toRemove.append(it.key()); - } - - // Also evict oldest if over hard cap - int excess = m_kittyTextures.size() - MAX_KITTY_TEXTURES; - for (int i = 0; i < excess && m_kittyTextures.size() > 0; ++i) { - uint32_t oldestFrame = UINT32_MAX; - uint32_t oldestId = 0; - for (auto it = m_kittyTextures.constBegin(); it != m_kittyTextures.constEnd(); ++it) { - if (toRemove.contains(it.key())) - continue; // skip IDs already marked for removal - if (it.value().lastSeenFrame < oldestFrame) { - oldestFrame = it.value().lastSeenFrame; - oldestId = it.key(); - } - } - if (oldestId) - toRemove.append(oldestId); - } - - for (uint32_t id : toRemove) { - auto it = m_kittyTextures.find(id); - if (it != m_kittyTextures.end()) { - m_kittyTexturesToDelete.append(it.value().texture); - m_kittyTextures.erase(it); - } - } -} - bool GLRenderer::Renderer::renderPostProcessPipeline(QOpenGLFramebufferObject *fbo) { // Update timing uniforms diff --git a/src/glrenderer_geometry.cpp b/src/glrenderer_geometry.cpp new file mode 100644 index 0000000..edec191 --- /dev/null +++ b/src/glrenderer_geometry.cpp @@ -0,0 +1,486 @@ +#include "glrenderer.h" + +#include + +namespace { +constexpr int kFloatsPerFlatVertex = 6; // pos(2) + color(4) — matches flat-vertex layout +constexpr uint32_t kMaxGraphemeLen = 128; // matches ghostty Cell.grapheme cap +constexpr float kArrowLen = 8.0f; +constexpr float kArrowHalfWidth = 6.0f; +constexpr int kLinkR = 100, kLinkG = 180, kLinkB = 255, kLinkA = 200; +} + +void GLRenderer::Renderer::createVBO() +{ + // Populated lazily in render() when dirty + m_vbo = QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); + m_vbo.create(); + m_vbo.setUsagePattern(QOpenGLBuffer::DynamicDraw); +} + +void GLRenderer::Renderer::createFlatVBO() +{ + m_flatVbo = QOpenGLBuffer(QOpenGLBuffer::VertexBuffer); + m_flatVbo.create(); + m_flatVbo.setUsagePattern(QOpenGLBuffer::DynamicDraw); +} + +void GLRenderer::Renderer::appendCircle(float cx, float cy, float radius, + float r, float g, float b, float a, int segments) +{ + const float step = 2.0f * static_cast(M_PI) / segments; + for (int i = 0; i < segments; ++i) { + float angle0 = step * i; + float angle1 = step * (i + 1); + // Triangle fan: center + angle i + angle i+1 + m_flatVertices << cx << cy << r << g << b << a; + m_flatVertices << (cx + radius * cosf(angle0)) + << (cy + radius * sinf(angle0)) + << r << g << b << a; + m_flatVertices << (cx + radius * cosf(angle1)) + << (cy + radius * sinf(angle1)) + << r << g << b << a; + } +} + +void GLRenderer::Renderer::buildMagnifierVertices(int fboW, int fboH) +{ + m_magVertices.clear(); + m_magVertexCount = 0; + + if (!m_magnifierVisible || !m_selecting || m_selStart == m_selEnd) + return; + + QPointF fingerPos = m_magnifierFingerPos; + + int srcW = TerminalView::MagnifierWidth / TerminalView::MagnifierZoom; // 90 + int srcH = TerminalView::MagnifierHeight / TerminalView::MagnifierZoom; // 50 + int srcX = static_cast(fingerPos.x()) - srcW / 2; + int srcY = static_cast(fingerPos.y()) - srcH / 2; + + if (fboW > srcW && fboH > srcH) { + srcX = qBound(0, srcX, fboW - srcW); + srcY = qBound(0, srcY, fboH - srcH); + } else { + srcX = qMax(0, srcX); + srcY = qMax(0, srcY); + } + + int destX = static_cast(fingerPos.x()) - TerminalView::MagnifierWidth / 2; + int destY = static_cast(fingerPos.y()) - TerminalView::MagnifierHeight - TerminalView::MagnifierOffset; + + // destY < 0 ⇒ magnifier would clip top → flip below finger + if (destY < 0) + destY = static_cast(fingerPos.y()) + TerminalView::MagnifierOffset; + destX = qBound(0, destX, fboW - TerminalView::MagnifierWidth); + destY = qBound(0, destY, fboH - TerminalView::MagnifierHeight); + + float dx0 = static_cast(destX); + float dy0 = static_cast(destY); + float dx1 = static_cast(destX + TerminalView::MagnifierWidth); + float dy1 = static_cast(destY + TerminalView::MagnifierHeight); + + // UV coords: [0,1] maps the full magnifier quad; the shader maps this to + // the source rectangle within m_pipelineTex via u_srcRect / u_srcTexSize. + float u0 = 0.0f; + float v0 = 0.0f; + float u1 = 1.0f; + float v1 = 1.0f; + + // 2 triangles (6 vertices), each: pos2 + texcoord2 = 4 floats + m_magVertices << dx0 << dy0 << u0 << v0; + m_magVertices << dx1 << dy0 << u1 << v0; + m_magVertices << dx1 << dy1 << u1 << v1; + m_magVertices << dx0 << dy0 << u0 << v0; + m_magVertices << dx1 << dy1 << u1 << v1; + m_magVertices << dx0 << dy1 << u0 << v1; + + m_magVertexCount = 6; +} + +void GLRenderer::Renderer::buildOverlayVertices(int fboW, int fboH) +{ + m_flatVertices.clear(); + m_flatVertexCount = 0; + + // Selection highlights + if (m_selecting && m_selStart != m_selEnd) { + float a = m_selectionHighlightColor.alphaF(); + float r = m_selectionHighlightColor.redF() * a; + float g = m_selectionHighlightColor.greenF() * a; + float b = m_selectionHighlightColor.blueF() * a; + + int sr = qBound(0, static_cast(m_selStart.y() - m_topPadding) / m_cellHeight, m_rows - 1); + int sc = qBound(0, static_cast(m_selStart.x()) / m_cellWidth, m_cols - 1); + int er = qBound(0, static_cast(m_selEnd.y() - m_topPadding) / m_cellHeight, m_rows - 1); + int ec = qBound(0, static_cast(m_selEnd.x()) / m_cellWidth, m_cols - 1); + + if (sr > er || (sr == er && sc > ec)) { + qSwap(sr, er); + qSwap(sc, ec); + } + + for (int row = sr; row <= er; ++row) { + float y = row * m_cellHeight + m_topPadding; + float x0 = (row == sr) ? sc * m_cellWidth : 0; + float x1 = (row == er) ? (ec + 1) * m_cellWidth : m_cols * m_cellWidth; + float y1 = y + m_cellHeight; + + // 2 triangles = 6 vertices, each: pos(2) + color(4) = 6 floats + m_flatVertices << x0 << y << r << g << b << a; + m_flatVertices << x1 << y << r << g << b << a; + m_flatVertices << x1 << y1 << r << g << b << a; + m_flatVertices << x0 << y << r << g << b << a; + m_flatVertices << x1 << y1 << r << g << b << a; + m_flatVertices << x0 << y1 << r << g << b << a; + } + } + + // Search highlights + if (m_searchActive && !m_searchMatches.isEmpty()) { + int scrollOffset = m_scrollOffset; + int visibleStartRow = scrollOffset; + int visibleEndRow = scrollOffset + m_rows; + + int startIdx = 0; + for (int i = 0; i < m_searchMatches.size(); ++i) { + if (m_searchMatches[i].row >= visibleStartRow) { + startIdx = i; + break; + } + if (m_searchMatches[i].row > visibleEndRow) { + startIdx = m_searchMatches.size(); + break; + } + } + + for (int i = startIdx; i < m_searchMatches.size(); ++i) { + const auto &match = m_searchMatches[i]; + if (match.row > visibleEndRow) break; + + int viewportRow = match.row - scrollOffset; + if (viewportRow < 0 || viewportRow >= m_rows) continue; + + float y = viewportRow * m_cellHeight + m_topPadding; + float x = match.cellCol * m_cellWidth; + float w = match.cellWidth * m_cellWidth; + + if (x + w > m_cols * m_cellWidth) + w = m_cols * m_cellWidth - x; + + float x1 = x + w; + float y1 = y + m_cellHeight; + + QColor color = (i == m_currentMatchIndex) ? m_searchCurrentColor : m_searchHighlightColor; + float a = color.alphaF(); + float cr = color.redF() * a; + float cg = color.greenF() * a; + float cb = color.blueF() * a; + + m_flatVertices << x << y << cr << cg << cb << a; + m_flatVertices << x1 << y << cr << cg << cb << a; + m_flatVertices << x1 << y1 << cr << cg << cb << a; + m_flatVertices << x << y << cr << cg << cb << a; + m_flatVertices << x1 << y1 << cr << cg << cb << a; + m_flatVertices << x << y1 << cr << cg << cb << a; + } + } + + // Link underlines + if (!m_linkSpans.isEmpty()) { + float la = kLinkA / 255.0f; + float lr = (kLinkR / 255.0f) * la; + float lg = (kLinkG / 255.0f) * la; + float lb = (kLinkB / 255.0f) * la; + + for (const auto &span : m_linkSpans) { + for (int r = span.startRow; r <= span.endRow; ++r) { + if (r < 0 || r >= m_rows) continue; + + int colStart = (r == span.startRow) ? span.startCol : 0; + int colEnd = (r == span.endRow) ? span.endCol : m_cols; + + // 2px-tall rect at bottom of cell + float y = r * m_cellHeight + m_topPadding + m_cellHeight - 2; + float x0 = colStart * m_cellWidth; + float x1 = colEnd * m_cellWidth; + float y1 = y + 2; + + m_flatVertices << x0 << y << lr << lg << lb << la; + m_flatVertices << x1 << y << lr << lg << lb << la; + m_flatVertices << x1 << y1 << lr << lg << lb << la; + m_flatVertices << x0 << y << lr << lg << lb << la; + m_flatVertices << x1 << y1 << lr << lg << lb << la; + m_flatVertices << x0 << y1 << lr << lg << lb << la; + } + } + } + + // Shell exit overlay (full-screen semi-transparent rect) + if (m_shellExited) { + float a = m_shellExitOverlayColor.alphaF(); + float r = m_shellExitOverlayColor.redF() * a; + float g = m_shellExitOverlayColor.greenF() * a; + float b = m_shellExitOverlayColor.blueF() * a; + + float x0 = 0.0f, y0 = 0.0f; + float x1 = static_cast(fboW); + float y1 = static_cast(fboH); + + m_flatVertices << x0 << y0 << r << g << b << a; + m_flatVertices << x1 << y0 << r << g << b << a; + m_flatVertices << x1 << y1 << r << g << b << a; + m_flatVertices << x0 << y0 << r << g << b << a; + m_flatVertices << x1 << y1 << r << g << b << a; + m_flatVertices << x0 << y1 << r << g << b << a; + } + + // Selection handles (tessellated circles) + if (m_handlesVisible && m_selecting && !m_magnifierVisible && m_selStart != m_selEnd) { + int sr = qBound(0, static_cast(m_selStart.y() - m_topPadding) / m_cellHeight, m_rows - 1); + int sc = qBound(0, static_cast(m_selStart.x()) / m_cellWidth, m_cols - 1); + int er = qBound(0, static_cast(m_selEnd.y() - m_topPadding) / m_cellHeight, m_rows - 1); + int ec = qBound(0, static_cast(m_selEnd.x()) / m_cellWidth, m_cols - 1); + + if (sr > er || (sr == er && sc > ec)) { + qSwap(sr, er); + qSwap(sc, ec); + } + + // Border + float ba = m_selectionHandleBorderColor.alphaF(); + float br = m_selectionHandleBorderColor.redF() * ba; + float bg = m_selectionHandleBorderColor.greenF() * ba; + float bb = m_selectionHandleBorderColor.blueF() * ba; + + // Fill + float fa = m_selectionHandleColor.alphaF(); + float fr = m_selectionHandleColor.redF() * fa; + float fg = m_selectionHandleColor.greenF() * fa; + float fb = m_selectionHandleColor.blueF() * fa; + + // Start handle — border then fill + float sx = sc * m_cellWidth; + float sy = (sr + 1) * m_cellHeight + m_topPadding; + appendCircle(sx, sy, TerminalView::HandleRadius + 2, br, bg, bb, ba); + appendCircle(sx, sy, TerminalView::HandleRadius, fr, fg, fb, fa); + + // End handle — border then fill + float ex = (ec + 1) * m_cellWidth; + float ey = (er + 1) * m_cellHeight + m_topPadding; + appendCircle(ex, ey, TerminalView::HandleRadius + 2, br, bg, bb, ba); + appendCircle(ex, ey, TerminalView::HandleRadius, fr, fg, fb, fa); + } + + // Magnifier arrow (drawn after magnifier quad via separate draw call) + if (m_magnifierVisible && m_selecting && m_selStart != m_selEnd) { + // Compute dest rect to find arrow position (same logic as buildMagnifierVertices) + QPointF fingerPos = m_magnifierFingerPos; + int destX = static_cast(fingerPos.x()) - TerminalView::MagnifierWidth / 2; + int unclampedDestY = static_cast(fingerPos.y()) - TerminalView::MagnifierHeight - TerminalView::MagnifierOffset; + int destY = unclampedDestY; + if (destY < 0) + destY = static_cast(fingerPos.y()) + TerminalView::MagnifierOffset; + destX = qBound(0, destX, fboW - TerminalView::MagnifierWidth); + destY = qBound(0, destY, fboH - TerminalView::MagnifierHeight); + + float arrowCenterX = destX + TerminalView::MagnifierWidth / 2.0f; + // Arrow at bottom edge of magnifier on screen, pointing DOWN toward finger. + // Ortho is Y-up; Qt scene graph flips the FBO so low Y = top on screen. + // Bottom of magnifier on screen = high FBO Y = destY + MagnifierHeight. + float arrowBase = static_cast(destY + TerminalView::MagnifierHeight); + float arrowTip = arrowBase + kArrowLen; + + // If magnifier was flipped below finger, arrow at top edge pointing UP + if (unclampedDestY < 0) { + arrowBase = static_cast(destY); + arrowTip = arrowBase - kArrowLen; + } + + float a = m_magnifierBorderColor.alphaF(); + float r = m_magnifierBorderColor.redF() * a; + float g = m_magnifierBorderColor.greenF() * a; + float b = m_magnifierBorderColor.blueF() * a; + + m_flatVertices << (arrowCenterX - kArrowHalfWidth) << arrowBase << r << g << b << a; + m_flatVertices << arrowCenterX << arrowTip << r << g << b << a; + m_flatVertices << (arrowCenterX + kArrowHalfWidth) << arrowBase << r << g << b << a; + } + + m_flatVertexCount = m_flatVertices.size() / kFloatsPerFlatVertex; + if (m_flatVertexCount > 0) { + m_flatVbo.bind(); + m_flatVbo.allocate(m_flatVertices.constData(), + m_flatVertices.size() * sizeof(float)); + m_flatVbo.release(); + } +} + +void GLRenderer::Renderer::buildCellVertices(GhosttyRenderState state) +{ + m_cellVertices.clear(); + m_cellVertices.reserve(m_cols * m_rows * 6); + + float bgAlpha = m_bgOpacity; + float bgR = m_postBgR, bgG = m_postBgG, bgB = m_postBgB; + float fgR = m_postFgR, fgG = m_postFgG, fgB = m_postFgB; + + GhosttyRenderStateRowIterator iterator; + ghostty_render_state_row_iterator_new(nullptr, &iterator); + ghostty_render_state_get(state, GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR, &iterator); + + GhosttyRenderStateRowCells cells; + ghostty_render_state_row_cells_new(nullptr, &cells); + + int y = m_topPadding; + int rowIdx = 0; + while (ghostty_render_state_row_iterator_next(iterator)) { + ghostty_render_state_row_get(iterator, + GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, + &cells); + + int x = 0; + int colIdx = 0; + while (ghostty_render_state_row_cells_next(cells)) { + // Wide flag + GhosttyCell rawCell = 0; + GhosttyCellWide wide = GHOSTTY_CELL_WIDE_NARROW; + if (ghostty_render_state_row_cells_get(cells, + GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW, &rawCell) == GHOSTTY_SUCCESS + && rawCell != 0) { + ghostty_cell_get(rawCell, GHOSTTY_CELL_DATA_WIDE, &wide); + } + + // Spacer-tail skip: + // The preceding WIDE_WIDE head already emitted a 2-cell quad covering + // this spacer's screen position (head advanced x by 2*cellWidth). + // Do NOT advance x — bare continue. + if (wide == GHOSTTY_CELL_WIDE_SPACER_TAIL) { + continue; + } + + GhosttyColorRgb cellBg; + float cBgR = bgR, cBgG = bgG, cBgB = bgB; + if (ghostty_render_state_row_cells_get( + cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_BG_COLOR, + &cellBg) == GHOSTTY_SUCCESS) { + cBgR = cellBg.r / 255.0f; + cBgG = cellBg.g / 255.0f; + cBgB = cellBg.b / 255.0f; + } + + GhosttyColorRgb cellFg; + float cFgR = fgR, cFgG = fgG, cFgB = fgB; + if (ghostty_render_state_row_cells_get( + cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_FG_COLOR, + &cellFg) == GHOSTTY_SUCCESS) { + cFgR = cellFg.r / 255.0f; + cFgG = cellFg.g / 255.0f; + cFgB = cellFg.b / 255.0f; + } + + GhosttyStyle cellStyle = GHOSTTY_INIT_SIZED(GhosttyStyle); + ghostty_render_state_row_cells_get( + cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_STYLE, + &cellStyle); + float deco = 0.0f; + if (cellStyle.underline > 0) deco = 1.0f; + else if (cellStyle.strikethrough) deco = 2.0f; + + float pFgR = cFgR, pFgG = cFgG, pFgB = cFgB, pFgA = 1.0f; + float pBgR = cBgR * bgAlpha, pBgG = cBgG * bgAlpha, pBgB = cBgB * bgAlpha, pBgA = bgAlpha; + + // Glyph lookup + uint32_t graphemesLen = 0; + ghostty_render_state_row_cells_get( + cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN, + &graphemesLen); + + float u0 = 0, v0 = 0, u1 = 0, v1 = 0; + if (graphemesLen > 0 && graphemesLen <= kMaxGraphemeLen) { + uint32_t buf[kMaxGraphemeLen]; + ghostty_render_state_row_cells_get( + cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF, + buf); + + const GlyphInfo &gi = (graphemesLen == 1) + ? m_atlas.glyph(buf[0], cellStyle.bold, cellStyle.italic) + : m_atlas.glyphCluster(buf, graphemesLen, cellStyle.bold, cellStyle.italic); + u0 = gi.u0; v0 = gi.v0; u1 = gi.u1; v1 = gi.v1; + } + + // Quad emission — width from grid wide flag + int cellSpan = (wide == GHOSTTY_CELL_WIDE_WIDE) ? 2 : 1; + float x0 = static_cast(x); + float y0 = static_cast(y); + float x1 = static_cast(x + cellSpan * m_cellWidth); + float y1 = static_cast(y + m_cellHeight); + + m_cellVertices.append({x0, y0, u0, v0, pFgR, pFgG, pFgB, pFgA, pBgR, pBgG, pBgB, pBgA, deco}); + m_cellVertices.append({x1, y0, u1, v0, pFgR, pFgG, pFgB, pFgA, pBgR, pBgG, pBgB, pBgA, deco}); + m_cellVertices.append({x1, y1, u1, v1, pFgR, pFgG, pFgB, pFgA, pBgR, pBgG, pBgB, pBgA, deco}); + m_cellVertices.append({x0, y0, u0, v0, pFgR, pFgG, pFgB, pFgA, pBgR, pBgG, pBgB, pBgA, deco}); + m_cellVertices.append({x1, y1, u1, v1, pFgR, pFgG, pFgB, pFgA, pBgR, pBgG, pBgB, pBgA, deco}); + m_cellVertices.append({x0, y1, u0, v1, pFgR, pFgG, pFgB, pFgA, pBgR, pBgG, pBgB, pBgA, deco}); + + x += cellSpan * m_cellWidth; + colIdx += cellSpan; + } + + y += m_cellHeight; + rowIdx++; + } + + ghostty_render_state_row_cells_free(cells); + ghostty_render_state_row_iterator_free(iterator); + + // Fill the cell-grid leftover (width % cellWidth, often ~1px) so the FBO + // is bg-filled edge-to-edge — without this the transparent clear-color + // strip shows as a gap when content slides during a session swipe. + // Texcoord (0,0) → atlas reserved transparent pixel → bg-only output + // (same as empty cells). Strips split on the grid boundary to avoid + // double-applying the premultiplied bg at the corner. + float sAlpha = m_bgOpacity; + float spBgR = m_postBgR * sAlpha, spBgG = m_postBgG * sAlpha, spBgB = m_postBgB * sAlpha, spBgA = sAlpha; + float spFgR = m_postFgR, spFgG = m_postFgG, spFgB = m_postFgB, spFgA = 1.0f; + + int gridW = m_cols * m_cellWidth; + int gridH = m_topPadding + m_rows * m_cellHeight; + + // Right strip: columns beyond the grid, full viewport height. + if (m_viewportWidth > gridW && m_cellWidth > 0) { + float x0 = static_cast(gridW), x1 = static_cast(m_viewportWidth); + float y0 = 0.0f, y1 = static_cast(m_viewportHeight); + m_cellVertices.append({x0, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + m_cellVertices.append({x1, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + m_cellVertices.append({x1, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + m_cellVertices.append({x0, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + m_cellVertices.append({x1, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + m_cellVertices.append({x0, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + } + + // Top strip: padding band above the grid (y in [0, m_topPadding]), grid width + // only — the right strip above already covers this band for x in [gridW, viewportWidth]. + if (m_topPadding > 0 && m_cellWidth > 0) { + float x0 = 0.0f, x1 = static_cast(gridW); + float y0 = 0.0f, y1 = static_cast(m_topPadding); + m_cellVertices.append({x0, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + m_cellVertices.append({x1, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + m_cellVertices.append({x1, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + m_cellVertices.append({x0, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + m_cellVertices.append({x1, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + m_cellVertices.append({x0, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + } + + // Bottom strip: rows beyond the grid, grid width only (the right strip covers the rest). + if (m_viewportHeight > gridH && m_cellHeight > 0) { + float x0 = 0.0f, x1 = static_cast(gridW); + float y0 = static_cast(gridH), y1 = static_cast(m_viewportHeight); + m_cellVertices.append({x0, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + m_cellVertices.append({x1, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + m_cellVertices.append({x1, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + m_cellVertices.append({x0, y0, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + m_cellVertices.append({x1, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + m_cellVertices.append({x0, y1, 0, 0, spFgR, spFgG, spFgB, spFgA, spBgR, spBgG, spBgB, spBgA, 0}); + } +} diff --git a/src/glrenderer_kitty.cpp b/src/glrenderer_kitty.cpp new file mode 100644 index 0000000..f3f6a22 --- /dev/null +++ b/src/glrenderer_kitty.cpp @@ -0,0 +1,371 @@ +#include "glrenderer.h" + +#include +#include + +namespace { +constexpr int kCleanupIntervalFrames = 60; +} + +void GLRenderer::Renderer::createKittyShaders() +{ + // kitty_image.glsl uses //! vertex / //! fragment markers we split on below + QFile shaderFile(QStringLiteral(":/shaders/kitty_image.glsl")); + if (!shaderFile.open(QIODevice::ReadOnly)) { + qWarning() << "Failed to open kitty_image.glsl"; + return; + } + QByteArray shaderSrc = shaderFile.readAll(); + shaderFile.close(); + + int vertIdx = shaderSrc.indexOf("//! vertex"); + int fragIdx = shaderSrc.indexOf("//! fragment"); + if (vertIdx < 0 || fragIdx < 0) { + qWarning() << "kitty_image.glsl missing vertex/fragment markers"; + return; + } + + QByteArray vertSrc = shaderSrc.mid(vertIdx, fragIdx - vertIdx); + QByteArray fragSrc = shaderSrc.mid(fragIdx); + + m_kittyProgram = new QOpenGLShaderProgram; + m_kittyProgram->addShaderFromSourceCode(QOpenGLShader::Vertex, vertSrc); + m_kittyProgram->addShaderFromSourceCode(QOpenGLShader::Fragment, fragSrc); + if (!m_kittyProgram->link()) { + qWarning() << "Kitty image shader link failed:" << m_kittyProgram->log(); + delete m_kittyProgram; + m_kittyProgram = nullptr; + return; + } + + m_kittyMatrixUniform = m_kittyProgram->uniformLocation("u_matrix"); + m_kittyTexUniform = m_kittyProgram->uniformLocation("u_image"); + m_kittyPositionAttr = m_kittyProgram->attributeLocation("position"); + m_kittyTexcoordAttr = m_kittyProgram->attributeLocation("texcoord"); +} + +void GLRenderer::Renderer::snapshotKittyGraphics(GhosttyTerminal terminal, GhosttyVt *vt) +{ + m_kittyPlacements.clear(); + + if (!terminal || !vt) + return; + + if (!m_kittyGraphicsEnabled) { + // Feature disabled — queue all textures for deferred deletion. + // GL calls must happen on the render thread, not the GUI thread. + for (auto it = m_kittyTextures.constBegin(); it != m_kittyTextures.constEnd(); ++it) + m_kittyTexturesToDelete.append(it.value().texture); + m_kittyTextures.clear(); + return; + } + + m_kittyFrameCounter++; + + if (m_kittyFrameCounter % kCleanupIntervalFrames == 0) + cleanupKittyCache(); + + // Snapshot placement data from the terminal (GUI thread — safe). + // The render thread will draw from this snapshot without touching + // ghostty_terminal_get, avoiding a data race with vtWrite. + GhosttyKittyGraphics graphics = nullptr; + ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS, &graphics); + if (!graphics) + return; + + for (GhosttyKittyPlacementLayer layer : {GHOSTTY_KITTY_PLACEMENT_LAYER_BELOW_TEXT, + GHOSTTY_KITTY_PLACEMENT_LAYER_ABOVE_TEXT}) { + GhosttyKittyGraphicsPlacementIterator iter = nullptr; + if (ghostty_kitty_graphics_placement_iterator_new(nullptr, &iter) != GHOSTTY_SUCCESS) + continue; + if (ghostty_kitty_graphics_get(graphics, + GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR, &iter) != GHOSTTY_SUCCESS) { + ghostty_kitty_graphics_placement_iterator_free(iter); + continue; + } + ghostty_kitty_graphics_placement_iterator_set(iter, + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_LAYER, &layer); + + while (ghostty_kitty_graphics_placement_next(iter)) { + bool isVirtual = false; + ghostty_kitty_graphics_placement_get(iter, + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IS_VIRTUAL, &isVirtual); + if (isVirtual) + continue; + + KittyPlacementSnapshot snap; + snap.layer = layer; + + ghostty_kitty_graphics_placement_get(iter, + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_IMAGE_ID, &snap.imageId); + if (snap.imageId == 0) + continue; + + GhosttyKittyGraphicsImage image = ghostty_kitty_graphics_image(graphics, snap.imageId); + if (!image) { + // Image deleted from storage — snapshot will trigger cache eviction + snap.imageExists = false; + m_kittyPlacements.append(snap); + continue; + } + snap.imageExists = true; + + ghostty_kitty_graphics_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_WIDTH, &snap.imgW); + ghostty_kitty_graphics_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_HEIGHT, &snap.imgH); + if (snap.imgW == 0 || snap.imgH == 0) + continue; + + GhosttyKittyGraphicsPlacementRenderInfo info = GHOSTTY_INIT_SIZED(GhosttyKittyGraphicsPlacementRenderInfo); + if (ghostty_kitty_graphics_placement_render_info(iter, image, terminal, &info) != GHOSTTY_SUCCESS) + continue; + if (!info.viewport_visible) + continue; + snap.renderInfo = info; + + ghostty_kitty_graphics_placement_get(iter, + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_X_OFFSET, &snap.xOffset); + ghostty_kitty_graphics_placement_get(iter, + GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_Y_OFFSET, &snap.yOffset); + + if (m_kittyTextures.contains(snap.imageId)) { + size_t checkPixelsLen = 0; + ghostty_kitty_graphics_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN, &checkPixelsLen); + if (checkPixelsLen != m_kittyTextures[snap.imageId].dataLen) { + // Image ID reused with different data — queue old texture for deletion + m_kittyTexturesToDelete.append(m_kittyTextures[snap.imageId].texture); + m_kittyTextures.remove(snap.imageId); + snap.needsUpload = true; + } + } else { + snap.needsUpload = true; + } + + if (snap.needsUpload) { + const uint8_t *pixels = nullptr; + size_t pixelsLen = 0; + ghostty_kitty_graphics_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR, &pixels); + ghostty_kitty_graphics_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN, &pixelsLen); + ghostty_kitty_graphics_image_get(image, GHOSTTY_KITTY_IMAGE_DATA_FORMAT, &snap.format); + if (pixels && pixelsLen > 0) { + snap.pixelData = QByteArray(reinterpret_cast(pixels), + static_cast(pixelsLen)); + snap.dataLen = pixelsLen; + } else { + continue; + } + } + + m_kittyPlacements.append(snap); + } + ghostty_kitty_graphics_placement_iterator_free(iter); + } +} + +void GLRenderer::Renderer::drainPendingKittyDeletions() +{ + if (m_kittyTexturesToDelete.isEmpty()) + return; + for (GLuint tex : m_kittyTexturesToDelete) + glDeleteTextures(1, &tex); + m_kittyTexturesToDelete.clear(); +} + +void GLRenderer::Renderer::drawKittyImageLayer(GhosttyKittyPlacementLayer layer, + const QMatrix4x4 &proj, int /* fboW */, int /* fboH */) +{ + if (!m_kittyProgram || !m_kittyProgram->isLinked()) + return; + + if (!m_kittyGraphicsEnabled) + return; + + bool hasAnyPlacement = false; + + for (const auto &snap : m_kittyPlacements) { + if (snap.layer != layer) + continue; + + // Image deleted from storage — evict from cache + if (!snap.imageExists) { + auto it = m_kittyTextures.find(snap.imageId); + if (it != m_kittyTextures.end()) { + m_kittyTexturesToDelete.append(it.value().texture); + m_kittyTextures.erase(it); + } + continue; + } + + uint32_t imgW = snap.imgW, imgH = snap.imgH; + if (imgW == 0 || imgH == 0) + continue; + + const auto &info = snap.renderInfo; + uint32_t xOffset = snap.xOffset, yOffset = snap.yOffset; + + if (snap.needsUpload && !m_kittyTextures.contains(snap.imageId)) { + const uint8_t *pixels = reinterpret_cast(snap.pixelData.constData()); + size_t pixelsLen = snap.dataLen; + + if (!pixels || pixelsLen == 0) + continue; + + GhosttyKittyImageFormat fmt = snap.format; + + GLenum glFmt = GL_RGBA; + bool convertedPixels = false; + if (fmt == GHOSTTY_KITTY_IMAGE_FORMAT_RGB) + glFmt = GL_RGB; + else if (fmt == GHOSTTY_KITTY_IMAGE_FORMAT_GRAY + || fmt == GHOSTTY_KITTY_IMAGE_FORMAT_GRAY_ALPHA) { + // Convert gray/gray-alpha to RGBA for GL upload + bool hasAlpha = (fmt == GHOSTTY_KITTY_IMAGE_FORMAT_GRAY_ALPHA); + size_t srcBpp = hasAlpha ? 2 : 1; + size_t convertedLen = static_cast(imgW) * static_cast(imgH) * 4; + uint8_t* rgba = static_cast(malloc(convertedLen)); + if (rgba) { + for (size_t i = 0; i < static_cast(imgW) * static_cast(imgH); ++i) { + uint8_t gray = pixels[i * srcBpp]; + uint8_t alpha = hasAlpha ? pixels[i * srcBpp + 1] : 255; + rgba[i * 4 + 0] = gray; + rgba[i * 4 + 1] = gray; + rgba[i * 4 + 2] = gray; + rgba[i * 4 + 3] = alpha; + } + pixels = rgba; + glFmt = GL_RGBA; + convertedPixels = true; + } else { + // Falling through would pass the undersized gray buffer to + // glTexImage2D (expects RGBA), causing a heap over-read. + qWarning() << "Kitty image: failed to allocate" << convertedLen + << "bytes for gray→RGBA conversion (image" + << snap.imageId << imgW << "x" << imgH << ")"; + continue; + } + } + + GLuint tex = 0; + glGenTextures(1, &tex); + glBindTexture(GL_TEXTURE_2D, tex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexImage2D(GL_TEXTURE_2D, 0, glFmt, imgW, imgH, 0, + glFmt, GL_UNSIGNED_BYTE, pixels); + glBindTexture(GL_TEXTURE_2D, 0); + if (convertedPixels) + free(const_cast(pixels)); + + KittyCachedTexture cached; + cached.texture = tex; + cached.width = imgW; + cached.height = imgH; + cached.lastSeenFrame = m_kittyFrameCounter; + cached.dataLen = pixelsLen; + m_kittyTextures.insert(snap.imageId, cached); + } else if (m_kittyTextures.contains(snap.imageId)) { + m_kittyTextures[snap.imageId].lastSeenFrame = m_kittyFrameCounter; + } + + if (!m_kittyTextures.contains(snap.imageId)) + continue; + + float destX = static_cast(info.viewport_col * m_cellWidth) + static_cast(xOffset); + float destY = m_topPadding + static_cast(info.viewport_row * m_cellHeight) + static_cast(yOffset); + float destW = static_cast(info.pixel_width); + float destH = static_cast(info.pixel_height); + + float uvX0 = static_cast(info.source_x) / static_cast(imgW); + float uvY0 = static_cast(info.source_y) / static_cast(imgH); + float uvX1 = static_cast(info.source_x + info.source_width) / static_cast(imgW); + float uvY1 = static_cast(info.source_y + info.source_height) / static_cast(imgH); + + // Clip partial visibility (negative viewport_row) + if (info.viewport_row < 0) { + int clippedPx = (-info.viewport_row) * m_cellHeight; + destY = m_topPadding; + destH -= clippedPx; + if (destH <= 0) + continue; + float uvScale = (uvY1 - uvY0) / static_cast(info.pixel_height); + uvY0 += uvScale * clippedPx; + } + + float x0 = destX, y0 = destY; + float x1 = destX + destW, y1 = destY + destH; + + float verts[24] = { + x0, y0, uvX0, uvY0, + x1, y0, uvX1, uvY0, + x1, y1, uvX1, uvY1, + x0, y0, uvX0, uvY0, + x1, y1, uvX1, uvY1, + x0, y1, uvX0, uvY1, + }; + + if (!hasAnyPlacement) { + m_kittyProgram->bind(); + m_kittyProgram->setUniformValue(m_kittyMatrixUniform, proj); + m_kittyProgram->setUniformValue(m_kittyTexUniform, 0); + glActiveTexture(GL_TEXTURE0); + glEnableVertexAttribArray(m_kittyPositionAttr); + glEnableVertexAttribArray(m_kittyTexcoordAttr); + hasAnyPlacement = true; + } + + glBindTexture(GL_TEXTURE_2D, m_kittyTextures[snap.imageId].texture); + + const int stride = 4 * sizeof(float); + glVertexAttribPointer(m_kittyPositionAttr, 2, GL_FLOAT, GL_FALSE, + stride, verts); + glVertexAttribPointer(m_kittyTexcoordAttr, 2, GL_FLOAT, GL_FALSE, + stride, verts + 2); + + glDrawArrays(GL_TRIANGLES, 0, 6); + } + + if (hasAnyPlacement) { + glDisableVertexAttribArray(m_kittyPositionAttr); + glDisableVertexAttribArray(m_kittyTexcoordAttr); + glBindTexture(GL_TEXTURE_2D, 0); + m_kittyProgram->release(); + } +} + +void GLRenderer::Renderer::cleanupKittyCache() +{ + if (m_kittyTextures.isEmpty()) + return; + + QList toRemove; + for (auto it = m_kittyTextures.constBegin(); it != m_kittyTextures.constEnd(); ++it) { + if (m_kittyFrameCounter - it.value().lastSeenFrame > KITTY_EVICTION_FRAMES) + toRemove.append(it.key()); + } + + // Also evict oldest if over hard cap + int excess = m_kittyTextures.size() - MAX_KITTY_TEXTURES; + for (int i = 0; i < excess && !m_kittyTextures.isEmpty(); ++i) { + uint32_t oldestFrame = UINT32_MAX; + uint32_t oldestId = 0; + for (auto it = m_kittyTextures.constBegin(); it != m_kittyTextures.constEnd(); ++it) { + if (toRemove.contains(it.key())) + continue; + if (it.value().lastSeenFrame < oldestFrame) { + oldestFrame = it.value().lastSeenFrame; + oldestId = it.key(); + } + } + if (oldestId) + toRemove.append(oldestId); + } + + for (uint32_t id : toRemove) { + auto it = m_kittyTextures.find(id); + if (it != m_kittyTextures.end()) { + m_kittyTexturesToDelete.append(it.value().texture); + m_kittyTextures.erase(it); + } + } +} diff --git a/src/glrenderer_pipeline.cpp b/src/glrenderer_pipeline.cpp new file mode 100644 index 0000000..b276a48 --- /dev/null +++ b/src/glrenderer_pipeline.cpp @@ -0,0 +1,207 @@ +#include "glrenderer.h" + +#include "settings.h" + +#include +#include +#include +#include + +void GLRenderer::Renderer::blitPipelineToFbo(QOpenGLFramebufferObject *fbo) +{ + if (!m_blitProgram || !m_blitProgram->isLinked() || !m_pipelineTex) + return; + + int w = fbo->width(); + int h = fbo->height(); + const float fw = static_cast(w); + const float fh = static_cast(h); + + QMatrix4x4 proj; + proj.ortho(0, w, 0, h, -1, 1); + + // Full-screen quad: 2 triangles, 6 vertices, each pos2+texcoord2 = 4 floats + float blitVerts[] = { + 0.0f, 0.0f, 0.0f, 0.0f, + fw, 0.0f, 1.0f, 0.0f, + fw, fh, 1.0f, 1.0f, + 0.0f, 0.0f, 0.0f, 0.0f, + fw, fh, 1.0f, 1.0f, + 0.0f, fh, 0.0f, 1.0f, + }; + + glBindFramebuffer(GL_FRAMEBUFFER, fbo->handle()); + glViewport(0, 0, w, h); + glDisable(GL_BLEND); + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, m_pipelineTex); + + m_blitProgram->bind(); + m_blitProgram->setUniformValue(m_blitMatrixUniform, proj); + m_blitProgram->setUniformValue(m_blitTexUniform, 0); + + const int stride = 4 * sizeof(float); + glVertexAttribPointer(m_blitPositionAttr, 2, GL_FLOAT, GL_FALSE, stride, blitVerts); + glVertexAttribPointer(m_blitTexcoordAttr, 2, GL_FLOAT, GL_FALSE, stride, blitVerts + 2); + glEnableVertexAttribArray(m_blitPositionAttr); + glEnableVertexAttribArray(m_blitTexcoordAttr); + glDrawArrays(GL_TRIANGLES, 0, 6); + glDisableVertexAttribArray(m_blitPositionAttr); + glDisableVertexAttribArray(m_blitTexcoordAttr); + + glBindTexture(GL_TEXTURE_2D, 0); + m_blitProgram->release(); +} + + +void GLRenderer::Renderer::detectES300() +{ + QOpenGLContext *ctx = QOpenGLContext::currentContext(); + if (!ctx) + return; + + QSurfaceFormat fmt = ctx->format(); + qDebug() << "GLRenderer: GL context: major=" << fmt.majorVersion() + << "minor=" << fmt.minorVersion() + << "ES=" << ctx->isOpenGLES() + << "GL_VERSION:" << (const char*)glGetString(GL_VERSION) + << "GL_RENDERER:" << (const char*)glGetString(GL_RENDERER); + + // Don't trust QSurfaceFormat version — SailfishOS libhybris often reports ES 2.0 + // even when the driver supports ES 3.0+. Just try compiling and see. + const char *testFragSrc = + "#version 300 es\n" + "precision mediump float;\n" + "out vec4 _out;\n" + "void main() { _out = vec4(1.0); }\n"; + + QOpenGLShaderProgram testProg; + bool ok = testProg.addShaderFromSourceCode(QOpenGLShader::Fragment, testFragSrc); + if (!ok) { + qDebug() << "GLRenderer: ES 3.0 probe failed — shader pipeline disabled"; + m_es300 = false; + return; + } + + m_es300 = true; + qDebug() << "GLRenderer: ES 3.0 confirmed (probe shader compiled)"; + // Marshal to the GUI thread — Settings is main-thread-only (settings.h:11-12) + QMetaObject::invokeMethod(Settings::instance(), + "setShaderPipelineAvailable", Qt::QueuedConnection, Q_ARG(bool, true)); +} + +void GLRenderer::Renderer::createPipelineFbo(int w, int h) +{ + if (m_pipelineFbo && m_pipelineTexW == w && m_pipelineTexH == h) + return; + + destroyPipelineFbo(); + + glGenTextures(1, &m_pipelineTex); + glBindTexture(GL_TEXTURE_2D, m_pipelineTex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glBindTexture(GL_TEXTURE_2D, 0); + + // Create FBO with color attachment only (no depth/stencil — not needed for 2D terminal rendering) + glGenFramebuffers(1, &m_pipelineFbo); + glBindFramebuffer(GL_FRAMEBUFFER, m_pipelineFbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_pipelineTex, 0); + + GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + if (status != GL_FRAMEBUFFER_COMPLETE) { + qWarning() << "GLRenderer: pipeline FBO incomplete, status=" << status; + destroyPipelineFbo(); + } else { + m_pipelineTexW = w; + m_pipelineTexH = h; + qDebug() << "GLRenderer: pipeline FBO created" << w << "x" << h; + } + + glBindFramebuffer(GL_FRAMEBUFFER, 0); +} + +void GLRenderer::Renderer::destroyPipelineFbo() +{ + if (m_pipelineFbo) { + glDeleteFramebuffers(1, &m_pipelineFbo); + m_pipelineFbo = 0; + } + if (m_pipelineTex) { + glDeleteTextures(1, &m_pipelineTex); + m_pipelineTex = 0; + } + m_pipelineTexW = 0; + m_pipelineTexH = 0; +} + +void GLRenderer::Renderer::createPingPongFbo(int w, int h) +{ + if (m_pingPongFbo && m_pingPongTexW == w && m_pingPongTexH == h) + return; + + destroyPingPongFbo(); + + glGenTextures(1, &m_pingPongTex); + glBindTexture(GL_TEXTURE_2D, m_pingPongTex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + glBindTexture(GL_TEXTURE_2D, 0); + + glGenFramebuffers(1, &m_pingPongFbo); + glBindFramebuffer(GL_FRAMEBUFFER, m_pingPongFbo); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, m_pingPongTex, 0); + + GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + if (status != GL_FRAMEBUFFER_COMPLETE) { + qWarning() << "GLRenderer: ping-pong FBO incomplete, status=" << status; + destroyPingPongFbo(); + } else { + m_pingPongTexW = w; + m_pingPongTexH = h; + qDebug() << "GLRenderer: ping-pong FBO created" << w << "x" << h; + } + + glBindFramebuffer(GL_FRAMEBUFFER, 0); +} + +void GLRenderer::Renderer::destroyPingPongFbo() +{ + if (m_pingPongFbo) { + glDeleteFramebuffers(1, &m_pingPongFbo); + m_pingPongFbo = 0; + } + if (m_pingPongTex) { + glDeleteTextures(1, &m_pingPongTex); + m_pingPongTex = 0; + } + m_pingPongTexW = 0; + m_pingPongTexH = 0; +} + +void GLRenderer::Renderer::runPostProcessPass(PostShader &shader, GLuint inputTex, GLuint outputFbo, int w, int h) +{ + glBindFramebuffer(GL_FRAMEBUFFER, outputFbo); + glViewport(0, 0, w, h); + glDisable(GL_BLEND); + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, inputTex); + + shader.program->bind(); + uploadPostShaderUniforms(shader, w, h); + + // No VBO: post vertex shader synthesises a fullscreen triangle from + // gl_VertexID (see glrenderer.cpp:191). 3 verts cover [-1,-1]..[3,3]. + glDrawArrays(GL_TRIANGLES, 0, 3); + + shader.program->release(); + glBindTexture(GL_TEXTURE_2D, 0); +} diff --git a/src/glrenderer_shaders.cpp b/src/glrenderer_shaders.cpp new file mode 100644 index 0000000..05daf34 --- /dev/null +++ b/src/glrenderer_shaders.cpp @@ -0,0 +1,568 @@ +#include "glrenderer.h" + +#include +#include +#include + +// GLSL ES 1.00 shaders — textured cell quads with per-cell fg/bg colors +// Cursor blink and style are handled in the fragment shader via uniforms to avoid +// rebuilding vertex data on every blink toggle or cursor style change. +static const char *vertexShaderSource = + "attribute vec2 position;\n" + "attribute vec2 texcoord;\n" + "attribute vec4 fg_color;\n" + "attribute vec4 bg_color;\n" + "attribute float deco_type;\n" + "uniform mat4 u_matrix;\n" + "varying vec2 v_texcoord;\n" + "varying vec2 v_cell;\n" + "varying vec4 v_fg_color;\n" + "varying vec4 v_bg_color;\n" + "varying float v_deco;\n" + "void main() {\n" + " gl_Position = u_matrix * vec4(position, 0.0, 1.0);\n" + " v_texcoord = texcoord;\n" + " v_cell = position;\n" + " v_fg_color = fg_color;\n" + " v_bg_color = bg_color;\n" + " v_deco = deco_type;\n" + "}\n"; + +static const char *fragmentShaderSource = + "precision mediump float;\n" + "varying vec2 v_texcoord;\n" + "varying vec2 v_cell;\n" + "varying vec4 v_fg_color;\n" + "varying vec4 v_bg_color;\n" + "varying float v_deco;\n" + "uniform sampler2D u_atlas;\n" + "uniform vec2 u_cursorPos;\n" + "uniform vec2 u_cellSize;\n" + "uniform float u_cursorBlink;\n" + "uniform float u_cursorStyle;\n" + "uniform float u_topPadding;\n" + "void main() {\n" + " vec4 fg = v_fg_color;\n" + " vec4 bg = v_bg_color;\n" + " vec2 adj_cell = v_cell - vec2(0.0, u_topPadding);\n" + " if (u_cursorBlink > 0.5 && u_cellSize.x > 0.0) {\n" + " vec2 cellCoord = floor(adj_cell / u_cellSize);\n" + " if (cellCoord == u_cursorPos) {\n" + " if (u_cursorStyle < 1.5) {\n" + " fg = v_bg_color;\n" + " bg = v_fg_color;\n" + " } else if (u_cursorStyle < 2.5) {\n" + " float cx = adj_cell.x - cellCoord.x * u_cellSize.x;\n" + " if (cx < 2.0) {\n" + " gl_FragColor = v_fg_color;\n" + " return;\n" + " }\n" + " } else if (u_cursorStyle < 3.5) {\n" + " float cy = adj_cell.y - cellCoord.y * u_cellSize.y;\n" + " if (cy > u_cellSize.y - 2.0) {\n" + " gl_FragColor = v_fg_color;\n" + " return;\n" + " }\n" + " } else {\n" + " float cx = adj_cell.x - cellCoord.x * u_cellSize.x;\n" + " float cy = adj_cell.y - cellCoord.y * u_cellSize.y;\n" + " if (cx < 1.0 || cx > u_cellSize.x - 1.0 ||\n" + " cy < 1.0 || cy > u_cellSize.y - 1.0) {\n" + " gl_FragColor = v_fg_color;\n" + " return;\n" + " }\n" + " }\n" + " }\n" + " }\n" + " float glyph_alpha = texture2D(u_atlas, v_texcoord).a;\n" + " vec4 color = mix(bg, fg, glyph_alpha);\n" + " // Text decorations: v_deco encodes type (0=none, 1=underline, 2=strikethrough)\n" + " if (v_deco > 0.5 && u_cellSize.x > 0.0) {\n" + " float cy = adj_cell.y - floor(adj_cell.y / u_cellSize.y) * u_cellSize.y;\n" + " if (v_deco < 1.5) {\n" + " // Underline: 2px at bottom of cell\n" + " if (cy > u_cellSize.y - 2.0)\n" + " color = vec4(fg.rgb, 1.0);\n" + " } else {\n" + " // Strikethrough: 2px at middle of cell\n" + " if (cy > u_cellSize.y * 0.5 - 1.0 && cy < u_cellSize.y * 0.5 + 1.0)\n" + " color = vec4(fg.rgb, 1.0);\n" + " }\n" + " }\n" + " gl_FragColor = color;\n" + "}\n"; + +// GLSL ES 1.00 flat-color shaders — solid-color rects for selection, search, link overlays +static const char *flatVertexShaderSource = + "attribute vec2 position;\n" + "attribute vec4 color;\n" + "uniform mat4 u_matrix;\n" + "varying vec4 v_color;\n" + "void main() {\n" + " gl_Position = u_matrix * vec4(position, 0.0, 1.0);\n" + " v_color = color;\n" + "}\n"; + +static const char *flatFragmentShaderSource = + "precision mediump float;\n" + "varying vec4 v_color;\n" + "void main() {\n" + " gl_FragColor = v_color;\n" + "}\n"; + +// GLSL ES 1.00 magnifier shaders — textured rounded-rect with SDF clip +static const char *magVertexShaderSource = + "attribute vec2 position;\n" + "attribute vec2 texcoord;\n" + "uniform mat4 u_matrix;\n" + "varying vec2 v_texcoord;\n" + "varying vec2 v_pos;\n" + "void main() {\n" + " gl_Position = u_matrix * vec4(position, 0.0, 1.0);\n" + " v_texcoord = texcoord;\n" + " v_pos = position;\n" + "}\n"; + +static const char *magFragmentShaderSource = + "precision mediump float;\n" + "varying vec2 v_texcoord;\n" + "varying vec2 v_pos;\n" + "uniform sampler2D u_sceneTex;\n" + "uniform vec4 u_destRect;\n" + "uniform vec4 u_srcRect;\n" + "uniform vec2 u_srcTexSize;\n" + "uniform float u_cornerRadius;\n" + "uniform vec4 u_borderColor;\n" + "uniform float u_borderWidth;\n" + "\n" + "float roundedRectSDF(vec2 p, vec2 center, vec2 halfSize, float radius) {\n" + " vec2 d = abs(p - center) - halfSize + radius;\n" + " return min(max(d.x, d.y), 0.0) + length(max(d, 0.0)) - radius;\n" + "}\n" + "\n" + "void main() {\n" + " vec2 center = u_destRect.xy + u_destRect.zw * 0.5;\n" + " vec2 halfSize = u_destRect.zw * 0.5;\n" + " float dist = roundedRectSDF(v_pos, center, halfSize, u_cornerRadius);\n" + " if (dist > 1.5) discard;\n" + " vec2 srcUV = (u_srcRect.xy + v_texcoord * u_srcRect.zw) / u_srcTexSize;\n" + " vec4 texColor = texture2D(u_sceneTex, srcUV);\n" + " float edgeAlpha = smoothstep(0.0, 1.5, -dist);\n" + " float borderMask = smoothstep(u_borderWidth, u_borderWidth - 1.5, -dist);\n" + " vec4 color = mix(texColor, u_borderColor, borderMask);\n" + " gl_FragColor = vec4(color.rgb * edgeAlpha, color.a * edgeAlpha);\n" + "}\n"; + +// GLSL ES 2.0 blit shaders — pipeline FBO → Qt FBO copy +static const char *blitVertexShaderSource = + "attribute vec2 position;\n" + "attribute vec2 texcoord;\n" + "uniform mat4 u_matrix;\n" + "varying vec2 v_texcoord;\n" + "void main() {\n" + " gl_Position = u_matrix * vec4(position, 0.0, 1.0);\n" + " v_texcoord = texcoord;\n" + "}\n"; + +static const char *blitFragmentShaderSource = + "precision mediump float;\n" + "varying vec2 v_texcoord;\n" + "uniform sampler2D u_texture;\n" + "void main() {\n" + " gl_FragColor = texture2D(u_texture, v_texcoord);\n" + "}\n"; + +// ES 3.0 post-processing shaders + +// Full-screen triangle vertex shader (ES 3.0, no VBO needed — gl_VertexID trick) +static const char *postVertexShaderSource = + "#version 300 es\n" + "void main() {\n" + " vec4 position;\n" + " position.x = (gl_VertexID == 2) ? 3.0 : -1.0;\n" + " position.y = (gl_VertexID == 0) ? -3.0 : 1.0;\n" + " position.z = 1.0;\n" + " position.w = 1.0;\n" + " gl_Position = position;\n" + "}\n"; + +// Ghostty-compatible shadertoy prefix for ES 3.0 (individual uniforms, no UBO) +static const char *shadertoyPrefixES300 = + "#version 300 es\n" + "precision mediump float;\n" + "\n" + "uniform vec3 iResolution;\n" + "uniform float iTime;\n" + "uniform float iTimeDelta;\n" + "uniform float iFrameRate;\n" + "uniform int iFrame;\n" + "uniform float iChannelTime[4];\n" + "uniform vec3 iChannelResolution[4];\n" + "uniform vec4 iMouse;\n" + "uniform vec4 iDate;\n" + "uniform float iSampleRate;\n" + "uniform vec4 iCurrentCursor;\n" + "uniform vec4 iPreviousCursor;\n" + "uniform vec4 iCurrentCursorColor;\n" + "uniform vec4 iPreviousCursorColor;\n" + "uniform int iCurrentCursorStyle;\n" + "uniform int iPreviousCursorStyle;\n" + "uniform int iCursorVisible;\n" + "uniform float iTimeCursorChange;\n" + "uniform float iTimeFocus;\n" + "uniform int iFocus;\n" + "uniform vec3 iPalette[256];\n" + "uniform vec3 iBackgroundColor;\n" + "uniform vec3 iForegroundColor;\n" + "uniform vec3 iCursorColor;\n" + "uniform vec3 iCursorText;\n" + "uniform vec3 iSelectionForegroundColor;\n" + "uniform vec3 iSelectionBackgroundColor;\n" + "\n" + "uniform sampler2D iChannel0;\n" + "\n" + "out vec4 _fragColor;\n" + "\n" + "#define texture2D texture\n" + "\n" + "#define CURSORSTYLE_BLOCK 0\n" + "#define CURSORSTYLE_BLOCK_HOLLOW 1\n" + "#define CURSORSTYLE_BAR 2\n" + "#define CURSORSTYLE_UNDERLINE 3\n" + "#define CURSORSTYLE_LOCK 4\n" + "\n" + "void mainImage( out vec4 fragColor, in vec2 fragCoord );\n" + "void main() { mainImage (_fragColor, gl_FragCoord.xy); }\n"; + +void GLRenderer::Renderer::createShaders() +{ + m_program = new QOpenGLShaderProgram; + if (!m_program->addShaderFromSourceCode(QOpenGLShader::Vertex, vertexShaderSource)) { + qWarning() << "GLRenderer: vertex shader compilation failed:" << m_program->log(); + delete m_program; + m_program = nullptr; + return; + } + if (!m_program->addShaderFromSourceCode(QOpenGLShader::Fragment, fragmentShaderSource)) { + qWarning() << "GLRenderer: fragment shader compilation failed:" << m_program->log(); + delete m_program; + m_program = nullptr; + return; + } + if (!m_program->link()) { + qWarning() << "GLRenderer: shader linking failed:" << m_program->log(); + delete m_program; + m_program = nullptr; + return; + } + + m_matrixUniform = m_program->uniformLocation("u_matrix"); + m_atlasUniform = m_program->uniformLocation("u_atlas"); + m_cursorPosUniform = m_program->uniformLocation("u_cursorPos"); + m_cellSizeUniform = m_program->uniformLocation("u_cellSize"); + m_cursorBlinkUniform = m_program->uniformLocation("u_cursorBlink"); + m_cursorStyleUniform = m_program->uniformLocation("u_cursorStyle"); + m_topPaddingUniform = m_program->uniformLocation("u_topPadding"); + m_positionAttr = m_program->attributeLocation("position"); + m_texcoordAttr = m_program->attributeLocation("texcoord"); + m_fgColorAttr = m_program->attributeLocation("fg_color"); + m_bgColorAttr = m_program->attributeLocation("bg_color"); + m_decoAttr = m_program->attributeLocation("deco_type"); +} + +void GLRenderer::Renderer::createFlatShaders() +{ + m_flatProgram = new QOpenGLShaderProgram; + if (!m_flatProgram->addShaderFromSourceCode(QOpenGLShader::Vertex, flatVertexShaderSource)) { + qWarning() << "GLRenderer: flat vertex shader compilation failed:" << m_flatProgram->log(); + delete m_flatProgram; + m_flatProgram = nullptr; + return; + } + if (!m_flatProgram->addShaderFromSourceCode(QOpenGLShader::Fragment, flatFragmentShaderSource)) { + qWarning() << "GLRenderer: flat fragment shader compilation failed:" << m_flatProgram->log(); + delete m_flatProgram; + m_flatProgram = nullptr; + return; + } + if (!m_flatProgram->link()) { + qWarning() << "GLRenderer: flat shader linking failed:" << m_flatProgram->log(); + delete m_flatProgram; + m_flatProgram = nullptr; + return; + } + + m_flatMatrixUniform = m_flatProgram->uniformLocation("u_matrix"); + m_flatPositionAttr = m_flatProgram->attributeLocation("position"); + m_flatColorAttr = m_flatProgram->attributeLocation("color"); +} + +void GLRenderer::Renderer::createMagShaders() +{ + m_magProgram = new QOpenGLShaderProgram; + if (!m_magProgram->addShaderFromSourceCode(QOpenGLShader::Vertex, magVertexShaderSource)) { + qWarning() << "GLRenderer: magnifier vertex shader compilation failed:" << m_magProgram->log(); + delete m_magProgram; + m_magProgram = nullptr; + return; + } + if (!m_magProgram->addShaderFromSourceCode(QOpenGLShader::Fragment, magFragmentShaderSource)) { + qWarning() << "GLRenderer: magnifier fragment shader compilation failed:" << m_magProgram->log(); + delete m_magProgram; + m_magProgram = nullptr; + return; + } + if (!m_magProgram->link()) { + qWarning() << "GLRenderer: magnifier shader linking failed:" << m_magProgram->log(); + delete m_magProgram; + m_magProgram = nullptr; + return; + } + + m_magMatrixUniform = m_magProgram->uniformLocation("u_matrix"); + m_magTexUniform = m_magProgram->uniformLocation("u_sceneTex"); + m_magDestRectUniform = m_magProgram->uniformLocation("u_destRect"); + m_magSrcRectUniform = m_magProgram->uniformLocation("u_srcRect"); + m_magSrcTexSizeUniform = m_magProgram->uniformLocation("u_srcTexSize"); + m_magCornerRadiusUniform = m_magProgram->uniformLocation("u_cornerRadius"); + m_magBorderColorUniform = m_magProgram->uniformLocation("u_borderColor"); + m_magBorderWidthUniform = m_magProgram->uniformLocation("u_borderWidth"); + m_magPositionAttr = m_magProgram->attributeLocation("position"); + m_magTexcoordAttr = m_magProgram->attributeLocation("texcoord"); +} + +void GLRenderer::Renderer::createBlitShader() +{ + m_blitProgram = new QOpenGLShaderProgram; + if (!m_blitProgram->addShaderFromSourceCode(QOpenGLShader::Vertex, blitVertexShaderSource)) { + qWarning() << "GLRenderer: blit vertex shader compilation failed:" << m_blitProgram->log(); + delete m_blitProgram; + m_blitProgram = nullptr; + return; + } + if (!m_blitProgram->addShaderFromSourceCode(QOpenGLShader::Fragment, blitFragmentShaderSource)) { + qWarning() << "GLRenderer: blit fragment shader compilation failed:" << m_blitProgram->log(); + delete m_blitProgram; + m_blitProgram = nullptr; + return; + } + if (!m_blitProgram->link()) { + qWarning() << "GLRenderer: blit shader linking failed:" << m_blitProgram->log(); + delete m_blitProgram; + m_blitProgram = nullptr; + return; + } + + m_blitMatrixUniform = m_blitProgram->uniformLocation("u_matrix"); + m_blitTexUniform = m_blitProgram->uniformLocation("u_texture"); + m_blitPositionAttr = m_blitProgram->attributeLocation("position"); + m_blitTexcoordAttr = m_blitProgram->attributeLocation("texcoord"); +} + +// Post shader loading & compilation + +void GLRenderer::Renderer::createPostShaders() +{ + m_postShader.program = new QOpenGLShaderProgram; + if (!m_postShader.program->addShaderFromSourceCode(QOpenGLShader::Vertex, postVertexShaderSource)) { + qWarning() << "GLRenderer: post vertex shader compilation failed:" << m_postShader.program->log(); + delete m_postShader.program; + m_postShader.program = nullptr; + return; + } + + // Fragment shader is loaded on demand via loadPostShader() (user-supplied file). + qDebug() << "GLRenderer: post shader infrastructure ready (ES 3.0, vertex shader compiled)"; +} + +void GLRenderer::Renderer::loadPostShader(const QString &path) +{ + if (!m_es300) { + qWarning() << "GLRenderer: cannot load post shader — ES 3.0 not available"; + return; + } + + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + qWarning() << "GLRenderer: cannot open post shader file:" << path; + m_postShaderActive = false; + return; + } + QByteArray userShaderSrc = file.readAll(); + file.close(); + + if (m_postShader.program) { + delete m_postShader.program; + m_postShader.program = nullptr; + m_postShaderActive = false; + } + + m_postShader.program = new QOpenGLShaderProgram; + + if (!m_postShader.program->addShaderFromSourceCode(QOpenGLShader::Vertex, postVertexShaderSource)) { + qWarning() << "GLRenderer: post vertex shader failed:" << m_postShader.program->log(); + delete m_postShader.program; + m_postShader.program = nullptr; + m_postShaderActive = false; + return; + } + + QByteArray fragSrc = QByteArray(shadertoyPrefixES300) + "\n" + userShaderSrc; + if (!m_postShader.program->addShaderFromSourceCode(QOpenGLShader::Fragment, fragSrc.constData())) { + qWarning() << "GLRenderer: post fragment shader compilation failed:" << m_postShader.program->log(); + delete m_postShader.program; + m_postShader.program = nullptr; + m_postShaderActive = false; + return; + } + + if (!m_postShader.program->link()) { + qWarning() << "GLRenderer: post shader linking failed:" << m_postShader.program->log(); + delete m_postShader.program; + m_postShader.program = nullptr; + m_postShaderActive = false; + return; + } + + m_postShader.loc.iResolution = m_postShader.program->uniformLocation("iResolution"); + m_postShader.loc.iTime = m_postShader.program->uniformLocation("iTime"); + m_postShader.loc.iTimeDelta = m_postShader.program->uniformLocation("iTimeDelta"); + m_postShader.loc.iFrameRate = m_postShader.program->uniformLocation("iFrameRate"); + m_postShader.loc.iFrame = m_postShader.program->uniformLocation("iFrame"); + m_postShader.loc.iChannelTime = m_postShader.program->uniformLocation("iChannelTime[0]"); + m_postShader.loc.iChannelResolution = m_postShader.program->uniformLocation("iChannelResolution[0]"); + m_postShader.loc.iMouse = m_postShader.program->uniformLocation("iMouse"); + m_postShader.loc.iDate = m_postShader.program->uniformLocation("iDate"); + m_postShader.loc.iSampleRate = m_postShader.program->uniformLocation("iSampleRate"); + m_postShader.loc.iCurrentCursor = m_postShader.program->uniformLocation("iCurrentCursor"); + m_postShader.loc.iPreviousCursor = m_postShader.program->uniformLocation("iPreviousCursor"); + m_postShader.loc.iCurrentCursorColor = m_postShader.program->uniformLocation("iCurrentCursorColor"); + m_postShader.loc.iPreviousCursorColor = m_postShader.program->uniformLocation("iPreviousCursorColor"); + m_postShader.loc.iCurrentCursorStyle = m_postShader.program->uniformLocation("iCurrentCursorStyle"); + m_postShader.loc.iPreviousCursorStyle = m_postShader.program->uniformLocation("iPreviousCursorStyle"); + m_postShader.loc.iCursorVisible = m_postShader.program->uniformLocation("iCursorVisible"); + m_postShader.loc.iTimeCursorChange = m_postShader.program->uniformLocation("iTimeCursorChange"); + m_postShader.loc.iTimeFocus = m_postShader.program->uniformLocation("iTimeFocus"); + m_postShader.loc.iFocus = m_postShader.program->uniformLocation("iFocus"); + m_postShader.loc.iPalette = m_postShader.program->uniformLocation("iPalette[0]"); + m_postShader.loc.iBackgroundColor = m_postShader.program->uniformLocation("iBackgroundColor"); + m_postShader.loc.iForegroundColor = m_postShader.program->uniformLocation("iForegroundColor"); + m_postShader.loc.iCursorColor = m_postShader.program->uniformLocation("iCursorColor"); + m_postShader.loc.iCursorText = m_postShader.program->uniformLocation("iCursorText"); + m_postShader.loc.iSelectionForegroundColor = m_postShader.program->uniformLocation("iSelectionForegroundColor"); + m_postShader.loc.iSelectionBackgroundColor = m_postShader.program->uniformLocation("iSelectionBackgroundColor"); + m_postShader.loc.iChannel0 = m_postShader.program->uniformLocation("iChannel0"); + + m_postShaderActive = true; + qDebug() << "GLRenderer: post shader loaded from" << path; +} + +void GLRenderer::Renderer::uploadPostShaderUniforms(PostShader &shader, int fboW, int fboH) +{ + const PostShaderUniforms &loc = shader.loc; + + if (loc.iResolution >= 0) + shader.program->setUniformValue(loc.iResolution, + static_cast(fboW), static_cast(fboH), 1.0f); + if (loc.iTime >= 0) + shader.program->setUniformValue(loc.iTime, m_postTime); + if (loc.iTimeDelta >= 0) + shader.program->setUniformValue(loc.iTimeDelta, m_postTimeDelta); + if (loc.iFrameRate >= 0) + shader.program->setUniformValue(loc.iFrameRate, m_postFrameRate); + if (loc.iFrame >= 0) + shader.program->setUniformValue(loc.iFrame, static_cast(m_postFrame)); + if (loc.iChannelTime >= 0) { + float chTime[4] = {m_postTime, 0.0f, 0.0f, 0.0f}; + glUniform1fv(loc.iChannelTime, 4, chTime); + } + if (loc.iChannelResolution >= 0) { + float chRes[12] = { + static_cast(fboW), static_cast(fboH), 1.0f, + 0, 0, 0, 0, 0, 0, 0, 0, 0 + }; + glUniform3fv(loc.iChannelResolution, 4, chRes); + } + if (loc.iMouse >= 0) + shader.program->setUniformValue(loc.iMouse, 0.0f, 0.0f, 0.0f, 0.0f); + if (loc.iDate >= 0) { + QDateTime now = QDateTime::currentDateTime(); + const QDate d = now.date(); + const QTime t = now.time(); + float secs = t.hour() * 3600.0f + t.minute() * 60.0f + + t.second() + t.msec() / 1000.0f; + shader.program->setUniformValue(loc.iDate, + static_cast(d.year()), + static_cast(d.month()), + static_cast(d.day()), + secs); + } + if (loc.iSampleRate >= 0) + shader.program->setUniformValue(loc.iSampleRate, 0.0f); + + // Cursor uniforms — Ghostty shader expects (x, y_top, w, h) + // In our Y-up ortho, cy = row*cellHeight + topPadding is the bottom edge. + // Add cellHeight to get the top edge (higher Y in Y-up = top of cell). + if (loc.iCurrentCursor >= 0) { + float cx = m_cursorX * m_cellWidth; + float cy = m_cursorY * m_cellHeight + m_topPadding + static_cast(m_cellHeight); + shader.program->setUniformValue(loc.iCurrentCursor, + cx, cy, + static_cast(m_cellWidth), + static_cast(m_cellHeight)); + } + if (loc.iPreviousCursor >= 0) { + float px = m_prevCursorX * m_cellWidth; + float py = m_prevCursorY * m_cellHeight + m_topPadding + static_cast(m_cellHeight); + shader.program->setUniformValue(loc.iPreviousCursor, + px, py, + static_cast(m_cellWidth), + static_cast(m_cellHeight)); + } + // Cursor color: use explicit color if available, else fall back to foreground + const float cr = m_postCursorColorHasValue ? m_postCursorR : m_postFgR; + const float cg = m_postCursorColorHasValue ? m_postCursorG : m_postFgG; + const float cb = m_postCursorColorHasValue ? m_postCursorB : m_postFgB; + if (loc.iCurrentCursorColor >= 0) + shader.program->setUniformValue(loc.iCurrentCursorColor, cr, cg, cb, 1.0f); + if (loc.iPreviousCursorColor >= 0) + shader.program->setUniformValue(loc.iPreviousCursorColor, 0.0f, 0.0f, 0.0f, 0.0f); + if (loc.iCurrentCursorStyle >= 0) + shader.program->setUniformValue(loc.iCurrentCursorStyle, m_cursorStyle); + if (loc.iPreviousCursorStyle >= 0) + shader.program->setUniformValue(loc.iPreviousCursorStyle, 0); + if (loc.iCursorVisible >= 0) + shader.program->setUniformValue(loc.iCursorVisible, m_cursorVisible ? 1 : 0); + if (loc.iTimeCursorChange >= 0) + shader.program->setUniformValue(loc.iTimeCursorChange, m_cursorChangeTime); + if (loc.iTimeFocus >= 0) + shader.program->setUniformValue(loc.iTimeFocus, 0.0f); + if (loc.iFocus >= 0) + shader.program->setUniformValue(loc.iFocus, 1); + + // 256-entry RGB palette + if (loc.iPalette >= 0) + glUniform3fv(loc.iPalette, 256, m_postPaletteData); + + if (loc.iBackgroundColor >= 0) + shader.program->setUniformValue(loc.iBackgroundColor, + m_postBgR, m_postBgG, m_postBgB); + if (loc.iForegroundColor >= 0) + shader.program->setUniformValue(loc.iForegroundColor, + m_postFgR, m_postFgG, m_postFgB); + if (loc.iCursorColor >= 0) + shader.program->setUniformValue(loc.iCursorColor, cr, cg, cb); + if (loc.iCursorText >= 0) + shader.program->setUniformValue(loc.iCursorText, + m_postBgR, m_postBgG, m_postBgB); + + // Selection colors — use reasonable defaults (not exposed by render state API) + if (loc.iSelectionForegroundColor >= 0) + shader.program->setUniformValue(loc.iSelectionForegroundColor, 0.0f, 0.0f, 0.0f); + if (loc.iSelectionBackgroundColor >= 0) + shader.program->setUniformValue(loc.iSelectionBackgroundColor, 0.4f, 0.6f, 1.0f); + + // iChannel0 = pipeline texture on unit 0 + if (loc.iChannel0 >= 0) + shader.program->setUniformValue(loc.iChannel0, 0); +} diff --git a/src/ipcmessage.h b/src/ipcmessage.h new file mode 100644 index 0000000..1aef6b2 --- /dev/null +++ b/src/ipcmessage.h @@ -0,0 +1,69 @@ +#ifndef IPCMESSAGE_H +#define IPCMESSAGE_H + +#include +#include +#include +#include +#include + +// IPC protocol for single-instance communication. +struct IpcMessage { + enum Type { Raise, Switch, Exec } type = Raise; + QString sessionName; + QString command; + QStringList args; + + static constexpr int kMaxSessionNameLength = 128; + + static QString sanitizeSessionName(const QString &name) { + QString clean = name; + clean.truncate(kMaxSessionNameLength); + clean.remove(QChar('\0')); + clean.remove(QChar('\n')); + clean.remove(QChar('\r')); + clean.remove(QChar(':')); // load-bearing: exec: protocol uses : as delimiter + return clean; + } + + static IpcMessage parse(const QByteArray &raw) { + IpcMessage msg; + QList parts = raw.split('\0'); + QByteArray header = parts.isEmpty() ? QByteArray() : parts.first(); + + if (header == "raise") { + msg.type = Raise; + } else if (header.startsWith("switch:")) { + msg.type = Switch; + msg.sessionName = sanitizeSessionName(QString::fromUtf8(header.mid(7))); + } else if (header.startsWith("exec:")) { + msg.type = Exec; + QByteArray afterPrefix = header.mid(5); + int colonPos = afterPrefix.indexOf(':'); + if (colonPos < 0) { msg.type = Raise; return msg; } + msg.sessionName = sanitizeSessionName(QString::fromUtf8(afterPrefix.left(colonPos))); + if (colonPos + 1 < afterPrefix.size()) + msg.command = QString::fromUtf8(afterPrefix.mid(colonPos + 1)); + for (int i = 1; i < parts.size(); i++) + if (!parts[i].isEmpty()) + msg.args.append(QString::fromUtf8(parts[i])); + } + return msg; + } + + static QByteArray encode(const QString &execCommand, const QStringList &execArgs, const QString &sessionName) { + if (!execCommand.isEmpty()) { + QByteArray cmdBytes = execCommand.toUtf8(); + for (const QString &arg : execArgs) { + cmdBytes.append('\0'); + cmdBytes.append(arg.toUtf8()); + } + return (QStringLiteral("exec:") + sessionName + QStringLiteral(":")).toUtf8() + cmdBytes + '\n'; + } else if (!sessionName.isEmpty()) { + return (QStringLiteral("switch:") + sessionName + QStringLiteral("\n")).toUtf8(); + } + return QByteArrayLiteral("raise\n"); + } +}; + +#endif // IPCMESSAGE_H diff --git a/src/ptymanager.cpp b/src/ptymanager.cpp index 2243ec2..c0e00ef 100644 --- a/src/ptymanager.cpp +++ b/src/ptymanager.cpp @@ -73,7 +73,10 @@ void PtyReaderThread::run() } } } - Q_EMIT readFinished(); + // Suppress on interruption: stop() has already disconnected, so the emit + // would be discarded and the reaper isn't needed. + if (!isInterruptionRequested()) + Q_EMIT readFinished(); } // PtyManager diff --git a/src/sessionmanager.cpp b/src/sessionmanager.cpp index 206931e..d2c72b4 100644 --- a/src/sessionmanager.cpp +++ b/src/sessionmanager.cpp @@ -1,4 +1,5 @@ #include "sessionmanager.h" +#include "sessionstore.h" #include "terminalview.h" #include "ptymanager.h" #include "settings.h" @@ -7,20 +8,11 @@ #include #include #include -#include -#include -#include #include -#include -#include #include #include #include #include -#include -#include -#include -#include static constexpr int kMaxSessionCount = 100; @@ -44,6 +36,7 @@ SessionManager::SessionManager(Settings *settings, QObject *parent) { // Initialize scrollback encryption (may fail gracefully — callers check isAvailable) m_encryptor = new ScrollEncryptor(this); + m_store = std::make_unique(m_settings, m_encryptor); m_saveTimer = new QTimer(this); m_saveTimer->setSingleShot(true); @@ -53,13 +46,14 @@ SessionManager::SessionManager(Settings *settings, QObject *parent) // changed — a scrollback-only arm (scheduleScrollbackSave) leaves // the flag false so continuous output doesn't fsync settings ~2x/sec. if (m_sessionsDirty) { - saveSessions(); + m_store->saveSessionsMetadata(m_sessions, m_activeSessionIndex, m_nextSessionId); m_sessionsDirty = false; } // Also encrypt scrollback incrementally for sessions whose content // changed since the last save. By the time aboutToQuit fires, most // sessions are already on disk — only a few remain dirty. - saveScrollbackIncremental(); + if (m_store->saveScrollbackIncremental(m_sessions, m_activeSessionIndex)) + scheduleScrollbackSave(); }); // Retry pending scrollback restores when encryption becomes available. @@ -80,7 +74,7 @@ SessionManager::SessionManager(Settings *settings, QObject *parent) m_pendingScrollbackRestores.clear(); for (const auto &p : pending) { if (p.view) // QPointer returns null if the object was deleted - restoreScrollbackForSession(p.view, p.sessionId); + m_store->restoreScrollbackForSession(p.view, p.sessionId, m_pendingScrollbackRestores); } // A scrollback save may have failed while encryption was unavailable, // leaving sessions dirty with no timer armed (the failure path doesn't @@ -124,7 +118,7 @@ SessionManager::SessionManager(Settings *settings, QObject *parent) if (!m_settings->scrollbackPersistence()) { // Purge all scrollback files immediately. Not gated on m_sessionsLoaded: // files exist on disk regardless of session state (privacy expectation). - cleanupScrollbackFiles(true); + m_store->cleanupScrollbackFiles(true); return; } if (!m_sessionsLoaded) @@ -146,7 +140,7 @@ SessionManager::SessionManager(Settings *settings, QObject *parent) // No-op when persistence is off (the disable handler already purged everything). if (!m_settings->scrollbackPersistence()) return; - cleanupScrollbackFiles(); // mtime-gated + m_store->cleanupScrollbackFiles(); // mtime-gated }); // Save sessions early on app quit — before QML engine destruction kills @@ -156,11 +150,11 @@ SessionManager::SessionManager(Settings *settings, QObject *parent) this, [this]() { QElapsedTimer timer; timer.start(); - saveSessions(); + m_store->saveSessionsMetadata(m_sessions, m_activeSessionIndex, m_nextSessionId); qint64 sessionMs = timer.elapsed(); // Catch sessions the debounce timer hasn't fired for yet; force=true // bypasses the 5s throttle so nothing is lost on shutdown. - saveScrollbackIncremental(true); + m_store->saveScrollbackIncremental(m_sessions, m_activeSessionIndex, true); qint64 totalMs = timer.elapsed(); if (totalMs > 1000) qWarning() << "Ghosteel: Quit save took" << totalMs << "ms" @@ -176,10 +170,10 @@ SessionManager::~SessionManager() // In production, aboutToQuit fires first (shells alive, CWD readable). // In tests or abnormal paths, this is the fallback (shells may be dead). if (m_sessionsLoaded && !m_savedOnQuit) { - saveSessions(); + m_store->saveSessionsMetadata(m_sessions, m_activeSessionIndex, m_nextSessionId); // Views are still alive here (cleaned up below), so we can still flush // the scrollback that aboutToQuit never got to save. - saveScrollbackIncremental(true); + m_store->saveScrollbackIncremental(m_sessions, m_activeSessionIndex, true); } // Cleanly stop each session's PTY before deleting the view. @@ -468,7 +462,7 @@ void SessionManager::removeSession(int index) // Delete scrollback file for removed session (regardless of persistence // toggle — if the session is gone, the file has no reason to exist) - QFile::remove(scrollbackFilePath(info.id)); + QFile::remove(m_store->scrollbackFilePath(info.id)); scheduleSave(); @@ -715,82 +709,6 @@ void SessionManager::setDbusRegistered(bool registered) Q_EMIT dbusRegisteredChanged(); } -QString SessionManager::socketPath() -{ - // Use XDG_RUNTIME_DIR directly — safe to call before QGuiApplication exists - const QString runtime = QString::fromLocal8Bit(qgetenv("XDG_RUNTIME_DIR")); - if (!runtime.isEmpty()) - return runtime + QStringLiteral("/ghosteel-singleton"); - return QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation) - + QStringLiteral("/ghosteel-singleton"); -} - -bool SessionManager::checkSingleInstance(const QString &execCommand, - const QStringList &execArgs, - const QString &sessionName) -{ - QLocalSocket socket; - socket.connectToServer(socketPath()); - if (socket.waitForConnected(500)) { - QByteArray msg = IpcMessage::encode(execCommand, execArgs, sessionName); - socket.write(msg); - socket.waitForBytesWritten(1000); - socket.disconnectFromServer(); - return true; - } - return false; -} - -void SessionManager::startSingleInstanceServer() -{ - m_localServer = new QLocalServer(this); - - auto failServer = [this]() { - delete m_localServer; - m_localServer = nullptr; - }; - - if (!m_localServer->listen(socketPath())) { - if (m_localServer->serverError() == QAbstractSocket::AddressInUseError) { - QLocalSocket probe; - probe.connectToServer(socketPath()); - if (probe.waitForConnected(200)) { - qWarning() << "Ghosteel: Another instance detected via socket probe"; - failServer(); - return; - } - QLocalServer::removeServer(socketPath()); - if (!m_localServer->listen(socketPath())) { - qWarning() << "Ghosteel: Single-instance server failed:" << m_localServer->errorString(); - failServer(); - return; - } - } else { - qWarning() << "Ghosteel: Single-instance server failed:" << m_localServer->errorString(); - failServer(); - return; - } - } - connect(m_localServer, &QLocalServer::newConnection, - this, &SessionManager::onNewInstanceConnection); -} - -void SessionManager::setCliArgs(const QString &execCommand, - const QStringList &execArgs, - const QString &sessionName) -{ - m_cliExecCommand = execCommand; - m_cliExecArgs = execArgs; - m_cliSessionName = IpcMessage::sanitizeSessionName(sessionName); -} - -void SessionManager::clearCliArgs() -{ - m_cliExecCommand.clear(); - m_cliExecArgs.clear(); - m_cliSessionName.clear(); -} - void SessionManager::processCliArgs() { if (m_cliExecCommand.isEmpty() && m_cliSessionName.isEmpty()) @@ -815,138 +733,36 @@ void SessionManager::processCliArgs() removeSession(named); } didSomething = true; - goto done; } } - for (int i = 0; i < m_sessions.size(); i++) { - if (m_sessions[i].name.isEmpty() && m_sessions[i].execArgs == fullArgs) { - setActiveSessionIndex(i); - didSomething = true; - goto done; + if (!didSomething) { + for (int i = 0; i < m_sessions.size(); i++) { + if (m_sessions[i].name.isEmpty() && m_sessions[i].execArgs == fullArgs) { + setActiveSessionIndex(i); + didSomething = true; + break; + } } } - createSessionWithCommand(m_cliSessionName, fullArgs); - didSomething = true; + if (!didSomething) { + createSessionWithCommand(m_cliSessionName, fullArgs); + didSomething = true; + } } else if (!m_cliSessionName.isEmpty()) { switchToSessionByName(m_cliSessionName); didSomething = true; } -done: clearCliArgs(); if (didSomething) Q_EMIT showTerminal(); } -void SessionManager::raiseWindow() -{ - const auto windows = QGuiApplication::topLevelWindows(); - if (!windows.isEmpty()) { - if (auto *window = windows.first()) { - window->raise(); - window->requestActivate(); - } - } -} - -void SessionManager::onNewInstanceConnection() -{ - QLocalSocket *socket = m_localServer->nextPendingConnection(); - if (!socket) return; - - // Race: sender may have written and disconnected before we get here. - auto processMessage = [this, socket]() { - QByteArray data = socket->readAll().trimmed(); - socket->deleteLater(); - - IpcMessage parsed = IpcMessage::parse(data); - if (parsed.type == IpcMessage::Raise) { - raiseWindow(); - } else if (parsed.type == IpcMessage::Switch) { - switchToSessionByName(parsed.sessionName); - raiseWindow(); - } else if (parsed.type == IpcMessage::Exec) { - if (parsed.command.isEmpty()) return; - setCliArgs(parsed.command, parsed.args, parsed.sessionName); - processCliArgs(); - // Delay raise to let QML process sessionCreated() signal - QTimer::singleShot(100, this, raiseWindow); - } - }; - - if (socket->bytesAvailable() > 0 || socket->state() == QLocalSocket::UnconnectedState) { - // Data already in buffer or socket already closed — process now - processMessage(); - } else { - // Wait for data to arrive; disconnected handles the case where - // the client connects but never writes then drops the connection. - connect(socket, &QLocalSocket::readyRead, this, processMessage); - connect(socket, &QLocalSocket::disconnected, this, processMessage); - } -} - -void SessionManager::saveSessions() -{ - QSettings &s = m_settings->raw(); - - // Clear old session entries - s.remove(QStringLiteral("sessionData")); - - // Skip anonymous command sessions during save - int saveIndex = 0; - for (int i = 0; i < m_sessions.size(); i++) { - SessionInfo &info = m_sessions[i]; - - if (info.isAnonymous()) - continue; - - QString group = QStringLiteral("sessionData/session_%1").arg(saveIndex); - s.beginGroup(group); - s.setValue(QStringLiteral("id"), info.id); - s.setValue(QStringLiteral("name"), info.name); - // Use live CWD from /proc if shell is running, otherwise use cached value - if (info.view) { - QString liveCwd = info.view->workingDirectory(); - if (!liveCwd.isEmpty()) - info.cachedWorkingDirectory = liveCwd; - } - QString cwd = info.cachedWorkingDirectory; - if (cwd.isEmpty()) - cwd = QDir::homePath(); - s.setValue(QStringLiteral("workingDirectory"), cwd); - s.setValue(QStringLiteral("autorunCommand"), info.autorunCommand); - // Persist per-session font size. When fontSize == 0 (track global - // default), don't read the live view size — that would clobber the - // sentinel with the resolved global value. - if (info.view && info.fontSize > 0) - info.fontSize = info.view->fontSize(); - s.setValue(QStringLiteral("fontSize"), info.fontSize); - s.setValue(QStringLiteral("keybarOpen"), info.keybarOpen); - s.setValue(QStringLiteral("keyboardVisible"), info.keyboardVisible); - s.setValue(QStringLiteral("createdAt"), info.createdAt); - s.setValue(QStringLiteral("lastUsedAt"), info.lastUsedAt); - s.endGroup(); - saveIndex++; - } - - s.beginGroup(QStringLiteral("sessions")); - s.setValue(QStringLiteral("count"), saveIndex); - s.setValue(QStringLiteral("nextId"), m_nextSessionId); - - int activeSessionId = (m_activeSessionIndex >= 0 - && m_activeSessionIndex < m_sessions.size()) - ? m_sessions[m_activeSessionIndex].id : -1; - s.setValue(QStringLiteral("activeId"), activeSessionId); - s.endGroup(); - - m_settings->save(); -} - void SessionManager::scheduleSave() { - // Session metadata changed — ensure saveSessions() runs on the next fire. + // Session metadata changed — ensure the timer fires to flush via m_store->saveSessionsMetadata(). m_sessionsDirty = true; if (m_sessionsLoaded) m_saveTimer->start(); @@ -960,141 +776,6 @@ void SessionManager::scheduleScrollbackSave() m_saveTimer->start(); } -QString SessionManager::scrollbackDir() const -{ - return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) - + QStringLiteral("/scrollback"); -} - -QString SessionManager::scrollbackFilePath(int sessionId) const -{ - return scrollbackDir() + QStringLiteral("/session_%1.vt").arg(sessionId); -} - -void SessionManager::saveSessionScrollback(SessionInfo &info) -{ - if (!info.view) - return; - - uint16_t cols = 0, rows = 0; - QByteArray data = info.view->exportScrollback(cols, rows); - if (data.isEmpty()) { - info.scrollbackDirty = false; - return; - } - - QByteArray output; - if (m_encryptor && m_encryptor->isAvailable()) - output = m_encryptor->encrypt(data); - if (output.isEmpty()) { - qWarning() << "Ghosteel: Scrollback encryption failed for session" - << info.id << ", skipping"; - return; // Don't write plaintext to disk - } - - // Atomic write via QSaveFile — commit() is a POSIX rename(), - // which is atomic on the same filesystem. No window where both - // old and new files are gone. - QSaveFile saveFile(scrollbackFilePath(info.id)); - if (saveFile.open(QIODevice::WriteOnly)) { - if (saveFile.write(output) != -1) { - // fsync before rename to ensure data is on disk — - // protects against power loss between write and rename - ::fsync(saveFile.handle()); - if (saveFile.commit()) { - info.scrollbackDirty = false; - // fsync the parent directory so the rename's dirent update - // survives a power loss (file content is already durable). - int dirFd = ::open(scrollbackDir().toUtf8().constData(), O_RDONLY); - if (dirFd >= 0) { - if (::fsync(dirFd) != 0) - qWarning() << "Scrollback: dir fsync failed:" << std::strerror(errno); - ::close(dirFd); - } else { - qWarning() << "Scrollback: dir fsync open failed:" << std::strerror(errno); - } - } else { - qWarning() << "Failed to commit scrollback:" << saveFile.errorString(); - } - } else { - qWarning() << "Failed to write scrollback:" << saveFile.errorString(); - } - } -} - -void SessionManager::saveScrollbackIncremental(bool force) -{ - if (!m_settings->scrollbackPersistence()) - return; - - QDir().mkpath(scrollbackDir()); - - // Throttle scrollback saves under continuous output (tail -f, builds): - // exportScrollback + Sailfish-Secrets D-Bus + fsync is expensive on a - // handset, and contentChanged fires every repaint, so an unthrottled - // 500ms debounce would re-encrypt ~2x/sec. Cap each session to one save - // per kMinScrollbackIntervalMs; force=true (quit) bypasses it. A sporadic - // edit landing inside a window is deferred up to ~5s — acceptable for - // scrollback durability (quit always force-saves). - static constexpr qint64 kMinScrollbackIntervalMs = 5000; - const qint64 now = QDateTime::currentMSecsSinceEpoch(); - bool anyThrottled = false; - - auto trySave = [&](SessionInfo &info) { - if (!info.scrollbackDirty) - return; // cheap pre-check; saveSessionScrollback does not re-check dirty - if (!force && info.lastScrollbackSaveMs > 0 - && (now - info.lastScrollbackSaveMs) < kMinScrollbackIntervalMs) { - // Stay dirty; re-armed below for the next window. - anyThrottled = true; - return; - } - saveSessionScrollback(info); // clears dirty on success - if (!info.scrollbackDirty) - info.lastScrollbackSaveMs = now; - }; - - // Process active session first for priority on quit - if (m_activeSessionIndex >= 0 && m_activeSessionIndex < m_sessions.size()) - trySave(m_sessions[m_activeSessionIndex]); - - for (int i = 0; i < m_sessions.size(); i++) { - if (i == m_activeSessionIndex) - continue; - trySave(m_sessions[i]); - } - - // A throttled session is still dirty — re-arm so it's retried at the next - // window. Throttled sessions skip exportScrollback entirely, so the - // intervening 500ms fires stay cheap. - if (anyThrottled) - scheduleScrollbackSave(); -} - -void SessionManager::cleanupScrollbackFiles(bool purgeAll) -{ - int retentionDays = m_settings->scrollbackRetentionDays(); - QDir dir(scrollbackDir()); - if (!dir.exists()) - return; - - QDateTime cutoff = QDateTime::currentDateTime().addDays(-retentionDays); - const QFileInfoList files = dir.entryInfoList(QDir::Files); - for (const QFileInfo &fi : files) { - if (fi.fileName().startsWith(QStringLiteral("session_"))) { - if (fi.fileName().endsWith(QStringLiteral(".vt"))) { - if (purgeAll || fi.lastModified() < cutoff) - QFile::remove(fi.absoluteFilePath()); - } else { - // Orphaned QSaveFile temp file from a crash during write. - // These have random suffixes (e.g. session_1.vt.aBcDeF). - // Safe to remove — they're never read on restore. - QFile::remove(fi.absoluteFilePath()); - } - } - } -} - void SessionManager::setActiveSessionFontSize(int size, bool updateGlobal) { if (m_activeSessionIndex < 0 || m_activeSessionIndex >= m_sessions.size()) @@ -1114,7 +795,7 @@ void SessionManager::setActiveSessionFontSize(int size, bool updateGlobal) return; } - size = qBound(6, size, 32); + size = qBound(Settings::kMinFontSize, size, Settings::kMaxFontSize); if (info.fontSize == size) { // Value unchanged, but still sync global if requested if (updateGlobal) @@ -1169,35 +850,6 @@ QString SessionManager::sessionDisplayName(int index) const return tr("Session %1").arg(index + 1); } -void SessionManager::restoreScrollbackForSession(TerminalView *view, int savedId) -{ - if (!m_settings->scrollbackPersistence()) - return; - - QString sbPath = scrollbackFilePath(savedId); - QFile sbFile(sbPath); - if (!sbFile.exists() || !sbFile.open(QIODevice::ReadOnly)) - return; - - if (sbFile.size() > 4 * 1024 * 1024) { - qWarning() << "Scrollback file too large, skipping:" << sbPath; - return; - } - - QByteArray sbData = sbFile.readAll(); - if (sbData.isEmpty()) - return; - - if (ScrollEncryptor::isEncryptedFormat(sbData) && m_encryptor && m_encryptor->isAvailable()) { - QByteArray restored = m_encryptor->decrypt(sbData); - if (!restored.isEmpty()) - view->setPendingScrollback(restored); - } else if (ScrollEncryptor::isEncryptedFormat(sbData)) { - // Encryption not yet available — queue for retry after availabilityChanged - m_pendingScrollbackRestores.append({view, savedId}); - } -} - int SessionManager::resolveActiveSession(int activeId, int legacyActiveIndex) const { if (activeId >= 0) { @@ -1237,7 +889,7 @@ bool SessionManager::restoreSessions() m_nextSessionId = nextId; - cleanupScrollbackFiles(); + m_store->cleanupScrollbackFiles(); for (int i = 0; i < count; i++) { QString group = QStringLiteral("sessionData/session_%1").arg(i); @@ -1262,7 +914,7 @@ bool SessionManager::restoreSessions() // Create session with restored settings TerminalView *view = new TerminalView(); view->setWorkingDirectory(workingDir); - // Apply persisted font size immediately so saveSessions() reads back + // Apply persisted font size immediately so the save path reads back the correct value // the correct value for non-active sessions (not the stale default 18). if (fontSize > 0) view->setFontSize(fontSize); @@ -1271,7 +923,7 @@ bool SessionManager::restoreSessions() if (!autorun.isEmpty()) view->setAutorunCommand(autorun); - restoreScrollbackForSession(view, savedId); + m_store->restoreScrollbackForSession(view, savedId, m_pendingScrollbackRestores); SessionInfo info; info.id = savedId; @@ -1311,7 +963,7 @@ bool SessionManager::restoreSessions() // Reset the metadata-dirty flag: restore's trailing setActiveSessionIndex // sets it via scheduleSave, but the just-loaded state is already on disk, - // so the next scrollback-only arm must not trigger a redundant saveSessions. + // so the next scrollback-only arm must not trigger a redundant metadata save. m_sessionsDirty = false; m_sessionsLoaded = true; rebuildSortedIndices(); diff --git a/src/sessionmanager.h b/src/sessionmanager.h index 5377658..43989b7 100644 --- a/src/sessionmanager.h +++ b/src/sessionmanager.h @@ -9,54 +9,15 @@ #include #include #include +#include + +#include "ipcmessage.h" +#include "sessionstore.h" class TerminalView; class ScrollEncryptor; class Settings; - -// Session scrollback lifecycle (restore → dirty → save): -// 1. restoreSessions() creates each view, sets justRestored=true. -// Geometry-update repaints fire contentChanged immediately, but -// the handler no-ops while justRestored is true (avoids re-encrypting -// just-restored scrollback on launch). -// 2. First real PTY byte arrives → titleChanged fires synchronously -// (inside vtWrite, before update() emits contentChanged) → clears -// justRestored. Subsequent contentChanged marks scrollbackDirty and -// schedules a debounced save. -// 3. Debounce timer (500ms) or aboutToQuit → saveScrollbackIncremental() -// encrypts only dirty sessions, active session first. -// 4. If encryption was unavailable at restore time, the file is queued -// in m_pendingScrollbackRestores and retried once when -// ScrollEncryptor::availabilityChanged fires. - -// Session taxonomy (two orthogonal dimensions): -// -// No command (execArgs empty) Command (execArgs set) -// No name Regular shell session Anonymous command session -// Named Named shell session Named command session -// -// Auto-remove: exit 0 → anonymous only; exit ≠ 0 → all command sessions. -// restartShell() clears execArgs → cancels pending auto-remove. -struct SessionInfo { - int id; - QString name; - QString cachedWorkingDirectory; // Persisted CWD for inactive sessions - QString autorunCommand; // Command to run when session starts - bool keybarOpen = true; // Whether the extra keys panel is open - bool keyboardVisible = true; // Whether the software keyboard is visible - int fontSize = 0; // Per-session font size (0 = use global default) - QString execCommand; // Command binary name from -e (display only) - QStringList execArgs; // Full command args including binary (for reuse matching) - qint64 createdAt = 0; // Epoch ms when session was created - qint64 lastUsedAt = 0; // Epoch ms when session was last switched to - TerminalView *view; - - bool isAnonymous() const { return !execArgs.isEmpty() && name.isEmpty(); } - bool isCommandSession() const { return !execArgs.isEmpty(); } - bool scrollbackDirty = false; // True if scrollback changed since last encrypt+save - bool justRestored = false; // True after restoreSessions(); skip dirty-marking until PTY data arrives - qint64 lastScrollbackSaveMs = 0; // Epoch ms of last successful scrollback save (throttle under continuous output) -}; +class SessionStore; class SessionManager : public QObject { @@ -165,7 +126,6 @@ class SessionManager : public QObject static TerminalView* sessionAtCallback(QQmlListProperty *prop, int index); static QString socketPath(); - void saveSessions(); void scheduleSave(); // metadata changed — arms timer + marks sessions dirty void scheduleScrollbackSave(); // scrollback-only change — arms timer without settings rewrite void rebuildSortedIndices(); @@ -178,21 +138,8 @@ class SessionManager : public QObject void finishSessionCreation(TerminalView *view, SessionInfo &info); static void raiseWindow(); void clearCliArgs(); - void restoreScrollbackForSession(TerminalView *view, int savedId); int resolveActiveSession(int activeId, int legacyActiveIndex) const; - // Scrollback persistence - void saveScrollbackIncremental(bool force = false); - void saveSessionScrollback(SessionInfo &info); - void cleanupScrollbackFiles(bool purgeAll = false); - QString scrollbackDir() const; - QString scrollbackFilePath(int sessionId) const; - - // Queued scrollback restores for when encryption becomes available - struct PendingScrollbackRestore { - QPointer view; - int sessionId; - }; QVector m_pendingScrollbackRestores; private Q_SLOTS: @@ -209,7 +156,7 @@ private Q_SLOTS: QTimer *m_saveTimer = nullptr; bool m_sessionsLoaded = false; bool m_savedOnQuit = false; - bool m_sessionsDirty = false; // true when session metadata changed since last saveSessions() + bool m_sessionsDirty = false; // true when session metadata changed since last metadata save bool m_dbusRegistered = false; // Single-instance socket server @@ -217,6 +164,7 @@ private Q_SLOTS: // Scrollback encryption (Sailfish Secrets + Crypto) ScrollEncryptor *m_encryptor = nullptr; + std::unique_ptr m_store; // CLI arguments (set from main(), processed by processCliArgs() from QML) QString m_cliExecCommand; @@ -224,63 +172,4 @@ private Q_SLOTS: QString m_cliSessionName; }; -// IPC protocol for single-instance communication. -struct IpcMessage { - enum Type { Raise, Switch, Exec } type = Raise; - QString sessionName; - QString command; - QStringList args; - - static constexpr int kMaxSessionNameLength = 128; - - static QString sanitizeSessionName(const QString &name) { - QString clean = name; - clean.truncate(kMaxSessionNameLength); - clean.remove(QChar('\0')); - clean.remove(QChar('\n')); - clean.remove(QChar('\r')); - clean.remove(QChar(':')); // load-bearing: exec: protocol uses : as delimiter - return clean; - } - - static IpcMessage parse(const QByteArray &raw) { - IpcMessage msg; - QList parts = raw.split('\0'); - QByteArray header = parts.isEmpty() ? QByteArray() : parts.first(); - - if (header == "raise") { - msg.type = Raise; - } else if (header.startsWith("switch:")) { - msg.type = Switch; - msg.sessionName = sanitizeSessionName(QString::fromUtf8(header.mid(7))); - } else if (header.startsWith("exec:")) { - msg.type = Exec; - QByteArray afterPrefix = header.mid(5); - int colonPos = afterPrefix.indexOf(':'); - if (colonPos < 0) { msg.type = Raise; return msg; } - msg.sessionName = sanitizeSessionName(QString::fromUtf8(afterPrefix.left(colonPos))); - if (colonPos + 1 < afterPrefix.size()) - msg.command = QString::fromUtf8(afterPrefix.mid(colonPos + 1)); - for (int i = 1; i < parts.size(); i++) - if (!parts[i].isEmpty()) - msg.args.append(QString::fromUtf8(parts[i])); - } - return msg; - } - - static QByteArray encode(const QString &execCommand, const QStringList &execArgs, const QString &sessionName) { - if (!execCommand.isEmpty()) { - QByteArray cmdBytes = execCommand.toUtf8(); - for (const QString &arg : execArgs) { - cmdBytes.append('\0'); - cmdBytes.append(arg.toUtf8()); - } - return (QStringLiteral("exec:") + sessionName + QStringLiteral(":")).toUtf8() + cmdBytes + '\n'; - } else if (!sessionName.isEmpty()) { - return (QStringLiteral("switch:") + sessionName + QStringLiteral("\n")).toUtf8(); - } - return QByteArrayLiteral("raise\n"); - } -}; - #endif // SESSIONMANAGER_H diff --git a/src/sessionstore.cpp b/src/sessionstore.cpp new file mode 100644 index 0000000..21978c2 --- /dev/null +++ b/src/sessionstore.cpp @@ -0,0 +1,246 @@ +#include "sessionstore.h" +#include "settings.h" +#include "scrollencryptor.h" +#include "terminalview.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +// Cap restored scrollback file size to prevent pathological memory use. +constexpr qint64 kMaxScrollbackFileBytes = 4 * 1024 * 1024; +} // namespace + +SessionStore::SessionStore(Settings *settings, ScrollEncryptor *encryptor) + : m_settings(settings) + , m_encryptor(encryptor) +{ +} + +void SessionStore::saveSessionsMetadata(QVector &sessions, int activeIndex, int nextSessionId) +{ + QSettings &s = m_settings->raw(); + + // Clear old session entries + s.remove(QStringLiteral("sessionData")); + + // Skip anonymous command sessions during save + int saveIndex = 0; + for (SessionInfo &info : sessions) { + + if (info.isAnonymous()) + continue; + + QString group = QStringLiteral("sessionData/session_%1").arg(saveIndex); + s.beginGroup(group); + s.setValue(QStringLiteral("id"), info.id); + s.setValue(QStringLiteral("name"), info.name); + // Use live CWD from /proc if shell is running, otherwise use cached value + if (info.view) { + QString liveCwd = info.view->workingDirectory(); + if (!liveCwd.isEmpty()) + info.cachedWorkingDirectory = liveCwd; + } + QString cwd = info.cachedWorkingDirectory; + if (cwd.isEmpty()) + cwd = QDir::homePath(); + s.setValue(QStringLiteral("workingDirectory"), cwd); + s.setValue(QStringLiteral("autorunCommand"), info.autorunCommand); + // Persist per-session font size. When fontSize == 0 (track global + // default), don't read the live view size — that would clobber the + // sentinel with the resolved global value. + if (info.view && info.fontSize > 0) + info.fontSize = info.view->fontSize(); + s.setValue(QStringLiteral("fontSize"), info.fontSize); + s.setValue(QStringLiteral("keybarOpen"), info.keybarOpen); + s.setValue(QStringLiteral("keyboardVisible"), info.keyboardVisible); + s.setValue(QStringLiteral("createdAt"), info.createdAt); + s.setValue(QStringLiteral("lastUsedAt"), info.lastUsedAt); + s.endGroup(); + saveIndex++; + } + + s.beginGroup(QStringLiteral("sessions")); + s.setValue(QStringLiteral("count"), saveIndex); + s.setValue(QStringLiteral("nextId"), nextSessionId); + + int activeSessionId = (activeIndex >= 0 + && activeIndex < sessions.size()) + ? sessions[activeIndex].id : -1; + s.setValue(QStringLiteral("activeId"), activeSessionId); + s.endGroup(); + + m_settings->save(); +} + +QString SessionStore::scrollbackDir() const +{ + return QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + + QStringLiteral("/scrollback"); +} + +QString SessionStore::scrollbackFilePath(int sessionId) const +{ + return scrollbackDir() + QStringLiteral("/session_%1.vt").arg(sessionId); +} + +void SessionStore::saveSessionScrollback(SessionInfo &info) +{ + if (!info.view) + return; + + uint16_t cols = 0, rows = 0; + QByteArray data = info.view->exportScrollback(cols, rows); + if (data.isEmpty()) { + info.scrollbackDirty = false; + return; + } + + QByteArray output; + if (m_encryptor && m_encryptor->isAvailable()) + output = m_encryptor->encrypt(data); + if (output.isEmpty()) { + qWarning() << "Ghosteel: Scrollback encryption failed for session" + << info.id << ", skipping"; + return; // Don't write plaintext to disk + } + + // Atomic write via QSaveFile — commit() is a POSIX rename(), + // which is atomic on the same filesystem. No window where both + // old and new files are gone. + QSaveFile saveFile(scrollbackFilePath(info.id)); + if (saveFile.open(QIODevice::WriteOnly)) { + if (saveFile.write(output) != -1) { + // fsync before rename to ensure data is on disk — + // protects against power loss between write and rename + ::fsync(saveFile.handle()); + if (saveFile.commit()) { + info.scrollbackDirty = false; + // fsync the parent directory so the rename's dirent update + // survives a power loss (file content is already durable). + int dirFd = ::open(scrollbackDir().toUtf8().constData(), O_RDONLY); + if (dirFd >= 0) { + if (::fsync(dirFd) != 0) + qWarning() << "Scrollback: dir fsync failed:" << std::strerror(errno); + ::close(dirFd); + } else { + qWarning() << "Scrollback: dir fsync open failed:" << std::strerror(errno); + } + } else { + qWarning() << "Failed to commit scrollback:" << saveFile.errorString(); + } + } else { + qWarning() << "Failed to write scrollback:" << saveFile.errorString(); + } + } +} + +bool SessionStore::saveScrollbackIncremental(QVector &sessions, int activeIndex, bool force) +{ + if (!m_settings->scrollbackPersistence()) + return false; + + QDir().mkpath(scrollbackDir()); + + // Throttle scrollback saves under continuous output (tail -f, builds): + // exportScrollback + Sailfish-Secrets D-Bus + fsync is expensive on a + // handset, and contentChanged fires every repaint, so an unthrottled + // 500ms debounce would re-encrypt ~2x/sec. Cap each session to one save + // per kMinScrollbackIntervalMs; force=true (quit) bypasses it. A sporadic + // edit landing inside a window is deferred up to ~5s — acceptable for + // scrollback durability (quit always force-saves). + static constexpr qint64 kMinScrollbackIntervalMs = 5000; + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + bool anyThrottled = false; + + auto trySave = [&](SessionInfo &info) { + if (!info.scrollbackDirty) + return; // cheap pre-check; saveSessionScrollback does not re-check dirty + if (!force && info.lastScrollbackSaveMs > 0 + && (now - info.lastScrollbackSaveMs) < kMinScrollbackIntervalMs) { + // Stay dirty; caller re-arms via the throttled return value. + anyThrottled = true; + return; + } + saveSessionScrollback(info); // clears dirty on success + if (!info.scrollbackDirty) + info.lastScrollbackSaveMs = now; + }; + + // Process active session first for priority on quit + if (activeIndex >= 0 && activeIndex < sessions.size()) + trySave(sessions[activeIndex]); + + for (int i = 0; i < sessions.size(); i++) { + if (i == activeIndex) + continue; + trySave(sessions[i]); + } + + // Returns anyThrottled so the caller re-arms. Throttled sessions skip + // exportScrollback entirely, so the intervening 500ms fires stay cheap. + return anyThrottled; +} + +void SessionStore::cleanupScrollbackFiles(bool purgeAll) +{ + int retentionDays = m_settings->scrollbackRetentionDays(); + QDir dir(scrollbackDir()); + if (!dir.exists()) + return; + + QDateTime cutoff = QDateTime::currentDateTime().addDays(-retentionDays); + const QFileInfoList files = dir.entryInfoList(QDir::Files); + for (const QFileInfo &fi : files) { + if (fi.fileName().startsWith(QStringLiteral("session_"))) { + if (fi.fileName().endsWith(QStringLiteral(".vt"))) { + if (purgeAll || fi.lastModified() < cutoff) + QFile::remove(fi.absoluteFilePath()); + } else { + // Orphaned QSaveFile temp file from a crash during write. + // These have random suffixes (e.g. session_1.vt.aBcDeF). + // Safe to remove — they're never read on restore. + QFile::remove(fi.absoluteFilePath()); + } + } + } +} + +void SessionStore::restoreScrollbackForSession(TerminalView *view, int savedId, + QVector &pending) +{ + if (!m_settings->scrollbackPersistence()) + return; + + QString sbPath = scrollbackFilePath(savedId); + QFile sbFile(sbPath); + if (!sbFile.exists() || !sbFile.open(QIODevice::ReadOnly)) + return; + + if (sbFile.size() > kMaxScrollbackFileBytes) { + qWarning() << "Scrollback file too large, skipping:" << sbPath; + return; + } + + QByteArray sbData = sbFile.readAll(); + if (sbData.isEmpty()) + return; + + const bool encrypted = ScrollEncryptor::isEncryptedFormat(sbData); + if (encrypted && m_encryptor && m_encryptor->isAvailable()) { + QByteArray restored = m_encryptor->decrypt(sbData); + if (!restored.isEmpty()) + view->setPendingScrollback(restored); + } else if (encrypted) { + // Encryption not yet available — queue for retry after availabilityChanged + pending.append({view, savedId}); + } +} diff --git a/src/sessionstore.h b/src/sessionstore.h new file mode 100644 index 0000000..b33cde5 --- /dev/null +++ b/src/sessionstore.h @@ -0,0 +1,92 @@ +#ifndef SESSIONSTORE_H +#define SESSIONSTORE_H + +#include +#include +#include +#include + +class Settings; +class ScrollEncryptor; +class TerminalView; + +// Session scrollback lifecycle (restore → dirty → save): +// 1. restoreSessions() creates each view, sets justRestored=true. +// Geometry-update repaints fire contentChanged immediately, but +// the handler no-ops while justRestored is true (avoids re-encrypting +// just-restored scrollback on launch). +// 2. First real PTY byte arrives → titleChanged fires synchronously +// (inside vtWrite, before update() emits contentChanged) → clears +// justRestored. Subsequent contentChanged marks scrollbackDirty and +// schedules a debounced save. +// 3. Debounce timer (500ms) or aboutToQuit → saveScrollbackIncremental() +// encrypts only dirty sessions, active session first. +// 4. If encryption was unavailable at restore time, the file is queued +// in the caller's pending-restore vector and retried once when +// ScrollEncryptor::availabilityChanged fires. + +// Session taxonomy (two orthogonal dimensions): +// +// No command (execArgs empty) Command (execArgs set) +// No name Regular shell session Anonymous command session +// Named Named shell session Named command session +// +// Auto-remove: exit 0 → anonymous only; exit ≠ 0 → all command sessions. +// restartShell() clears execArgs → cancels pending auto-remove. +struct SessionInfo { + int id; + QString name; + QString cachedWorkingDirectory; // Persisted CWD for inactive sessions + QString autorunCommand; // Command to run when session starts + bool keybarOpen = true; // Whether the extra keys panel is open + bool keyboardVisible = true; // Whether the software keyboard is visible + int fontSize = 0; // Per-session font size (0 = use global default) + QString execCommand; // Command binary name from -e (display only) + QStringList execArgs; // Full command args including binary (for reuse matching) + qint64 createdAt = 0; // Epoch ms when session was created + qint64 lastUsedAt = 0; // Epoch ms when session was last switched to + TerminalView *view; + + bool isAnonymous() const { return !execArgs.isEmpty() && name.isEmpty(); } + bool isCommandSession() const { return !execArgs.isEmpty(); } + bool scrollbackDirty = false; // True if scrollback changed since last encrypt+save + bool justRestored = false; // True after restoreSessions(); skip dirty-marking until PTY data arrives + qint64 lastScrollbackSaveMs = 0; // Epoch ms of last successful scrollback save (throttle under continuous output) +}; + +struct PendingScrollbackRestore { + QPointer view; + int sessionId; +}; + +class SessionStore { +public: + SessionStore(Settings *settings, ScrollEncryptor *encryptor); + + // Updates info.cachedWorkingDirectory and info.fontSize in-place from live view state. + void saveSessionsMetadata(QVector &sessions, int activeIndex, int nextSessionId); + + // Encrypt + save scrollback for sessions marked dirty. Returns true if + // throttled (5s min interval per session) and the caller should re-arm + // the save timer; false otherwise. force=true bypasses the throttle + // (used on aboutToQuit). + bool saveScrollbackIncremental(QVector &sessions, int activeIndex, bool force = false); + + // purgeAll=true bypasses retention and removes every scrollback file (used when persistence is disabled). + void cleanupScrollbackFiles(bool purgeAll = false); + + // If the file is encrypted but the encryptor isn't ready yet, append to pending for retry on availabilityChanged. + void restoreScrollbackForSession(TerminalView *view, int savedId, + QVector &pending); + + QString scrollbackFilePath(int sessionId) const; + +private: + Settings *m_settings; + ScrollEncryptor *m_encryptor; + + void saveSessionScrollback(SessionInfo &info); + QString scrollbackDir() const; +}; + +#endif // SESSIONSTORE_H diff --git a/src/settings.h b/src/settings.h index 5032ce6..11be080 100644 --- a/src/settings.h +++ b/src/settings.h @@ -29,6 +29,8 @@ class Settings : public QObject Q_PROPERTY(int clipboardReadPolicy READ clipboardReadPolicy WRITE setClipboardReadPolicy NOTIFY clipboardReadPolicyChanged) Q_PROPERTY(QString customShaderPath READ customShaderPath WRITE setCustomShaderPath NOTIFY customShaderPathChanged) Q_PROPERTY(bool shaderPipelineAvailable READ shaderPipelineAvailable NOTIFY shaderPipelineAvailableChanged) + Q_PROPERTY(int minFontSize READ minFontSize CONSTANT) + Q_PROPERTY(int maxFontSize READ maxFontSize CONSTANT) public: // Session sort modes — values must match SessionPage.qml @@ -38,6 +40,11 @@ class Settings : public QObject public: static Settings *instance(); + // setFontSize intentionally does NOT clamp — "raw store, clamp-at-consume" + // (see tst_settings_behavior). + static constexpr int kMinFontSize = 6; + static constexpr int kMaxFontSize = 32; + // Test constructor: allows injecting a custom settings path explicit Settings(const QString &settingsPath, QObject *parent = nullptr); @@ -50,6 +57,8 @@ class Settings : public QObject int fontSize() const { return m_fontSize; } void setFontSize(int size); + int minFontSize() const { return kMinFontSize; } + int maxFontSize() const { return kMaxFontSize; } QString fontFamily() const { return m_fontFamily; } void setFontFamily(const QString &family); diff --git a/src/singleinstance.cpp b/src/singleinstance.cpp new file mode 100644 index 0000000..6570920 --- /dev/null +++ b/src/singleinstance.cpp @@ -0,0 +1,132 @@ +#include "sessionmanager.h" +#include "ipcmessage.h" + +#include +#include +#include +#include +#include +#include + +QString SessionManager::socketPath() +{ + // Use XDG_RUNTIME_DIR directly — safe to call before QGuiApplication exists + const QString runtime = QString::fromLocal8Bit(qgetenv("XDG_RUNTIME_DIR")); + if (!runtime.isEmpty()) + return runtime + QStringLiteral("/ghosteel-singleton"); + return QStandardPaths::writableLocation(QStandardPaths::RuntimeLocation) + + QStringLiteral("/ghosteel-singleton"); +} + +bool SessionManager::checkSingleInstance(const QString &execCommand, + const QStringList &execArgs, + const QString &sessionName) +{ + QLocalSocket socket; + socket.connectToServer(socketPath()); + if (socket.waitForConnected(500)) { + QByteArray msg = IpcMessage::encode(execCommand, execArgs, sessionName); + socket.write(msg); + socket.waitForBytesWritten(1000); + socket.disconnectFromServer(); + return true; + } + return false; +} + +void SessionManager::startSingleInstanceServer() +{ + m_localServer = new QLocalServer(this); + + auto failServer = [this]() { + delete m_localServer; + m_localServer = nullptr; + }; + + const QString path = socketPath(); + + if (!m_localServer->listen(path)) { + if (m_localServer->serverError() == QAbstractSocket::AddressInUseError) { + QLocalSocket probe; + probe.connectToServer(path); + if (probe.waitForConnected(200)) { + qWarning() << "Ghosteel: Another instance detected via socket probe"; + failServer(); + return; + } + QLocalServer::removeServer(path); + if (!m_localServer->listen(path)) { + qWarning() << "Ghosteel: Single-instance server failed:" << m_localServer->errorString(); + failServer(); + return; + } + } else { + qWarning() << "Ghosteel: Single-instance server failed:" << m_localServer->errorString(); + failServer(); + return; + } + } + connect(m_localServer, &QLocalServer::newConnection, + this, &SessionManager::onNewInstanceConnection); +} + +void SessionManager::setCliArgs(const QString &execCommand, + const QStringList &execArgs, + const QString &sessionName) +{ + m_cliExecCommand = execCommand; + m_cliExecArgs = execArgs; + m_cliSessionName = IpcMessage::sanitizeSessionName(sessionName); +} + +void SessionManager::clearCliArgs() +{ + m_cliExecCommand.clear(); + m_cliExecArgs.clear(); + m_cliSessionName.clear(); +} + +void SessionManager::raiseWindow() +{ + const auto windows = QGuiApplication::topLevelWindows(); + if (!windows.isEmpty()) { + if (auto *window = windows.first()) { + window->raise(); + window->requestActivate(); + } + } +} + +void SessionManager::onNewInstanceConnection() +{ + QLocalSocket *socket = m_localServer->nextPendingConnection(); + if (!socket) return; + + // Race: sender may have written and disconnected before we get here. + auto processMessage = [this, socket]() { + QByteArray data = socket->readAll().trimmed(); + socket->deleteLater(); + + IpcMessage parsed = IpcMessage::parse(data); + if (parsed.type == IpcMessage::Raise) { + raiseWindow(); + } else if (parsed.type == IpcMessage::Switch) { + switchToSessionByName(parsed.sessionName); + raiseWindow(); + } else if (parsed.type == IpcMessage::Exec) { + if (parsed.command.isEmpty()) return; + setCliArgs(parsed.command, parsed.args, parsed.sessionName); + processCliArgs(); + // Delay raise to let QML process sessionCreated() signal + QTimer::singleShot(100, this, raiseWindow); + } + }; + + if (socket->bytesAvailable() > 0 || socket->state() == QLocalSocket::UnconnectedState) { + processMessage(); + } else { + // disconnected also covers: client connects, never writes, drops. + connect(socket, &QLocalSocket::readyRead, this, processMessage); + connect(socket, &QLocalSocket::disconnected, this, processMessage); + } +} diff --git a/src/terminalview.cpp b/src/terminalview.cpp index b4f41e6..07272e6 100644 --- a/src/terminalview.cpp +++ b/src/terminalview.cpp @@ -919,733 +919,6 @@ int TerminalView::handleHitTest(const QPointF &pos) const return 0; // No hit } -void TerminalView::resetSessionSwipe() -{ - if (!m_sessionSwiping) - return; - m_sessionSwiping = false; - setKeepMouseGrab(false); - setKeepTouchGrab(false); - Q_EMIT sessionSwipeCancelled(); -} - -void TerminalView::resetTouchInteractionState() -{ - if (m_longPressTimerId) { - killTimer(m_longPressTimerId); - m_longPressTimerId = 0; - } - if (m_selecting) - clearSelection(); - m_pendingLinkTap = false; - m_draggingHandle = 0; -} - -void TerminalView::mousePressEvent(QMouseEvent *event) -{ - // A new press always supersedes a stale swipe (e.g. after a TouchCancel - // that never delivered a release). - resetSessionSwipe(); - - if (m_shellExited) { - restartShell(); - event->accept(); - return; - } - - if (event->button() == Qt::LeftButton) { - m_cursorBlinkVisible = true; - m_lastInputTime.start(); - - m_mouseTrackingActive = m_vt->isMouseTracking(); - - if (m_mouseTrackingActive) { - // Safety net: reject touches in the pull-down zone. In practice, - // touchEvent accepts TUI touches before synthesis, so this is - // rarely reached. - if (event->pos().y() < m_pullDownZoneHeight) { - QQuickItem::mousePressEvent(event); - return; - } - - sendMouseEvent(GHOSTTY_MOUSE_ACTION_PRESS, GHOSTTY_MOUSE_BUTTON_LEFT, - event->pos(), KeyMapping::mapQtModifiers(event->modifiers())); - - // Tell encoder a button is pressed (enables motion events) - m_mouseButtonPressed = true; - m_vt->setMouseButtonPressed(true); - - // Prevent SilicaFlickable from stealing drag gestures - setKeepMouseGrab(true); - event->accept(); - return; - } - - // Check if tapping a selection handle (before tap detection or clearing) - int handle = handleHitTest(event->pos()); - if (handle != 0) { - m_draggingHandle = handle; - m_handlesVisible = false; - m_magnifierVisible = true; - m_tapCount = 0; // Prevent phantom triple-tap after handle drag - setKeepMouseGrab(true); - event->accept(); - return; - } - - // Check if tapping a link — defer opening to release for clean interaction - { - QPointF cell = cellFromPixel(event->pos()); - if (cell.x() >= 0 && cell.y() >= 0) { - QString uri = findLinkAt(static_cast(cell.x()), - static_cast(cell.y())); - if (!uri.isEmpty()) { - m_pendingLinkTap = true; - m_tappedLinkUri = uri; - m_linkTapStartPos = event->pos(); - setKeepMouseGrab(true); - event->accept(); - return; - } - } - } - - qint64 now = QDateTime::currentMSecsSinceEpoch(); - qreal dist = QLineF(event->pos(), m_lastTapPos).length(); - bool withinWindow = (m_tapCount > 0) - && (now - m_lastTapTime) <= TapTimeoutMs - && dist <= TapDistancePx; - - if (withinWindow) { - m_tapCount = qMin(m_tapCount + 1, 3); - } else { - m_tapCount = 1; - } - m_lastTapTime = now; - m_lastTapPos = event->pos(); - - if (m_tapCount == 2) { - clearSelection(); - selectWordAt(event->pos()); - event->accept(); - return; - } - if (m_tapCount == 3) { - clearSelection(); - selectLineAt(event->pos()); - event->accept(); - return; - } - - clearSelection(); - m_selStart = event->pos(); - m_selEnd = event->pos(); - m_longPressTimerId = startTimer(LongPressTimeout); - event->accept(); - return; - } - QQuickItem::mousePressEvent(event); -} - -void TerminalView::mouseMoveEvent(QMouseEvent *event) -{ - if (m_draggingHandle != 0) { - if (m_draggingHandle == 1) - m_selStart = event->pos(); - else - m_selEnd = event->pos(); - - // Magnifier stays visible during handle drags — no velocity-based hiding. - // It was set visible on mousePress and should remain so until release. - - m_lastInputTime.start(); - - update(); - event->accept(); - return; - } - - if (m_selecting) { - // Only track movement for long-press drags (handles not yet visible). - // Word/line selections have finalized endpoints — use handles to adjust. - if (!m_handlesVisible) { - m_selEnd = event->pos(); - } - - // Magnifier stays visible for the whole drag — no velocity-based hiding. - // Visibility is bracketed by timerEvent (show on long-press fire) and - // mouseReleaseEvent (hide on release). Velocity gating caused flicker - // (hysteresis band sat inside typical drag velocity) and stuck-invisible - // when the finger stopped mid-drag (no move events to revive it). - - // Keep cursor blink paused during active selection to prevent - // full redraws that cause magnifier flicker - m_lastInputTime.start(); - - update(); - event->accept(); - return; - } - - if (m_mouseTrackingActive) { - GhosttyMouseButton btn = m_mouseButtonPressed - ? GHOSTTY_MOUSE_BUTTON_LEFT : GHOSTTY_MOUSE_BUTTON_UNKNOWN; - sendMouseEvent(GHOSTTY_MOUSE_ACTION_MOTION, btn, - event->pos(), KeyMapping::mapQtModifiers(event->modifiers())); - event->accept(); - return; - } - - // Session-swipe classifier — runs only after every grabbing branch above - // has returned (NORMAL single-finger, pre-selection window). - if (!m_pendingLinkTap && !m_multiTouchActive && m_sessionSwipeEnabled - && m_gestureMode == GestureMode::Undecided && !m_sessionSwiping) { - QPointF delta = event->pos() - m_lastTapPos; // m_lastTapPos set in press - if (qAbs(delta.x()) > SwipeMinHorizontalPx - && qAbs(delta.x()) > qAbs(delta.y()) * SwipeDominanceRatio) { - if (m_longPressTimerId) { killTimer(m_longPressTimerId); m_longPressTimerId = 0; } - m_sessionSwiping = true; - m_swipeStartX = event->pos().x(); - // Mirror the multitouch pattern — lock BOTH mouse and touch grab so - // terminalFlickable can't steal the sequence mid-swipe (on Qt 5.6 / - // Sailfish, mouse grab alone does NOT stop touch stealing). - setKeepMouseGrab(true); - setKeepTouchGrab(true); - Q_EMIT sessionSwipeStarted(); - } - } - if (m_sessionSwiping) { - Q_EMIT sessionSwipeProgress(event->pos().x() - m_swipeStartX); - event->accept(); - return; - } - - QQuickItem::mouseMoveEvent(event); -} - -void TerminalView::mouseReleaseEvent(QMouseEvent *event) -{ - if (m_longPressTimerId) { - killTimer(m_longPressTimerId); - m_longPressTimerId = 0; - } - - // Session-swipe commit/cancel. - if (m_sessionSwiping) { - m_sessionSwiping = false; - setKeepMouseGrab(false); - setKeepTouchGrab(false); - qreal dx = event->pos().x() - m_swipeStartX; - if (qAbs(dx) > width() * SwipeCommitFraction) { - Q_EMIT sessionSwipeCommitted(dx < 0 ? 1 : -1); // leftward → next session - } else { - Q_EMIT sessionSwipeCancelled(); - } - event->accept(); - return; - } - - if (m_mouseTrackingActive) { - // Re-check live tracking state — the app may have disabled mouse - // tracking between press and release (e.g. htop exiting to shell). - if (m_vt->isMouseTracking()) { - sendMouseEvent(GHOSTTY_MOUSE_ACTION_RELEASE, GHOSTTY_MOUSE_BUTTON_LEFT, - event->pos(), KeyMapping::mapQtModifiers(event->modifiers())); - } - - m_mouseButtonPressed = false; - m_vt->setMouseButtonPressed(false); - m_mouseTrackingActive = false; - setKeepMouseGrab(false); - - event->accept(); - return; - } - - // Open link on clean tap-release (no significant drag) - if (m_pendingLinkTap) { - qreal dragDist = QLineF(m_linkTapStartPos, event->pos()).length(); - m_pendingLinkTap = false; - setKeepMouseGrab(false); - QString uri = m_tappedLinkUri; - m_tappedLinkUri.clear(); - if (dragDist < TapDistancePx && !uri.isEmpty()) { - Q_EMIT linkActivated(uri); - event->accept(); - return; - } - // Fall through to normal release handling if finger moved - } - - if (m_draggingHandle != 0) { - if (m_draggingHandle == 1) - m_selStart = event->pos(); - else - m_selEnd = event->pos(); - m_draggingHandle = 0; - m_magnifierVisible = false; - m_handlesVisible = true; - setKeepMouseGrab(false); - copySelection(); - update(); - event->accept(); - return; - } - - if (m_selecting) { - // Only update endpoint for long-press drags (handles not yet visible). - // Word/line selections already finalized their endpoints. - if (!m_handlesVisible) { - m_selEnd = event->pos(); - // Cancel if finger didn't move enough — long-press without drag - // would create a phantom single-character selection - if (QLineF(m_selStart, m_selEnd).length() < TapDistancePx) { - clearSelection(); - event->accept(); - return; - } - copySelection(); - } - m_magnifierVisible = false; - m_handlesVisible = true; - update(); - event->accept(); - return; - } - // Release mouse grab acquired by touchEvent multi-touch/TUI path. - setKeepMouseGrab(false); - QQuickItem::mouseReleaseEvent(event); -} - -void TerminalView::wheelEvent(QWheelEvent *event) -{ - if (!m_vt || !m_vt->terminal()) { - QQuickItem::wheelEvent(event); - return; - } - - // When mouse tracking is active, forward scroll as mouse buttons 4/5 - if (m_vt->isMouseTracking()) { - int delta = event->angleDelta().y(); - GhosttyMods mods = KeyMapping::mapQtModifiers(event->modifiers()); - GhosttyMouseButton button = (delta > 0) ? GHOSTTY_MOUSE_BUTTON_FOUR - : GHOSTTY_MOUSE_BUTTON_FIVE; - sendMouseEvent(GHOSTTY_MOUSE_ACTION_PRESS, button, event->pos(), mods); - sendMouseEvent(GHOSTTY_MOUSE_ACTION_RELEASE, button, event->pos(), mods); - event->accept(); - return; - } - - // Qt wheel events give delta in 1/8 degree units. - // A typical mouse wheel click is 120 units = 15 degrees = 3 lines. - int delta = event->angleDelta().y(); // positive = up, negative = down - - // Accumulate fractional scroll lines so sub-line deltas aren't lost - qreal newDelta = -static_cast(delta) / 40.0; - auto scrollResult = TextUtil::accumulateScroll(m_scrollAccumulator, newDelta); - m_scrollAccumulator = scrollResult.accumulator; - int lines = scrollResult.lines; - - if (lines != 0) { - // Ghostty scroll: negative delta = scroll up (toward scrollback) - GhosttyTerminalScrollViewport scroll = {}; - scroll.tag = GHOSTTY_SCROLL_VIEWPORT_DELTA; - scroll.value.delta = -lines; - ghostty_terminal_scroll_viewport(m_vt->terminal(), scroll); - m_linkScanDirty = true; - update(); - } - - event->accept(); -} - -void TerminalView::touchEvent(QTouchEvent *event) -{ - if (!m_vt || !m_vt->terminal()) { - QQuickItem::touchEvent(event); - return; - } - - const auto points = event->touchPoints(); - - // ── Multi-touch (2+ fingers) ────────────────────────────────── - if (points.size() >= 2) { - setKeepMouseGrab(true); - // Qt 5.6: the touch grab is a SEPARATE mechanism from the mouse grab. - // SilicaFlickable (a filtering parent) steals the touch grab via its - // childMouseEventFilter — setKeepMouseGrab alone does NOT stop this. - // setKeepTouchGrab(true) denies the steal so two-finger scroll/pinch - // stays with the terminal instead of triggering the PullDownMenu. - setKeepTouchGrab(true); - - // ACTIVE grab — passive flags above only prevent future steals. - // SilicaFlickable ignores them once its drag recogniser has armed. - // grabTouchPoints()/grabMouse() wrest the grab back immediately. - { - QVector ids; - ids.reserve(points.size()); - for (const auto &p : points) - ids.append(p.id()); - grabTouchPoints(ids); - grabMouse(); - } - - switch (event->type()) { - case QEvent::TouchEnd: - case QEvent::TouchCancel: - handleMultiTouchEnd(); - break; - default: - // Start the gesture on the FIRST ≥2-point event of any type. - // When the second finger lands after the first, Qt delivers a - // TouchUpdate (not TouchBegin), so keying off the event type alone - // would skip handleMultiTouchBegin — and the Flickable would never - // be disabled, re-opening the PullDownMenu bug for staggered taps. - if (!m_multiTouchActive) - handleMultiTouchBegin(points); - else - handleMultiTouchUpdate(points); - break; - } - event->accept(); - return; - } - - // ── Drop below 2 points during active multi-touch gesture ──── - // Check m_multiTouchActive too: a brief two-finger tap may never - // leave Undecided, but must still end (or the Flickable stays disabled). - if (m_multiTouchActive || m_gestureMode != GestureMode::Undecided) { - handleMultiTouchEnd(); - } - - // ── Single-finger events ────────────────────────────────────── - // TUI mode (mouse tracking): accept + grab + forward as synthetic - // mouse/wheel events. Normal mode: fall through to QQuickItem — - // accepting would break the Flickable's press-delay disambiguation - // (instant drag → pull-down, press-hold → selection). - - if (m_mouseTrackingActive) { - if (event->type() == QEvent::TouchBegin && points.size() == 1) { - handleTuiTouchBegin(event, points.first()); - return; - } - if (event->type() == QEvent::TouchUpdate && points.size() == 1) { - handleTuiTouchUpdate(event, points.first()); - return; - } - if (event->type() == QEvent::TouchEnd - || event->type() == QEvent::TouchCancel) { - handleTuiTouchEnd(event, points); - return; - } - } - - // Normal mode: native fall-through — let Qt synthesise mouse events and - // the Flickable handle pull-down disambiguation. - if (event->type() == QEvent::TouchCancel) { - resetSessionSwipe(); // no release follows a cancel; keep the flag honest - resetTouchInteractionState(); - } - QQuickItem::touchEvent(event); -} - -void TerminalView::handleTuiTouchBegin(QTouchEvent *event, - const QTouchEvent::TouchPoint &pt) -{ - resetSessionSwipe(); // TUI path grabs everything; keep the swipe flag honest - event->accept(); - setKeepMouseGrab(true); - setKeepTouchGrab(true); - Q_EMIT requestParentInteractive(false); - grabTouchPoints(QVector{ pt.id() }); - grabMouse(); - m_tuiDragLastY = pt.pos().y(); - m_tuiScrollAccumulator = 0; - QMouseEvent synthPress(QEvent::MouseButtonPress, - pt.pos(), pt.screenPos(), - Qt::LeftButton, Qt::LeftButton, - event->modifiers()); - mousePressEvent(&synthPress); -} - -void TerminalView::handleTuiTouchUpdate(QTouchEvent *event, - const QTouchEvent::TouchPoint &pt) -{ - event->accept(); - - // Convert vertical drag delta to wheel events for TUI scroll. - qreal deltaY = pt.pos().y() - m_tuiDragLastY; - m_tuiDragLastY = pt.pos().y(); - qreal newDelta = deltaY / m_cellHeight; // positive: down-drag = scroll up (natural scrolling) - auto scrollResult = TextUtil::accumulateScroll( - m_tuiScrollAccumulator, newDelta); - m_tuiScrollAccumulator = scrollResult.accumulator; - if (scrollResult.lines != 0) { - GhosttyMods mods = KeyMapping::mapQtModifiers(event->modifiers()); - GhosttyMouseButton btn = (scrollResult.lines > 0) - ? GHOSTTY_MOUSE_BUTTON_FOUR : GHOSTTY_MOUSE_BUTTON_FIVE; - for (int i = 0; i < qAbs(scrollResult.lines); ++i) { - sendMouseEvent(GHOSTTY_MOUSE_ACTION_PRESS, btn, pt.pos(), mods); - sendMouseEvent(GHOSTTY_MOUSE_ACTION_RELEASE, btn, pt.pos(), mods); - } - } - - // Also forward mouse motion for TUI click/drag/selection. - QMouseEvent synthMove(QEvent::MouseMove, - pt.pos(), pt.screenPos(), - Qt::LeftButton, Qt::LeftButton, - event->modifiers()); - mouseMoveEvent(&synthMove); -} - -void TerminalView::handleTuiTouchEnd(QTouchEvent *event, - const QList &points) -{ - if (points.size() == 1) { - const auto &pt = points.first(); - QMouseEvent synthRel(QEvent::MouseButtonRelease, - pt.pos(), pt.screenPos(), - Qt::LeftButton, Qt::NoButton, - event->modifiers()); - mouseReleaseEvent(&synthRel); - } - Q_EMIT requestParentInteractive(true); - m_tuiScrollAccumulator = 0; - setKeepMouseGrab(false); - setKeepTouchGrab(false); - event->accept(); -} - -void TerminalView::handleMultiTouchBegin(const QList &points) -{ - resetSessionSwipe(); // a second finger lands → abandon any in-progress swipe - - // Shell exited — ignore multi-touch, let it fall through to parent - if (m_shellExited) - return; - - // Clean up any previous gesture that wasn't properly ended. This can - // happen if TouchEnd was missed (e.g., window deactivated, touch stolen - // by another item) and a new gesture starts while the overlay is still - // visible. Without this, pinchingChanged(false) is never emitted. - if (m_multiTouchActive || m_gestureMode != GestureMode::Undecided) { - handleMultiTouchEnd(); - } - - // Disable the parent Flickable immediately — passive grabs aren't - // enough; SilicaFlickable steals the gesture before scroll-commit. - Q_EMIT requestParentInteractive(false); - m_multiTouchActive = true; - - // Cancel any active long-press / selection / handle drag - if (m_longPressTimerId) { - killTimer(m_longPressTimerId); - m_longPressTimerId = 0; - } - if (m_draggingHandle != 0) { - m_draggingHandle = 0; - m_magnifierVisible = false; - m_handlesVisible = true; - setKeepMouseGrab(false); - } - if (m_selecting) - clearSelection(); - - // Record initial positions - qreal avgY = (points[0].pos().y() + points[1].pos().y()) / 2.0; - m_twoFingerLastY = avgY; - - // TUI apps or pinch-to-zoom disabled: force scroll mode - if (m_vt->isMouseTracking() || !Settings::instance()->pinchToZoom()) { - m_gestureMode = GestureMode::Scrolling; - return; - } - - // Undecided — record initial geometry for later classification - QPointF p0 = points[0].pos(); - QPointF p1 = points[1].pos(); - m_pinchInitialDistance = QLineF(p0, p1).length(); - m_gestureInitialCentroid = QPointF((p0.x() + p1.x()) / 2.0, - (p0.y() + p1.y()) / 2.0); - m_gestureMode = GestureMode::Undecided; - m_pinchCandidateFrames = 0; -} - -void TerminalView::handleMultiTouchUpdate(const QList &points) -{ - // If any touch point was released, end the gesture immediately. Qt may - // deliver a TouchUpdate with a released point before TouchEnd arrives. - // Without this, the gesture continues processing a stale finger position - // and the overlay may not hide if the final TouchEnd is also missed. - for (const auto &p : points) { - if (p.state() & Qt::TouchPointReleased) { - handleMultiTouchEnd(); - return; - } - } - - QPointF p0 = points[0].pos(); - QPointF p1 = points[1].pos(); - qreal currentDistance = QLineF(p0, p1).length(); - QPointF currentCentroid((p0.x() + p1.x()) / 2.0, - (p0.y() + p1.y()) / 2.0); - - switch (m_gestureMode) { - case GestureMode::Undecided: { - // --- Check for pinch classification --- - qreal distanceRatio = (m_pinchInitialDistance > 0) - ? currentDistance / m_pinchInitialDistance : 1.0; - bool ratioExceeded = (distanceRatio > PinchRatioThreshold) - || (distanceRatio < 1.0 / PinchRatioThreshold); - - if (ratioExceeded) { - m_pinchCandidateFrames++; - if (m_pinchCandidateFrames >= PinchRatioFrames) { - // Commit to pinch mode - m_gestureMode = GestureMode::Pinching; - m_pinchBaseFontSize = m_fontSize; - m_lastAppliedFontSize = m_fontSize; - // Reset baseline so scale starts at 1.0 at commitment - m_pinchInitialDistance = currentDistance; - m_pinchAtDefault = false; - Q_EMIT pinchingChanged(true); - return; - } - } else { - m_pinchCandidateFrames = 0; - } - - // --- Check for scroll classification --- - QPointF centroidDelta = currentCentroid - m_gestureInitialCentroid; - if (qAbs(centroidDelta.y()) > ScrollMinDistancePx - && qAbs(centroidDelta.y()) > qAbs(centroidDelta.x())) { - // Commit to scroll mode - m_gestureMode = GestureMode::Scrolling; - // Reset lastY to current centroid so first scroll delta is clean - m_twoFingerLastY = currentCentroid.y(); - return; - } - - return; - } - - case GestureMode::Pinching: { - qreal scale = (m_pinchInitialDistance > 0) - ? currentDistance / m_pinchInitialDistance : 1.0; - // Power-curve dampening: requires more finger travel for the same font - // delta. Exponent < 1 softens the response around scale=1.0 so small - // finger movements no longer produce large font jumps. - qreal dampedScale = std::pow(scale, PinchScaleExponent); - int rawTarget = qRound(m_pinchBaseFontSize * dampedScale); - - if (rawTarget <= 6) { - // Snap to global default — QML shows "Default", pinch-end stores 0 - int defaultSize = Settings::instance()->fontSize(); - if (defaultSize != m_lastAppliedFontSize) { - setFontSize(defaultSize); - m_lastAppliedFontSize = defaultSize; - } - if (!m_pinchAtDefault) { - m_pinchAtDefault = true; - Q_EMIT pinchAtDefaultChanged(true); - } - } else { - int targetSize = qBound(6, rawTarget, 32); - if (targetSize != m_lastAppliedFontSize) { - setFontSize(targetSize); - m_lastAppliedFontSize = targetSize; - } - if (m_pinchAtDefault) { - m_pinchAtDefault = false; - Q_EMIT pinchAtDefaultChanged(false); - } - } - return; - } - - case GestureMode::Scrolling: { - qreal avgY = (p0.y() + p1.y()) / 2.0; - qreal deltaY = avgY - m_twoFingerLastY; - m_twoFingerLastY = avgY; - - qreal newDelta = -deltaY / m_cellHeight; - auto touchScrollResult = TextUtil::accumulateScroll(m_touchScrollAccumulator, newDelta); - m_touchScrollAccumulator = touchScrollResult.accumulator; - int lines = touchScrollResult.lines; - - if (lines != 0) { - GhosttyTerminalScrollViewport scroll = {}; - scroll.tag = GHOSTTY_SCROLL_VIEWPORT_DELTA; - scroll.value.delta = lines; - ghostty_terminal_scroll_viewport(m_vt->terminal(), scroll); - m_linkScanDirty = true; - update(); - } - return; - } - } -} - -void TerminalView::handleMultiTouchEnd() -{ - if (m_gestureMode == GestureMode::Pinching) { - Q_EMIT pinchingChanged(false); - if (m_pinchAtDefault) { - m_pinchAtDefault = false; - Q_EMIT pinchAtDefaultChanged(false); - } - } - - // Restore the parent SilicaFlickable so single-finger pull-down works again. - Q_EMIT requestParentInteractive(true); - - m_gestureMode = GestureMode::Undecided; - m_pinchCandidateFrames = 0; - m_multiTouchActive = false; - m_twoFingerLastY = 0; - m_touchScrollAccumulator = 0; - setKeepMouseGrab(false); - setKeepTouchGrab(false); -} - -void TerminalView::timerEvent(QTimerEvent *event) -{ - if (event->timerId() == m_longPressTimerId) { - killTimer(m_longPressTimerId); - m_longPressTimerId = 0; - m_selecting = true; - m_magnifierVisible = true; - // Prevent parent SilicaFlickable from stealing the drag - setKeepMouseGrab(true); - update(); - return; - } - if (event->timerId() == m_blinkTimerId) { - if (m_lastInputTime.isValid() && - m_lastInputTime.elapsed() < BlinkPauseMs) { - m_cursorBlinkVisible = true; - update(); - return; - } - - // Blink cursor by default. Only stop when terminal explicitly requests - // a steady cursor (DECSCUSR mode 2, 4, or 6). - GhosttyRenderState state = m_vt ? m_vt->renderState() : nullptr; - bool cursorBlinking = true; - if (state) { - ghostty_render_state_get(state, - GHOSTTY_RENDER_STATE_DATA_CURSOR_BLINKING, - &cursorBlinking); - } - if (cursorBlinking) { - m_cursorBlinkVisible = !m_cursorBlinkVisible; - update(); - } - return; - } - QQuickItem::timerEvent(event); -} - void TerminalView::sendKeyEvent(GhosttyKey key, GhosttyKeyAction action, GhosttyMods mods, const QString &text) { @@ -1770,231 +1043,6 @@ void TerminalView::suppressNextKeyboardAutoShow() m_suppressKeyboardAutoShow = true; } -void TerminalView::openSearch() -{ - if (m_searchActive) - return; - - m_searchActive = true; - - if (m_vt) { - m_searchCache = m_vt->extractSearchText(); - buildCellMapping(); - } - - clearSelection(); -} - -void TerminalView::closeSearch() -{ - if (!m_searchActive) - return; - - m_searchActive = false; - m_searchPattern.clear(); - m_searchCache.clear(); - m_cellMapping.clear(); - m_searchMatches.clear(); - m_currentMatchIndex = -1; - update(); - Q_EMIT searchMatchCountChanged(); - Q_EMIT currentMatchIndexChanged(); -} - -void TerminalView::buildCellMapping() -{ - // Build cell-to-character index mapping for CJK/emoji support. - // Wide characters (CJK, some emoji) occupy 2 terminal cells but - // produce 1 character in the QString. Without this mapping, search - // highlight positions would be wrong for non-ASCII text. - m_cellMapping.clear(); - m_cellMapping.reserve(m_searchCache.size()); - size_t totalRows = 0; - uint16_t cols = 0; - GhosttyTerminal terminal = m_vt ? m_vt->terminal() : nullptr; - if (terminal) { - ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_TOTAL_ROWS, &totalRows); - ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_COLS, &cols); - } - if (!terminal || cols == 0) { - for (int row = 0; row < m_searchCache.size(); row++) - m_cellMapping.append(QVector()); - return; - } - for (int row = 0; row < m_searchCache.size(); row++) { - QVector mapping; - if (row < static_cast(totalRows)) { - mapping.resize(static_cast(cols)); - int charIdx = 0; - const QString &line = m_searchCache[row]; - // Use the wide-spacer cache from extractSearchText() when available, - // avoiding redundant ghostty_terminal_grid_ref calls per cell. - const QVector> &spacers = m_vt->wideSpacerCache(); - bool hasSpacerCache = (row < spacers.size() - && spacers[row].size() == static_cast(cols)); - for (int cell = 0; cell < static_cast(cols); cell++) { - mapping[cell] = charIdx; - bool isSpacer = hasSpacerCache - ? spacers[row][cell] - : GhosttyVt::isWideCharSpacer(terminal, static_cast(cell), static_cast(row)); - if (isSpacer) { - continue; - } - if (charIdx < line.size()) - charIdx++; - } - } - m_cellMapping.append(mapping); - } -} - -void TerminalView::setSearchPattern(const QString &pattern) -{ - if (pattern == m_searchPattern) - return; - - m_searchPattern = pattern; - - // Re-extract if cache is empty or terminal received new data since last extract - if (m_vt && (m_searchCache.isEmpty() || m_vt->isSearchTextDirty())) { - m_searchCache = m_vt->extractSearchText(); - buildCellMapping(); - } - - performSearch(); - - if (m_currentMatchIndex >= 0) - scrollToMatch(m_currentMatchIndex); - - update(); -} - -void TerminalView::performSearch() -{ - m_searchMatches.clear(); - m_currentMatchIndex = -1; - - if (m_searchPattern.isEmpty() || m_searchCache.isEmpty()) { - Q_EMIT searchMatchCountChanged(); - Q_EMIT currentMatchIndexChanged(); - return; - } - - // Case-insensitive search across all rows (capped to prevent OOM) - static const int MaxSearchMatches = 10000; - bool searchDone = false; - for (int row = 0; row < m_searchCache.size() && !searchDone; row++) { - int col = 0; - const QString &line = m_searchCache[row]; - while (col < line.size()) { - int idx = line.indexOf(m_searchPattern, col, Qt::CaseInsensitive); - if (idx < 0) - break; - - // Map character index to cell column using the cell mapping. - // For pure ASCII, cellCol == idx. For CJK/emoji, the mapping - // accounts for wide characters occupying 2 cells. - int cellCol = idx; - int cellWidth = m_searchPattern.size(); - if (row < m_cellMapping.size() && !m_cellMapping[row].isEmpty()) { - const QVector &mapping = m_cellMapping[row]; - // Find cell column for the start of the match - for (int cell = 0; cell < mapping.size(); cell++) { - if (mapping[cell] == idx) { - cellCol = cell; - break; - } - } - // Find cell width: count cells from cellCol that span the match characters - int matchEnd = idx + m_searchPattern.size(); - cellWidth = 0; - for (int cell = cellCol; cell < mapping.size(); cell++) { - if (mapping[cell] >= matchEnd) - break; - cellWidth++; - } - // Extend cellWidth past a trailing wide-char spacer tail: the spacer - // shares the next cell's charIdx in the mapping (spacers don't advance it). - // Note: assumes TAIL spacers (CJK/emoji wide chars); HEAD spacers (rare RTL) - // would also satisfy mapping[tail]==mapping[tail+1] and may over-extend. - int tail = cellCol + cellWidth; - if (tail < mapping.size() && tail + 1 < mapping.size() - && mapping[tail] == mapping[tail + 1]) { - cellWidth++; - } - if (cellWidth == 0) - cellWidth = 1; // safety - } - - m_searchMatches.append({row, cellCol, cellWidth}); - if (m_searchMatches.size() >= MaxSearchMatches) { - searchDone = true; - break; - } - col = idx + 1; - } - } - - if (!m_searchMatches.isEmpty()) - m_currentMatchIndex = 0; - - Q_EMIT searchMatchCountChanged(); - Q_EMIT currentMatchIndexChanged(); -} - -void TerminalView::scrollToMatch(int index) -{ - if (index < 0 || index >= m_searchMatches.size() || !m_vt || !m_vt->terminal()) - return; - - const auto &match = m_searchMatches[index]; - - GhosttyTerminalScrollbar scrollbar = {}; - ghostty_terminal_get(m_vt->terminal(), GHOSTTY_TERMINAL_DATA_SCROLLBAR, &scrollbar); - - int matchRow = match.row; - int viewTop = static_cast(scrollbar.offset); - int viewLen = static_cast(scrollbar.len); - - if (viewLen > 0 && matchRow >= viewTop && matchRow < viewTop + viewLen) - return; - - int targetTop = matchRow - viewLen / 2; - if (targetTop < 0) - targetTop = 0; - int delta = targetTop - viewTop; - - if (delta != 0) { - GhosttyTerminalScrollViewport scroll = {}; - scroll.tag = GHOSTTY_SCROLL_VIEWPORT_DELTA; - scroll.value.delta = delta; - ghostty_terminal_scroll_viewport(m_vt->terminal(), scroll); - update(); - } -} - -void TerminalView::findNext() -{ - if (m_searchMatches.isEmpty()) - return; - - m_currentMatchIndex = (m_currentMatchIndex + 1) % m_searchMatches.size(); - scrollToMatch(m_currentMatchIndex); - Q_EMIT currentMatchIndexChanged(); - update(); -} - -void TerminalView::findPrevious() -{ - if (m_searchMatches.isEmpty()) - return; - - m_currentMatchIndex = (m_currentMatchIndex - 1 + m_searchMatches.size()) % m_searchMatches.size(); - scrollToMatch(m_currentMatchIndex); - Q_EMIT currentMatchIndexChanged(); - update(); -} - void TerminalView::runAutorunCommand() { if (m_autorunCommand.isEmpty() || !m_pty @@ -2020,191 +1068,3 @@ void TerminalView::scrollViewportToBottom() } } -// --------------------------------------------------------------------------- -// Link detection — OSC 8 hyperlinks + regex URL scanning -// --------------------------------------------------------------------------- - -void TerminalView::refreshLinks() -{ - if (!m_vt || !m_vt->terminal() || m_cols == 0 || m_rows == 0) { - m_linkScanDirty = false; - return; - } - - // When URL auto-detection is disabled, skip regex scanning. - // OSC 8 hyperlinks still work — they're resolved independently - // via getHyperlinkAt() in findLinkAt(). - if (!Settings::instance()->urlAutoDetect()) { - m_currentLinks.clear(); - m_linkScanDirty = false; - return; - } - - GhosttyRenderState state = m_vt->renderState(); - if (!state) { - m_linkScanDirty = false; - return; - } - - m_currentLinks.clear(); - - // Serialize visible viewport to flat text + QChar→cell coordinate map. - // This mirrors Ghostty's renderer/link.zig approach: build a contiguous - // string from the visible cells, then run the regex on it, and map match - // offsets back to cell coordinates. - // - // Uses codepoint-based approach (GRAPHEMES_BUF) to build a QString directly, - // avoiding the GRAPHEMES_UTF8 two-call pattern and ensuring QChar indices - // from QRegularExpression match charMap positions exactly. - - QString flatText; - flatText.reserve(static_cast(m_rows * m_cols)); - - // charMap[i] = {col, row} for QChar position i in flatText - QVector charMap; - charMap.reserve(static_cast(m_rows * m_cols)); - - // Iterate visible viewport using the render state row iterator - GhosttyRenderStateRowIterator iterator; - ghostty_render_state_row_iterator_new(nullptr, &iterator); - ghostty_render_state_get(state, GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR, &iterator); - - GhosttyRenderStateRowCells cells; - ghostty_render_state_row_cells_new(nullptr, &cells); - - uint16_t rowIdx = 0; - while (ghostty_render_state_row_iterator_next(iterator)) { - ghostty_render_state_row_get(iterator, - GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, - &cells); - - // Check if this row is soft-wrapped (don't insert \n for wrapped rows) - GhosttyRow rawRow = 0; - bool isWrapped = false; - if (ghostty_render_state_row_get(iterator, - GHOSTTY_RENDER_STATE_ROW_DATA_RAW, - &rawRow) == GHOSTTY_SUCCESS) { - ghostty_row_get(rawRow, GHOSTTY_ROW_DATA_WRAP, &isWrapped); - } - - uint16_t colIdx = 0; - while (ghostty_render_state_row_cells_next(cells)) { - // Skip wide char spacer via render state (not grid_ref) - GhosttyCell rawCell = 0; - if (ghostty_render_state_row_cells_get( - cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW, - &rawCell) == GHOSTTY_SUCCESS - && GhosttyVt::isWideSpacerCell(rawCell)) { - colIdx++; - continue; - } - - uint32_t graphemeLen = 0; - if (ghostty_render_state_row_cells_get( - cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN, - &graphemeLen) == GHOSTTY_SUCCESS && graphemeLen > 0 - && graphemeLen <= 128) { - uint32_t graphemes[128] = {}; - if (ghostty_render_state_row_cells_get( - cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF, - graphemes) == GHOSTTY_SUCCESS) { - for (uint32_t g = 0; g < graphemeLen; ++g) { - uint32_t cp = graphemes[g]; - if (cp > 0xFFFF) { - // Supplementary plane — surrogate pair (2 QChars) - flatText.append(QChar(static_cast( - 0xD800 + ((cp - 0x10000) >> 10)))); - charMap.append({colIdx, rowIdx}); - flatText.append(QChar(static_cast( - 0xDC00 + ((cp - 0x10000) & 0x3FF)))); - charMap.append({colIdx, rowIdx}); - } else { - flatText.append(QChar(static_cast(cp))); - charMap.append({colIdx, rowIdx}); - } - } - } else { - flatText.append(QChar(' ')); - charMap.append({colIdx, rowIdx}); - } - } else { - flatText.append(QChar(' ')); - charMap.append({colIdx, rowIdx}); - } - colIdx++; - } - - if (!isWrapped && rowIdx + 1 < m_rows) { - flatText.append(QChar('\n')); - charMap.append({static_cast(m_cols), rowIdx}); // sentinel - } - - rowIdx++; - } - - ghostty_render_state_row_cells_free(cells); - ghostty_render_state_row_iterator_free(iterator); - - // Run regex on the flat text — match.capturedStart()/capturedEnd() are - // QChar indices that directly index into charMap. - if (flatText.isEmpty()) { - m_linkScanDirty = false; - return; - } - - m_currentLinks = TextUtil::findUrls(flatText, charMap); - - // Filter out URLs on the cursor's viewport row — the cursor row is - // where user input appears, and typed URLs should not be clickable. - // Only output (non-input) rows should have interactive links. - // Note: OSC 8 hyperlinks (application-emitted) on the cursor row are - // intentionally NOT filtered — they carry explicit metadata, unlike - // regex-detected URLs which are a heuristic. - bool cursorInView = false; - uint16_t cursorViewportY = 0; - ghostty_render_state_get(state, - GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_HAS_VALUE, - &cursorInView); - if (cursorInView) { - ghostty_render_state_get(state, - GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_Y, - &cursorViewportY); - m_currentLinks.erase( - std::remove_if(m_currentLinks.begin(), m_currentLinks.end(), - [cursorViewportY](const TextUtil::LinkSpan &span) { - return cursorViewportY >= span.startRow - && cursorViewportY <= span.endRow; - }), - m_currentLinks.end()); - } - - m_linkScanDirty = false; -} - -QString TerminalView::findRegexLinkAt(int col, int row) const -{ - for (int i = 0; i < m_currentLinks.size(); ++i) { - const TextUtil::LinkSpan &span = m_currentLinks[i]; - if (row < span.startRow || row > span.endRow) - continue; - if (row == span.startRow && col < span.startCol) - continue; - if (row == span.endRow && col >= span.endCol) - continue; - return span.uri; - } - return {}; -} - -QString TerminalView::findLinkAt(int col, int row) -{ - // OSC 8 hyperlinks take priority (explicit application metadata) - if (m_vt) { - QString uri = m_vt->getHyperlinkAt(static_cast(col), - static_cast(row)); - if (!uri.isEmpty()) - return uri; - } - // Fall back to regex-detected URLs - return findRegexLinkAt(col, row); -} diff --git a/src/terminalview_links.cpp b/src/terminalview_links.cpp new file mode 100644 index 0000000..569a1a1 --- /dev/null +++ b/src/terminalview_links.cpp @@ -0,0 +1,188 @@ +#include "terminalview.h" +#include "settings.h" +#include "textutil.h" + +#include + +namespace { +constexpr uint32_t kMaxGraphemesPerCell = 128; // matches ghostty Cell.grapheme cap +} + +void TerminalView::refreshLinks() +{ + if (!m_vt || !m_vt->terminal() || m_cols == 0 || m_rows == 0) { + m_linkScanDirty = false; + return; + } + + // When URL auto-detection is disabled, skip regex scanning. + // OSC 8 hyperlinks still work — they're resolved independently + // via getHyperlinkAt() in findLinkAt(). + if (!Settings::instance()->urlAutoDetect()) { + m_currentLinks.clear(); + m_linkScanDirty = false; + return; + } + + GhosttyRenderState state = m_vt->renderState(); + if (!state) { + m_linkScanDirty = false; + return; + } + + m_currentLinks.clear(); + + // Serialize visible viewport to flat text + QChar→cell map, mirroring + // Ghostty's renderer/link.zig: build one string, run regex, map offsets + // back to cells. + // + // Uses GRAPHEMES_BUF (codepoints) rather than GRAPHEMES_UTF8 so QChar + // indices from QRegularExpression line up with charMap positions. + + QString flatText; + flatText.reserve(static_cast(m_rows * m_cols)); + + // charMap[i] = {col, row} for QChar position i in flatText + QVector charMap; + charMap.reserve(static_cast(m_rows * m_cols)); + + GhosttyRenderStateRowIterator iterator; + ghostty_render_state_row_iterator_new(nullptr, &iterator); + ghostty_render_state_get(state, GHOSTTY_RENDER_STATE_DATA_ROW_ITERATOR, &iterator); + + GhosttyRenderStateRowCells cells; + ghostty_render_state_row_cells_new(nullptr, &cells); + + uint16_t rowIdx = 0; + while (ghostty_render_state_row_iterator_next(iterator)) { + ghostty_render_state_row_get(iterator, + GHOSTTY_RENDER_STATE_ROW_DATA_CELLS, + &cells); + + // Soft-wrapped rows get no trailing \n (continuation of the line above) + GhosttyRow rawRow = 0; + bool isWrapped = false; + if (ghostty_render_state_row_get(iterator, + GHOSTTY_RENDER_STATE_ROW_DATA_RAW, + &rawRow) == GHOSTTY_SUCCESS) { + ghostty_row_get(rawRow, GHOSTTY_ROW_DATA_WRAP, &isWrapped); + } + + uint16_t colIdx = 0; + auto appendBlank = [&] { + flatText.append(QChar(' ')); + charMap.append({colIdx, rowIdx}); + }; + while (ghostty_render_state_row_cells_next(cells)) { + // Skip wide char spacer via render state (not grid_ref) + GhosttyCell rawCell = 0; + if (ghostty_render_state_row_cells_get( + cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW, + &rawCell) == GHOSTTY_SUCCESS + && GhosttyVt::isWideSpacerCell(rawCell)) { + colIdx++; + continue; + } + + uint32_t graphemeLen = 0; + if (ghostty_render_state_row_cells_get( + cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_LEN, + &graphemeLen) == GHOSTTY_SUCCESS && graphemeLen > 0 + && graphemeLen <= kMaxGraphemesPerCell) { + uint32_t graphemes[kMaxGraphemesPerCell] = {}; + if (ghostty_render_state_row_cells_get( + cells, GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_GRAPHEMES_BUF, + graphemes) == GHOSTTY_SUCCESS) { + for (uint32_t g = 0; g < graphemeLen; ++g) { + uint32_t cp = graphemes[g]; + if (cp > 0xFFFF) { + // Supplementary plane — surrogate pair (2 QChars) + flatText.append(QChar(static_cast( + 0xD800 + ((cp - 0x10000) >> 10)))); + charMap.append({colIdx, rowIdx}); + flatText.append(QChar(static_cast( + 0xDC00 + ((cp - 0x10000) & 0x3FF)))); + charMap.append({colIdx, rowIdx}); + } else { + flatText.append(QChar(static_cast(cp))); + charMap.append({colIdx, rowIdx}); + } + } + } else { + appendBlank(); + } + } else { + appendBlank(); + } + colIdx++; + } + + if (!isWrapped && rowIdx + 1 < m_rows) { + flatText.append(QChar('\n')); + charMap.append({static_cast(m_cols), rowIdx}); // sentinel + } + + rowIdx++; + } + + ghostty_render_state_row_cells_free(cells); + ghostty_render_state_row_iterator_free(iterator); + + // Run regex on the flat text — match.capturedStart()/capturedEnd() are + // QChar indices that directly index into charMap. + if (flatText.isEmpty()) { + m_linkScanDirty = false; + return; + } + + m_currentLinks = TextUtil::findUrls(flatText, charMap); + + // Drop regex-detected URLs on the cursor row — typed input shouldn't + // be clickable. OSC 8 hyperlinks are exempt (explicit app metadata, + // resolved in findLinkAt()). + bool cursorInView = false; + uint16_t cursorViewportY = 0; + ghostty_render_state_get(state, + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_HAS_VALUE, + &cursorInView); + if (cursorInView) { + ghostty_render_state_get(state, + GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_Y, + &cursorViewportY); + m_currentLinks.erase( + std::remove_if(m_currentLinks.begin(), m_currentLinks.end(), + [cursorViewportY](const TextUtil::LinkSpan &span) { + return cursorViewportY >= span.startRow + && cursorViewportY <= span.endRow; + }), + m_currentLinks.end()); + } + + m_linkScanDirty = false; +} + +QString TerminalView::findRegexLinkAt(int col, int row) const +{ + for (const TextUtil::LinkSpan &span : m_currentLinks) { + if (row < span.startRow || row > span.endRow) + continue; + if (row == span.startRow && col < span.startCol) + continue; + if (row == span.endRow && col >= span.endCol) + continue; + return span.uri; + } + return {}; +} + +QString TerminalView::findLinkAt(int col, int row) +{ + // OSC 8 hyperlinks take priority (explicit application metadata) + if (m_vt) { + QString uri = m_vt->getHyperlinkAt(static_cast(col), + static_cast(row)); + if (!uri.isEmpty()) + return uri; + } + return findRegexLinkAt(col, row); +} diff --git a/src/terminalview_search.cpp b/src/terminalview_search.cpp new file mode 100644 index 0000000..6dd5a88 --- /dev/null +++ b/src/terminalview_search.cpp @@ -0,0 +1,224 @@ +#include "terminalview.h" +#include + +namespace { +// Upper bound on collected search matches to prevent unbounded memory growth +// on pathological terminal output. 10000 matches × ~12 bytes ≈ 120 KB. +constexpr int kMaxSearchMatches = 10000; +} + +void TerminalView::openSearch() +{ + if (m_searchActive) + return; + + m_searchActive = true; + + if (m_vt) { + m_searchCache = m_vt->extractSearchText(); + buildCellMapping(); + } + + clearSelection(); +} + +void TerminalView::closeSearch() +{ + if (!m_searchActive) + return; + + m_searchActive = false; + m_searchPattern.clear(); + m_searchCache.clear(); + m_cellMapping.clear(); + m_searchMatches.clear(); + m_currentMatchIndex = -1; + update(); + Q_EMIT searchMatchCountChanged(); + Q_EMIT currentMatchIndexChanged(); +} + +void TerminalView::buildCellMapping() +{ + // Build cell-to-character index mapping for CJK/emoji support. + // Wide chars (CJK/emoji) take 2 cells but produce 1 QChar; + // without this mapping, search highlights would be wrong. + m_cellMapping.clear(); + m_cellMapping.reserve(m_searchCache.size()); + size_t totalRows = 0; + uint16_t cols = 0; + GhosttyTerminal terminal = m_vt ? m_vt->terminal() : nullptr; + if (terminal) { + ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_TOTAL_ROWS, &totalRows); + ghostty_terminal_get(terminal, GHOSTTY_TERMINAL_DATA_COLS, &cols); + } + if (!terminal || cols == 0) { + m_cellMapping.resize(m_searchCache.size()); + return; + } + const int colsInt = static_cast(cols); + for (int row = 0; row < m_searchCache.size(); row++) { + QVector mapping; + if (row < static_cast(totalRows)) { + mapping.resize(colsInt); + int charIdx = 0; + const QString &line = m_searchCache[row]; + // Use the wide-spacer cache from extractSearchText() when available, + // avoiding redundant ghostty_terminal_grid_ref calls per cell. + const QVector> &spacers = m_vt->wideSpacerCache(); + bool hasSpacerCache = (row < spacers.size() + && spacers[row].size() == colsInt); + for (int cell = 0; cell < colsInt; cell++) { + mapping[cell] = charIdx; + bool isSpacer = hasSpacerCache + ? spacers[row][cell] + : GhosttyVt::isWideCharSpacer(terminal, static_cast(cell), static_cast(row)); + if (isSpacer) { + continue; + } + if (charIdx < line.size()) + charIdx++; + } + } + m_cellMapping.append(mapping); + } +} + +void TerminalView::setSearchPattern(const QString &pattern) +{ + if (pattern == m_searchPattern) + return; + + m_searchPattern = pattern; + + if (m_vt && (m_searchCache.isEmpty() || m_vt->isSearchTextDirty())) { + m_searchCache = m_vt->extractSearchText(); + buildCellMapping(); + } + + performSearch(); + + if (m_currentMatchIndex >= 0) + scrollToMatch(m_currentMatchIndex); + + update(); +} + +void TerminalView::performSearch() +{ + m_searchMatches.clear(); + m_currentMatchIndex = -1; + + if (m_searchPattern.isEmpty() || m_searchCache.isEmpty()) { + Q_EMIT searchMatchCountChanged(); + Q_EMIT currentMatchIndexChanged(); + return; + } + + bool searchDone = false; + for (int row = 0; row < m_searchCache.size() && !searchDone; row++) { + int col = 0; + const QString &line = m_searchCache[row]; + while (col < line.size()) { + int idx = line.indexOf(m_searchPattern, col, Qt::CaseInsensitive); + if (idx < 0) + break; + + int cellCol = idx; // ASCII default; mapping below adjusts for wide chars. + int cellWidth = m_searchPattern.size(); + if (row < m_cellMapping.size() && !m_cellMapping[row].isEmpty()) { + const QVector &mapping = m_cellMapping[row]; + for (int cell = 0; cell < mapping.size(); cell++) { + if (mapping[cell] == idx) { + cellCol = cell; + break; + } + } + int matchEnd = idx + m_searchPattern.size(); + cellWidth = 0; + for (int cell = cellCol; cell < mapping.size(); cell++) { + if (mapping[cell] >= matchEnd) + break; + cellWidth++; + } + // Extend cellWidth past a trailing wide-char spacer tail: the spacer + // shares the next cell's charIdx in the mapping (spacers don't advance it). + // Note: assumes TAIL spacers (CJK/emoji wide chars); HEAD spacers (rare RTL) + // would also satisfy mapping[tail]==mapping[tail+1] and may over-extend. + int tail = cellCol + cellWidth; + if (tail < mapping.size() && tail + 1 < mapping.size() + && mapping[tail] == mapping[tail + 1]) { + cellWidth++; + } + if (cellWidth == 0) + cellWidth = 1; + } + + m_searchMatches.append({row, cellCol, cellWidth}); + if (m_searchMatches.size() >= kMaxSearchMatches) { + searchDone = true; + break; + } + col = idx + 1; + } + } + + if (!m_searchMatches.isEmpty()) + m_currentMatchIndex = 0; + + Q_EMIT searchMatchCountChanged(); + Q_EMIT currentMatchIndexChanged(); +} + +void TerminalView::scrollToMatch(int index) +{ + if (index < 0 || index >= m_searchMatches.size() || !m_vt || !m_vt->terminal()) + return; + + const auto &match = m_searchMatches[index]; + + GhosttyTerminalScrollbar scrollbar = {}; + ghostty_terminal_get(m_vt->terminal(), GHOSTTY_TERMINAL_DATA_SCROLLBAR, &scrollbar); + + int matchRow = match.row; + int viewTop = static_cast(scrollbar.offset); + int viewLen = static_cast(scrollbar.len); + + if (viewLen > 0 && matchRow >= viewTop && matchRow < viewTop + viewLen) + return; + + // Center the match row in the viewport. + const int halfView = viewLen / 2; + const int targetTop = std::max(0, matchRow - halfView); + const int delta = targetTop - viewTop; + + if (delta != 0) { + GhosttyTerminalScrollViewport scroll = {}; + scroll.tag = GHOSTTY_SCROLL_VIEWPORT_DELTA; + scroll.value.delta = delta; + ghostty_terminal_scroll_viewport(m_vt->terminal(), scroll); + update(); + } +} + +void TerminalView::findNext() +{ + if (m_searchMatches.isEmpty()) + return; + + m_currentMatchIndex = (m_currentMatchIndex + 1) % m_searchMatches.size(); + scrollToMatch(m_currentMatchIndex); + Q_EMIT currentMatchIndexChanged(); + update(); +} + +void TerminalView::findPrevious() +{ + if (m_searchMatches.isEmpty()) + return; + + m_currentMatchIndex = (m_currentMatchIndex - 1 + m_searchMatches.size()) % m_searchMatches.size(); + scrollToMatch(m_currentMatchIndex); + Q_EMIT currentMatchIndexChanged(); + update(); +} diff --git a/src/terminalview_touch.cpp b/src/terminalview_touch.cpp new file mode 100644 index 0000000..7b0398f --- /dev/null +++ b/src/terminalview_touch.cpp @@ -0,0 +1,723 @@ +#include "terminalview.h" +#include "settings.h" +#include "keymapping.h" + +#include +#include +#include + +namespace { +// Qt wheel delta is in 1/8° units; 120 units = 15° = 3 lines → 40 units/line. +constexpr qreal kWheelUnitsPerLine = 40.0; +} // namespace + +void TerminalView::resetSessionSwipe() +{ + if (!m_sessionSwiping) + return; + m_sessionSwiping = false; + setKeepMouseGrab(false); + setKeepTouchGrab(false); + Q_EMIT sessionSwipeCancelled(); +} + +void TerminalView::resetTouchInteractionState() +{ + if (m_longPressTimerId) { + killTimer(m_longPressTimerId); + m_longPressTimerId = 0; + } + if (m_selecting) + clearSelection(); + m_pendingLinkTap = false; + m_draggingHandle = 0; +} + +void TerminalView::mousePressEvent(QMouseEvent *event) +{ + // A new press always supersedes a stale swipe (e.g. after a TouchCancel + // that never delivered a release). + resetSessionSwipe(); + + if (m_shellExited) { + restartShell(); + event->accept(); + return; + } + + if (event->button() == Qt::LeftButton) { + m_cursorBlinkVisible = true; + m_lastInputTime.start(); + + m_mouseTrackingActive = m_vt->isMouseTracking(); + + if (m_mouseTrackingActive) { + // Safety net: reject touches in the pull-down zone. In practice, + // touchEvent accepts TUI touches before synthesis, so this is + // rarely reached. + if (event->pos().y() < m_pullDownZoneHeight) { + QQuickItem::mousePressEvent(event); + return; + } + + sendMouseEvent(GHOSTTY_MOUSE_ACTION_PRESS, GHOSTTY_MOUSE_BUTTON_LEFT, + event->pos(), KeyMapping::mapQtModifiers(event->modifiers())); + + // Enables motion-event delivery on subsequent drag + m_mouseButtonPressed = true; + m_vt->setMouseButtonPressed(true); + + // Prevent SilicaFlickable from stealing drag gestures + setKeepMouseGrab(true); + event->accept(); + return; + } + + // Order matters: handle-tap must precede tap-count reset and selection clearing + int handle = handleHitTest(event->pos()); + if (handle != 0) { + m_draggingHandle = handle; + m_handlesVisible = false; + m_magnifierVisible = true; + m_tapCount = 0; // Prevent phantom triple-tap after handle drag + setKeepMouseGrab(true); + event->accept(); + return; + } + + // Defer link open to release so a drag abandons it cleanly + { + QPointF cell = cellFromPixel(event->pos()); + if (cell.x() >= 0 && cell.y() >= 0) { + QString uri = findLinkAt(static_cast(cell.x()), + static_cast(cell.y())); + if (!uri.isEmpty()) { + m_pendingLinkTap = true; + m_tappedLinkUri = uri; + m_linkTapStartPos = event->pos(); + setKeepMouseGrab(true); + event->accept(); + return; + } + } + } + + qint64 now = QDateTime::currentMSecsSinceEpoch(); + qreal dist = QLineF(event->pos(), m_lastTapPos).length(); + bool withinWindow = (m_tapCount > 0) + && (now - m_lastTapTime) <= TapTimeoutMs + && dist <= TapDistancePx; + + if (withinWindow) { + m_tapCount = qMin(m_tapCount + 1, 3); + } else { + m_tapCount = 1; + } + m_lastTapTime = now; + m_lastTapPos = event->pos(); + + if (m_tapCount == 2) { + clearSelection(); + selectWordAt(event->pos()); + event->accept(); + return; + } + if (m_tapCount == 3) { + clearSelection(); + selectLineAt(event->pos()); + event->accept(); + return; + } + + clearSelection(); + m_selStart = event->pos(); + m_selEnd = event->pos(); + m_longPressTimerId = startTimer(LongPressTimeout); + event->accept(); + return; + } + QQuickItem::mousePressEvent(event); +} + +void TerminalView::mouseMoveEvent(QMouseEvent *event) +{ + if (m_draggingHandle != 0) { + if (m_draggingHandle == 1) + m_selStart = event->pos(); + else + m_selEnd = event->pos(); + + // Magnifier stays visible during handle drags — no velocity-based hiding. + // It was set visible on mousePress and should remain so until release. + + m_lastInputTime.start(); + + update(); + event->accept(); + return; + } + + if (m_selecting) { + // Only track movement for long-press drags (handles not yet visible). + // Word/line selections have finalized endpoints — use handles to adjust. + if (!m_handlesVisible) { + m_selEnd = event->pos(); + } + + // Magnifier stays visible for the whole drag — no velocity-based hiding. + // Visibility is bracketed by timerEvent (show on long-press fire) and + // mouseReleaseEvent (hide on release). Velocity gating caused flicker + // (hysteresis band sat inside typical drag velocity) and stuck-invisible + // when the finger stopped mid-drag (no move events to revive it). + + // Keep cursor blink paused during active selection to prevent + // full redraws that cause magnifier flicker + m_lastInputTime.start(); + + update(); + event->accept(); + return; + } + + if (m_mouseTrackingActive) { + GhosttyMouseButton btn = m_mouseButtonPressed + ? GHOSTTY_MOUSE_BUTTON_LEFT : GHOSTTY_MOUSE_BUTTON_UNKNOWN; + sendMouseEvent(GHOSTTY_MOUSE_ACTION_MOTION, btn, + event->pos(), KeyMapping::mapQtModifiers(event->modifiers())); + event->accept(); + return; + } + + // Session-swipe classifier — runs only after every grabbing branch above + // has returned (NORMAL single-finger, pre-selection window). + if (!m_pendingLinkTap && !m_multiTouchActive && m_sessionSwipeEnabled + && m_gestureMode == GestureMode::Undecided && !m_sessionSwiping) { + QPointF delta = event->pos() - m_lastTapPos; // m_lastTapPos set in press + if (qAbs(delta.x()) > SwipeMinHorizontalPx + && qAbs(delta.x()) > qAbs(delta.y()) * SwipeDominanceRatio) { + if (m_longPressTimerId) { killTimer(m_longPressTimerId); m_longPressTimerId = 0; } + m_sessionSwiping = true; + m_swipeStartX = event->pos().x(); + // Mirror the multitouch pattern — lock BOTH mouse and touch grab so + // terminalFlickable can't steal the sequence mid-swipe (on Qt 5.6 / + // Sailfish, mouse grab alone does NOT stop touch stealing). + setKeepMouseGrab(true); + setKeepTouchGrab(true); + Q_EMIT sessionSwipeStarted(); + } + } + if (m_sessionSwiping) { + Q_EMIT sessionSwipeProgress(event->pos().x() - m_swipeStartX); + event->accept(); + return; + } + + QQuickItem::mouseMoveEvent(event); +} + +void TerminalView::mouseReleaseEvent(QMouseEvent *event) +{ + if (m_longPressTimerId) { + killTimer(m_longPressTimerId); + m_longPressTimerId = 0; + } + + if (m_sessionSwiping) { + m_sessionSwiping = false; + setKeepMouseGrab(false); + setKeepTouchGrab(false); + qreal dx = event->pos().x() - m_swipeStartX; + if (qAbs(dx) > width() * SwipeCommitFraction) { + Q_EMIT sessionSwipeCommitted(dx < 0 ? 1 : -1); // leftward → next session + } else { + Q_EMIT sessionSwipeCancelled(); + } + event->accept(); + return; + } + + if (m_mouseTrackingActive) { + // Re-check live tracking state — the app may have disabled mouse + // tracking between press and release (e.g. htop exiting to shell). + if (m_vt->isMouseTracking()) { + sendMouseEvent(GHOSTTY_MOUSE_ACTION_RELEASE, GHOSTTY_MOUSE_BUTTON_LEFT, + event->pos(), KeyMapping::mapQtModifiers(event->modifiers())); + } + + m_mouseButtonPressed = false; + m_vt->setMouseButtonPressed(false); + m_mouseTrackingActive = false; + setKeepMouseGrab(false); + + event->accept(); + return; + } + + // Only on clean tap — significant drag abandons the link + if (m_pendingLinkTap) { + qreal dragDist = QLineF(m_linkTapStartPos, event->pos()).length(); + m_pendingLinkTap = false; + setKeepMouseGrab(false); + QString uri = m_tappedLinkUri; + m_tappedLinkUri.clear(); + if (dragDist < TapDistancePx && !uri.isEmpty()) { + Q_EMIT linkActivated(uri); + event->accept(); + return; + } + // Fall through to normal release handling if finger moved + } + + if (m_draggingHandle != 0) { + if (m_draggingHandle == 1) + m_selStart = event->pos(); + else + m_selEnd = event->pos(); + m_draggingHandle = 0; + m_magnifierVisible = false; + m_handlesVisible = true; + setKeepMouseGrab(false); + copySelection(); + update(); + event->accept(); + return; + } + + if (m_selecting) { + // Only update endpoint for long-press drags (handles not yet visible). + // Word/line selections already finalized their endpoints. + if (!m_handlesVisible) { + m_selEnd = event->pos(); + // Cancel if finger didn't move enough — long-press without drag + // would create a phantom single-character selection + if (QLineF(m_selStart, m_selEnd).length() < TapDistancePx) { + clearSelection(); + event->accept(); + return; + } + copySelection(); + } + m_magnifierVisible = false; + m_handlesVisible = true; + update(); + event->accept(); + return; + } + // Release mouse grab acquired by touchEvent multi-touch/TUI path. + setKeepMouseGrab(false); + QQuickItem::mouseReleaseEvent(event); +} + +void TerminalView::wheelEvent(QWheelEvent *event) +{ + if (!m_vt || !m_vt->terminal()) { + QQuickItem::wheelEvent(event); + return; + } + + // When mouse tracking is active, forward scroll as mouse buttons 4/5 + if (m_vt->isMouseTracking()) { + int delta = event->angleDelta().y(); + GhosttyMods mods = KeyMapping::mapQtModifiers(event->modifiers()); + GhosttyMouseButton button = (delta > 0) ? GHOSTTY_MOUSE_BUTTON_FOUR + : GHOSTTY_MOUSE_BUTTON_FIVE; + sendMouseEvent(GHOSTTY_MOUSE_ACTION_PRESS, button, event->pos(), mods); + sendMouseEvent(GHOSTTY_MOUSE_ACTION_RELEASE, button, event->pos(), mods); + event->accept(); + return; + } + + int delta = event->angleDelta().y(); // positive = up, negative = down + + // Accumulate fractional scroll lines so sub-line deltas aren't lost + qreal newDelta = -static_cast(delta) / kWheelUnitsPerLine; + auto scrollResult = TextUtil::accumulateScroll(m_scrollAccumulator, newDelta); + m_scrollAccumulator = scrollResult.accumulator; + int lines = scrollResult.lines; + + if (lines != 0) { + // Ghostty scroll: negative delta = scroll up (toward scrollback) + GhosttyTerminalScrollViewport scroll = {}; + scroll.tag = GHOSTTY_SCROLL_VIEWPORT_DELTA; + scroll.value.delta = -lines; + ghostty_terminal_scroll_viewport(m_vt->terminal(), scroll); + m_linkScanDirty = true; + update(); + } + + event->accept(); +} + +void TerminalView::touchEvent(QTouchEvent *event) +{ + if (!m_vt || !m_vt->terminal()) { + QQuickItem::touchEvent(event); + return; + } + + const auto points = event->touchPoints(); + + if (points.size() >= 2) { + setKeepMouseGrab(true); + // Qt 5.6: the touch grab is a SEPARATE mechanism from the mouse grab. + // SilicaFlickable (a filtering parent) steals the touch grab via its + // childMouseEventFilter — setKeepMouseGrab alone does NOT stop this. + // setKeepTouchGrab(true) denies the steal so two-finger scroll/pinch + // stays with the terminal instead of triggering the PullDownMenu. + setKeepTouchGrab(true); + + // ACTIVE grab — passive flags above only prevent future steals. + // SilicaFlickable ignores them once its drag recogniser has armed. + // grabTouchPoints()/grabMouse() wrest the grab back immediately. + { + QVector ids; + ids.reserve(points.size()); + for (const auto &p : points) + ids.append(p.id()); + grabTouchPoints(ids); + grabMouse(); + } + + switch (event->type()) { + case QEvent::TouchEnd: + case QEvent::TouchCancel: + handleMultiTouchEnd(); + break; + default: + // Start the gesture on the FIRST ≥2-point event of any type. + // When the second finger lands after the first, Qt delivers a + // TouchUpdate (not TouchBegin), so keying off the event type alone + // would skip handleMultiTouchBegin — and the Flickable would never + // be disabled, re-opening the PullDownMenu bug for staggered taps. + if (!m_multiTouchActive) + handleMultiTouchBegin(points); + else + handleMultiTouchUpdate(points); + break; + } + event->accept(); + return; + } + + // Check m_multiTouchActive too: a brief two-finger tap may never + // leave Undecided, but must still end (or the Flickable stays disabled). + if (m_multiTouchActive || m_gestureMode != GestureMode::Undecided) { + handleMultiTouchEnd(); + } + + // TUI mode (mouse tracking): accept + grab + forward as synthetic + // mouse/wheel events. Normal mode: fall through to QQuickItem — + // accepting would break the Flickable's press-delay disambiguation + // (instant drag → pull-down, press-hold → selection). + + if (m_mouseTrackingActive) { + if (event->type() == QEvent::TouchBegin && points.size() == 1) { + handleTuiTouchBegin(event, points.first()); + return; + } + if (event->type() == QEvent::TouchUpdate && points.size() == 1) { + handleTuiTouchUpdate(event, points.first()); + return; + } + if (event->type() == QEvent::TouchEnd + || event->type() == QEvent::TouchCancel) { + handleTuiTouchEnd(event, points); + return; + } + } + + // Normal mode: native fall-through — let Qt synthesise mouse events and + // the Flickable handle pull-down disambiguation. + if (event->type() == QEvent::TouchCancel) { + resetSessionSwipe(); // no release follows a cancel; keep the flag honest + resetTouchInteractionState(); + } + QQuickItem::touchEvent(event); +} + +void TerminalView::handleTuiTouchBegin(QTouchEvent *event, + const QTouchEvent::TouchPoint &pt) +{ + resetSessionSwipe(); // TUI path grabs everything; keep the swipe flag honest + event->accept(); + setKeepMouseGrab(true); + setKeepTouchGrab(true); + Q_EMIT requestParentInteractive(false); + grabTouchPoints(QVector{ pt.id() }); + grabMouse(); + m_tuiDragLastY = pt.pos().y(); + m_tuiScrollAccumulator = 0; + QMouseEvent synthPress(QEvent::MouseButtonPress, + pt.pos(), pt.screenPos(), + Qt::LeftButton, Qt::LeftButton, + event->modifiers()); + mousePressEvent(&synthPress); +} + +void TerminalView::handleTuiTouchUpdate(QTouchEvent *event, + const QTouchEvent::TouchPoint &pt) +{ + event->accept(); + + qreal deltaY = pt.pos().y() - m_tuiDragLastY; + m_tuiDragLastY = pt.pos().y(); + qreal newDelta = deltaY / m_cellHeight; // positive: down-drag = scroll up (natural scrolling) + auto scrollResult = TextUtil::accumulateScroll( + m_tuiScrollAccumulator, newDelta); + m_tuiScrollAccumulator = scrollResult.accumulator; + if (scrollResult.lines != 0) { + GhosttyMods mods = KeyMapping::mapQtModifiers(event->modifiers()); + GhosttyMouseButton btn = (scrollResult.lines > 0) + ? GHOSTTY_MOUSE_BUTTON_FOUR : GHOSTTY_MOUSE_BUTTON_FIVE; + for (int i = 0; i < qAbs(scrollResult.lines); ++i) { + sendMouseEvent(GHOSTTY_MOUSE_ACTION_PRESS, btn, pt.pos(), mods); + sendMouseEvent(GHOSTTY_MOUSE_ACTION_RELEASE, btn, pt.pos(), mods); + } + } + + // Also forward mouse motion for TUI click/drag/selection. + QMouseEvent synthMove(QEvent::MouseMove, + pt.pos(), pt.screenPos(), + Qt::LeftButton, Qt::LeftButton, + event->modifiers()); + mouseMoveEvent(&synthMove); +} + +void TerminalView::handleTuiTouchEnd(QTouchEvent *event, + const QList &points) +{ + if (points.size() == 1) { + const auto &pt = points.first(); + QMouseEvent synthRel(QEvent::MouseButtonRelease, + pt.pos(), pt.screenPos(), + Qt::LeftButton, Qt::NoButton, + event->modifiers()); + mouseReleaseEvent(&synthRel); + } + Q_EMIT requestParentInteractive(true); + m_tuiScrollAccumulator = 0; + setKeepMouseGrab(false); + setKeepTouchGrab(false); + event->accept(); +} + +void TerminalView::handleMultiTouchBegin(const QList &points) +{ + resetSessionSwipe(); // a second finger lands → abandon any in-progress swipe + + // Shell exited — ignore multi-touch, let it fall through to parent + if (m_shellExited) + return; + + // Clean up any previous gesture that wasn't properly ended. This can + // happen if TouchEnd was missed (e.g., window deactivated, touch stolen + // by another item) and a new gesture starts while the overlay is still + // visible. Without this, pinchingChanged(false) is never emitted. + if (m_multiTouchActive || m_gestureMode != GestureMode::Undecided) { + handleMultiTouchEnd(); + } + + // Disable the parent Flickable immediately — passive grabs aren't + // enough; SilicaFlickable steals the gesture before scroll-commit. + Q_EMIT requestParentInteractive(false); + m_multiTouchActive = true; + + if (m_longPressTimerId) { + killTimer(m_longPressTimerId); + m_longPressTimerId = 0; + } + if (m_draggingHandle != 0) { + m_draggingHandle = 0; + m_magnifierVisible = false; + m_handlesVisible = true; + setKeepMouseGrab(false); + } + if (m_selecting) + clearSelection(); + + qreal avgY = (points[0].pos().y() + points[1].pos().y()) / 2.0; + m_twoFingerLastY = avgY; + + // TUI apps or pinch-to-zoom disabled: force scroll mode + if (m_vt->isMouseTracking() || !Settings::instance()->pinchToZoom()) { + m_gestureMode = GestureMode::Scrolling; + return; + } + + // Undecided — defer classification to Update + QPointF p0 = points[0].pos(); + QPointF p1 = points[1].pos(); + m_pinchInitialDistance = QLineF(p0, p1).length(); + m_gestureInitialCentroid = (p0 + p1) / 2.0; + m_gestureMode = GestureMode::Undecided; + m_pinchCandidateFrames = 0; +} + +void TerminalView::handleMultiTouchUpdate(const QList &points) +{ + // If any touch point was released, end the gesture immediately. Qt may + // deliver a TouchUpdate with a released point before TouchEnd arrives. + // Without this, the gesture continues processing a stale finger position + // and the overlay may not hide if the final TouchEnd is also missed. + for (const auto &p : points) { + if (p.state() & Qt::TouchPointReleased) { + handleMultiTouchEnd(); + return; + } + } + + QPointF p0 = points[0].pos(); + QPointF p1 = points[1].pos(); + qreal currentDistance = QLineF(p0, p1).length(); + QPointF currentCentroid = (p0 + p1) / 2.0; + + const qreal pinchScale = (m_pinchInitialDistance > 0) + ? currentDistance / m_pinchInitialDistance : 1.0; + + switch (m_gestureMode) { + case GestureMode::Undecided: { + bool ratioExceeded = (pinchScale > PinchRatioThreshold) + || (pinchScale < 1.0 / PinchRatioThreshold); + + if (ratioExceeded) { + m_pinchCandidateFrames++; + if (m_pinchCandidateFrames >= PinchRatioFrames) { + m_gestureMode = GestureMode::Pinching; + m_pinchBaseFontSize = m_fontSize; + m_lastAppliedFontSize = m_fontSize; + // Reset baseline so scale starts at 1.0 at commitment + m_pinchInitialDistance = currentDistance; + m_pinchAtDefault = false; + Q_EMIT pinchingChanged(true); + return; + } + } else { + m_pinchCandidateFrames = 0; + } + + QPointF centroidDelta = currentCentroid - m_gestureInitialCentroid; + if (qAbs(centroidDelta.y()) > ScrollMinDistancePx + && qAbs(centroidDelta.y()) > qAbs(centroidDelta.x())) { + m_gestureMode = GestureMode::Scrolling; + // Reset lastY to current centroid so first scroll delta is clean + m_twoFingerLastY = currentCentroid.y(); + return; + } + + return; + } + + case GestureMode::Pinching: { + // Power-curve dampening: requires more finger travel for the same font + // delta. Exponent < 1 softens the response around scale=1.0 so small + // finger movements no longer produce large font jumps. + qreal dampedScale = std::pow(pinchScale, PinchScaleExponent); + int rawTarget = qRound(m_pinchBaseFontSize * dampedScale); + + if (rawTarget <= Settings::kMinFontSize) { + // Snap to global default — QML shows "Default", pinch-end stores 0 + int defaultSize = Settings::instance()->fontSize(); + if (defaultSize != m_lastAppliedFontSize) { + setFontSize(defaultSize); + m_lastAppliedFontSize = defaultSize; + } + if (!m_pinchAtDefault) { + m_pinchAtDefault = true; + Q_EMIT pinchAtDefaultChanged(true); + } + } else { + int targetSize = qBound(Settings::kMinFontSize, rawTarget, Settings::kMaxFontSize); + if (targetSize != m_lastAppliedFontSize) { + setFontSize(targetSize); + m_lastAppliedFontSize = targetSize; + } + if (m_pinchAtDefault) { + m_pinchAtDefault = false; + Q_EMIT pinchAtDefaultChanged(false); + } + } + return; + } + + case GestureMode::Scrolling: { + qreal avgY = (p0.y() + p1.y()) / 2.0; + qreal deltaY = avgY - m_twoFingerLastY; + m_twoFingerLastY = avgY; + + qreal newDelta = -deltaY / m_cellHeight; + auto touchScrollResult = TextUtil::accumulateScroll(m_touchScrollAccumulator, newDelta); + m_touchScrollAccumulator = touchScrollResult.accumulator; + int lines = touchScrollResult.lines; + + if (lines != 0) { + GhosttyTerminalScrollViewport scroll = {}; + scroll.tag = GHOSTTY_SCROLL_VIEWPORT_DELTA; + scroll.value.delta = lines; + ghostty_terminal_scroll_viewport(m_vt->terminal(), scroll); + m_linkScanDirty = true; + update(); + } + return; + } + } +} + +void TerminalView::handleMultiTouchEnd() +{ + if (m_gestureMode == GestureMode::Pinching) { + Q_EMIT pinchingChanged(false); + if (m_pinchAtDefault) { + m_pinchAtDefault = false; + Q_EMIT pinchAtDefaultChanged(false); + } + } + + // Restore the parent SilicaFlickable so single-finger pull-down works again. + Q_EMIT requestParentInteractive(true); + + m_gestureMode = GestureMode::Undecided; + m_pinchCandidateFrames = 0; + m_multiTouchActive = false; + m_twoFingerLastY = 0; + m_touchScrollAccumulator = 0; + setKeepMouseGrab(false); + setKeepTouchGrab(false); +} + +void TerminalView::timerEvent(QTimerEvent *event) +{ + if (event->timerId() == m_longPressTimerId) { + killTimer(m_longPressTimerId); + m_longPressTimerId = 0; + m_selecting = true; + m_magnifierVisible = true; + // Prevent parent SilicaFlickable from stealing the drag + setKeepMouseGrab(true); + update(); + return; + } + if (event->timerId() == m_blinkTimerId) { + if (m_lastInputTime.isValid() && + m_lastInputTime.elapsed() < BlinkPauseMs) { + m_cursorBlinkVisible = true; + update(); + return; + } + + // Blink cursor by default. Only stop when terminal explicitly requests + // a steady cursor (DECSCUSR mode 2, 4, or 6). + GhosttyRenderState state = m_vt ? m_vt->renderState() : nullptr; + bool cursorBlinking = true; + if (state) { + ghostty_render_state_get(state, + GHOSTTY_RENDER_STATE_DATA_CURSOR_BLINKING, + &cursorBlinking); + } + if (cursorBlinking) { + m_cursorBlinkVisible = !m_cursorBlinkVisible; + update(); + } + return; + } + QQuickItem::timerEvent(event); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3604734..a5f831c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,6 +24,8 @@ include_directories(${CMAKE_SOURCE_DIR}/../ghostty/include) add_executable(tst_session_persistence tst_session_persistence.cpp ${CMAKE_SOURCE_DIR}/../src/sessionmanager.cpp + ${CMAKE_SOURCE_DIR}/../src/sessionstore.cpp + ${CMAKE_SOURCE_DIR}/../src/singleinstance.cpp ${CMAKE_SOURCE_DIR}/../src/settings.cpp ${CMAKE_SOURCE_DIR}/../src/scrollencryptor.cpp ${CMAKE_SOURCE_DIR}/stubs/terminalview.cpp diff --git a/tests/tst_pty_reader.cpp b/tests/tst_pty_reader.cpp index 727be46..b07a6c9 100644 --- a/tests/tst_pty_reader.cpp +++ b/tests/tst_pty_reader.cpp @@ -112,8 +112,9 @@ private slots: reader.requestInterruption(); - QVERIFY(finishedSpy.count() > 0 || finishedSpy.wait(3000)); - reader.wait(3000); + // readFinished must not fire on interruption (only EOF/error). + QVERIFY(reader.wait(3000)); + QCOMPARE(finishedSpy.count(), 0); ::close(pipefd[1]); }