All files / src/web watch-panel.js

99% Statements 200/202
94.23% Branches 49/52
92.3% Functions 36/39
99.44% Lines 178/179

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339                4x 4x 4x 4x 4x                                   248x   248x 248x   248x   248x 248x 248x 248x 248x   248x 248x 1488x 1488x 10x 10x 10x   1488x   248x         248x 3x 3x   248x   248x                   248x 248x 248x   1126x 6x   248x         256x 256x 256x     256x 60x 60x 42x 9x 9x 9x 9x   42x 6x 6x             446x   446x 446x 446x 446x       52x 52x         124x 124x 124x 20x   20x           124x 17x 17x   124x 9x 9x 9x   124x 124x 124x 21x 21x             128x   128x 128x       160x 131x 131x                         18x 18x 18x 18x       52x       45x 45x 225x 225x 225x 225x 225x   45x 45x 45x   45x                         45x 45x 45x 52x 52x 52x 52x   45x 45x 270x 270x 7x 7x 7x   270x   45x   45x 45x 45x 71x 71x 71x 71x 71x 71x 71x   45x 45x 45x 20x 20x   45x 45x 45x 45x 45x 45x 45x 45x 45x 45x 45x 45x 45x 45x 4x 4x 4x 4x 3x   45x 45x   45x 45x 45x 3x 3x 3x 3x 3x 3x   45x 45x 45x 45x 45x         90x 90x 90x 90x 90x 90x 90x 90x 90x         105x 105x 48x 48x 48x   105x 105x 50x 50x                                                
import { hexword } from "../hex.js";
import { RamStat, RomStat } from "../paging.js";
import { formatWatch, parseCondition, WatchFormats, writeStops } from "../watches.js";
import { FloatingPanel, MenuGroup } from "./floating-panel.js";
import { StartEdge } from "./panel-layout.js";
import { bindGotoForm } from "./debug-dom.js";
import { placePopup, popupBehaviour, setText, textButton } from "./dom-utils.js";
 
const MinPanelSize = { w: 200, h: 80 };
const RowCells = 5;
const AddressSpace = 0x10000;
const MaxAddress = AddressSpace - 1;
const DeviceNote = "A device register stops it on every write to it, never on a change the device makes itself.";
 
/**
 * A floating list of addresses and their values, read once a frame while it is open and the machine runs, and not
 * at all while it is closed. Watches are added by name or address, or from a menu offered at an address (`offer`),
 * and the add box gives the keyboard back to the machine on Escape or once a watch is added. A watch can stop the
 * machine on a write that changes it; the CPU takes its slower path only while one does.
 */
export class WatchPanel {
    /**
     * @param {object} options
     * @param {(address: number) => number} options.peek - reads without side effects
     * @param {(text: string) => {address: number, label: string} | undefined} options.resolve
     * @param {import("./floating-panel.js").PanelArrangement} options.arrangement
     * @param {(address: number, view: import("../paging.js").PagingView) => void} options.searchWrites - finds what
     *     last wrote the byte `view` shows at `address`
     */
    constructor({ peek, resolve, arrangement, searchWrites }) {
        this._peek = peek;
        /** @type {Watch[]} */
        this._watches = [];
        this._cpu = null;
        /** @type {{watches: Watch[], target: number} | null} */
        this._fired = null;
        /** @type {{opener: HTMLElement | null, popup: HTMLElement} | null} */
        this._openPopup = null;
        this.panel = document.getElementById("watch-panel");
        this._list = this.panel.querySelector(".watch-list");
        this._empty = this.panel.querySelector(".watch-empty");
        this._menu = document.getElementById("watch-menu");
        /** @type {Offered | null} */
        this._offered = null;
        for (const format of WatchFormats) {
            const choice = textButton("watch-menu-choice", `Watch ${format.name}`, format.description);
            choice.addEventListener("click", () => {
                this._showPopup(null);
                this._add(this._offered, format);
                this.floating.open();
            });
            this._menu.append(choice);
        }
        this._searchWrites = textButton(
            "watch-menu-choice",
            "Who wrote this?",
            "Replay the history step back keeps to find the last write to this byte",
        );
        this._searchWrites.addEventListener("click", () => {
            this._showPopup(null);
            searchWrites(this._offered.address, this._offered.view);
        });
        this._menu.append(this._searchWrites);
 
        this.floating = new FloatingPanel({
            panel: this.panel,
            id: "watches",
            title: "Watches",
            menuGroup: MenuGroup.cpu,
            group: null,
            place: () => ({ x: StartEdge, y: StartEdge }),
            minSize: MinPanelSize,
            arrangement,
        });
        this.floating.whileOpen(() => this.update());
        this.floating.addEventListener("close", () => this._showPopup(null));
        popupBehaviour(
            this._menu,
            () => (this._openPopup ? [this._openPopup.opener, this._openPopup.popup] : null),
            () => this._showPopup(null),
        );
        bindGotoForm(document.getElementById("watch-add-form"), resolve, (found) => this._add(found, WatchFormats[0]));
    }
 
    /** Sets the breakpoints of the watches that stop the machine on `cpu`, and no longer on the last one. */
    setCpu(cpu) {
        for (const watch of this._watches) this._unregister(watch);
        this._cpu = cpu;
        for (const watch of this._watches) this._register(watch);
        // When a watch's breakpoint is changed elsewhere, such as from the console, so that it is no longer a stopping
        // write breakpoint over the watch's bytes, the watch stops breaking.
        cpu.breakpoints.onChange(() => {
            const now = new Map(cpu.breakpoints.list().map((bp) => [bp.id, bp]));
            for (const watch of this._watches) {
                const intact = watch.breakpoints.every((id, i) => {
                    const bp = now.get(id);
                    const [start, end] = watch.ranges[i];
                    const covers = bp?.kind === "write" && bp.start === start && bp.end === end;
                    return covers && bp.enabled && bp.action === "stop";
                });
                if (intact) continue;
                watch.breakpoints = watch.breakpoints.filter((id) => now.has(id));
                watch.disarm();
            }
        });
    }
 
    /** Highlights the watches that stopped the machine, opening the panel to show them, or none if none did. */
    showWhichFired() {
        const cpu = this._cpu;
        // Only the stop the CPU is still in: a run outside the loop, such as a rewind preview, can fire one and go on.
        const fired = cpu?.halted && this._fired?.target === cpu.targetCycles ? this._fired.watches : [];
        for (const watch of this._watches) watch.row.classList.toggle("watch-fired", fired.includes(watch));
        if (fired.length > 0) this.floating.open();
        this._fired = null;
    }
 
    clearFired() {
        this._fired = null;
        this.showWhichFired();
    }
 
    /** Sets `watch`'s write breakpoints over its bytes, wrapping past &FFFF, if it stops the machine. */
    _register(watch) {
        this._unregister(watch);
        const cpu = this._cpu;
        if (!watch.breaks || !cpu) return;
        const last = watch.address + watch.format.bytes - 1;
        const ranges =
            last <= MaxAddress
                ? [[watch.address, last]]
                : [
                      [watch.address, MaxAddress],
                      [0, last - AddressSpace],
                  ];
        const accepts = (address, byte, pc) => {
            const stat = cpu.memStat[cpu.memStatOffset + (address >>> 8)];
            return stat !== RomStat && writeStops(this._peek, watch, address, byte, stat === RamStat, cpu, pc);
        };
        const onHit = () => {
            const target = cpu.targetCycles;
            if (this._fired?.target !== target) this._fired = { watches: [], target };
            Eif (!this._fired.watches.includes(watch)) this._fired.watches.push(watch);
        };
        const named = watch.label !== `$${hexword(watch.address)}`;
        watch.ranges = ranges;
        for (const [start, end] of ranges) {
            const label = named && start === watch.address ? watch.label : undefined;
            watch.breakpoints.push(
                cpu.breakpoints.add({ kind: "write", start, end, accepts, onHit, owner: "watch", label }),
            );
        }
    }
 
    _unregister(watch) {
        const ids = watch.breakpoints;
        // Emptied first, so that removing them is not taken for their being removed elsewhere.
        watch.breakpoints = [];
        for (const id of ids) this._cpu.breakpoints.remove(id);
    }
 
    update() {
        for (const watch of this._watches) {
            const text = formatWatch(this._peek, watch.address, watch.format);
            setText(watch.value, text);
        }
    }
 
    /**
     * Offers a menu of formats to watch `what` in, as the CPU pages it, at the window position `x`, `y`, and if
     * `searchable` to find what last wrote the byte its view shows.
     * @param {Offered} what
     * @param {number} x
     * @param {number} y
     * @param {boolean} searchable
     */
    offer(what, x, y, searchable) {
        this._offered = what;
        this._searchWrites.hidden = !searchable;
        this._showPopup({ opener: null, popup: this._menu });
        placePopup(this._menu, x, y);
    }
 
    dismissMenu() {
        if (this._openPopup?.popup === this._menu) this._showPopup(null);
    }
 
    _add({ address, label }, initialFormat) {
        const row = document.createElement("tr");
        const cell = (className, text) => {
            const td = document.createElement("td");
            td.className = className;
            td.textContent = text;
            row.append(td);
            return td;
        };
        const name = cell("watch-name", label);
        name.title = `${label}: $${hexword(address)}`;
        const value = cell("watch-value", "");
        /** @type {Watch} */
        const watch = {
            address,
            label,
            format: initialFormat,
            value,
            row,
            breaks: false,
            condition: null,
            breakpoints: [],
            ranges: [],
            disarm: () => {},
        };
 
        const picker = textButton("watch-format dropdown-toggle", "", "");
        const [choices, choiceCell] = this._popupRow(picker, "watch-formats");
        const show = (format) => {
            watch.format = format;
            picker.textContent = format.name;
            picker.title = `Shown as ${format.description}`;
            this._register(watch);
        };
        show(watch.format);
        for (const format of WatchFormats) {
            const choice = textButton("watch-format-choice", format.name, format.description);
            choice.addEventListener("click", () => {
                show(format);
                this._showPopup(null);
                this.update();
            });
            choiceCell.append(choice);
        }
        cell("", "").append(picker);
 
        const breaker = textButton("watch-break dropdown-toggle", "", "");
        const [breaks, breakCell] = this._popupRow(breaker, "watch-breaks");
        const arm = (on, condition) => {
            watch.breaks = on;
            watch.condition = condition;
            breaker.textContent = `brk${condition?.text ?? ""}`;
            breaker.classList.toggle("armed", on);
            const when = condition ? `on a write that makes it ${condition.text}` : "on a write that changes it";
            breaker.title = `${on ? "Stops the machine" : "Stop the machine"} ${when}. ${DeviceNote}`;
            this._register(watch);
        };
        watch.disarm = () => arm(false, null);
        arm(false, null);
        const choose = (on, condition) => {
            arm(on, condition);
            this._showPopup(null);
        };
        const off = textButton("watch-break-choice", "off", "Never stop the machine for it");
        off.addEventListener("click", () => choose(false, null));
        const onChange = textButton("watch-break-choice", "on change", "Stop the machine on a write that changes it");
        onChange.addEventListener("click", () => choose(true, null));
        const conditionForm = document.createElement("form");
        const conditionBox = document.createElement("input");
        conditionBox.type = "text";
        conditionBox.className = "form-control form-control-sm panel-field watch-condition";
        conditionBox.placeholder = "e.g. 42, > $20";
        conditionBox.setAttribute("aria-label", `Stop the machine when ${label} becomes`);
        conditionBox.autocomplete = "off";
        conditionBox.addEventListener("input", () => conditionBox.classList.remove("is-invalid"));
        conditionForm.append(conditionBox);
        conditionForm.addEventListener("submit", (e) => {
            e.preventDefault();
            const condition = parseCondition(conditionBox.value);
            conditionBox.classList.toggle("is-invalid", condition === undefined);
            if (!condition) return;
            choose(true, condition);
        });
        breakCell.append(off, onChange, conditionForm);
        cell("", "").append(breaker);
 
        const remove = textButton("watch-remove", "×", `Stop watching ${label}`);
        remove.setAttribute("aria-label", remove.title);
        remove.addEventListener("click", () => {
            this._watches.splice(this._watches.indexOf(watch), 1);
            row.remove();
            choices.remove();
            breaks.remove();
            this._empty.hidden = this._watches.length > 0;
            this._unregister(watch);
        });
        cell("", "").append(remove);
        this._list.append(row, choices, breaks);
        this._watches.push(watch);
        this._empty.hidden = true;
        this.update();
    }
 
    /** A hidden row of choices under a watch, which `opener` shows and hides; returns it and its one cell. */
    _popupRow(opener, className) {
        opener.setAttribute("aria-expanded", "false");
        const popup = document.createElement("tr");
        popup.className = className;
        popup.hidden = true;
        const cell = document.createElement("td");
        cell.colSpan = RowCells;
        popup.append(cell);
        opener.addEventListener("click", () => this._showPopup(popup.hidden ? { opener, popup } : null));
        return [popup, cell];
    }
 
    /** Opens `open`'s popup, closing any other, or closes them all for null. */
    _showPopup(open) {
        const was = this._openPopup;
        if (was && was.popup !== open?.popup) {
            if (was.popup.contains(document.activeElement)) document.activeElement.blur();
            was.popup.hidden = true;
            was.opener?.setAttribute("aria-expanded", "false");
        }
        this._openPopup = open;
        if (!open) return;
        open.popup.hidden = false;
        open.opener?.setAttribute("aria-expanded", "true");
    }
}
 
/**
 * @typedef {object} Watch
 * @property {number} address
 * @property {import("../watches.js").WatchFormat} format
 * @property {HTMLElement} value
 * @property {HTMLElement} row
 * @property {string} label
 * @property {boolean} breaks - whether a write changing it stops the machine
 * @property {number[]} breakpoints - the ids of its write breakpoints, while it breaks
 * @property {number[][]} ranges - the first and last address each of them covers
 * @property {() => void} disarm
 * @property {import("../watches.js").WatchCondition | null} condition - what the new value must be to stop it
 */
 
/**
 * @typedef {object} Offered
 * @property {number} address
 * @property {string} label
 * @property {import("../paging.js").PagingView} view - the view its byte was offered from
 */