/* GCOVR Custom JavaScript - Tree View & Interactivity */ (function() { 'use strict'; // Wait for DOM ready document.addEventListener('DOMContentLoaded', function() { initTheme(); initSidebar(); initSidebarResize(); initMobileMenu(); initFileTree(); initNavOverride(); initBreadcrumbs(); initSearch(); initFunctionRows(); initSorting(); initToggleButtons(); initCoverageNav(); initTreeControls(); initViewToggle(); initSettingsDropdown(); initTlaNavigation(); initLineHighlight(); initColumnToggles(); initPopupResize(); initFileNavTooltips(); initFileNavKeys(); initFunctionListPersistence(); // Reveal page now that all init is done document.documentElement.classList.remove('no-transitions'); // Prefetch linked pages on hover for instant navigation initPrefetch(); }); // =========================================== // Breadcrumb Links // =========================================== // Find a node in the tree by its link (HTML filename) and return // the full ancestor path as an array of nodes from root to target. function findPathInTree(nodes, targetLink) { for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; if (node.link === targetLink) { return [node]; } if (node.children) { var childPath = findPathInTree(node.children, targetLink); if (childPath) { return [node].concat(childPath); } } } return null; } function initBreadcrumbs() { var currentSpan = document.querySelector('.breadcrumb .current'); if (!currentSpan || !window.GCOVR_TREE_DATA) { if (currentSpan) currentSpan.classList.add('ready'); return; } // Find current page in tree by its HTML filename — this is unambiguous // since each page only appears once in the tree. var currentPage = window.location.pathname.split('/').pop() || 'index.html'; var treePath = findPathInTree(window.GCOVR_TREE_DATA, currentPage); if (!treePath || treePath.length === 0) { currentSpan.classList.add('ready'); return; } // Build breadcrumb from the tree path (ancestor nodes → current node) var fragment = document.createDocumentFragment(); var matchedSegments = []; // Fill an element with the segments of a (possibly joined) name like // "boost/url", rendering "boost", a separator, "url". Used so a joined // directory shows its segments inline yet remains one hyperlink target. function appendSegments(parentEl, name) { var segments = name.split('/'); for (var k = 0; k < segments.length; k++) { if (k > 0) { var inner = document.createElement('span'); inner.className = 'separator'; inner.textContent = '/'; parentEl.appendChild(inner); } parentEl.appendChild(document.createTextNode(segments[k])); } } for (var i = 0; i < treePath.length; i++) { var node = treePath[i]; var isLast = (i === treePath.length - 1); if (i > 0) { var sep = document.createElement('span'); sep.className = 'separator'; sep.textContent = '/'; fragment.appendChild(sep); } matchedSegments.push(node.name); if (node.link && !isLast) { var a = document.createElement('a'); a.href = node.link; appendSegments(a, node.name); fragment.appendChild(a); } else { var span = document.createElement('span'); span.className = 'current-file'; appendSegments(span, node.name); fragment.appendChild(span); } } currentSpan.innerHTML = ''; currentSpan.appendChild(fragment); currentSpan.classList.add('ready'); // Update source-filename to match breadcrumb path var sourceFilename = document.querySelector('.source-filename'); if (sourceFilename) { sourceFilename.textContent = matchedSegments.join('/'); } } // =========================================== // Theme Toggle // =========================================== function initTheme() { const toggle = document.getElementById('theme-toggle'); const iconSun = toggle ? toggle.querySelector('.icon-sun') : null; const iconMoon = toggle ? toggle.querySelector('.icon-moon') : null; // Get system preference function getSystemTheme() { return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; } // Get effective theme: saved preference or OS default function getEffectiveTheme() { var saved = localStorage.getItem('gcovr-theme'); return (saved === 'light' || saved === 'dark') ? saved : getSystemTheme(); } // Apply theme to document function applyTheme(theme) { document.documentElement.setAttribute('data-theme', theme); if (iconSun) iconSun.style.display = (theme === 'dark') ? 'block' : 'none'; if (iconMoon) iconMoon.style.display = (theme === 'light') ? 'block' : 'none'; } // Apply current theme applyTheme(getEffectiveTheme()); // Listen for system theme changes — only apply if no stored preference window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', function() { var saved = localStorage.getItem('gcovr-theme'); if (saved !== 'light' && saved !== 'dark') { applyTheme(getSystemTheme()); } }); // Toggle between light and dark on click if (toggle) { toggle.addEventListener('click', function() { var current = getEffectiveTheme(); var next = (current === 'dark') ? 'light' : 'dark'; localStorage.setItem('gcovr-theme', next); applyTheme(next); }); } } // =========================================== // Tree Controls (Expand/Collapse All) // =========================================== function initTreeControls() { var expandBtn = document.getElementById('expand-all'); var collapseBtn = document.getElementById('collapse-all'); if (expandBtn) { expandBtn.addEventListener('click', function() { document.querySelectorAll('.tree-item').forEach(function(item) { if (!item.classList.contains('no-children')) { item.classList.add('expanded'); var toggle = item.querySelector(':scope > .tree-item-header > .tree-folder-toggle'); if (toggle) toggle.textContent = '−'; } }); saveExpandedFolders(); }); } if (collapseBtn) { collapseBtn.addEventListener('click', function() { document.querySelectorAll('.tree-item').forEach(function(item) { item.classList.remove('expanded'); var toggle = item.querySelector(':scope > .tree-item-header > .tree-folder-toggle'); if (toggle) toggle.textContent = '+'; }); saveExpandedFolders(); }); } } // =========================================== // Sidebar Toggle // =========================================== function initSidebar() { const sidebar = document.getElementById('sidebar'); const toggle = document.getElementById('sidebar-toggle'); const header = sidebar ? sidebar.querySelector('.sidebar-header') : null; if (!sidebar) return; // Load saved state const isCollapsed = localStorage.getItem('sidebar-collapsed') === 'true'; if (isCollapsed) { sidebar.classList.add('collapsed'); } // Toggle button if (toggle) { toggle.addEventListener('click', function() { sidebar.classList.toggle('collapsed'); sidebar.classList.remove('hover-expand'); var isNowCollapsed = sidebar.classList.contains('collapsed'); localStorage.setItem('sidebar-collapsed', isNowCollapsed); // Restore custom width when un-collapsing if (!isNowCollapsed) { var savedWidth = localStorage.getItem('gcovr-sidebar-width'); if (savedWidth) { document.documentElement.style.setProperty('--sidebar-width', savedWidth + 'px'); } } }); } // Hover expand - expands when hovering sidebar content (not header or no-expand zones) var hoverTimeout = null; var HOVER_DELAY = 150; // ms delay before expanding var isOverContent = false; // Check if element is within a no-expand zone function isInNoExpandZone(el) { while (el && el !== sidebar) { if (el.classList && el.classList.contains('no-expand')) { return true; } el = el.parentElement; } return false; } function scheduleExpand() { if (hoverTimeout) return; // already scheduled if (sidebar.classList.contains('hover-expand')) return; // already expanded hoverTimeout = setTimeout(function() { if (isOverContent) { sidebar.classList.add('hover-expand'); } hoverTimeout = null; }, HOVER_DELAY); } function cancelExpand() { if (hoverTimeout) { clearTimeout(hoverTimeout); hoverTimeout = null; } sidebar.classList.remove('hover-expand'); } sidebar.addEventListener('mouseenter', function(e) { if (!sidebar.classList.contains('collapsed')) return; // Check if entering over content area (not header or no-expand zones) if (!header.contains(e.target) && !isInNoExpandZone(e.target)) { isOverContent = true; scheduleExpand(); } }); sidebar.addEventListener('mousemove', function(e) { if (!sidebar.classList.contains('collapsed')) return; var wasOverContent = isOverContent; isOverContent = !header.contains(e.target) && !isInNoExpandZone(e.target); if (isOverContent && !wasOverContent && !sidebar.classList.contains('hover-expand')) { scheduleExpand(); } }); sidebar.addEventListener('mouseleave', function() { isOverContent = false; cancelExpand(); }); } // =========================================== // Sidebar Resize // =========================================== function initSidebarResize() { var sidebar = document.getElementById('sidebar'); var handle = document.getElementById('sidebar-resize-handle'); if (!sidebar || !handle) return; var MIN_WIDTH = 200; var startX, startWidth; // Restore saved width var savedWidth = localStorage.getItem('gcovr-sidebar-width'); if (savedWidth && !sidebar.classList.contains('collapsed')) { var w = parseInt(savedWidth, 10); if (w >= MIN_WIDTH) { document.documentElement.style.setProperty('--sidebar-width', w + 'px'); } } function getMaxWidth() { return Math.floor(window.innerWidth * 0.5); } function onMouseMove(e) { var newWidth = startWidth + (e.clientX - startX); var maxW = getMaxWidth(); if (newWidth < MIN_WIDTH) newWidth = MIN_WIDTH; if (newWidth > maxW) newWidth = maxW; document.documentElement.style.setProperty('--sidebar-width', newWidth + 'px'); } function onMouseUp() { document.body.classList.remove('sidebar-resizing'); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); // Save the current width var computed = parseInt(getComputedStyle(sidebar).width, 10); localStorage.setItem('gcovr-sidebar-width', computed); } handle.addEventListener('mousedown', function(e) { if (sidebar.classList.contains('collapsed')) return; e.preventDefault(); startX = e.clientX; startWidth = parseInt(getComputedStyle(sidebar).width, 10); document.body.classList.add('sidebar-resizing'); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }); // Double-click to reset to default width var DEFAULT_WIDTH = 320; handle.addEventListener('dblclick', function() { if (sidebar.classList.contains('collapsed')) return; document.documentElement.style.setProperty('--sidebar-width', DEFAULT_WIDTH + 'px'); localStorage.setItem('gcovr-sidebar-width', DEFAULT_WIDTH); }); } // =========================================== // Mobile Menu // =========================================== function initMobileMenu() { var sidebar = document.getElementById('sidebar'); var menuBtn = document.getElementById('mobile-menu-btn'); var backdrop = document.getElementById('sidebar-backdrop'); if (!menuBtn || !sidebar) return; // Open sidebar on hamburger click menuBtn.addEventListener('click', function() { sidebar.classList.add('mobile-open'); }); // Close on backdrop click if (backdrop) { backdrop.addEventListener('click', function() { sidebar.classList.remove('mobile-open'); }); } // Close when clicking a navigation link sidebar.addEventListener('click', function(e) { if (e.target.closest('a[href]')) { sidebar.classList.remove('mobile-open'); } }); // Close on escape key document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && sidebar.classList.contains('mobile-open')) { sidebar.classList.remove('mobile-open'); } }); } // =========================================== // File Tree - Load from tree.json // =========================================== function initFileTree() { var treeContainer = document.getElementById('file-tree'); if (!treeContainer) return; // Tree data is produced already-normalized and already-sorted by the // upstream tooling (Python's gcovr_build_tree.py or gcovr itself), so // no normalize/sort pass is needed here. We just join single-child // dir chains for sidebar compactness before rendering. joinSingleChildDirs(window.GCOVR_TREE_DATA); sortTree(window.GCOVR_TREE_DATA); renderTree(treeContainer, window.GCOVR_TREE_DATA); } // Re-sort the tree: directories first, then files, alphabetically within // each group. Python already sorts each level, but normalizeTree creates // synthetic directory nodes from multi-segment FILE entries (e.g. a deep // chain like subdir1/subdir2/subdir3/file.hpp that gcovr itself collapsed). // Those synthetic dirs end up wherever the originating file landed in the // Python sort — i.e. in the file bucket — so without this pass they appear // mixed in with the files instead of at the top with the other directories. function sortTree(nodes) { if (!nodes || nodes.length === 0) return; nodes.sort(function(a, b) { var aIsDir = a.isDirectory || (a.children && a.children.length > 0); var bIsDir = b.isDirectory || (b.children && b.children.length > 0); if (aIsDir && !bIsDir) return -1; if (!aIsDir && bIsDir) return 1; var aName = (a.name || '').toLowerCase(); var bName = (b.name || '').toLowerCase(); return aName.localeCompare(bName); }); for (var i = 0; i < nodes.length; i++) { if (nodes[i].children) sortTree(nodes[i].children); } } // Join chains of single-child directories into one sidebar entry. // If a directory contains nothing but one child directory, the two are // merged: the name becomes "parent/child", and the link + stats are taken // from the (deepest) child. The grandchildren become the new children. // Repeats until the chain ends (multiple children, or a file appears). // Result: e.g. include > boost > url > [files] becomes include/boost/url // as a single entry whose click navigates straight to the url directory. function joinSingleChildDirs(nodes) { if (!nodes) return; for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; if (!node.isDirectory || !node.children) continue; while (node.children.length === 1 && node.children[0].isDirectory) { var child = node.children[0]; node.name = node.name + '/' + child.name; // The joined entry represents the deepest directory for clicks // and for the coverage stats shown next to it. Statically-named // assignments avoid the dynamic [key] indexing that static // analyzers flag as a prototype-pollution risk. if (child.link) node.link = child.link; if (child.coverage) node.coverage = child.coverage; if (child.coverageClass) node.coverageClass = child.coverageClass; if (child.linesTotal) node.linesTotal = child.linesTotal; if (child.linesExec) node.linesExec = child.linesExec; if (child.linesCoverage) node.linesCoverage = child.linesCoverage; if (child.linesClass) node.linesClass = child.linesClass; if (child.functionsCoverage) node.functionsCoverage = child.functionsCoverage; if (child.functionsClass) node.functionsClass = child.functionsClass; if (child.branchesCoverage) node.branchesCoverage = child.branchesCoverage; if (child.branchesClass) node.branchesClass = child.branchesClass; node.children = child.children || []; } joinSingleChildDirs(node.children); } } // Save expanded folder paths to localStorage function saveExpandedFolders() { var paths = []; document.querySelectorAll('.tree-item.expanded[data-tree-path]').forEach(function(el) { paths.push(el.getAttribute('data-tree-path')); }); localStorage.setItem('gcovr-expanded-folders', JSON.stringify(paths)); } function renderTree(container, tree) { container.innerHTML = ''; if (!tree || tree.length === 0) { container.innerHTML = '