From b81a8cad229b28c818c6143b7a78cbc3d8a66217 Mon Sep 17 00:00:00 2001 From: henz Date: Sun, 11 Oct 2026 10:10:12 +0800 Subject: [PATCH 1/3] e-stepper: a Vertical checkbox chooses the arrangement of the panes; the program may shrink to one line Co-Authored-By: Claude Sonnet 5.5 --- .changeset/estepper-vertical-choice.md | 5 ++ src/web/e-stepper/src/EStepperView.tsx | 53 +++++++++++++++---- .../src/__tests__/EStepperView.test.tsx | 49 +++++++++++++++-- 3 files changed, 94 insertions(+), 13 deletions(-) create mode 100644 .changeset/estepper-vertical-choice.md diff --git a/.changeset/estepper-vertical-choice.md b/.changeset/estepper-vertical-choice.md new file mode 100644 index 0000000..d385e63 --- /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 diff --git a/src/web/e-stepper/src/EStepperView.tsx b/src/web/e-stepper/src/EStepperView.tsx index fd635de..58fd015 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,25 @@ 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; +const MIN_PROGRAM_WIDTH = 80; const DIVIDER_SPACE = 2 * 8 + 2; type Props = { @@ -227,7 +234,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 +334,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 +343,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); @@ -346,18 +364,20 @@ export default function EStepperView({ steps, profile, error, cseDiagram }: Prop // width at the tab's current size, however the share was set. const paneSpace = Math.max(0, containerSize.width - DIVIDER_SPACE); const programWidthFor = (share: number) => - Math.round(Math.min(paneSpace - MIN_PANE_WIDTH, Math.max(MIN_PANE_WIDTH, share * paneSpace))); + Math.round( + Math.min(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 +439,21 @@ 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..2e5d4b1 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,46 @@ 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); + 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 +399,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 80px, the diagram 200px. act(() => listeners.pointermove({ clientX: 0 })); - expect(left()).toBe("0 0 200px"); + expect(left()).toBe("0 0 80px"); act(() => listeners.pointermove({ clientX: 2000 })); expect(left()).toBe(`0 0 ${1182 - 200}px`); act(() => listeners.pointerup({ clientX: 2000 })); From 21f4c27125748e953b4920412653163b6d1bcd1c Mon Sep 17 00:00:00 2001 From: henz Date: Sun, 11 Oct 2026 10:12:01 +0800 Subject: [PATCH 2/3] e-stepper: the program pane may be as narrow as about three characters Co-Authored-By: Claude Sonnet 5.5 --- .changeset/estepper-vertical-choice.md | 2 +- src/web/e-stepper/src/EStepperView.tsx | 3 ++- src/web/e-stepper/src/__tests__/EStepperView.test.tsx | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.changeset/estepper-vertical-choice.md b/.changeset/estepper-vertical-choice.md index d385e63..849ca4a 100644 --- a/.changeset/estepper-vertical-choice.md +++ b/.changeset/estepper-vertical-choice.md @@ -2,4 +2,4 @@ "@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 +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 58fd015..0295ed9 100644 --- a/src/web/e-stepper/src/EStepperView.tsx +++ b/src/web/e-stepper/src/EStepperView.tsx @@ -48,7 +48,8 @@ const STACKED_DIVIDER_SPACE = 2 * 8 + 6 + 2 * 2; */ const DEFAULT_PROGRAM_SHARE = 0.45; const MIN_PANE_WIDTH = 200; -const MIN_PROGRAM_WIDTH = 80; +/** 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 = { diff --git a/src/web/e-stepper/src/__tests__/EStepperView.test.tsx b/src/web/e-stepper/src/__tests__/EStepperView.test.tsx index 2e5d4b1..3370d62 100644 --- a/src/web/e-stepper/src/__tests__/EStepperView.test.tsx +++ b/src/web/e-stepper/src/__tests__/EStepperView.test.tsx @@ -399,9 +399,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`); - // The program keeps at least 80px, the diagram 200px. + // The program keeps at least 44px (about three characters), the diagram 200px. act(() => listeners.pointermove({ clientX: 0 })); - expect(left()).toBe("0 0 80px"); + expect(left()).toBe("0 0 44px"); act(() => listeners.pointermove({ clientX: 2000 })); expect(left()).toBe(`0 0 ${1182 - 200}px`); act(() => listeners.pointerup({ clientX: 2000 })); From ad3789d33ff19f054a370ed702ac63cd727a86df Mon Sep 17 00:00:00 2001 From: henz Date: Sun, 11 Oct 2026 10:32:52 +0800 Subject: [PATCH 3/3] e-stepper: a nonnegative program width in narrow tabs; the Vertical button states its state Co-Authored-By: Claude Sonnet 5.5 --- src/web/e-stepper/src/EStepperView.tsx | 16 ++++++++++- .../src/__tests__/EStepperView.test.tsx | 27 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/web/e-stepper/src/EStepperView.tsx b/src/web/e-stepper/src/EStepperView.tsx index 0295ed9..1855601 100644 --- a/src/web/e-stepper/src/EStepperView.tsx +++ b/src/web/e-stepper/src/EStepperView.tsx @@ -364,9 +364,20 @@ 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_PROGRAM_WIDTH, share * paneSpace)), + 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( @@ -446,10 +457,13 @@ export default function EStepperView({ steps, profile, error, cseDiagram }: Prop style={{ marginLeft: 8 }} title="Program above the environment, instead of beside it" aria-label="Vertical" + aria-pressed={!wide} onClick={() => setVertical(v => !(v ?? false))} > { 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(); });