Freetar: Adjustable columns + global Show Columns toggle

80vh columns, width from longest line, per-column ▲▼ controls, per-URL save; global "Show Columns" toggle.

// ==UserScript==
// @name         Freetar: Adjustable columns + global Show Columns toggle
// @namespace    https://example.com/
// @version      3.1
// @description  80vh columns, width from longest line, per-column ▲▼ controls, per-URL save; global "Show Columns" toggle.
// @match        http://localhost:22000/*
// @run-at       document-idle
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function () {
  'use strict';

  const SELECTOR = 'div.tab.font-monospace';
  const STORAGE_KEY_PREFIX = 'ftColumns:';
  const GLOBAL_ENABLE_KEY = 'ftEnabled';
  const SAVE_DEBOUNCE = 150;
  const EXTRA_CH = 3; // extra width beyond the longest line
  const TOGGLE_ID = 'checkbox_show_columns';

  let enabled = GM_getValue(GLOBAL_ENABLE_KEY, true);
  let saveTimer = null;
  let moEnhancer = null;
  let moToolbar = null;
  let enhanced = false;
  let originalHTML = null;
  let tabEl = null;
  let onResizeHandler = null;

  function storageKeyForPage() {
    return STORAGE_KEY_PREFIX + location.pathname;
  }

  function saveCounts(root) {
    if (saveTimer) clearTimeout(saveTimer);
    saveTimer = setTimeout(() => {
      const counts = Array.from(root.querySelectorAll('.ft-col-body'))
        .map(b => b.children.length);
      try {
        GM_setValue(storageKeyForPage(), JSON.stringify({ counts, v: 2 }));
      } catch {}
    }, SAVE_DEBOUNCE);
  }

  function loadCounts() {
    try {
      const raw = GM_getValue(storageKeyForPage());
      if (!raw) return null;
      const parsed = JSON.parse(raw);
      if (parsed && Array.isArray(parsed.counts)) return parsed.counts;
      return null;
    } catch {
      return null;
    }
  }

  // Convert original tab content into div.ft-line elements (one per visual line)
  function extractLines(container) {
    const lines = [];
    let buffer = [];

    const flush = () => {
      const el = document.createElement('div');
      el.className = 'ft-line';
      if (buffer.length === 0) {
        el.innerHTML = ' '; // keep blank line
      } else {
        buffer.forEach(n => el.appendChild(n));
      }
      lines.push(el);
      buffer = [];
    };

    Array.from(container.childNodes).forEach(node => {
      if (node.nodeName === 'BR') {
        flush();
      } else {
        buffer.push(node.cloneNode(true));
      }
    });
    if (buffer.length) flush();
    return lines;
  }

  function computeLongestLineCh(lines) {
    let maxLen = 0;
    for (const line of lines) {
      const t = line.textContent.replace(/\u00A0/g, ' ');
      if (t.length > maxLen) maxLen = t.length;
    }
    return maxLen + EXTRA_CH;
  }

  function injectStyles() {
    if (document.querySelector('style[data-ft-enhanced-style="1"]')) return;
    const style = document.createElement('style');
    style.setAttribute('data-ft-enhanced-style', '1');
    style.textContent = `
      :root { color-scheme: light dark; }

      /* Make Bootstrap container full-width for more room */
      .container {
        max-width: 100% !important;
        width: 100% !important;
      }

      ${SELECTOR}.ft-enhanced {
        display: flex;
        align-items: stretch;
        gap: 2rem;
        height: 80vh;
        max-height: 80vh;
        overflow-x: auto;
        overflow-y: hidden;
        -webkit-overflow-scrolling: touch;
        overscroll-behavior-x: contain;
        scrollbar-gutter: stable both-edges;
        padding: 10px;
        font-variant-ligatures: none;
      }

      ${SELECTOR}.ft-enhanced .ft-col {
        flex: 0 0 auto;
        width: var(--ft-col-width, 60ch);
        min-width: var(--ft-col-width, 60ch);
        max-width: var(--ft-col-width, 60ch);
        display: flex;
        flex-direction: column;
        position: relative;
        padding: 0 10px; /* internal padding so columns don't crowd */
        border-right: 1px solid rgba(128,128,128,0.35); /* vertical rule */
      }
      ${SELECTOR}.ft-enhanced .ft-col:last-child { border-right: none; }

      ${SELECTOR}.ft-enhanced .ft-col-body {
        flex: 1 1 auto;
        overflow: hidden; /* no vertical scroll; we push lines to next col */
        line-height: 1.45;
      }

      ${SELECTOR}.ft-enhanced .ft-line {
        white-space: pre; /* do not wrap; width is set from longest line */
        padding-block: 2px;
      }

      ${SELECTOR}.ft-enhanced .ft-col-controls {
        flex: 0 0 auto;
        display: flex;
        gap: 6px;
        justify-content: center;
        padding: 6px 0 4px;
        opacity: 0.8;
      }

      ${SELECTOR}.ft-enhanced .ft-btn {
        width: 24px;
        height: 24px;
        border-radius: 999px;
        border: 1px solid color-mix(in oklab, currentColor 25%, transparent);
        background: color-mix(in oklab, currentColor 5%, transparent);
        color: inherit;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        font-size: 12px;
        cursor: pointer;
        opacity: 0.55;
        transition: opacity .15s ease, background-color .15s ease;
        user-select: none;
      }
      ${SELECTOR}.ft-enhanced .ft-btn:hover {
        opacity: 0.95;
        background: color-mix(in oklab, currentColor 10%, transparent);
      }

      /* Small screens: fall back to a single flowing block */
      @media (max-width: 768px) {
        ${SELECTOR}.ft-enhanced {
          height: auto;
          max-height: none;
          overflow: visible;
          display: block;
          padding: 10px 0;
        }
        ${SELECTOR}.ft-enhanced .ft-col {
          width: auto;
          min-width: 0;
          max-width: none;
          border-right: none;
          padding: 0;
        }
        ${SELECTOR}.ft-enhanced .ft-col-body { overflow: visible; }
        ${SELECTOR}.ft-enhanced .ft-col-controls {
          justify-content: flex-start;
          padding-bottom: 0.75rem;
        }
      }
    `;
    document.head.appendChild(style);
  }

  function removeStyles() {
    const el = document.querySelector('style[data-ft-enhanced-style="1"]');
    if (el) el.remove();
  }

  function createColumn() {
    const col = document.createElement('div');
    col.className = 'ft-col';
    const body = document.createElement('div');
    body.className = 'ft-col-body';
    const controls = document.createElement('div');
    controls.className = 'ft-col-controls';

    const btnUp = document.createElement('button');
    btnUp.className = 'ft-btn';
    btnUp.title = 'Pull one line from the next column';
    btnUp.textContent = '▲';

    const btnDown = document.createElement('button');
    btnDown.className = 'ft-btn';
    btnDown.title = 'Push one line to the next column';
    btnDown.textContent = '▼';

    controls.append(btnUp, btnDown);
    col.append(body, controls);
    return { col, body, btnUp, btnDown };
  }

  function getColumns(root) {
    return Array.from(root.querySelectorAll('.ft-col'));
  }
  function getBodies(root) {
    return Array.from(root.querySelectorAll('.ft-col-body'));
  }

  function ensureNoOverflow(root) {
    let bodies = getBodies(root);
    for (let i = 0; i < bodies.length; i++) {
      const body = bodies[i];
      while (body.scrollHeight > body.clientHeight && body.lastElementChild) {
        const next = bodies[i + 1] || createColumnAndAttach(root).body;
        bodies = getBodies(root);
        const current = bodies[i];
        const nextBody = bodies[i + 1];
        nextBody.prepend(current.lastElementChild);
      }
    }
    // Remove trailing empty columns (keep at least one)
    const cols = getColumns(root);
    for (let i = cols.length - 1; i > 0; i--) {
      const b = cols[i].querySelector('.ft-col-body');
      if (b && b.children.length === 0) cols[i].remove();
      else break;
    }
  }

  function createColumnAndAttach(root) {
    const parts = createColumn();
    root.appendChild(parts.col);
    attachHandlers(root, parts);
    return parts;
  }

  function attachHandlers(root, parts) {
    parts.btnDown.addEventListener('click', () => {
      const cols = getColumns(root);
      const idx = cols.indexOf(parts.col);
      const bodies = getBodies(root);
      const body = bodies[idx];
      if (!body || !body.lastElementChild) return;

      const next = bodies[idx + 1] || createColumnAndAttach(root).body;
      next.prepend(body.lastElementChild);

      ensureNoOverflow(root);
      saveCounts(root);
    });

    parts.btnUp.addEventListener('click', () => {
      const cols = getColumns(root);
      const idx = cols.indexOf(parts.col);
      const bodies = getBodies(root);
      const body = bodies[idx];
      const next = bodies[idx + 1];
      if (!body || !next || !next.firstElementChild) return;

      const candidate = next.firstElementChild;
      body.append(candidate);

      if (body.scrollHeight > body.clientHeight) {
        next.prepend(body.lastElementChild);
        return;
      }
      ensureNoOverflow(root);
      saveCounts(root);
    });
  }

  function buildAuto(root, lines) {
    let c = createColumnAndAttach(root);
    for (let i = 0; i < lines.length; i++) {
      c.body.append(lines[i]);
      if (c.body.scrollHeight > c.body.clientHeight) {
        const next = createColumnAndAttach(root);
        next.body.prepend(c.body.lastElementChild);
        c = next;
      }
    }
    ensureNoOverflow(root);
  }

  function buildFromCounts(root, lines, counts) {
    let i = 0;
    for (let colIdx = 0; colIdx < counts.length; colIdx++) {
      const { body } = createColumnAndAttach(root);
      for (let put = 0; put < counts[colIdx] && i < lines.length; put++) {
        body.append(lines[i++]);
      }
    }
    while (i < lines.length) {
      const { body } = createColumnAndAttach(root);
      while (i < lines.length) {
        body.append(lines[i++]);
        if (body.scrollHeight > body.clientHeight) {
          const next = createColumnAndAttach(root);
          next.body.prepend(body.lastElementChild);
          break;
        }
      }
    }
    ensureNoOverflow(root);
  }

  function enhance() {
    if (enhanced) return;
    const tab = document.querySelector(SELECTOR);
    if (!tab) return;

    // Save original content for restoration
    tabEl = tab;
    originalHTML = tabEl.innerHTML;

    injectStyles();

    // Build columns from lines
    const lines = extractLines(tabEl);
    const longestCh = computeLongestLineCh(lines);

    tabEl.classList.add('ft-enhanced');
    tabEl.style.setProperty('--ft-col-width', `${longestCh}ch`);
    tabEl.innerHTML = '';

    const saved = loadCounts();
    if (saved && saved.length) {
      buildFromCounts(tabEl, lines, saved);
    } else {
      buildAuto(tabEl, lines);
      saveCounts(tabEl);
    }

    // keep columns safe after layout settles
    setTimeout(() => {
      ensureNoOverflow(tabEl);
      saveCounts(tabEl);
    }, 0);

    onResizeHandler = () => {
      ensureNoOverflow(tabEl);
      saveCounts(tabEl);
    };
    window.addEventListener('resize', onResizeHandler, { passive: true });

    // If page mutates, re-ensure (but do not re-extract/replace)
    if (!moEnhancer) {
      moEnhancer = new MutationObserver(() => {
        if (!enabled) return;
        ensureNoOverflow(tabEl);
      });
      moEnhancer.observe(document.documentElement, { childList: true, subtree: true });
    }

    enhanced = true;
  }

  function disableAndRestore() {
    if (!enhanced) return;
    // Remove resize handler
    if (onResizeHandler) {
      window.removeEventListener('resize', onResizeHandler);
      onResizeHandler = null;
    }
    // Disconnect enhancer observer
    if (moEnhancer) {
      moEnhancer.disconnect();
      moEnhancer = null;
    }
    // Restore original content
    if (tabEl) {
      tabEl.classList.remove('ft-enhanced');
      tabEl.removeAttribute('style');
      tabEl.innerHTML = originalHTML || tabEl.innerHTML;
    }
    // Remove injected styles so page returns to original layout
    removeStyles();

    enhanced = false;
  }

  // Insert the "Show Columns" toggle (global)
  function insertToggle() {
    if (document.getElementById(TOGGLE_ID)) return;

    // Find the block that contains transpose controls
    const transposeDown = document.getElementById('transpose_down');
    if (!transposeDown) return;

    const transposeBlock = transposeDown.closest('div');
    const toolbar = transposeBlock?.parentElement;
    if (!toolbar) return;

    // Create switch styled like "Show chords"
    const wrap = document.createElement('div');
    wrap.className = 'form-check form-switch me-4';

    const input = document.createElement('input');
    input.className = 'form-check-input';
    input.type = 'checkbox';
    input.role = 'switch';
    input.id = TOGGLE_ID;
    input.checked = !!enabled;

    const label = document.createElement('label');
    label.className = 'form-check-label';
    label.setAttribute('for', TOGGLE_ID);
    label.textContent = 'Show Columns';

    wrap.append(input, label);

    // Insert right after the transpose block
    if (transposeBlock.nextSibling) {
      toolbar.insertBefore(wrap, transposeBlock.nextSibling);
    } else {
      toolbar.appendChild(wrap);
    }

    input.addEventListener('change', () => {
      enabled = input.checked;
      GM_setValue(GLOBAL_ENABLE_KEY, enabled);
      if (enabled) {
        // If previously disabled, re-enable columns
        enhance();
      } else {
        // Turn off and restore original layout
        disableAndRestore();
      }
    });
  }

  function ensureToggleInserted() {
    insertToggle();
    if (!moToolbar) {
      moToolbar = new MutationObserver(() => insertToggle());
      moToolbar.observe(document.documentElement, { childList: true, subtree: true });
    }
  }

  function init() {
    ensureToggleInserted();
    if (enabled) {
      enhance();
    } else {
      disableAndRestore();
    }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();