Source: ui/hotkeyController.js

/**
 * @module HotKeyController
 * @author Radim Brnka
 * @description Keyboard shortcuts controller attached to the UI
 * @link https://www.freecodecamp.org/news/javascript-keycode-list-keypress-event-key-codes/
 * @copyright Synaptory Fractal Traveler, 2025-2026
 * @license MIT
 */

import {
    captureScreenshot,
    copyInfoToClipboard,
    isAnimationActive,
    isJuliaMode,
    randomizeColors,
    reset,
    resetAppState,
    showEditCoordsDialog,
    showSaveViewDialog,
    startJuliaDive,
    switchFractalMode,
    switchFractalTypeWithPersistence,
    toggleCenterLines,
    toggleDebugMode,
    toggleDemo,
    toggleHeader,
    travelToPreset,
    updateColorTheme,
    updatePaletteCycleButtonState,
    updatePaletteDropdownState,
} from "./ui";
import {
    CONSOLE_GROUP_STYLE,
    CONSOLE_MESSAGE_STYLE,
    DEBUG_LEVEL,
    DEBUG_MODE,
    FF_PERSISTENT_FRACTAL_SWITCHING,
    FRACTAL_TYPE,
    log,
    ROTATION_DIRECTION
} from "../global/constants";
import {JuliaRenderer} from "../renderers/juliaRenderer";

//region CONSTANTS > ---------------------------------------------------------------------------------------------------
/**
 * Keys not allowed to work in animation mode
 * @type {string[]}
 */
const ANIMATION_KEYS_BLACKLIST = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Space", "KeyQ", "KeyW"];

/**
 * Pan movement animation duration
 * @type {number}
 */
const PAN_SPEED = 50;

/**
 * Pan step size per keystroke
 * @type {number}
 */
const PAN_STEP = .1;

/**
 * Smooth (Shift) pan step size per keystroke
 * @type {number}
 */
const PAN_SMOOTH_STEP = .01;

/**
 * Normal rotation animation step size
 * @type {number}
 */
const ROTATION_ANIMATION_STEP = .1;

/**
 * Smooth rotation animation step size
 * @type {number}
 */
const ROTATION_ANIMATION_SMOOTH_STEP = .01;

/**
 * Zoom step per keystroke
 * @type {number}
 */
const ZOOM_ANIMATION_STEP = .1;

/**
 * Smooth (Shift) zoom step per keystroke
 * @type {number}
 */
const ZOOM_ANIMATION_SMOOTH_STEP = .01;

/**
 * Smooth stepping: step size
 * @type {number}
 */
const JULIA_HOTKEY_C_STEP = .001;

/**
 * Super smooth stepping multiplier (SHIFT)
 * @type {number}
 */
const JULIA_HOTKEY_C_SMOOTH_MULTIPLIER = .1;

/**
 * Smooth stepping: animation duration
 * @type {number}
 */
const JULIA_HOTKEY_C_SPEED = 10;
//endregion ------------------------------------------------------------------------------------------------------------

let rotationActive = false;
let initialized = false;
let fractalApp;

/**
 * Keydown event handler
 * @param {KeyboardEvent} event
 * @return {Promise<void>}
 */
async function onKeyDown(event) {

    // Do not steal hotkeys from user input fields (search boxes, sliders, etc.)
    const target = /** @type {HTMLElement|null} */ (event.target);
    const tag = target?.tagName?.toLowerCase();
    const isTypingTarget =
        !!target &&
        (
            tag === 'input' ||
            tag === 'textarea' ||
            tag === 'select' ||
            target.isContentEditable
        );

    if (isTypingTarget) return;

    if (DEBUG_MODE) console.log(`%c onKeyDown: %c Pressed key/code ${event.shiftKey ? 'Shift + ' : ''}${event.ctrlKey ? 'CTRL + ' : ''}${event.altKey ? 'ALT + ' : ''}${event.code}/${event.key}`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);

    let handled = false;

    const rotationSpeed = event.shiftKey ? ROTATION_ANIMATION_SMOOTH_STEP : ROTATION_ANIMATION_STEP;
    let rotationDirection = ROTATION_DIRECTION.CW;

    let deltaPanX = 0;
    let deltaPanY = 0;
    let deltaCx = 0;
    let deltaCy = 0;

    // Treat MAC Option button as Alt
    const altKey = event.altKey || event.code === 'Alt';

    if (isAnimationActive() && ANIMATION_KEYS_BLACKLIST.includes(event.code)) {
        event.preventDefault();
        return;
    }
    switch (event.code) {
        // TODO add shift/non-shift to slow down or speed up the rotation instead of stop.
        case 'KeyQ': // Rotation counter-clockwise
            rotationDirection = ROTATION_DIRECTION.CCW;
        case 'KeyW': // Rotation clockwise
            if (rotationActive) {
                fractalApp.stopCurrentRotationAnimation();
                rotationActive = false;
            } else {
                rotationActive = true;
                await fractalApp.animateInfiniteRotation(rotationDirection, rotationSpeed);
            }
            handled = true;
            break;

        case 'KeyE': // Debug lines
            toggleCenterLines();
            handled = true;
            break;

        case 'KeyR': // Reset
            if (event.shiftKey) await reset();
            handled = true;
            break;

        case 'KeyC': // Copy info to clipboard
            copyInfoToClipboard();
            handled = true;
            break;

        case 'KeyL': // DEBUG BAR toggle
            toggleDebugMode();
            handled = true;
            break;

        case 'KeyZ': // Switch between fractals with constant p/c
            if (!FF_PERSISTENT_FRACTAL_SWITCHING) break;

            await switchFractalTypeWithPersistence(isJuliaMode() ? FRACTAL_TYPE.MANDELBROT : FRACTAL_TYPE.JULIA);
            handled = true;
            break;

        case 'KeyT': // Random colors / cycle palettes
            if (altKey) {
                // Alt+T: Reset to first palette (matches UI button behavior)
                await fractalApp.applyPaletteByIndex(0, 250, updateColorTheme);
                updatePaletteDropdownState();
                updatePaletteCycleButtonState();
            } else if (event.shiftKey) {
                // Shift+T: Toggle palette cycling
                if (fractalApp.paletteCyclingActive) {
                    // Stop cycling
                    fractalApp.stopCurrentColorAnimations();
                } else {
                    // Start cycling
                    fractalApp.startPaletteCycling(2000, 3000, updateColorTheme, updatePaletteDropdownState);
                }
                updatePaletteCycleButtonState();
            } else {
                // T: Pick a random palette
                await randomizeColors();
            }
            handled = true;
            break;

        case 'KeyA': // Forced resize
            fractalApp.resizeCanvas();
            handled = true;
            break;

        case 'KeyS': // Screenshot or Save View
            if (altKey) {
                // Alt+S: Save current view
                showSaveViewDialog();
            } else if (event.shiftKey) {
                // Shift+S: Screenshot
                captureScreenshot();
            }
            handled = true;
            break;

        case 'KeyB': // Julia legacy renderer toggle
            if (isJuliaMode() && DEBUG_MODE === DEBUG_LEVEL.FULL) {
                JuliaRenderer.FF_LEGACY_JULIA_RENDERER = !JuliaRenderer.FF_LEGACY_JULIA_RENDERER
                await switchFractalMode(FRACTAL_TYPE.JULIA);
            }
            handled = true;
            break;

        case 'KeyD': // Start/stop demo
            await toggleDemo();
            handled = true;
            break;

        case 'KeyM': // Riemann Mode
            if (DEBUG_MODE === DEBUG_LEVEL.FULL) await switchFractalMode(FRACTAL_TYPE.RIEMANN);
            handled = true;
            break;

        case 'KeyN':
            if (DEBUG_MODE) {
                log('Analytic Extension toggled.');
                fractalApp.useAnalyticExtension = !fractalApp.useAnalyticExtension;
                fractalApp.draw();
            }
            handled = true;
            break;

        case 'KeyF': // Toggle adaptive quality
            fractalApp.toggleAdaptiveQuality();
            handled = true;
            break;

        case 'NumpadAdd': // Numpad + (Shift for soft step)
        case 'Equal': // = or + key
            if (fractalApp.adaptiveQualityEnabled) {
                const step = event.shiftKey ? 25 : 100;
                fractalApp.adjustExtraIterations(step);
            }
            handled = true;
            break;

        case 'NumpadSubtract': // Numpad - (Shift for soft step)
        case 'Minus': // - key
            if (fractalApp.adaptiveQualityEnabled) {
                const step = event.shiftKey ? -25 : -100;
                fractalApp.adjustExtraIterations(step);
            }
            handled = true;
            break;

        case 'KeyP':
            showEditCoordsDialog();
            handled = true;
            break;

        case "ArrowLeft":
            deltaPanX = altKey ? 0 : -(event.shiftKey ? PAN_SMOOTH_STEP : PAN_STEP);
            deltaCx = altKey ? (JULIA_HOTKEY_C_STEP * (event.shiftKey ? JULIA_HOTKEY_C_SMOOTH_MULTIPLIER : 1)) : 0;
            handled = true;
            break;

        case "ArrowRight":
            deltaPanX = altKey ? 0 : (event.shiftKey ? PAN_SMOOTH_STEP : PAN_STEP);
            deltaCx = altKey ? (JULIA_HOTKEY_C_STEP * (event.shiftKey ? -JULIA_HOTKEY_C_SMOOTH_MULTIPLIER : -1)) : 0;
            handled = true;
            break;

        case "ArrowDown":
            deltaPanY = altKey ? 0 : -(event.shiftKey ? PAN_SMOOTH_STEP : PAN_STEP);
            deltaCy = altKey ? (JULIA_HOTKEY_C_STEP * (event.shiftKey ? -JULIA_HOTKEY_C_SMOOTH_MULTIPLIER : -1)) : 0;
            handled = true;
            break;

        case "ArrowUp":
            deltaPanY = altKey ? 0 : (event.shiftKey ? PAN_SMOOTH_STEP : PAN_STEP);
            deltaCy = altKey ? (JULIA_HOTKEY_C_STEP * (event.shiftKey ? JULIA_HOTKEY_C_SMOOTH_MULTIPLIER : 1)) : 0;
            handled = true;
            break;

        case "Space":
            const increment = event.shiftKey ? ZOOM_ANIMATION_SMOOTH_STEP : ZOOM_ANIMATION_STEP;
            const zoomFactor = (event.ctrlKey || altKey) ? (1 + increment) : (1 - increment);

            let targetZoom = fractalApp.zoom * zoomFactor;

            if (targetZoom >= fractalApp.MAX_ZOOM && targetZoom <= fractalApp.MIN_ZOOM) {
                // Use animateZoomToNoPan to preserve DD pan precision at deep zooms
                await fractalApp.animateZoomToNoPan(targetZoom, 20);
                resetAppState();
            }
            handled = true;
            break;

        case "Enter":
            toggleHeader();
            handled = true;
            break;

        default: // Case nums and others:
            const match = event.code.match(/^(Digit|Numpad)(\d)$/);
            if (match) {
                const index = parseInt(match[2], 0); // match[2] contains the digit pressed
                if (event.shiftKey && isJuliaMode()) {
                    await startJuliaDive(fractalApp.DIVES, index);
                } else {
                    await travelToPreset(fractalApp.PRESETS, index);
                }
                handled = true;
            }
            break;
    }

    // Handling pan changes
    if (deltaPanX || deltaPanY) {
        let r = fractalApp.rotation;

        // Reflect the zoom factor for consistent pan speed at different zoom levels
        const effectiveDeltaX = (deltaPanX * fractalApp.zoom) * Math.cos(r) - (deltaPanY * fractalApp.zoom) * Math.sin(r);
        const effectiveDeltaY = (deltaPanX * fractalApp.zoom) * Math.sin(r) + (deltaPanY * fractalApp.zoom) * Math.cos(r);

        await fractalApp.animatePanBy([effectiveDeltaX, effectiveDeltaY], PAN_SPEED);
        fractalApp.noteInteraction(160);

        resetAppState();
    }

    // Handling C changes
    if ((deltaCx || deltaCy) && isJuliaMode()) {
        const effectiveDeltaCx = deltaCx * fractalApp.zoom;
        const effectiveDeltaCy = deltaCy * fractalApp.zoom;

        await fractalApp.animateToC([fractalApp.c[0] + effectiveDeltaCx, fractalApp.c[1] + effectiveDeltaCy], JULIA_HOTKEY_C_SPEED);
        resetAppState();
    }

    if (handled) {
        event.preventDefault();
    }
}

/**
 * Initializes the keyboard event listener
 * @param {FractalRenderer} app
 */
export function initHotKeys(app) {
    if (initialized) {
        console.warn(`%c initHotKeys: %c Redundant initialization skipped!`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);
        return;
    }

    fractalApp = app;
    document.addEventListener("keydown", onKeyDown);
    initialized = true;

    log('Initialized.', 'initHotKeys');
}

/** Destructor. Removes event listeners and cleans up */
export function destroyHotKeys() {
    if (!initialized) {
        console.warn(`%c destroyHotKeys: %c Nothing to destroy!`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);
        return;
    }

    fractalApp = null;
    initialized = false;

    document.removeEventListener("keydown", onKeyDown);

    console.log(`%c destroyHotKeys: %c Destroyed.`, CONSOLE_GROUP_STYLE, CONSOLE_MESSAGE_STYLE);
}