# presentation/widgets/surface.widget.ts

> 198 lines of code and 41 definitions.

Tree: Site tree
Language: typescript
Layer: product
Canonical: https://banes-lab.com/anatomy/tree#file-presentation-widgets-surface-widget-ts
Source text: https://banes-lab.com/source/tree/presentation/widgets/surface.widget.ts.txt

Listed in [presentation/widgets](https://banes-lab.com/api/source/tree/presentation/widgets.md), after [presentation/widgets/site.widget.ts](https://banes-lab.com/source/tree/presentation/widgets/site.widget.ts.md) and before [presentation/widgets/tab.widget.ts](https://banes-lab.com/source/tree/presentation/widgets/tab.widget.ts.md).

## Definitions

- `editSegments` (lexical_declaration, line 56)
- `scriptOf` (lexical_declaration, line 80)
- `prefersStill` (lexical_declaration, line 20)
- `typingSegment` (lexical_declaration, line 24)
- `commandSegments` (lexical_declaration, line 39)
- `typing` (lexical_declaration, line 40)
- `removals` (lexical_declaration, line 57)
- `insertions` (lexical_declaration, line 63)
- `at` (lexical_declaration, line 64)
- `indent` (lexical_declaration, line 65)
- `{ frames, opening }` (lexical_declaration, line 81)
- `outputs` (lexical_declaration, line 82)
- `first` (lexical_declaration, line 83)
- `openingFocus` (lexical_declaration, line 84)
- `lines` (lexical_declaration, line 85)
- `body` (lexical_declaration, line 86)
- `rest` (lexical_declaration, line 87)
- `edit` (lexical_declaration, line 88)
- `firstChanged` (lexical_declaration, line 89)
- `lead` (lexical_declaration, line 92)
- `output` (lexical_declaration, line 103)
- `hold` (lexical_declaration, line 109)
- `rewind` (lexical_declaration, line 139)
- `back` (lexical_declaration, line 146)
- `SurfacePlayer` (class_declaration, line 156, exported)
- `parts` (public_field_definition, line 157, exported)
- `timer` (public_field_definition, line 158, exported)
- `settled` (public_field_definition, line 159, exported)
- `still` (public_field_definition, line 160, exported)
- `paused` (public_field_definition, line 161, exported)
- `visible` (public_field_definition, line 162, exported)
- `constructor` (method_definition, line 164, exported)
- `shell` (lexical_declaration, line 165, exported)
- `venue` (lexical_declaration, line 166, exported)
- `script` (lexical_declaration, line 168, exported)
- `start` (method_definition, line 175, exported)
- `observer` (lexical_declaration, line 177, exported)
- `sync` (method_definition, line 189, exported)
- `toggle` (method_definition, line 197, exported)
- `renderControls` (method_definition, line 203, exported)
- `button` (lexical_declaration, line 204, exported)

## Source

```typescript
import {
    COMMAND_CHAR_MS,
    ENTRY_CHAR_MS,
    FRAME_HOLD_MS,
    LOOP_HOLD_MS,
    OPENING_HOLD_MS,
    OUTPUT_LINE_MS,
    REWIND_LINE_MS,
    TYPED_HOLD_MS,
} from "#configuration/constants/surface.constants";
import type { LineEdit, Segment, SurfaceRecording, SurfaceScript } from "#types/surface.types";
import { VenuePane, createPlaybackButton } from "#presentation/components/surface.component";
import { indentOf, lineEdit, outputLines, outputRest } from "#core/converters/surface.converter";
import type { Disposer } from "#types/base.types";
import type { PlayerParts } from "#types/figure.types";
import { REDUCED_MOTION_QUERY } from "#configuration/constants/diagram.constants";
import { ShellPane } from "#presentation/components/shell.component";
import { SurfaceTimer } from "#presentation/timers/surface.timer";

const prefersStill = function prefersStill(): boolean {
    return typeof globalThis.matchMedia === "function" && globalThis.matchMedia(REDUCED_MOTION_QUERY).matches;
};

const typingSegment = function typingSegment(
    length: number,
    charMs: number,
    begin: () => void,
    render: (count: number, done: boolean) => void,
): Segment {
    return {
        advance: (progress) => {
            render(Math.ceil(progress * length), progress >= 1);
        },
        begin,
        ms: Math.max(length, 1) * charMs,
    };
};

const commandSegments = function commandSegments(command: string, shell: ShellPane): Segment[] {
    const typing = typingSegment(
        command.length,
        COMMAND_CHAR_MS,
        () => {
            shell.prompt();
        },
        (count, done) => {
            shell.type(command.slice(0, count));
            if (done) {
                shell.typed();
            }
        },
    );
    return [typing, { ms: TYPED_HOLD_MS }];
};

const editSegments = function editSegments(edit: LineEdit, venue: VenuePane): Segment[] {
    const removals = Array.from({ length: edit.removed }, (_, step): Segment => ({
        begin: () => {
            venue.remove(edit.at + edit.removed - 1 - step);
        },
        ms: REWIND_LINE_MS,
    }));
    const insertions = edit.inserted.map((line, offset) => {
        const at = edit.at + offset;
        const indent = indentOf(line.text);
        return typingSegment(
            line.text.length - indent,
            ENTRY_CHAR_MS,
            () => {
                venue.insert(at, line);
            },
            (count, done) => {
                venue.type(at, line.text.slice(0, indent + count), done);
            },
        );
    });
    return [...removals, ...insertions];
};

const scriptOf = function scriptOf(recording: SurfaceRecording, shell: ShellPane, venue: VenuePane): SurfaceScript {
    const { frames, opening } = recording;
    const outputs = new Map<string, string>();
    const first = frames.at(0);
    const openingFocus = first === undefined ? 0 : lineEdit(opening, first.lines).at;
    let lines = opening;
    const body = frames.flatMap((frame): Segment[] => {
        const rest = outputRest(outputs.get(frame.command), frame.output);
        const edit = lineEdit(lines, frame.lines);
        const firstChanged = frame.lines.findIndex((line) => line.changed);
        outputs.set(frame.command, frame.output);
        lines = frame.lines.map((line) => line.text);
        const lead: Segment[] =
            rest === null
                ? commandSegments(frame.command, shell)
                : [
                      {
                          begin: () => {
                              shell.resume(frame.command);
                          },
                          ms: TYPED_HOLD_MS,
                      },
                  ];
        const output = outputLines(rest ?? frame.output).map((line): Segment => ({
            begin: () => {
                shell.append(line);
            },
            ms: OUTPUT_LINE_MS,
        }));
        const hold: Segment = {
            begin: () => {
                venue.mark(frame.lines);
                if (firstChanged !== -1 && edit.inserted.length === 0) {
                    venue.focus(firstChanged);
                }
            },
            ms: FRAME_HOLD_MS,
        };
        return [
            {
                begin: () => {
                    venue.mark([]);
                },
                ms: 0,
            },
            ...lead,
            ...editSegments(edit, venue),
            ...output,
            hold,
        ];
    });
    const start: Segment = {
        begin: () => {
            shell.clear();
            venue.show(opening);
            venue.focus(openingFocus);
        },
        ms: OPENING_HOLD_MS,
    };
    const rewind: Segment = {
        begin: () => {
            shell.clear();
            venue.mark([]);
        },
        ms: 0,
    };
    const back = lineEdit(
        lines,
        opening.map((text) => ({ changed: false, text })),
    );
    return {
        segments: [start, ...body, { ms: LOOP_HOLD_MS }, rewind, ...editSegments(back, venue)],
        settled: body.length + 1,
    };
};

export class SurfacePlayer {
    private readonly parts: PlayerParts;
    private readonly timer: SurfaceTimer;
    private readonly settled: number;
    private readonly still = prefersStill();
    private paused: boolean;
    private visible = false;

    public constructor(parts: PlayerParts, recording: SurfaceRecording) {
        const shell = new ShellPane();
        const venue = new VenuePane(recording.frames.at(0)?.surface ?? "");
        parts.stage.replaceChildren(shell.element, venue.element);
        const script = scriptOf(recording, shell, venue);
        this.parts = parts;
        this.timer = new SurfaceTimer(script.segments);
        this.settled = script.settled;
        this.paused = this.still;
    }

    public start(): Disposer {
        this.timer.settle(this.still ? this.settled : 1);
        const observer = new IntersectionObserver((entries) => {
            this.visible = entries.some((entry) => entry.isIntersecting);
            this.sync();
        });
        observer.observe(this.parts.figure);
        this.renderControls();
        return () => {
            observer.disconnect();
            this.timer.halt();
        };
    }

    private sync(): void {
        if (!this.paused && this.visible) {
            this.timer.run();
        } else {
            this.timer.halt();
        }
    }

    private toggle(): void {
        this.paused = !this.paused;
        this.renderControls();
        this.sync();
    }

    private renderControls(): void {
        const button = createPlaybackButton(this.paused, () => {
            this.toggle();
        });
        this.parts.controls.replaceChildren(button);
    }
}
```
