diff --git a/.changeset/estepper-vertical-choice.md b/.changeset/estepper-vertical-choice.md new file mode 100644 index 0000000..849ca4a --- /dev/null +++ b/.changeset/estepper-vertical-choice.md @@ -0,0 +1,5 @@ +--- +"@sourceacademy/web-e-stepper": patch +--- + +e-stepper: a "Vertical" checkbox chooses between the program beside or above the environment (the tab's width only gives the first choice); the program pane may shrink to about one line high and three characters wide diff --git a/src/web/e-stepper/src/EStepperView.tsx b/src/web/e-stepper/src/EStepperView.tsx index fd635de..1855601 100644 --- a/src/web/e-stepper/src/EStepperView.tsx +++ b/src/web/e-stepper/src/EStepperView.tsx @@ -2,6 +2,7 @@ import { Button, ButtonGroup, Card, + Checkbox, Classes, Popover, Pre, @@ -29,19 +30,26 @@ 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 are shown side by side. */ +/** + * 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; +const MIN_PROGRAM_HEIGHT = 40; /** In a stacked tab: the two 8px gaps around the divider, its 6px, and its two 2px margins. */ const STACKED_DIVIDER_SPACE = 2 * 8 + 6 + 2 * 2; /** - * In a wide tab, the program pane's share of the width left for the two panes, and the least width - * of either pane. That width is the tab's less the two 8px gaps around the divider and the + * In a wide tab, the program pane's share of the width left for the two panes, and the least widths + * of the panes. That width is the tab's less the two 8px gaps around the divider and the * divider's own 2px (6px wide, with -2px margins; see styles.ts). */ const DEFAULT_PROGRAM_SHARE = 0.45; const MIN_PANE_WIDTH = 200; +/** About three characters of the program, with the pane's 10px padding on both sides. */ +const MIN_PROGRAM_WIDTH = 44; const DIVIDER_SPACE = 2 * 8 + 2; type Props = { @@ -227,7 +235,15 @@ 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; - const wide = containerSize.width >= WIDE_LAYOUT_MIN_WIDTH; + // 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(null); + useEffect(() => { + if (containerSize.width > 0) { + setVertical(v => v ?? containerSize.width < WIDE_LAYOUT_MIN_WIDTH); + } + }, [containerSize.width]); + const wide = vertical === null ? false : !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. @@ -319,7 +335,7 @@ export default function EStepperView({ steps, profile, error, cseDiagram }: Prop // always in view, to be dragged back. const maxProgramHeight = mainSize.height > 0 - ? Math.max(MIN_PANE_HEIGHT, mainSize.height - STACKED_DIVIDER_SPACE - MIN_PANE_HEIGHT) + ? Math.max(MIN_PROGRAM_HEIGHT, mainSize.height - STACKED_DIVIDER_SPACE - MIN_PANE_HEIGHT) : Infinity; const stackedProgramHeight = Math.min(programHeight, maxProgramHeight); const startResize = useCallback( @@ -328,7 +344,10 @@ export default function EStepperView({ steps, profile, error, cseDiagram }: Prop const startHeight = stackedProgramHeight; const onMove = (e: PointerEvent) => setProgramHeight( - Math.min(maxProgramHeight, Math.max(MIN_PANE_HEIGHT, startHeight + e.clientY - startY)), + Math.min( + maxProgramHeight, + Math.max(MIN_PROGRAM_HEIGHT, startHeight + e.clientY - startY), + ), ); const onUp = () => { window.removeEventListener("pointermove", onMove); @@ -345,19 +364,32 @@ export default function EStepperView({ steps, profile, error, cseDiagram }: Prop // resized). The pane's width is worked out at every render, so either pane keeps its least // width at the tab's current size, however the share was set. const paneSpace = Math.max(0, containerSize.width - DIVIDER_SPACE); + // In a tab too narrow for both least widths the program keeps its own and the diagram gets what is + // left (the width is never negative, nor more than there is). const programWidthFor = (share: number) => - Math.round(Math.min(paneSpace - MIN_PANE_WIDTH, Math.max(MIN_PANE_WIDTH, share * paneSpace))); + Math.round( + Math.max( + 0, + Math.min( + paneSpace, + Math.min( + Math.max(MIN_PROGRAM_WIDTH, paneSpace - MIN_PANE_WIDTH), + Math.max(MIN_PROGRAM_WIDTH, share * paneSpace), + ), + ), + ), + ); const programWidth = programWidthFor(programShare); const startWidthResize = useCallback( (event: React.PointerEvent) => { event.preventDefault(); - if (paneSpace <= 2 * MIN_PANE_WIDTH) return; + if (paneSpace <= MIN_PROGRAM_WIDTH + MIN_PANE_WIDTH) return; const startX = event.clientX; const startWidth = programWidth; const onMove = (e: PointerEvent) => { const width = Math.min( paneSpace - MIN_PANE_WIDTH, - Math.max(MIN_PANE_WIDTH, startWidth + e.clientX - startX), + Math.max(MIN_PROGRAM_WIDTH, startWidth + e.clientX - startX), ); setProgramShare(width / paneSpace); }; @@ -419,6 +451,24 @@ export default function EStepperView({ steps, profile, error, cseDiagram }: Prop {usingHostDiagram ? null : ( // The host's diagram has its own toolbar for these (its arrow filters, "Clear Dead // Frames"); these are for the plugin's own diagram. diff --git a/src/web/e-stepper/src/__tests__/EStepperView.test.tsx b/src/web/e-stepper/src/__tests__/EStepperView.test.tsx index 123376f..38cff6b 100644 --- a/src/web/e-stepper/src/__tests__/EStepperView.test.tsx +++ b/src/web/e-stepper/src/__tests__/EStepperView.test.tsx @@ -1,7 +1,7 @@ import type { CseDiagramViewProps } from "@sourceacademy/common-cse-machine"; import type { EStepperStep, SyntaxProfile } from "@sourceacademy/common-e-stepper"; import { act, createElement } from "react"; -import { Button, Popover, Slider, Switch } from "@blueprintjs/core"; +import { Button, Checkbox, Popover, Slider, Switch } from "@blueprintjs/core"; import TestRenderer from "react-test-renderer"; import { beforeEach, describe, expect, test, vi } from "vitest"; @@ -299,7 +299,8 @@ describe("EStepperView", () => { view.root.find(n => hasClass(n, "estepper-divider")).props.onPointerDown({ clientY: 0 }), ); act(() => listeners.pointermove({ clientY: -100000 })); - expect(view.root.find(n => hasClass(n, "estepper-left")).props.style.flex).toBe("0 0 80px"); + // The program may shrink to about one line. + expect(view.root.find(n => hasClass(n, "estepper-left")).props.style.flex).toBe("0 0 40px"); vi.unstubAllGlobals(); }); @@ -325,6 +326,73 @@ describe("EStepperView", () => { vi.unstubAllGlobals(); }); + test("the panes' arrangement starts from the tab's width, then is the user's choice (Vertical)", () => { + const observers: ((entries: { contentRect: { width: number; height: number } }[]) => void)[] = + []; + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: (typeof observers)[number]) { + observers.push(callback); + } + observe() {} + disconnect() {} + }, + ); + const view = render({ steps: [fixture[1]], profile }, 1200); + const narrow = () => + hasClass( + view.root.find(n => hasClass(n, "estepper-main")), + "narrow", + ); + const vertical = () => + view.root.findAllByType(Button).find(b => b.props["aria-label"] === "Vertical")!; + const resize = (width: number) => + act(() => observers.forEach(cb => cb([{ contentRect: { width, height: 600 } }]))); + // Wide at first: side by side, the box not ticked. + expect(narrow()).toBe(false); + expect(view.root.findByType(Checkbox).props.checked).toBe(false); + // Making the tab narrow does not rearrange it. + resize(500); + expect(narrow()).toBe(false); + // The user asks for the vertical arrangement, and then for the other one again. + act(() => vertical().props.onClick()); + expect(narrow()).toBe(true); + expect(view.root.findByType(Checkbox).props.checked).toBe(true); + resize(1400); + expect(narrow()).toBe(true); + act(() => vertical().props.onClick()); + expect(narrow()).toBe(false); + // The button states its state; the checkbox in it is only a picture. + expect(vertical().props["aria-pressed"]).toBe(false); + act(() => vertical().props.onClick()); + expect(vertical().props["aria-pressed"]).toBe(true); + expect(view.root.findByType(Checkbox).props.tabIndex).toBe(-1); + vi.unstubAllGlobals(); + }); + + test("side by side in a tab too narrow for both panes: the program keeps its least width, never negative", () => { + const observers: ((entries: { contentRect: { width: number; height: number } }[]) => void)[] = + []; + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: (typeof observers)[number]) { + observers.push(callback); + } + observe() {} + disconnect() {} + }, + ); + const view = render({ steps: [fixture[1]], profile }, 1200); + const left = () => view.root.find(n => hasClass(n, "estepper-left")).props.style.flex; + for (const width of [250, 150, 10]) { + act(() => observers.forEach(cb => cb([{ contentRect: { width, height: 600 } }]))); + expect(left()).toBe(`0 0 ${Math.min(44, Math.max(0, width - 18))}px`); + } + vi.unstubAllGlobals(); + }); + test("puts the panes side by side in a wide tab, with a divider that resizes the program pane", () => { // The tab's size, as the ResizeObservers report it (one for the tab, one for the diagram). const observers: ((entries: { contentRect: { width: number; height: number } }[]) => void)[] = @@ -358,9 +426,9 @@ describe("EStepperView", () => { act(() => divider.props.onPointerDown({ clientX: 600, preventDefault: () => {} })); act(() => listeners.pointermove({ clientX: 480 })); expect(left()).toBe(`0 0 ${Math.round(0.45 * 1182) - 120}px`); - // Either pane keeps at least 200px. + // The program keeps at least 44px (about three characters), the diagram 200px. act(() => listeners.pointermove({ clientX: 0 })); - expect(left()).toBe("0 0 200px"); + expect(left()).toBe("0 0 44px"); act(() => listeners.pointermove({ clientX: 2000 })); expect(left()).toBe(`0 0 ${1182 - 200}px`); act(() => listeners.pointerup({ clientX: 2000 }));