Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/estepper-layout-memory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@sourceacademy/web-e-stepper": patch
---

e-stepper: the panes start side by side (Vertical off) whatever the tab's width, and the arrangement and the divider stay where the user put them after a run
5 changes: 4 additions & 1 deletion src/web/e-stepper/src/EStepperHostPlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from "@sourceacademy/conductor/conduit";
import { createElement, useSyncExternalStore } from "react";

import EStepperView from "./EStepperView";
import EStepperView, { type PaneLayout } from "./EStepperView";

/** The side-content tab id used by the host to show/hide the e-stepper tab. */
const TAB_ID = "e-stepper";
Expand Down Expand Up @@ -65,6 +65,8 @@ export class EStepperHostPlugin implements IPlugin {

const subscribe = (listener: () => void) => this.subscribe(listener);
const getState = () => this.getState();
// Kept here, not in the view: a run may mount the view anew, and the user's divider stays.
const layout: PaneLayout = {};
const cseDiagram: ICseDiagramService | undefined = hostServices?.cseDiagram;
function EStepperTab() {
const state = useSyncExternalStore(subscribe, getState);
Expand All @@ -73,6 +75,7 @@ export class EStepperHostPlugin implements IPlugin {
profile: state.profile,
error: state.error,
cseDiagram,
layout,
});
}

Expand Down
63 changes: 45 additions & 18 deletions src/web/e-stepper/src/EStepperView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,6 @@ import EnvDiagram from "./EnvDiagram";
import ProgramArrows, { ENV_ATTRIBUTE, REF_ATTRIBUTE } from "./ProgramArrows";
import { injectEStepperStyles } from "./styles";

/**
* Width (px) from which the program and the diagram start out side by side: the arrangement is
* chosen by the tab's width once, when the tab is first measured, and the user's after that.
*/
const WIDE_LAYOUT_MIN_WIDTH = 900;
const DEFAULT_PROGRAM_HEIGHT = 240;
/** The least height of the diagram below the program, and of the program: about one line of it. */
const MIN_PANE_HEIGHT = 80;
Expand All @@ -58,8 +53,37 @@ type Props = {
error?: string | null;
/** The host's CSE machine visualization, if it lends one (see `ICseDiagramService`). */
cseDiagram?: ICseDiagramService;
/**
* The user's choices about the arrangement of the panes. The host keeps this object across runs,
* which may mount the view anew, so that the divider stays where the user put it.
*/
layout?: PaneLayout;
};

/** The arrangement of the panes that the user chose; a missing field is still the default. */
export type PaneLayout = { vertical?: boolean; programHeight?: number; programShare?: number };

/** Like `useState`, but the value is also written to (and starts from) the host's `layout`. */
function useRemembered<K extends keyof PaneLayout>(
layout: PaneLayout | undefined,
key: K,
fallback: NonNullable<PaneLayout[K]>,
) {
const [value, setValue] = useState<NonNullable<PaneLayout[K]>>(
(layout?.[key] as NonNullable<PaneLayout[K]> | undefined) ?? fallback,
);
const set = useCallback(
(update: React.SetStateAction<NonNullable<PaneLayout[K]>>) =>
setValue(old => {
const next = typeof update === "function" ? update(old) : update;
if (layout) layout[key] = next;
return next;
}),
[layout, key],
);
return [value, set] as const;
}

/** Whether the step is a stop for breakpoint navigation: it evaluates a `breakpoint()` statement. */
const isBreakpoint = (step: EStepperStep): boolean =>
step.markers?.some(m => m.redexNodeType === "DebuggerStatement") ?? false;
Expand Down Expand Up @@ -193,12 +217,20 @@ function useSize(): [(element: HTMLElement | null) => void, { width: number; hei
*/
export { sliderLabels };

export default function EStepperView({ steps, profile, error, cseDiagram }: Props) {
export default function EStepperView({ steps, profile, error, cseDiagram, layout }: Props) {
const [stepValue, setStepValue] = useState(1);
const [hovered, setHovered] = useState<string | null>(null);
const [hoveredFrame, setHoveredFrame] = useState<string | null>(null);
const [programHeight, setProgramHeight] = useState(DEFAULT_PROGRAM_HEIGHT);
const [programShare, setProgramShare] = useState(DEFAULT_PROGRAM_SHARE);
const [programHeight, setProgramHeight] = useRemembered(
layout,
"programHeight",
DEFAULT_PROGRAM_HEIGHT,
);
const [programShare, setProgramShare] = useRemembered(
layout,
"programShare",
DEFAULT_PROGRAM_SHARE,
);
const [outputOpen, setOutputOpen] = useState(true);
// Display options. Program references (arrows from the program into the diagram) are off until the user asks.
const [showArrows, setShowArrows] = useState(true);
Expand Down Expand Up @@ -235,15 +267,10 @@ export default function EStepperView({ steps, profile, error, cseDiagram }: Prop
const hasRun = lastStep > 0;
const stepIndex = hasRun ? Math.min(stepValue, lastStep) - 1 : 0;
const step = hasRun ? steps[stepIndex] : undefined;
// The arrangement of the panes: side by side ("wide") or one above the other ("vertical"). It
// starts from the tab's width when that is first known, and is the user's choice from then on.
const [vertical, setVertical] = useState<boolean | null>(null);
useEffect(() => {
if (containerSize.width > 0) {
setVertical(v => v ?? containerSize.width < WIDE_LAYOUT_MIN_WIDTH);
}
}, [containerSize.width]);
const wide = vertical === null ? false : !vertical;
// The arrangement of the panes: side by side ("wide", the default) or one above the other
// ("vertical"), as the user chooses, whatever the tab's width.
const [vertical, setVertical] = useRemembered(layout, "vertical", false);
const wide = !vertical;

// The double arrows (and `a` / `e`) jump to the previous / next breakpoint, or to the first / last
// step when there is none, as in the stepper.
Expand Down Expand Up @@ -458,7 +485,7 @@ export default function EStepperView({ steps, profile, error, cseDiagram }: Prop
title="Program above the environment, instead of beside it"
aria-label="Vertical"
aria-pressed={!wide}
onClick={() => setVertical(v => !(v ?? false))}
onClick={() => setVertical(v => !v)}
>
<Checkbox
readOnly
Expand Down
29 changes: 28 additions & 1 deletion src/web/e-stepper/src/__tests__/EStepperView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,14 @@ describe("EStepperView", () => {
expect(view.root.findAllByType(konva("konva-stage"))).toHaveLength(1);
});

test("stacks the panes in a narrow tab, with a divider that resizes the program pane", () => {
test("stacks the panes when Vertical is chosen, with a divider that resizes the program pane", () => {
const view = render({ steps: [fixture[1]], profile });
act(() =>
view.root
.findAllByType(Button)
.find(b => b.props["aria-label"] === "Vertical")!
.props.onClick(),
);
const main = view.root.find(n => hasClass(n, "estepper-main"));
expect(hasClass(main, "narrow")).toBe(true);
const listeners: Record<string, (e: { clientY: number }) => void> = {};
Expand All @@ -278,10 +284,31 @@ describe("EStepperView", () => {
vi.unstubAllGlobals();
});

test("a view mounted anew (after a run) starts from the host's remembered arrangement", () => {
const layout = {};
const first = render({ steps: [fixture[1]], profile, layout });
act(() =>
first.root
.findAllByType(Button)
.find(b => b.props["aria-label"] === "Vertical")!
.props.onClick(),
);
act(() => first.unmount());
const second = render({ steps: [fixture[1]], profile, layout });
const main = second.root.find(n => hasClass(n, "estepper-main"));
expect(hasClass(main, "narrow")).toBe(true);
});

test("the divider can not be dragged out of view: the diagram below keeps its least height", () => {
// The panes' container is 600px high (see `render`'s node mock): 80px stay for the diagram,
// and 26px for the divider and its gaps.
const view = render({ steps: [fixture[1]], profile });
act(() =>
view.root
.findAllByType(Button)
.find(b => b.props["aria-label"] === "Vertical")!
.props.onClick(),
);
const listeners: Record<string, (e: { clientY: number }) => void> = {};
vi.stubGlobal("window", {
addEventListener: (type: string, fn: (e: { clientY: number }) => void) =>
Expand Down
Loading