From 99cd496bb25acd877844fb8f8d06b6b14fcf1e67 Mon Sep 17 00:00:00 2001 From: raymondginger Date: Sun, 20 Sep 2026 21:07:27 +0800 Subject: [PATCH] desktop: resize the conversation pane by dragging its edge The width setting (#151) shipped as a slider in Settings; the handle the issue also asked for is the same preference reached from the pane itself. - The pointer previews --conversation-width and one write lands on release, so a drag costs one localStorage write instead of one per pointermove. - Arrow keys step by the slider's 5%, Home/End jump to the ends of the range, and a double click restores the default. The strip is a role="separator", so the share it manages is announced. - Shares come from the column's rendered width plus the pointer delta, because 100 means the built-in 820px cap rather than "fill the workspace": a drag writes 40-99 and leaves the cap to the slider. - The preview goes through useAppearance, which keeps the store the only writer of appearance state; applyAppearanceSetting moves one custom property instead of repainting the theme on every pointermove. - Hidden below 1080px, where the workspace is already at or under the cap and the column spans it; the slider still resizes it there. Refs #151. --- desktop/README.md | 7 +- desktop/src/App.tsx | 2 + desktop/src/app/appearance.ts | 58 +++++- desktop/src/app/i18n.ts | 2 + desktop/src/app/useAppearance.ts | 21 +- .../thread/ConversationSplitter.module.css | 65 ++++++ .../thread/ConversationSplitter.test.tsx | 169 ++++++++++++++++ .../features/thread/ConversationSplitter.tsx | 186 ++++++++++++++++++ .../thread/conversationWidthResize.test.ts | 83 ++++++++ .../thread/conversationWidthResize.ts | 119 +++++++++++ 10 files changed, 701 insertions(+), 11 deletions(-) create mode 100644 desktop/src/features/thread/ConversationSplitter.module.css create mode 100644 desktop/src/features/thread/ConversationSplitter.test.tsx create mode 100644 desktop/src/features/thread/ConversationSplitter.tsx create mode 100644 desktop/src/features/thread/conversationWidthResize.test.ts create mode 100644 desktop/src/features/thread/conversationWidthResize.ts diff --git a/desktop/README.md b/desktop/README.md index e999e737..c6133be5 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -230,7 +230,12 @@ deepcode -c personal-openrouter -m --effort auto ### Appearance and imported themes **Settings → Appearance** controls the machine-local theme, conversation width, -and typography. **Import VS Code theme** accepts one local `.json` or `.jsonc` +and typography. The conversation column also has a drag handle on its right +edge: dragging resizes it, the arrow keys step by 5%, Home and End jump to the +ends of the range, and a double click restores the default. Each of those writes +the same stored preference the slider edits — a drag stays below 100, which is +the built-in cap rather than "fill the workspace". **Import VS Code theme** +accepts one local `.json` or `.jsonc` color-theme file, validates every value in its `colors` object, and maps the supported workbench colors onto DeepCode's complete palette. Unmapped tokens come from the inferred light or dark base, so an imported theme never leaves a diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 637f4acc..82d5fa92 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -13,6 +13,7 @@ import { type ComposerLaunchIntent, } from "./features/execution/Composer"; import { DesktopSidebar } from "./features/navigation/DesktopSidebar"; +import { ConversationSplitter } from "./features/thread/ConversationSplitter"; import { ThreadHeader } from "./features/thread/ThreadHeader"; import { useTranscriptMode } from "./features/thread/transcriptMode"; import type { ClientRuntime } from "./rpc/contracts"; @@ -417,6 +418,7 @@ export function App({ runtime }: { runtime: ClientRuntime }) { /> )} + {showingThreads && selectedThread ? : null} {inspectorVisible ? ( diff --git a/desktop/src/app/appearance.ts b/desktop/src/app/appearance.ts index 64eb6cd7..e795b969 100644 --- a/desktop/src/app/appearance.ts +++ b/desktop/src/app/appearance.ts @@ -100,6 +100,18 @@ function clampNumber(value: unknown, min: number, max: number, fallback: number) return Math.min(max, Math.max(min, Math.round(parsed))); } +/** + * The conversation column's own slider domain. Exported because the drag + * handle that resizes the same column reads its floor, ceiling and step from + * here rather than restating them. + */ +export const CONVERSATION_WIDTH_RANGE = { + min: 40, + max: 100, + step: 5, + unit: "%", +} as const; + const CONVERSATION_WIDTH: AppearanceSetting<"conversationWidth"> = { key: "conversationWidth", label: "Conversation width", @@ -107,9 +119,14 @@ const CONVERSATION_WIDTH: AppearanceSetting<"conversationWidth"> = { "How much of the window the conversation column fills. The default keeps " + "lines short for readability; widen it to use more of a large display.", cssVariable: "--conversation-width", - range: { min: 40, max: 100, step: 5, unit: "%" }, + range: CONVERSATION_WIDTH_RANGE, sanitize: (value) => - clampNumber(value, 40, 100, APPEARANCE_DEFAULTS.conversationWidth), + clampNumber( + value, + CONVERSATION_WIDTH_RANGE.min, + CONVERSATION_WIDTH_RANGE.max, + APPEARANCE_DEFAULTS.conversationWidth, + ), // 100% restores the built-in cap rather than stretching edge to edge, so // the default stays exactly what it was before this setting existed. toCss: (value) => (value >= 100 ? "min(820px, 100%)" : `${value}%`), @@ -198,16 +215,39 @@ export function writeAppearance(state: AppearanceState): void { * Push the state onto `root`, clearing anything left at its default so the * stylesheet's own value shows through instead of a duplicate copy of it. */ +/** + * Push one setting onto `root`, clearing it when it sits at its default so the + * stylesheet's own value shows through instead of a duplicate copy of it. + * + * Exported for the conversation pane's drag handle (#151): a drag previews its + * own setting on every pointermove, and that preview must not restyle the theme + * dozens of times a second to move one column. + */ +export function applyAppearanceSetting( + key: K, + value: AppearanceState[K], + root: HTMLElement, +): void { + // Matched by key, so narrowing the table's union to this key is sound. + const setting = APPEARANCE_SETTINGS.find((candidate) => candidate.key === key) as + | AppearanceSetting + | undefined; + if (!setting?.cssVariable) return; + const rendered = setting.toCss ? setting.toCss(value) : String(value); + if (rendered === "" || value === APPEARANCE_DEFAULTS[key]) { + root.style.removeProperty(setting.cssVariable); + } else { + root.style.setProperty(setting.cssVariable, rendered); + } +} + +/** + * Push the state onto `root` — one pass over the table, in table order. + */ export function applyAppearance(state: AppearanceState, root: HTMLElement): void { for (const setting of APPEARANCE_SETTINGS) { if (!setting.cssVariable) continue; - const value = state[setting.key] as never; - const rendered = setting.toCss ? setting.toCss(value) : String(value); - if (rendered === "" || value === APPEARANCE_DEFAULTS[setting.key]) { - root.style.removeProperty(setting.cssVariable); - } else { - root.style.setProperty(setting.cssVariable, rendered); - } + applyAppearanceSetting(setting.key, state[setting.key], root); } applyImportedTheme(state.theme === "imported" ? state.importedTheme : null, root); diff --git a/desktop/src/app/i18n.ts b/desktop/src/app/i18n.ts index c99665c1..92cd3b44 100644 --- a/desktop/src/app/i18n.ts +++ b/desktop/src/app/i18n.ts @@ -198,6 +198,8 @@ const ZH_CN: Record = { "thread.review": "审查", "thread.closeReview": "关闭审查面板", "thread.openReview": "打开审查面板", + "thread.splitterLabel": "调整对话宽度", + "thread.splitterHint": "拖动调整对话宽度 · 双击恢复默认", // Approval card "approval.label": "需要审批", "approval.decision": "决定: {{status}}", diff --git a/desktop/src/app/useAppearance.ts b/desktop/src/app/useAppearance.ts index 55fa2a19..6c85e8b0 100644 --- a/desktop/src/app/useAppearance.ts +++ b/desktop/src/app/useAppearance.ts @@ -3,6 +3,7 @@ import { useCallback, useSyncExternalStore } from "react"; import { APPEARANCE_DEFAULTS, applyAppearance, + applyAppearanceSetting, readAppearance, writeAppearance, type AppearanceState, @@ -32,6 +33,22 @@ function flush(): void { if (element) applyAppearance(state, element); } +/** + * Paint a width that is not stored yet — a drag in progress — or put the stored + * one back with `null`. + * + * Storage is untouched either way and only the width property moves, so this + * stays one apply path rather than a second one: the next `commit` repaints + * everything from `state`, which also means a commit made during a drag wins + * until the next pointermove previews again. + */ +function previewConversationWidth(value: number | null): void { + const element = root(); + if (element) { + applyAppearanceSetting("conversationWidth", value ?? state.conversationWidth, element); + } +} + function subscribe(listener: () => void): () => void { listeners.add(listener); // The first subscriber marks the app as mounted; paint the saved @@ -61,6 +78,8 @@ export interface AppearanceController { /** Update related preferences atomically (used when importing a palette). */ update(patch: Partial): void; reset(): void; + /** Show an in-progress width without storing it; `null` restores the stored one. */ + previewConversationWidth(value: number | null): void; } export function useAppearance(): AppearanceController { @@ -80,7 +99,7 @@ export function useAppearance(): AppearanceController { const reset = useCallback(() => commit({ ...APPEARANCE_DEFAULTS }), []); - return { appearance, set, update, reset }; + return { appearance, set, update, reset, previewConversationWidth }; } /** Reset module state between tests. */ diff --git a/desktop/src/features/thread/ConversationSplitter.module.css b/desktop/src/features/thread/ConversationSplitter.module.css new file mode 100644 index 00000000..c6154ba1 --- /dev/null +++ b/desktop/src/features/thread/ConversationSplitter.module.css @@ -0,0 +1,65 @@ +/* Drag handle on the right edge of the centred conversation column (#151). + + It reads the same --conversation-width the column, its composer and the goal + rail read, so the strip sits on the edge all three share: half its own width + to the right of it, which is what measureColumnWidth() reads back off the DOM + to turn a drag into a share of the workspace. The one caveat — the strip is + anchored to the workspace while the column is centred inside the scroller, so + a scrollbar moves the true edge by a few pixels without moving that offset — + is documented in conversationWidthResize.ts. */ +.splitter { + position: absolute; + top: 0; + bottom: 0; + left: calc(50% + var(--conversation-width) * 0.5); + width: 12px; + margin-left: -6px; + border-radius: var(--radius-pill); + cursor: col-resize; + touch-action: none; +} + +.splitter::before { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 1px; + height: min(180px, 40%); + transform: translate(-50%, -50%); + border-radius: var(--radius-pill); + background: var(--border-strong); + opacity: 0.55; + /* Not repeated in a reduced-motion block: tokens.css already forces + `transition-duration: 0.01ms` on everything under that preference. */ + transition: + width 120ms ease, + opacity 120ms ease, + background 120ms ease; +} + +.splitter:hover::before, +.splitter:focus-visible::before, +.splitter[data-dragging="true"]::before { + width: 2px; + background: var(--border-emphasis); + opacity: 1; +} + +/* tokens.css outlines the form controls and nothing else; this is a + role="separator" div, so it brings its own ring. */ +.splitter:focus-visible { + outline: 3px solid var(--signal-soft); + outline-offset: 1px; +} + +/* Under this width the workspace is at or below the 820px cap the default stands + for — 1080px window − 248px sidebar − 12px workspace margins (an open + inspector, or a topic rail, narrows it further) — so the column already spans + the space and the strip would sit on the scrollbar. The settings slider still + resizes the column there. */ +@media (max-width: 1080px) { + .splitter { + display: none; + } +} diff --git a/desktop/src/features/thread/ConversationSplitter.test.tsx b/desktop/src/features/thread/ConversationSplitter.test.tsx new file mode 100644 index 00000000..fc9f3cac --- /dev/null +++ b/desktop/src/features/thread/ConversationSplitter.test.tsx @@ -0,0 +1,169 @@ +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { __resetAppearanceStoreForTests } from "../../app/useAppearance"; +import { ConversationSplitter } from "./ConversationSplitter"; +import { SPLITTER_MAX, SPLITTER_MIN } from "./conversationWidthResize"; + +const STORAGE_KEY = "deepcode.desktop.appearance.v1"; +const WORKSPACE_WIDTH = 1000; +const COLUMN_WIDTH = 700; +const HANDLE_WIDTH = 12; +/** Mirrors the stylesheet: the strip is centred on the column's right edge. */ +const EDGE = WORKSPACE_WIDTH / 2 + COLUMN_WIDTH / 2; + +function box(left: number, width: number): DOMRect { + return { + left, + width, + top: 0, + right: left + width, + bottom: 0, + height: 0, + x: left, + y: 0, + toJSON: () => ({}), + } as DOMRect; +} + +function storedWidth(): number | undefined { + const raw = localStorage.getItem(STORAGE_KEY); + return raw === null ? undefined : JSON.parse(raw).conversationWidth; +} + +function cssWidth(): string { + return document.documentElement.style.getPropertyValue("--conversation-width"); +} + +/** + * The splitter measures its parent, so it is rendered inside a stand-in + * workspace whose geometry jsdom would otherwise report as all zeroes. + */ +function mount() { + const view = render( +
+ +
, + ); + const workspace = view.getByTestId("workspace"); + const handle = view.getByRole("separator"); + workspace.getBoundingClientRect = () => box(0, WORKSPACE_WIDTH); + handle.getBoundingClientRect = () => box(EDGE - HANDLE_WIDTH / 2, HANDLE_WIDTH); + return { handle, unmount: view.unmount }; +} + +beforeEach(() => { + localStorage.clear(); + document.documentElement.removeAttribute("style"); + __resetAppearanceStoreForTests(); +}); + +afterEach(() => { + cleanup(); +}); + +describe("ConversationSplitter", () => { + it("previews while dragging and writes the share once, on release", () => { + const { handle } = mount(); + + fireEvent.pointerDown(handle, { pointerId: 1, button: 0, clientX: EDGE }); + fireEvent.pointerMove(window, { pointerId: 1, clientX: EDGE + 40 }); + + // 700px column + 2 * 40px = 780px of a 1000px workspace. + expect(cssWidth()).toBe("78%"); + expect(storedWidth()).toBeUndefined(); + + fireEvent.pointerUp(window, { pointerId: 1 }); + expect(storedWidth()).toBe(78); + }); + + it("only moves the width while previewing, not the rest of the appearance", () => { + const { handle } = mount(); + + fireEvent.pointerDown(handle, { pointerId: 1, button: 0, clientX: EDGE }); + fireEvent.pointerMove(window, { pointerId: 1, clientX: EDGE + 40 }); + + // A preview repaints one custom property: no theme attribute, no font stack. + expect(document.documentElement.getAttribute("data-theme")).toBeNull(); + expect(document.documentElement.style.getPropertyValue("--font-size-base")).toBe(""); + expect(cssWidth()).toBe("78%"); + }); + + it("leaves the preference and the stylesheet alone when the press does not move", () => { + const { handle } = mount(); + + fireEvent.pointerDown(handle, { pointerId: 1, button: 0, clientX: EDGE }); + fireEvent.pointerUp(window, { pointerId: 1, clientX: EDGE }); + + // The default is measured as 70% here; committing it on a stray click would + // silently narrow the column. + expect(storedWidth()).toBeUndefined(); + expect(cssWidth()).toBe(""); + }); + + it("puts the stored width back when it unmounts mid-drag", () => { + const { handle, unmount } = mount(); + + fireEvent.pointerDown(handle, { pointerId: 1, button: 0, clientX: EDGE }); + fireEvent.pointerMove(window, { pointerId: 1, clientX: EDGE + 40 }); + expect(cssWidth()).toBe("78%"); + + // Switching threads with the pointer still down must not leave the preview + // painted over the stored preference. + unmount(); + expect(cssWidth()).toBe(""); + expect(storedWidth()).toBeUndefined(); + }); + + it("ignores pointer moves that belong to another pointer", () => { + const { handle } = mount(); + + fireEvent.pointerDown(handle, { pointerId: 1, button: 0, clientX: EDGE }); + fireEvent.pointerMove(window, { pointerId: 2, clientX: EDGE + 40 }); + fireEvent.pointerUp(window, { pointerId: 1 }); + + expect(storedWidth()).toBeUndefined(); + }); + + it("steps with the arrow keys and jumps to the ends of the range", () => { + const { handle } = mount(); + + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + expect(storedWidth()).toBe(65); + + fireEvent.keyDown(handle, { key: "End" }); + expect(storedWidth()).toBe(SPLITTER_MAX); + + fireEvent.keyDown(handle, { key: "ArrowRight" }); + expect(storedWidth()).toBe(SPLITTER_MAX); + + fireEvent.keyDown(handle, { key: "Home" }); + expect(storedWidth()).toBe(SPLITTER_MIN); + + fireEvent.keyDown(handle, { key: "Tab" }); + expect(storedWidth()).toBe(SPLITTER_MIN); + }); + + it("reveals the share it manages once one is stored", () => { + const { handle } = mount(); + + // At the default the handle announces no number rather than the cap. + expect(handle.getAttribute("aria-valuenow")).toBeNull(); + + fireEvent.keyDown(handle, { key: "End" }); + expect(handle.getAttribute("aria-valuenow")).toBe(String(SPLITTER_MAX)); + expect(handle.getAttribute("aria-valuetext")).toBe(`${SPLITTER_MAX}%`); + }); + + it("resets to the default width on double click", () => { + const { handle } = mount(); + + fireEvent.keyDown(handle, { key: "End" }); + expect(cssWidth()).toBe(`${SPLITTER_MAX}%`); + + fireEvent.doubleClick(handle); + expect(storedWidth()).toBe(100); + // 100 is the built-in cap, so the preference drops its override again. + expect(cssWidth()).toBe(""); + }); +}); diff --git a/desktop/src/features/thread/ConversationSplitter.tsx b/desktop/src/features/thread/ConversationSplitter.tsx new file mode 100644 index 00000000..ccf722a5 --- /dev/null +++ b/desktop/src/features/thread/ConversationSplitter.tsx @@ -0,0 +1,186 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type KeyboardEvent, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { useTranslation } from "react-i18next"; + +import { APPEARANCE_DEFAULTS, CONVERSATION_WIDTH_RANGE } from "../../app/appearance"; +import { useAppearance } from "../../app/useAppearance"; +import { + measureColumnWidth, + percentFromDrag, + percentFromKey, + projectPercent, + SPLITTER_MAX, + SPLITTER_MIN, + SPLITTER_STEP, +} from "./conversationWidthResize"; +import styles from "./ConversationSplitter.module.css"; + +/** The two boxes a drag is measured against, taken once at press time. */ +interface Measurement { + renderedWidth: number; + workspaceWidth: number; +} + +interface Drag extends Measurement { + pointerId: number; + startX: number; + startPercent: number; + preview: number | null; +} + +/** + * Drag handle on the right edge of the conversation column (#151). + * + * It edits the preference the settings slider edits. The pointer previews + * `--conversation-width` as it moves and commits once on release, so a drag + * costs one localStorage write instead of one per pointermove. + * + * Moves are mapped from the column's rendered width, not from where the pointer + * sits, because 100% is the built-in 820px cap rather than the widest the pane + * can get — see `conversationWidthResize.ts` for that arithmetic. + */ +export function ConversationSplitter() { + const { appearance, set, previewConversationWidth } = useAppearance(); + const { t } = useTranslation(); + const handle = useRef(null); + const drag = useRef(null); + const [dragging, setDragging] = useState(false); + + const measure = useCallback((): Measurement | null => { + const element = handle.current; + const workspace = element?.parentElement; + if (!element || !workspace) return null; + const workspaceBox = workspace.getBoundingClientRect(); + const handleBox = element.getBoundingClientRect(); + return { + workspaceWidth: workspaceBox.width, + renderedWidth: measureColumnWidth(workspaceBox, handleBox), + }; + }, []); + + /** The share the stored preference currently renders as. */ + const currentPercent = useCallback( + (measured?: Measurement | null) => { + const box = measured ?? measure(); + return projectPercent( + appearance.conversationWidth, + box?.renderedWidth ?? 0, + box?.workspaceWidth ?? 0, + ); + }, + [appearance.conversationWidth, measure], + ); + + useEffect(() => { + if (!dragging) return; + + const move = (event: PointerEvent) => { + const state = drag.current; + if (!state || event.pointerId !== state.pointerId) return; + state.preview = percentFromDrag( + state.renderedWidth, + state.workspaceWidth, + event.clientX - state.startX, + ); + previewConversationWidth(state.preview); + }; + + const finish = () => { + const state = drag.current; + drag.current = null; + setDragging(false); + if (!state) return; + if (state.preview === null || state.preview === state.startPercent) { + // A press that never moved must not rewrite the preference: the default + // reads as ~57% on a 1440px window, and committing that on a stray click + // would silently narrow the column. + previewConversationWidth(null); + return; + } + set("conversationWidth", state.preview); + }; + + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", finish); + window.addEventListener("pointercancel", finish); + return () => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", finish); + window.removeEventListener("pointercancel", finish); + }; + }, [dragging, previewConversationWidth, set]); + + // Switching threads mid-drag unmounts this with a preview still painted. + useEffect(() => () => previewConversationWidth(null), [previewConversationWidth]); + + const onPointerDown = (event: ReactPointerEvent) => { + if (event.pointerType === "mouse" && event.button !== 0) return; + const measured = measure(); + if (!measured) return; + drag.current = { + pointerId: event.pointerId, + startX: event.clientX, + startPercent: currentPercent(measured), + preview: null, + ...measured, + }; + // Keep receiving moves once the pointer leaves the 12px strip. jsdom has no + // pointer capture, hence the guard. + if (typeof event.currentTarget.setPointerCapture === "function") { + event.currentTarget.setPointerCapture(event.pointerId); + } + event.preventDefault(); + setDragging(true); + }; + + const onKeyDown = (event: KeyboardEvent) => { + const next = percentFromKey(currentPercent(), event.key, SPLITTER_STEP); + if (next === null) return; + event.preventDefault(); + set("conversationWidth", next); + }; + + const onDoubleClick = () => { + set("conversationWidth", APPEARANCE_DEFAULTS.conversationWidth); + }; + + // Announcing a number while the stored value is the cap would be a guess: 100 + // is 100% of a narrow workspace and ~57% of a wide one. The gaps close as soon + // as the two controls write a real share. + const percent = + appearance.conversationWidth === APPEARANCE_DEFAULTS.conversationWidth + ? undefined + : appearance.conversationWidth; + const value = + percent === undefined + ? {} + : { + "aria-valuemin": SPLITTER_MIN, + "aria-valuemax": SPLITTER_MAX, + "aria-valuenow": percent, + "aria-valuetext": `${percent}${CONVERSATION_WIDTH_RANGE.unit}`, + }; + + return ( +
+ ); +} diff --git a/desktop/src/features/thread/conversationWidthResize.test.ts b/desktop/src/features/thread/conversationWidthResize.test.ts new file mode 100644 index 00000000..973c0a21 --- /dev/null +++ b/desktop/src/features/thread/conversationWidthResize.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { + measureColumnWidth, + percentFromDrag, + percentFromKey, + projectPercent, + SPLITTER_MAX, + SPLITTER_MIN, + SPLITTER_STEP, +} from "./conversationWidthResize"; + +describe("measureColumnWidth", () => { + it("reads a centred column's full width off the handle's centre line", () => { + const width = measureColumnWidth( + { left: 0, width: 1000 }, + { left: 844, width: 12 }, + ); + expect(width).toBe(700); + }); + + it("never reports a negative width", () => { + const width = measureColumnWidth( + { left: 0, width: 1000 }, + { left: 100, width: 12 }, + ); + expect(width).toBe(0); + }); +}); + +describe("percentFromDrag", () => { + it("moves the grabbed edge with the pointer", () => { + // A centred column grows on both sides, so 40px of travel is 80px of width. + expect(percentFromDrag(700, 1000, 40)).toBe(78); + }); + + it("clamps to the slider's minimum", () => { + expect(percentFromDrag(700, 1000, -400)).toBe(SPLITTER_MIN); + }); + + it("stops below the legacy cap", () => { + expect(percentFromDrag(700, 1000, 400)).toBe(SPLITTER_MAX); + }); + + it("stays usable when there is nothing to measure", () => { + expect(percentFromDrag(0, 0, 10)).toBe(SPLITTER_MAX); + }); +}); + +describe("projectPercent", () => { + it("passes a stored share through", () => { + expect(projectPercent(70, 700, 1000)).toBe(70); + }); + + it("measures the default instead of trusting the cap", () => { + expect(projectPercent(100, 700, 1000)).toBe(70); + }); + + it("keeps the measurement inside the scale", () => { + expect(projectPercent(100, 1000, 1000)).toBe(SPLITTER_MAX); + }); + + it("falls back when there is nothing to measure", () => { + expect(projectPercent(100, 0, 0)).toBe(SPLITTER_MAX); + }); +}); + +describe("percentFromKey", () => { + it("steps by the slider's step", () => { + expect(percentFromKey(70, "ArrowRight", SPLITTER_STEP)).toBe(75); + expect(percentFromKey(70, "ArrowLeft", SPLITTER_STEP)).toBe(65); + }); + + it("runs to the ends of the range", () => { + expect(percentFromKey(70, "Home", SPLITTER_STEP)).toBe(SPLITTER_MIN); + expect(percentFromKey(70, "End", SPLITTER_STEP)).toBe(SPLITTER_MAX); + }); + + it("ignores keys it does not own", () => { + expect(percentFromKey(70, "Tab", SPLITTER_STEP)).toBeNull(); + expect(percentFromKey(70, "Enter", SPLITTER_STEP)).toBeNull(); + }); +}); diff --git a/desktop/src/features/thread/conversationWidthResize.ts b/desktop/src/features/thread/conversationWidthResize.ts new file mode 100644 index 00000000..c19aa083 --- /dev/null +++ b/desktop/src/features/thread/conversationWidthResize.ts @@ -0,0 +1,119 @@ +/** + * Geometry behind the conversation-width splitter (#151). + * + * The conversation column is centred in the workspace — `width: + * var(--conversation-width); margin: 0 auto` in ThreadConversation, Composer + * and GoalRail — so its right edge sits at `workspaceCentre + width / 2`. The + * handle rides that edge and derives each new share from the column's *rendered* + * width plus the pointer delta, because a centred column grows on both sides at + * once: + * + * nextWidth = renderedWidth + 2 * deltaX + * share = nextWidth / workspaceWidth * 100 + * + * Measuring the rendered width is what keeps the grabbed edge under the cursor. + * `conversationWidth` is not a plain percentage at its maximum: 100 means "the + * width the column had before this setting existed" (`min(820px, 100%)`), which + * on a window wider than 820px is *narrower* than 99%. An absolute + * pointer-to-percentage mapping would have to pick one of the two scales and + * would jump on the first pixel of movement; a delta from the measured edge + * stays continuous either way. + */ + +import { APPEARANCE_DEFAULTS, CONVERSATION_WIDTH_RANGE } from "../../app/appearance"; + +/** Lowest share the splitter writes — the slider's own minimum. */ +export const SPLITTER_MIN = CONVERSATION_WIDTH_RANGE.min; + +/** + * Highest share the splitter writes. The slider's maximum is excluded: 100 is + * the legacy cap, not "fill the workspace", so a drag that wrote it would + * *shrink* the column on any window wider than 820px. The default stays + * reachable from the slider and from the splitter's double-click reset. + */ +export const SPLITTER_MAX = CONVERSATION_WIDTH_RANGE.max - 1; + +/** One keyboard gesture, in the slider's own step. */ +export const SPLITTER_STEP = CONVERSATION_WIDTH_RANGE.step; + +export interface Box { + left: number; + width: number; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +/** + * Rounded share of the workspace, held inside the splitter's range. + * + * A drag lands on any whole share rather than on the slider's 5% step: that step + * is the other control's own keyboard grain, and rounding a drag to it would + * make the edge jump under the cursor. + */ +function shareOf(width: number, workspaceWidth: number): number { + return clamp(Math.round((width / workspaceWidth) * 100), SPLITTER_MIN, SPLITTER_MAX); +} + +/** + * Width of the centred column, read off the workspace box and the handle's + * centre line. + * + * The handle is anchored to the workspace while the column is centred in the + * scroller inside it, so a classic scrollbar takes ~8px from the column and + * moves its true edge by half of that. A drag is deltas off this reading, so the + * offset shifts the mapping by about a percent of the workspace instead of + * accumulating; reading the column directly would mean anchoring the handle from + * inside the scroller, where it would scroll away with the transcript. + */ +export function measureColumnWidth(workspace: Box, handle: Box): number { + const centre = workspace.left + workspace.width / 2; + const edge = handle.left + handle.width / 2; + return Math.max(0, (edge - centre) * 2); +} + +/** + * The share of the workspace the column currently occupies. A stored value is + * already a share; the default (100) is a cap that says nothing about the share + * it happens to take, so that one is measured rather than believed. + */ +export function projectPercent( + stored: number, + renderedWidth: number, + workspaceWidth: number, +): number { + if (stored !== APPEARANCE_DEFAULTS.conversationWidth) { + return clamp(Math.round(stored), SPLITTER_MIN, SPLITTER_MAX); + } + if (!(workspaceWidth > 0)) return SPLITTER_MAX; + return shareOf(renderedWidth, workspaceWidth); +} + +/** Share that keeps the grabbed edge under the pointer. */ +export function percentFromDrag( + renderedWidth: number, + workspaceWidth: number, + deltaX: number, +): number { + if (!(workspaceWidth > 0)) return SPLITTER_MAX; + return shareOf(renderedWidth + 2 * deltaX, workspaceWidth); +} + +/** Share after a keyboard gesture, or null when the key belongs to someone else. */ +export function percentFromKey(current: number, key: string, step: number): number | null { + switch (key) { + case "ArrowLeft": + case "ArrowUp": + return clamp(current - step, SPLITTER_MIN, SPLITTER_MAX); + case "ArrowRight": + case "ArrowDown": + return clamp(current + step, SPLITTER_MIN, SPLITTER_MAX); + case "Home": + return SPLITTER_MIN; + case "End": + return SPLITTER_MAX; + default: + return null; + } +}