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
33 changes: 33 additions & 0 deletions web/e2e/terminal-osc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,39 @@ test("a session's row follows a program that works for less than the time betwee
await expect(sessions.locator("[data-program-status]")).toHaveCount(0);
});

// Two programs that began working at different moments turn their rings together. Left to start when each appears,
// one ring points up while the other points down.
test("the rings of two working programs turn in step, whenever each began", async ({ page, installation }) => {
test.skip(process.platform === "win32", "printf escape sequences are POSIX shell syntax");
await openApplication(page, installation);
const sessions = page.getByRole("navigation", { name: "Primary" }).getByRole("list", { name: "Open sessions" });
const screen = page.getByRole("region", { name: /^Terminal for / });
// Both sessions are open before either program works, so a ring appears in a list that is already drawn.
await openLocalShell(page);
await openLocalShell(page);
await expect(sessions.getByRole("listitem")).toHaveCount(2);
const rings = sessions.locator('[data-program-status="working"]');
for (const row of [1, 0]) {
await sessions.getByRole("listitem").nth(row).getByRole("button").first().click();
await expect(screen).not.toContainText("read -r");
await expect(screen).toContainText(/[$#%>]/, { timeout: 20_000 });
await terminalKeyboard(page).focus();
await page.keyboard.type(`${reportProgramStatus("state=working")}; read -r _`);
await page.keyboard.press("Enter");
await expect(rings).toHaveCount(2 - row, { timeout: 15_000 });
// The second program begins a part of a turn after the first.
await page.waitForTimeout(370);
}

// Both angles are read in one step, so the rings have no time to turn between the two readings.
const degrees = await rings.evaluateAll((marks) => marks.map((mark) => {
const turn = new DOMMatrixReadOnly(getComputedStyle(mark).transform);
return (Math.atan2(turn.b, turn.a) * 180) / Math.PI;
}));
const apart = Math.abs(((degrees[0]! - degrees[1]! + 540) % 360) - 180);
expect(apart, `the rings stand at ${degrees.map((angle) => angle.toFixed(0)).join("° and ")}°`).toBeLessThan(2);
});

// Programs say what they are doing with OSC 7501. The session's row shows it in
// place of its dot and destination: working and blocked while they last, a done
// result until the session has been on screen since it came.
Expand Down
11 changes: 9 additions & 2 deletions web/src/terminal/SessionMarker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { CSSProperties } from "react";
import type { TerminalSession } from "../api/terminalSessions";
import type { ProgramProgress } from "./programProgress";
import type { ShownProgramStatus } from "./programStatus";
import { useSharedTurn } from "../ui/useSharedTurn";
import { sessionMarkerClass } from "./sessionStatus";

// The connection dot takes the size of the program status marks, an 11px circle,
Expand Down Expand Up @@ -54,8 +55,14 @@ export function SessionMarker({ session, unread, programStatus, programProgress
style={{ "--sshc-progress": `${programProgress.percent}%` } as CSSProperties} />;
}
if (programStatus !== null || programProgress !== null) {
const source = programStatus === null ? { "data-program-progress": "indeterminate" } : { "data-program-status": "working" };
return <span aria-hidden="true" {...source} className={`${markerCircle} animate-spin border-2 border-accent/25 border-t-accent`} />;
return <TurningRing source={programStatus === null ? { "data-program-progress": "indeterminate" } : { "data-program-status": "working" }} />;
}
return <span aria-hidden="true" className={`${markerCircle} ${sessionMarkerClass(session, unread)}`} />;
}

// TurningRing is the ring that turns while a program works without saying how far. It is a component of its own so
// that it is set in step with the other rows' rings when it appears, whenever its program began.
function TurningRing({ source }: { source: { "data-program-progress": "indeterminate" } | { "data-program-status": "working" } }) {
const ring = useSharedTurn<HTMLSpanElement>();
return <span ref={ring} aria-hidden="true" {...source} className={`${markerCircle} animate-spin border-2 border-accent/25 border-t-accent`} />;
}
4 changes: 4 additions & 0 deletions web/src/ui/Spinner.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { useSharedTurn } from "./useSharedTurn";

// Spinner は、終わるまでの時間が分からない処理を待っている間に回る輪である。色は文字の色を
// 受け継ぐので、置く場所の text-* で変える。読み上げには出さず、待っていることは近くの文か
// aria-busy で伝える。動きを減らす設定のときは index.css の決まりで止まる。
export function Spinner({ className = "size-3.5" }: { className?: string }) {
const ring = useSharedTurn<HTMLSpanElement>();
return (
<span
ref={ring}
aria-hidden="true"
className={`inline-block shrink-0 animate-spin rounded-full border-2 border-current border-r-transparent ${className}`}
/>
Expand Down
13 changes: 13 additions & 0 deletions web/src/ui/useSharedTurn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useLayoutEffect, useRef } from "react";

// 回る印(animate-spin)は、付いた瞬間から回り始める。そのままだと、同じ画面にある2つの印の向きが
// そろわない(片方は上、片方は下を向いて回る)。useSharedTurn は、印のアニメーションの始まりをページの
// 時計の 0 に置き、いつ付いた印も同じ向きで回るようにする。返す ref を回る要素に付ける。
export function useSharedTurn<Mark extends Element>() {
const mark = useRef<Mark>(null);
useLayoutEffect(() => {
// 動きを減らす設定ではアニメーションが無く、何もしない。jsdom は getAnimations を持たない。
for (const animation of mark.current?.getAnimations?.() ?? []) animation.startTime = 0;
}, []);
return mark;
}
Loading