diff --git a/src/api/SurfaceClasses.ts b/src/api/SurfaceClasses.ts new file mode 100644 index 000000000..9a2ed2982 --- /dev/null +++ b/src/api/SurfaceClasses.ts @@ -0,0 +1,210 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Logger } from "@utils/Logger"; +import { useForceUpdater } from "@utils/react"; +import { useEffect } from "@webpack/common"; +import type { CSSProperties, FocusEventHandler, MouseEventHandler, RefCallback } from "react"; + +/** + * Adds plugin-owned semantic data attributes and a limited set of props to + * stable Discord-owned layout surfaces, so plugins can style/track layout + * state without patching compiled Discord JSX directly. + * + * This is not a generic prop bus and not for plugin-local components. It + * intentionally does not expose className: injected props land before or + * after Discord's own props depending on the surface, so class merging + * cannot be guaranteed. Use data attributes as CSS hooks instead. + */ + +export type SurfaceId = + | "base" + | "sidebar" + | "guildBar" + | "channelList" + | "membersList" + | "titleBar" + | "headerBar" + | "userArea"; + +export interface SurfaceProvidedProps { + ref?: RefCallback; + style?: CSSProperties; + [dataAttribute: `data-${string}`]: string | undefined; + onFocusCapture?: FocusEventHandler; + onBlurCapture?: FocusEventHandler; + onMouseDownCapture?: MouseEventHandler; + onMouseOverCapture?: MouseEventHandler; + onMouseOutCapture?: MouseEventHandler; +} + +export type SurfacePropsProvider = () => SurfaceProvidedProps | undefined; + +interface SurfaceInstance { + forceUpdate(callback?: () => void): void; +} + +const propsProviders = new Map>(); +const listeners = new Map void>>(); +const surfaceInstances = new Map>(); +const failedPropsProviders = new WeakSet(); +const logger = new Logger("SurfaceClasses"); + +function getPropsProviderSet(surfaceId: SurfaceId) { + let set = propsProviders.get(surfaceId); + + if (set == null) { + set = new Set(); + propsProviders.set(surfaceId, set); + } + + return set; +} + +function getListenerSet(surfaceId: SurfaceId) { + let set = listeners.get(surfaceId); + + if (set == null) { + set = new Set(); + listeners.set(surfaceId, set); + } + + return set; +} + +function chainHandlers( + first?: (event: E) => void, + second?: (event: E) => void +) { + if (!first) return second; + if (!second) return first; + + return (event: E) => { + first(event); + second(event); + }; +} + +function chainRefs(first?: RefCallback, second?: RefCallback) { + if (!first) return second; + if (!second) return first; + + return (instance: T | null) => { + first(instance); + second(instance); + }; +} + +function mergeSurfaceProvidedProps(target: SurfaceProvidedProps, source: SurfaceProvidedProps) { + const ref = chainRefs(target.ref, source.ref); + if (ref) target.ref = ref; + const onFocusCapture = chainHandlers(target.onFocusCapture, source.onFocusCapture); + if (onFocusCapture) target.onFocusCapture = onFocusCapture; + const onBlurCapture = chainHandlers(target.onBlurCapture, source.onBlurCapture); + if (onBlurCapture) target.onBlurCapture = onBlurCapture; + const onMouseDownCapture = chainHandlers(target.onMouseDownCapture, source.onMouseDownCapture); + if (onMouseDownCapture) target.onMouseDownCapture = onMouseDownCapture; + const onMouseOverCapture = chainHandlers(target.onMouseOverCapture, source.onMouseOverCapture); + if (onMouseOverCapture) target.onMouseOverCapture = onMouseOverCapture; + const onMouseOutCapture = chainHandlers(target.onMouseOutCapture, source.onMouseOutCapture); + if (onMouseOutCapture) target.onMouseOutCapture = onMouseOutCapture; + + if (source.style) { + target.style = { ...target.style, ...source.style }; + } + + // Copy only string data-* attributes so providers cannot smuggle in + // arbitrary props (className in particular is deliberately unsupported). + for (const [key, value] of Object.entries(source)) { + if (key.startsWith("data-") && typeof value === "string") { + target[key as `data-${string}`] = value; + } + } + + return target; +} + +function getSurfaceProps(surfaceId: SurfaceId) { + const props: SurfaceProvidedProps = {}; + + for (const provider of propsProviders.get(surfaceId) ?? []) { + let providedProps: SurfaceProvidedProps | undefined; + + try { + providedProps = provider(); + } catch (error) { + if (!failedPropsProviders.has(provider)) { + failedPropsProviders.add(provider); + logger.error(`Surface props provider failed for ${surfaceId}`, error); + } + continue; + } + + if (providedProps) { + mergeSurfaceProvidedProps(props, providedProps); + } + } + + return props; +} + +function notifyOneSurface(surfaceId: SurfaceId) { + const surfaceInstance = surfaceInstances.get(surfaceId)?.deref(); + if (surfaceInstance) { + surfaceInstance.forceUpdate(); + } else { + surfaceInstances.delete(surfaceId); + } + + for (const listener of listeners.get(surfaceId) ?? []) { + listener(); + } +} + +export function addSurfacePropsProvider(surfaceId: SurfaceId, provider: SurfacePropsProvider) { + getPropsProviderSet(surfaceId).add(provider); + notifyOneSurface(surfaceId); + + return () => { + propsProviders.get(surfaceId)?.delete(provider); + notifyOneSurface(surfaceId); + }; +} + +/** Re-renders a surface after the state backing one of its providers changed. */ +export function notifySurfaceClassesChanged(surfaceId: SurfaceId) { + notifyOneSurface(surfaceId); +} + +/** @internal Injected by SurfaceClassesAPI patch (do NOT call directly) */ +export function _getSurfaceProps(surfaceId: SurfaceId) { + return getSurfaceProps(surfaceId); +} + +/** @internal Injected by SurfaceClassesAPI patch (do NOT call directly) */ +export function _useSurfaceProps(surfaceId: SurfaceId) { + const forceUpdate = useForceUpdater(); + + useEffect(() => { + const listener = () => forceUpdate(); + + getListenerSet(surfaceId).add(listener); + return () => { listeners.get(surfaceId)?.delete(listener); }; + }, [surfaceId]); + + return getSurfaceProps(surfaceId); +} + +/** + * @internal Injected by SurfaceClassesAPI patch (do NOT call directly). + * Only used for class component surfaces where hooks cannot be injected into render(). + * Function component surfaces use _useSurfaceProps instead. + */ +export function _trackSurfaceInstance(surfaceId: SurfaceId, instance: SurfaceInstance) { + if (surfaceInstances.get(surfaceId)?.deref() !== instance) { + surfaceInstances.set(surfaceId, new WeakRef(instance)); + } +} diff --git a/src/api/index.ts b/src/api/index.ts index a5085a889..6e361f5e4 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -40,6 +40,7 @@ import * as $ProfileSections from "./ProfileSections"; import * as $ServerList from "./ServerList"; import * as $Settings from "./Settings"; import * as $Styles from "./Styles"; +import * as $SurfaceClasses from "./SurfaceClasses"; import * as $Themes from "./Themes"; import * as $UserSettings from "./UserSettings"; @@ -98,6 +99,12 @@ export const Badges = $Badges; */ export const ServerList = $ServerList; +/** + * An API allowing plugins to add semantic data attributes and limited props + * to stable Discord layout surfaces without patching them directly. + */ +export const SurfaceClasses = $SurfaceClasses; + /** * An API allowing you to add components as message accessories. */ diff --git a/src/equicordplugins/_api/surfaceClasses.ts b/src/equicordplugins/_api/surfaceClasses.ts new file mode 100644 index 000000000..75ba91cb3 --- /dev/null +++ b/src/equicordplugins/_api/surfaceClasses.ts @@ -0,0 +1,68 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { EquicordDevs } from "@utils/constants"; +import definePlugin from "@utils/types"; + +export default definePlugin({ + name: "SurfaceClassesAPI", + description: "API to add plugin-owned semantic data attributes and limited props to stable Discord layout surfaces.", + authors: [EquicordDevs.benjii], + + patches: [ + { + find: "AnnouncementsSpoilerIcon:", + replacement: [ + { + match: /"data-fullscreen":\i.{0,150},\{isSidebarOpen:/, + replace: '...Vencord.Api.SurfaceClasses._useSurfaceProps("base"),$&' + }, + { + match: /"data-collapsed":\i,.{0,130}themeOverride/, + replace: '...Vencord.Api.SurfaceClasses._useSurfaceProps("sidebar"),$&' + }, + { + match: /\.CHANNEL_SIDEBAR_RESIZED,\{.{0,100}\i=\{/, + replace: '$&...Vencord.Api.SurfaceClasses._useSurfaceProps("channelList"),' + }, + { + match: /ref:\i.{0,70}#{intl::vTl6Lk::raw}\),/, + replace: "...Vencord.Api.SurfaceClasses._useSurfaceProps('userArea'),$&" + } + ] + }, + { + find: "#{intl::GUILDS_BAR_A11Y_LABEL}", + replacement: [ + { + match: /"aria-label":.{0,50}#{intl::GUILDS_BAR_A11Y_LABEL}\),children:/, + replace: '...Vencord.Api.SurfaceClasses._useSurfaceProps("guildBar"),$&' + }, + ] + }, + { + find: "#{intl::MEMBERS_LIST_LANDMARK_LABEL}", + replacement: { + match: /"aria-labelledby":.{0,130}#{intl::MEMBERS_LIST_LANDMARK_LABEL}/, + replace: '...(Vencord.Api.SurfaceClasses._trackSurfaceInstance("membersList",this),Vencord.Api.SurfaceClasses._getSurfaceProps("membersList")),$&' + } + }, + { + find: '?"refresh-title-bar-small":', + replacement: { + match: /"data-window-chrome":"true"/, + replace: '...Vencord.Api.SurfaceClasses._useSurfaceProps("headerBar"),$&' + } + }, + { + find: ".Masks.HEADER_BAR_BADGE_TOP:", + replacement: { + match: /"aria-label":\i.{0,25}role:\i,ref:\i/, + replace: '...Vencord.Api.SurfaceClasses._useSurfaceProps("titleBar"),$&' + } + } + ] +}); diff --git a/src/equicordplugins/collapsibleUi/index.tsx b/src/equicordplugins/collapsibleUi/index.tsx new file mode 100644 index 000000000..2369c0c93 --- /dev/null +++ b/src/equicordplugins/collapsibleUi/index.tsx @@ -0,0 +1,542 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { ChannelToolbarButton } from "@api/HeaderBar"; +import { addSurfacePropsProvider, notifySurfaceClassesChanged, type SurfaceId, type SurfaceProvidedProps } from "@api/SurfaceClasses"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { EquicordDevs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; +import { classes } from "@utils/misc"; +import definePlugin from "@utils/types"; +import { Clickable, ContextMenuApi, Menu } from "@webpack/common"; +import type { FocusEvent as ReactFocusEvent, MouseEvent as ReactMouseEvent, ReactNode, SVGProps } from "react"; + +import { collapseSettingKeys, PanelId, panelRegistry, setCollapseSettingChangeHandler, settings, setUserAreaDetachSettingChangeHandler, toolbarPanelOrder } from "./settings"; +import managedStyle from "./style.css?managed"; + +const cl = classNameFactory("vc-collapsible-ui-"); + +const panelDependentSurfaces: Record = { + guildBar: ["guildBar", "userArea"], + channelList: ["channelList", "base", "sidebar", "userArea"], + membersList: ["membersList"], + chatButtons: [], + titleBar: ["titleBar"], + headerBar: ["headerBar", "base"], + userArea: ["userArea", "sidebar"], +}; + +const DETACHED_USER_AREA_WIDTH = 312; +const DETACHED_USER_AREA_HEIGHT = 88; +const DETACHED_USER_AREA_MARGIN = 16; +const DETACHED_USER_AREA_DEFAULT_OFFSET_X = 24; +const DETACHED_USER_AREA_DEFAULT_OFFSET_Y = 88; + +let providerUnsubs: Array<() => void> = []; +let channelListExpandedByInteraction = false; +let headerBarExpandedByInteraction = false; +let headerBarPointerTrackerEnabled = false; +let userAreaDragState: { offsetX: number; offsetY: number; width: number; height: number; } | undefined; +let detachedUserAreaDragPosition: { x: number; y: number; } | undefined; +let detachedUserAreaPositionChanged = false; +let detachedUserAreaAnimationFrame: number | undefined; +let channelListElement: HTMLElement | null = null; +let userAreaElement: HTMLElement | null = null; +const surfaceCssPxCache = new Map(); + +function PanelsIcon(props: SVGProps) { + return ( + + + + ); +} + +function isPanelCollapsed(panelId: PanelId) { + return settings.plain[panelRegistry[panelId].collapsedKey]; +} + +function usePanelCollapsed(panelId: PanelId) { + const key = panelRegistry[panelId].collapsedKey; + return settings.use([key])[key]; +} + +function notifyPanelSurfacesChanged(panelId: PanelId) { + for (const surfaceId of panelDependentSurfaces[panelId]) { + notifySurfaceClassesChanged(surfaceId); + } +} + +function setHeaderBarExpandedByInteraction(expanded: boolean) { + if (headerBarExpandedByInteraction === expanded) return; + headerBarExpandedByInteraction = expanded; + notifySurfaceClassesChanged("base"); + notifySurfaceClassesChanged("headerBar"); +} + +function setChannelListExpandedByInteraction(expanded: boolean) { + if (channelListExpandedByInteraction === expanded) return; + channelListExpandedByInteraction = expanded; + notifySurfaceClassesChanged("base"); + notifySurfaceClassesChanged("sidebar"); +} + +function syncPanelCollapsedState(panelId: PanelId, collapsed: boolean) { + if (panelId === "channelList") { + channelListExpandedByInteraction = false; + } + + if (panelId === "headerBar") { + setHeaderBarPointerTrackerEnabled(collapsed); + if (!collapsed) { + headerBarExpandedByInteraction = false; + } + } + + notifyPanelSurfacesChanged(panelId); +} + +function syncAllPanelCollapsedStates() { + for (const panelId of toolbarPanelOrder) { + syncPanelCollapsedState(panelId, isPanelCollapsed(panelId)); + } +} + +// Reads a plugin CSS length variable from a mounted surface so the managed +// stylesheet stays the single source of truth. Cached while the pointer +// tracker runs; cleared each time tracking starts so theme overrides of the +// variables are picked up without querying computed style per mouse move. +function readSurfacePx(property: string, fallback: number) { + const cached = surfaceCssPxCache.get(property); + if (cached != null) return cached; + + const host = userAreaElement ?? channelListElement; + if (!host) return fallback; + + const value = parseFloat(getComputedStyle(host).getPropertyValue(property)); + if (!Number.isFinite(value)) return fallback; + + surfaceCssPxCache.set(property, value); + return value; +} + +// Electron drag regions do not provide stable hover events, so keep this as a +// coordinate-only tracker while headerbar collapse is enabled. +function handleHeaderBarPointerMove(event: MouseEvent) { + if (!isPanelCollapsed("headerBar")) { + setHeaderBarPointerTrackerEnabled(false); + setHeaderBarExpandedByInteraction(false); + return; + } + + const interactionHeight = headerBarExpandedByInteraction + ? readSurfacePx("--vc-cui-header-bar-height", 32) + : readSurfacePx("--vc-cui-collapsed-block-size", 8); + setHeaderBarExpandedByInteraction(event.clientY >= 0 && event.clientY <= interactionHeight); +} + +function setHeaderBarPointerTrackerEnabled(enabled: boolean) { + if (headerBarPointerTrackerEnabled === enabled) return; + headerBarPointerTrackerEnabled = enabled; + + if (enabled) { + surfaceCssPxCache.clear(); + document.addEventListener("mousemove", handleHeaderBarPointerMove, true); + } else { + document.removeEventListener("mousemove", handleHeaderBarPointerMove, true); + } +} + +function setPanelCollapsed(panelId: PanelId, collapsed: boolean) { + const key = panelRegistry[panelId].collapsedKey; + if (settings.plain[key] === collapsed) return; + settings.store[key] = collapsed; +} + +function togglePanel(panelId: PanelId) { + setPanelCollapsed(panelId, !isPanelCollapsed(panelId)); +} + +function openToolbarMenu(event: ReactMouseEvent) { + ContextMenuApi.openContextMenu(event, () => ); +} + +function containsRelatedTarget(event: ReactFocusEvent | ReactMouseEvent) { + const { currentTarget, relatedTarget } = event; + return relatedTarget instanceof Node && currentTarget.contains(relatedTarget); +} + +function nodeWithin(element: HTMLElement | null, node: unknown) { + return node instanceof Node && !!element?.contains(node); +} + +function eventWithin(element: HTMLElement | null, event: ReactFocusEvent | ReactMouseEvent) { + if (!element) return false; + + if (nodeWithin(element, event.target)) return true; + if (event.nativeEvent.composedPath().some(node => nodeWithin(element, node))) return true; + + if ("clientX" in event && "clientY" in event) { + const rect = element.getBoundingClientRect(); + return event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom; + } + + return false; +} + +function handleSidebarEnter(event: ReactFocusEvent | ReactMouseEvent) { + if (eventWithin(userAreaElement, event)) { + setChannelListExpandedByInteraction(false); + return; + } + + if (isPanelCollapsed("channelList") && eventWithin(channelListElement, event)) { + setChannelListExpandedByInteraction(true); + } +} + +function handleSidebarLeave(event: ReactFocusEvent | ReactMouseEvent) { + if (nodeWithin(userAreaElement, event.relatedTarget)) { + setChannelListExpandedByInteraction(false); + return; + } + + if (eventWithin(channelListElement, event) && !nodeWithin(channelListElement, event.relatedTarget)) { + setChannelListExpandedByInteraction(false); + return; + } + + if (containsRelatedTarget(event)) return; + if (isPanelCollapsed("channelList")) setChannelListExpandedByInteraction(false); +} + +function isInteractiveDragTarget(node: unknown) { + if (!(node instanceof HTMLElement)) return false; + + if (node.isContentEditable) return true; + + switch (node.tagName) { + case "A": + case "BUTTON": + case "INPUT": + case "SELECT": + case "TEXTAREA": + return true; + } + + return node.getAttribute("role") === "button" || node.getAttribute("aria-haspopup") === "menu"; +} + +function shouldDetachUserArea() { + return settings.plain.detachUserArea && !isPanelCollapsed("userArea"); +} + +function setChannelListElement(element: HTMLElement | null) { + channelListElement = element; +} + +function setUserAreaElement(element: HTMLElement | null) { + userAreaElement = element; +} + +function syncUserAreaDetachState() { + notifySurfaceClassesChanged("sidebar"); + notifySurfaceClassesChanged("userArea"); +} + +function clampUserAreaPosition(x: number, y: number, width = DETACHED_USER_AREA_WIDTH, height = DETACHED_USER_AREA_HEIGHT) { + const maxX = Math.max(DETACHED_USER_AREA_MARGIN, window.innerWidth - width - DETACHED_USER_AREA_MARGIN); + const maxY = Math.max(DETACHED_USER_AREA_MARGIN, window.innerHeight - height - DETACHED_USER_AREA_MARGIN); + + return { + x: Math.min(Math.max(DETACHED_USER_AREA_MARGIN, x), maxX), + y: Math.min(Math.max(DETACHED_USER_AREA_MARGIN, y), maxY), + }; +} + +function getDetachedUserAreaPosition() { + if (detachedUserAreaDragPosition) return detachedUserAreaDragPosition; + + const storedX = settings.plain.detachedUserAreaX; + const storedY = settings.plain.detachedUserAreaY; + const defaultPosition = clampUserAreaPosition( + window.innerWidth - DETACHED_USER_AREA_WIDTH - DETACHED_USER_AREA_DEFAULT_OFFSET_X, + window.innerHeight - DETACHED_USER_AREA_HEIGHT - DETACHED_USER_AREA_DEFAULT_OFFSET_Y + ); + + if (!Number.isFinite(storedX) || !Number.isFinite(storedY) || storedX < 0 || storedY < 0) return defaultPosition; + return clampUserAreaPosition(storedX, storedY); +} + +function persistDetachedUserAreaPosition(x: number, y: number, width?: number, height?: number) { + const position = clampUserAreaPosition(x, y, width, height); + const roundedX = Math.round(position.x); + const roundedY = Math.round(position.y); + if (settings.plain.detachedUserAreaX === roundedX && settings.plain.detachedUserAreaY === roundedY) return; + + settings.store.detachedUserAreaX = roundedX; + settings.store.detachedUserAreaY = roundedY; +} + +function scheduleDetachedUserAreaUpdate() { + if (detachedUserAreaAnimationFrame != null) return; + + detachedUserAreaAnimationFrame = requestAnimationFrame(() => { + detachedUserAreaAnimationFrame = undefined; + notifySurfaceClassesChanged("userArea"); + }); +} + +function cancelDetachedUserAreaUpdate() { + if (detachedUserAreaAnimationFrame == null) return; + + cancelAnimationFrame(detachedUserAreaAnimationFrame); + detachedUserAreaAnimationFrame = undefined; +} + +function handleDetachedUserAreaMouseMove(event: MouseEvent) { + if (!userAreaDragState) return; + const position = clampUserAreaPosition( + event.clientX - userAreaDragState.offsetX, + event.clientY - userAreaDragState.offsetY, + userAreaDragState.width, + userAreaDragState.height + ); + if (detachedUserAreaDragPosition?.x === position.x && detachedUserAreaDragPosition.y === position.y) return; + + detachedUserAreaDragPosition = position; + detachedUserAreaPositionChanged = true; + scheduleDetachedUserAreaUpdate(); +} + +function stopDetachedUserAreaDrag() { + const dragState = userAreaDragState; + cancelDetachedUserAreaUpdate(); + if (dragState && detachedUserAreaDragPosition && detachedUserAreaPositionChanged) { + persistDetachedUserAreaPosition(detachedUserAreaDragPosition.x, detachedUserAreaDragPosition.y, dragState.width, dragState.height); + } + + userAreaDragState = undefined; + detachedUserAreaDragPosition = undefined; + detachedUserAreaPositionChanged = false; + document.removeEventListener("mousemove", handleDetachedUserAreaMouseMove, true); + document.removeEventListener("mouseup", stopDetachedUserAreaDrag, true); +} + +function startDetachedUserAreaDrag(event: ReactMouseEvent) { + if (!shouldDetachUserArea() || event.button !== 0) return; + + const path = event.nativeEvent.composedPath(); + if (path.some(node => node !== event.currentTarget && isInteractiveDragTarget(node))) return; + + const rect = event.currentTarget.getBoundingClientRect(); + const height = Math.min(rect.height, DETACHED_USER_AREA_HEIGHT); + detachedUserAreaDragPosition = getDetachedUserAreaPosition(); + detachedUserAreaPositionChanged = false; + userAreaDragState = { + offsetX: event.clientX - rect.left, + offsetY: event.clientY - rect.top, + width: rect.width, + height, + }; + + document.addEventListener("mousemove", handleDetachedUserAreaMouseMove, true); + document.addEventListener("mouseup", stopDetachedUserAreaDrag, true); +} + +const ToolbarMenu = ErrorBoundary.wrap(({ onClose }: { onClose(): void; }) => { + const store = settings.use(collapseSettingKeys); + + return ( + + {toolbarPanelOrder.map(panelId => { + const panel = panelRegistry[panelId]; + const collapsed = store[panel.collapsedKey]; + + return ( + togglePanel(panelId)} + /> + ); + })} + + ); +}, { noop: true }); + +const ToolbarButtons = ErrorBoundary.wrap(() => { + const store = settings.use(collapseSettingKeys); + const anyCollapsed = toolbarPanelOrder.some(panelId => store[panelRegistry[panelId].collapsedKey]); + + return ( + + ); +}, { noop: true }); + +const CollapsedMenuButton = ErrorBoundary.wrap(() => ( + + + +), { noop: true }); + +const ChatButtonsRow = ErrorBoundary.wrap(({ buttons }: { buttons: ReactNode[]; }) => { + const chatButtonsCollapsed = usePanelCollapsed("chatButtons"); + + if (buttons.length === 0) return <>{buttons}; + + return ( +
+
+ {buttons} +
+ +
+ ); +}, { noop: true }); + +export default definePlugin({ + name: "CollapsibleUI", + description: "Native collapsible channel, member, chat button, and user area surfaces.", + tags: ["Appearance", "Customisation", "Chat", "Servers"], + dependencies: ["HeaderBarAPI", "ChatInputButtonAPI", "SurfaceClassesAPI"], + authors: [EquicordDevs.benjii], + searchTerms: ["ui", "sidebar", "collapsible"], + managedStyle, + settings, + + headerBarButton: { + icon: PanelsIcon, + location: "channeltoolbar", + priority: 25, + render: () => , + }, + + chatBarButtonWrapper: { + wrapper: (buttons: ReactNode) => { + if (!Array.isArray(buttons) || buttons.length === 0) return buttons; + return ; + }, + priority: 0, + }, + + start() { + const panelAttr = (classId: string, collapsed: boolean): SurfaceProvidedProps => ({ + [`data-vc-collapsible-ui-${classId}`]: "", + [`data-vc-collapsible-ui-${classId}-${collapsed ? "collapsed" : "expanded"}`]: "", + } as SurfaceProvidedProps); + + providerUnsubs = [ + addSurfacePropsProvider("guildBar", () => panelAttr(panelRegistry.guildBar.classId, isPanelCollapsed("guildBar"))), + addSurfacePropsProvider("channelList", () => { + const attrs: SurfaceProvidedProps = panelAttr(panelRegistry.channelList.classId, isPanelCollapsed("channelList")); + attrs.ref = setChannelListElement; + return attrs; + }), + addSurfacePropsProvider("membersList", () => panelAttr(panelRegistry.membersList.classId, isPanelCollapsed("membersList"))), + addSurfacePropsProvider("titleBar", () => panelAttr(panelRegistry.titleBar.classId, isPanelCollapsed("titleBar"))), + addSurfacePropsProvider("headerBar", () => { + const collapsed = isPanelCollapsed("headerBar"); + const attrs: SurfaceProvidedProps = panelAttr(panelRegistry.headerBar.classId, collapsed); + if (collapsed && headerBarExpandedByInteraction) { + attrs["data-vc-collapsible-ui-header-bar-interaction-expanded"] = ""; + } + attrs.onFocusCapture = () => { + if (isPanelCollapsed("headerBar")) setHeaderBarExpandedByInteraction(true); + }; + attrs.onBlurCapture = event => { + if (containsRelatedTarget(event)) return; + if (isPanelCollapsed("headerBar")) setHeaderBarExpandedByInteraction(false); + }; + return attrs; + }), + addSurfacePropsProvider("userArea", () => { + const uaCollapsed = isPanelCollapsed("userArea"); + const clCollapsed = isPanelCollapsed("channelList"); + const gbCollapsed = isPanelCollapsed("guildBar"); + const userAreaDetached = shouldDetachUserArea(); + const attrs: SurfaceProvidedProps = panelAttr(panelRegistry.userArea.classId, uaCollapsed); + attrs.ref = setUserAreaElement; + if (userAreaDetached) { + const position = getDetachedUserAreaPosition(); + attrs["data-vc-collapsible-ui-user-area-detached"] = ""; + attrs.style = { + left: 0, + top: 0, + transform: `translate3d(${position.x}px, ${position.y}px, 0)`, + }; + attrs.onMouseDownCapture = startDetachedUserAreaDrag; + } + if (!userAreaDetached && clCollapsed) { + attrs["data-vc-collapsible-ui-user-area-channel-list-collapsed"] = ""; + } + if (!userAreaDetached && gbCollapsed) { + attrs["data-vc-collapsible-ui-user-area-guild-bar-collapsed"] = ""; + } + return attrs; + }), + addSurfacePropsProvider("base", () => { + const channelListCollapsed = isPanelCollapsed("channelList"); + const headerBarCollapsed = isPanelCollapsed("headerBar"); + return { + "data-vc-collapsible-ui-base": "", + [`data-vc-collapsible-ui-base-channel-list-${channelListCollapsed ? "collapsed" : "expanded"}`]: "", + ...(channelListCollapsed && channelListExpandedByInteraction ? { "data-vc-collapsible-ui-base-channel-list-interaction-expanded": "" } : {}), + ...(headerBarCollapsed && !headerBarExpandedByInteraction ? { "data-vc-collapsible-ui-base-header-bar-collapsed": "" } : {}), + ...(headerBarCollapsed && headerBarExpandedByInteraction ? { "data-vc-collapsible-ui-base-header-bar-expanded": "" } : {}), + } as SurfaceProvidedProps; + }), + addSurfacePropsProvider("sidebar", () => { + const collapsed = isPanelCollapsed("channelList"); + const userAreaDetached = shouldDetachUserArea(); + return { + "data-vc-collapsible-ui-sidebar": "", + [`data-vc-collapsible-ui-sidebar-channel-list-${collapsed ? "collapsed" : "expanded"}`]: "", + ...(collapsed && channelListExpandedByInteraction ? { "data-vc-collapsible-ui-sidebar-channel-list-expanded": "" } : {}), + ...(userAreaDetached ? { "data-vc-collapsible-ui-sidebar-user-area-detached": "" } : {}), + onFocusCapture: handleSidebarEnter, + onBlurCapture: handleSidebarLeave, + onMouseOverCapture: handleSidebarEnter, + onMouseOutCapture: handleSidebarLeave, + } as SurfaceProvidedProps; + }), + ]; + + setCollapseSettingChangeHandler(syncPanelCollapsedState); + setUserAreaDetachSettingChangeHandler(syncUserAreaDetachState); + syncAllPanelCollapsedStates(); + }, + + stop() { + setCollapseSettingChangeHandler(undefined); + setUserAreaDetachSettingChangeHandler(undefined); + stopDetachedUserAreaDrag(); + providerUnsubs.forEach(unsub => unsub()); + providerUnsubs = []; + setHeaderBarPointerTrackerEnabled(false); + channelListElement = null; + userAreaElement = null; + channelListExpandedByInteraction = false; + headerBarExpandedByInteraction = false; + surfaceCssPxCache.clear(); + }, +}); diff --git a/src/equicordplugins/collapsibleUi/settings.ts b/src/equicordplugins/collapsibleUi/settings.ts new file mode 100644 index 000000000..d7316b49e --- /dev/null +++ b/src/equicordplugins/collapsibleUi/settings.ts @@ -0,0 +1,149 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { definePluginSettings } from "@api/Settings"; +import { OptionType } from "@utils/types"; + +export const panelRegistry = { + guildBar: { + id: "guildBar", + classId: "guild-bar", + label: "Guild Bar", + collapsedKey: "guildBarCollapsed", + }, + channelList: { + id: "channelList", + classId: "channel-list", + label: "Channel List", + collapsedKey: "channelListCollapsed", + }, + membersList: { + id: "membersList", + classId: "members-list", + label: "Members List", + collapsedKey: "membersListCollapsed", + }, + chatButtons: { + id: "chatButtons", + classId: "chat-buttons", + label: "Message Buttons", + collapsedKey: "chatButtonsCollapsed", + }, + titleBar: { + id: "titleBar", + classId: "title-bar", + label: "Title Bar", + collapsedKey: "titleBarCollapsed", + }, + headerBar: { + id: "headerBar", + classId: "header-bar", + label: "Header Bar", + collapsedKey: "headerBarCollapsed", + }, + userArea: { + id: "userArea", + classId: "user-area", + label: "User Area", + collapsedKey: "userAreaCollapsed", + }, +} as const; + +export type PanelId = keyof typeof panelRegistry; + +export const toolbarPanelOrder = ["guildBar", "channelList", "membersList", "chatButtons", "titleBar", "headerBar", "userArea"] as const satisfies readonly PanelId[]; + +export const collapseSettingKeys = toolbarPanelOrder.map(panelId => panelRegistry[panelId].collapsedKey); + +type CollapseSettingChangeHandler = (panelId: PanelId, collapsed: boolean) => void; +type UserAreaDetachSettingChangeHandler = () => void; + +let collapseSettingChangeHandler: CollapseSettingChangeHandler | undefined; +let userAreaDetachSettingChangeHandler: UserAreaDetachSettingChangeHandler | undefined; + +export function setCollapseSettingChangeHandler(handler: CollapseSettingChangeHandler | undefined) { + collapseSettingChangeHandler = handler; +} + +export function setUserAreaDetachSettingChangeHandler(handler: UserAreaDetachSettingChangeHandler | undefined) { + userAreaDetachSettingChangeHandler = handler; +} + +function onCollapseSettingChanged(panelId: PanelId) { + return (collapsed: boolean) => collapseSettingChangeHandler?.(panelId, collapsed); +} + +export const settings = definePluginSettings({ + detachUserArea: { + type: OptionType.BOOLEAN, + description: "Detach the user area so it can be moved freely when it is not collapsed.", + default: false, + onChange: () => userAreaDetachSettingChangeHandler?.(), + }, + detachedUserAreaX: { + type: OptionType.NUMBER, + description: "Persist the detached user area x position.", + default: -1, + hidden: true, + onChange: () => userAreaDetachSettingChangeHandler?.(), + }, + detachedUserAreaY: { + type: OptionType.NUMBER, + description: "Persist the detached user area y position.", + default: -1, + hidden: true, + onChange: () => userAreaDetachSettingChangeHandler?.(), + }, + guildBarCollapsed: { + type: OptionType.BOOLEAN, + description: "Persist the guild bar as collapsed.", + default: false, + hidden: true, + onChange: onCollapseSettingChanged("guildBar"), + }, + channelListCollapsed: { + type: OptionType.BOOLEAN, + description: "Persist the channel list as collapsed.", + default: false, + hidden: true, + onChange: onCollapseSettingChanged("channelList"), + }, + membersListCollapsed: { + type: OptionType.BOOLEAN, + description: "Persist the members list as collapsed.", + default: false, + hidden: true, + onChange: onCollapseSettingChanged("membersList"), + }, + chatButtonsCollapsed: { + type: OptionType.BOOLEAN, + description: "Persist the message button row as collapsed.", + default: false, + hidden: true, + onChange: onCollapseSettingChanged("chatButtons"), + }, + titleBarCollapsed: { + type: OptionType.BOOLEAN, + description: "Persist the title bar as collapsed.", + default: false, + hidden: true, + onChange: onCollapseSettingChanged("titleBar"), + }, + headerBarCollapsed: { + type: OptionType.BOOLEAN, + description: "Persist the header bar as collapsed.", + default: false, + hidden: true, + onChange: onCollapseSettingChanged("headerBar"), + }, + userAreaCollapsed: { + type: OptionType.BOOLEAN, + description: "Persist the user area as collapsed.", + default: false, + hidden: true, + onChange: onCollapseSettingChanged("userArea"), + }, +}); diff --git a/src/equicordplugins/collapsibleUi/style.css b/src/equicordplugins/collapsibleUi/style.css new file mode 100644 index 000000000..6de46c265 --- /dev/null +++ b/src/equicordplugins/collapsibleUi/style.css @@ -0,0 +1,349 @@ +[data-vc-collapsible-ui-base], +[data-vc-collapsible-ui-guild-bar], +[data-vc-collapsible-ui-channel-list], +[data-vc-collapsible-ui-members-list], +[data-vc-collapsible-ui-title-bar], +[data-vc-collapsible-ui-header-bar], +[data-vc-collapsible-ui-user-area], +.vc-collapsible-ui-chat-buttons { + --vc-cui-peek-size: 12px; + --vc-cui-user-area-collapsed-width: 52px; + --vc-cui-header-bar-height: 32px; + --vc-cui-collapsed-block-size: 8px; + --vc-cui-guild-bar-expanded-width: 64px; + --vc-cui-channel-list-min-width: 264px; + --vc-cui-channel-list-max-width: 432px; + --vc-cui-members-list-expanded-width: 240px; + --vc-cui-title-bar-expanded-height: 46px; + --vc-cui-chat-buttons-offset: 24px; + --vc-cui-restore-button-size: 24px; + --vc-cui-fade-delay: 80ms; + --vc-cui-transition: 140ms ease; +} + +[data-vc-collapsible-ui-base-channel-list-collapsed] { + /* stylelint-disable-next-line value-keyword-case */ + grid-template-columns: [start] min-content [guildsEnd] min-content [sidebarEnd] var(--vc-cui-peek-size) [channelsEnd] 1fr [end] !important; +} + +[data-vc-collapsible-ui-base-channel-list-collapsed][data-vc-collapsible-ui-base-channel-list-interaction-expanded] { + /* stylelint-disable-next-line value-keyword-case */ + grid-template-columns: [start] min-content [guildsEnd] min-content [sidebarEnd] var(--custom-guild-sidebar-width, 312px) [channelsEnd] 1fr [end] !important; +} + +[data-vc-collapsible-ui-guild-bar], +[data-vc-collapsible-ui-channel-list], +[data-vc-collapsible-ui-members-list], +[data-vc-collapsible-ui-title-bar], +[data-vc-collapsible-ui-header-bar], +[data-vc-collapsible-ui-user-area] { + overflow: hidden; + transition: opacity var(--vc-cui-transition); +} + +[data-vc-collapsible-ui-sidebar] { + position: relative; +} + +[data-vc-collapsible-ui-sidebar-user-area-detached] { + --custom-app-panels-height: 0px !important; +} + +[data-vc-collapsible-ui-sidebar-channel-list-collapsed] { + z-index: 5; + overflow: visible !important; +} + +[data-vc-collapsible-ui-guild-bar-collapsed], +[data-vc-collapsible-ui-channel-list-collapsed], +[data-vc-collapsible-ui-members-list-collapsed] { + width: var(--vc-cui-peek-size) !important; + min-width: var(--vc-cui-peek-size) !important; + max-width: var(--vc-cui-peek-size) !important; + flex: 0 0 var(--vc-cui-peek-size) !important; + opacity: 0.01; + + /* Delay only the opacity fade; dimensions change immediately to avoid animated layout work. */ + transition-delay: var(--vc-cui-fade-delay); +} + +[data-vc-collapsible-ui-guild-bar-collapsed]:is(:hover, :focus-within) { + width: var(--vc-cui-guild-bar-expanded-width) !important; + min-width: var(--vc-cui-guild-bar-expanded-width) !important; + max-width: var(--vc-cui-guild-bar-expanded-width) !important; + flex-basis: var(--vc-cui-guild-bar-expanded-width) !important; + opacity: 1; + transition-delay: 0ms; +} + +[data-vc-collapsible-ui-channel-list-expanded], +[data-vc-collapsible-ui-channel-list-collapsed]:is(:hover, :focus-within) { + width: var(--custom-guild-sidebar-width, 312px) !important; + min-width: var(--vc-cui-channel-list-min-width) !important; + max-width: var(--vc-cui-channel-list-max-width) !important; + flex-basis: var(--custom-guild-sidebar-width, 312px) !important; + opacity: 1; + transition-delay: 0ms; +} + +[data-vc-collapsible-ui-sidebar-channel-list-expanded] { + width: var(--custom-guild-sidebar-width, 312px) !important; + min-width: var(--custom-guild-sidebar-width, 312px) !important; + max-width: var(--custom-guild-sidebar-width, 312px) !important; +} + +[data-vc-collapsible-ui-sidebar-channel-list-expanded] [data-vc-collapsible-ui-channel-list-collapsed] { + width: var(--custom-guild-sidebar-width, 312px) !important; + min-width: var(--vc-cui-channel-list-min-width) !important; + max-width: var(--vc-cui-channel-list-max-width) !important; + flex-basis: var(--custom-guild-sidebar-width, 312px) !important; + opacity: 1; +} + +[data-vc-collapsible-ui-members-list-collapsed] { + flex: 0 1 auto !important; + flex-basis: auto !important; +} + +[data-vc-collapsible-ui-members-list-collapsed]:is(:hover, :focus-within) { + width: var(--vc-cui-members-list-expanded-width) !important; + min-width: var(--vc-cui-members-list-expanded-width) !important; + max-width: var(--vc-cui-members-list-expanded-width) !important; + flex-basis: auto !important; + opacity: 1; +} + +[data-vc-collapsible-ui-title-bar-collapsed] { + height: var(--vc-cui-collapsed-block-size) !important; + min-height: var(--vc-cui-collapsed-block-size) !important; + max-height: var(--vc-cui-collapsed-block-size) !important; + padding-block: 0 !important; + color: transparent !important; + opacity: 0.01 !important; +} + +[data-vc-collapsible-ui-title-bar-collapsed]:is(:hover, :focus-within) { + height: var(--vc-cui-title-bar-expanded-height) !important; + min-height: var(--vc-cui-title-bar-expanded-height) !important; + max-height: var(--vc-cui-title-bar-expanded-height) !important; + color: inherit !important; + opacity: 1 !important; +} + +[data-vc-collapsible-ui-base-header-bar-collapsed] { + --custom-app-top-bar-height: var(--vc-cui-collapsed-block-size) !important; +} + +[data-vc-collapsible-ui-base-header-bar-expanded] { + --custom-app-top-bar-height: var(--vc-cui-header-bar-height) !important; +} + +[data-vc-collapsible-ui-header-bar-collapsed] { + position: relative; + z-index: 10; + height: var(--vc-cui-collapsed-block-size) !important; + min-height: var(--vc-cui-collapsed-block-size) !important; + max-height: var(--vc-cui-collapsed-block-size) !important; + opacity: 0.01; + -webkit-app-region: drag; + overflow: visible; +} + +[data-vc-collapsible-ui-header-bar-expanded] { + opacity: 1; +} + +[data-vc-collapsible-ui-header-bar-collapsed]::before { + position: absolute; + z-index: 2; + inset: 0; + height: var(--vc-cui-collapsed-block-size); + content: ""; + pointer-events: auto; + -webkit-app-region: no-drag; +} + +[data-vc-collapsible-ui-header-bar-collapsed] > * { + pointer-events: none; +} + +[data-vc-collapsible-ui-user-area-collapsed] > * { + opacity: 0; + pointer-events: none; +} + +[data-vc-collapsible-ui-header-bar-collapsed][data-vc-collapsible-ui-header-bar-interaction-expanded] { + height: var(--vc-cui-header-bar-height) !important; + min-height: var(--vc-cui-header-bar-height) !important; + max-height: var(--vc-cui-header-bar-height) !important; + opacity: 1; + overflow: visible; +} + +[data-vc-collapsible-ui-header-bar-collapsed][data-vc-collapsible-ui-header-bar-interaction-expanded]::before { + display: none; +} + +[data-vc-collapsible-ui-header-bar-collapsed][data-vc-collapsible-ui-header-bar-interaction-expanded] > * { + pointer-events: auto; +} + +[data-vc-collapsible-ui-user-area] { + background: var(--background-secondary-alt) !important; +} + +[data-vc-collapsible-ui-user-area-collapsed] { + height: var(--vc-cui-peek-size) !important; + min-height: var(--vc-cui-peek-size) !important; + max-height: var(--vc-cui-peek-size) !important; + padding-block: 0 !important; + border-top-width: 0 !important; +} + +[data-vc-collapsible-ui-user-area-collapsed]:is(:hover, :focus-within) { + height: auto !important; + min-height: 0 !important; + max-height: none !important; +} + +[data-vc-collapsible-ui-user-area-collapsed]:is(:hover, :focus-within) > * { + opacity: 1; + pointer-events: auto; +} + +[data-vc-collapsible-ui-sidebar-channel-list-collapsed]:not([data-vc-collapsible-ui-sidebar-user-area-detached]) [data-vc-collapsible-ui-user-area] { + width: var(--vc-cui-user-area-collapsed-width) !important; + min-width: var(--vc-cui-user-area-collapsed-width) !important; + max-width: var(--vc-cui-user-area-collapsed-width) !important; + z-index: 4; +} + +[data-vc-collapsible-ui-sidebar-channel-list-collapsed]:not([data-vc-collapsible-ui-sidebar-user-area-detached]) [data-vc-collapsible-ui-user-area-expanded] { + position: absolute !important; + right: auto !important; + bottom: 6px !important; + left: 6px !important; + height: var(--vc-cui-user-area-collapsed-width) !important; + min-height: var(--vc-cui-user-area-collapsed-width) !important; + max-height: var(--vc-cui-user-area-collapsed-width) !important; + flex: 0 0 auto !important; + align-self: flex-start; + overflow: hidden !important; +} + +[data-vc-collapsible-ui-sidebar-channel-list-collapsed]:not([data-vc-collapsible-ui-sidebar-user-area-detached]) [data-vc-collapsible-ui-user-area-expanded] > * { + width: var(--custom-guild-sidebar-width, 312px); + min-width: var(--vc-cui-channel-list-min-width); + max-width: var(--vc-cui-channel-list-max-width); + opacity: 1; + pointer-events: auto; +} + +[data-vc-collapsible-ui-sidebar-channel-list-collapsed]:not([data-vc-collapsible-ui-sidebar-user-area-detached]) [data-vc-collapsible-ui-user-area-expanded]:is(:hover, :focus-within) { + width: var(--custom-guild-sidebar-width, 312px) !important; + min-width: var(--vc-cui-channel-list-min-width) !important; + max-width: var(--vc-cui-channel-list-max-width) !important; + height: auto !important; + min-height: 0 !important; + max-height: none !important; + overflow: visible !important; +} + +[data-vc-collapsible-ui-sidebar-channel-list-collapsed]:not([data-vc-collapsible-ui-sidebar-user-area-detached]) [data-vc-collapsible-ui-user-area-guild-bar-collapsed][data-vc-collapsible-ui-user-area-collapsed] { + width: var(--vc-cui-peek-size) !important; + min-width: var(--vc-cui-peek-size) !important; + max-width: var(--vc-cui-peek-size) !important; +} + +[data-vc-collapsible-ui-sidebar-channel-list-expanded]:not([data-vc-collapsible-ui-sidebar-user-area-detached]) [data-vc-collapsible-ui-user-area] { + width: var(--custom-guild-sidebar-width, 312px) !important; + min-width: var(--vc-cui-channel-list-min-width) !important; + max-width: var(--vc-cui-channel-list-max-width) !important; +} + +[data-vc-collapsible-ui-user-area-detached] { + position: fixed !important; + right: auto !important; + bottom: auto !important; + width: var(--custom-guild-sidebar-width, 312px) !important; + min-width: var(--custom-guild-sidebar-width, 312px) !important; + max-width: var(--custom-guild-sidebar-width, 312px) !important; + height: auto !important; + min-height: 0 !important; + max-height: none !important; + cursor: grab; + z-index: 2 !important; +} + +[data-vc-collapsible-ui-user-area-detached]:active { + cursor: grabbing; +} + +.vc-collapsible-ui-chat-buttons { + position: relative; + display: flex; + align-items: center; + gap: 4px; + min-inline-size: 0; +} + +.vc-collapsible-ui-chat-buttons-items { + display: flex; + align-items: center; + min-inline-size: 0; + overflow: hidden; + transition: + transform var(--vc-cui-transition), + opacity var(--vc-cui-transition); + transform-origin: right center; +} + +.vc-collapsible-ui-chat-buttons-collapsed .vc-collapsible-ui-chat-buttons-items { + position: absolute; + top: 50%; + right: var(--vc-cui-chat-buttons-offset); + width: max-content; + opacity: 0; + pointer-events: none; + transform: translateY(-50%) scaleX(0); + transition-delay: var(--vc-cui-fade-delay); +} + +.vc-collapsible-ui-chat-buttons-collapsed:is(:hover, :focus-within) .vc-collapsible-ui-chat-buttons-items { + opacity: 1; + pointer-events: auto; + transform: translateY(-50%) scaleX(1); + transition-delay: 0ms; +} + +.vc-collapsible-ui-restore-button { + display: flex; + align-items: center; + justify-content: center; + width: var(--vc-cui-restore-button-size); + height: var(--vc-cui-restore-button-size); + border-radius: var(--radius-sm); + color: var(--interactive-normal); +} + +.vc-collapsible-ui-restore-button:hover { + color: var(--interactive-hover); + background: var(--background-modifier-hover); +} + +.vc-collapsible-ui-chat-buttons:not(.vc-collapsible-ui-chat-buttons-collapsed) .vc-collapsible-ui-restore-button { + display: none; +} + +@media (prefers-reduced-motion: reduce) { + [data-vc-collapsible-ui-guild-bar], + [data-vc-collapsible-ui-channel-list], + [data-vc-collapsible-ui-members-list], + [data-vc-collapsible-ui-title-bar], + [data-vc-collapsible-ui-header-bar], + [data-vc-collapsible-ui-user-area], + .vc-collapsible-ui-chat-buttons, + .vc-collapsible-ui-chat-buttons-items { + --vc-cui-transition: 0ms; + } +}