diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 2680592ce..07bc58338 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -673,6 +673,28 @@ pub struct AppearanceSettings { pub status_bar_widgets: BTreeMap, /// Customize Main Toolbar; `None` = the default toolbar. pub main_toolbar: Option, + /// Appearance › Background Image: an image shown through the IDE window. + pub background_image: BackgroundImageSettings, +} + +/// Appearance › Background Image. An empty `path` shows no image. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(default, rename_all = "camelCase")] +pub struct BackgroundImageSettings { + pub path: String, + /// Percent, 0–100. + pub opacity: u8, + /// `scale` (cover the window) | `plain` (original size) | `tile`. + pub fill: String, + /// Where the image is anchored: `center`, `top-left`, `top`, `top-right`, `left`, `right`, + /// `bottom-left`, `bottom`, `bottom-right`. + pub anchor: String, +} + +impl Default for BackgroundImageSettings { + fn default() -> Self { + Self { path: String::new(), opacity: 15, fill: "scale".into(), anchor: "center".into() } + } } /// Main toolbar entries per side, in order: title widget ids (`project`, @@ -743,6 +765,7 @@ impl Default for AppearanceSettings { navigation_bar: NavigationBarPosition::StatusBar, status_bar_widgets: BTreeMap::new(), main_toolbar: None, + background_image: BackgroundImageSettings::default(), } } } diff --git a/docs/en/guide/themes.mdx b/docs/en/guide/themes.mdx index eac0f5aed..52a31ab9b 100644 --- a/docs/en/guide/themes.mdx +++ b/docs/en/guide/themes.mdx @@ -24,6 +24,7 @@ The same page also has: - **Accent color**: the UI's accent color. - **Different tool window background**: set tool windows apart from the editor. +- **Background Image**: an image shown through the project window, with its **Opacity**, **Fill** (scale, original size or tile) and **Anchor**. - **Accessibility**: **Use contrast scrollbars** and **Adjust colors for red-green vision deficiency** (for protanopia and deuteranopia). - **Compact mode** under **UI Options**. diff --git a/docs/en/reference/settings.mdx b/docs/en/reference/settings.mdx index 911040883..37f911f97 100644 --- a/docs/en/reference/settings.mdx +++ b/docs/en/reference/settings.mdx @@ -109,6 +109,7 @@ Settings › Appearance & Behavior › Appearance, unless noted. | `uiFontFallback` | `""` | **UI fallback font**. | | `contrastScrollbars` | `false` | **Use contrast scrollbars**. | | `redGreenVision` | `false` | **Adjust colors for red-green vision deficiency**. | +| `backgroundImage` | `opacity: 15` | **Background Image**: `path` (empty: none), `opacity` (0–100), `fill` (`scale` / `plain` / `tile`), `anchor` (`center`, `top-left`, …). | | `compactMode` | `false` | **Compact mode**. | | `showMainToolbar` / `showStatusBar` | `true` / `true` | **Show main toolbar**, **Show status bar**. | | `navigationBar` | `"statusBar"` | **Navigation bar**: `top`, `statusBar` or `hidden`. | diff --git a/docs/zh/guide/themes.mdx b/docs/zh/guide/themes.mdx index 16bced627..1c0fc96f2 100644 --- a/docs/zh/guide/themes.mdx +++ b/docs/zh/guide/themes.mdx @@ -24,6 +24,7 @@ description: "选择界面主题和编辑器配色方案,导入 IntelliJ 主 - **强调色**:界面的强调色。 - **不同的工具窗口背景**:让工具窗口与编辑器区分开。 +- **背景图像**:透过项目窗口显示的图像,可设置 **不透明度**、**填充**(缩放、原始大小或平铺)和 **位置**。 - **辅助功能**:**使用对比度滚动条** 和 **针对红绿色觉缺陷调整颜色**(适用于红色盲和绿色盲)。 - **UI 选项** 下的 **紧凑模式**。 diff --git a/docs/zh/reference/settings.mdx b/docs/zh/reference/settings.mdx index b7df3d644..fcb2a9173 100644 --- a/docs/zh/reference/settings.mdx +++ b/docs/zh/reference/settings.mdx @@ -109,6 +109,7 @@ exclude = ["**/.git", "**/node_modules", "**/target", "**/dist"] | `uiFontFallback` | `""` | **UI 备用字体**。 | | `contrastScrollbars` | `false` | **使用对比度滚动条**。 | | `redGreenVision` | `false` | **针对红绿色觉缺陷调整颜色**。 | +| `backgroundImage` | `opacity: 15` | **背景图像**:`path`(空为不显示)、`opacity`(0–100)、`fill`(`scale` / `plain` / `tile`)、`anchor`(`center`、`top-left` 等)。 | | `compactMode` | `false` | **紧凑模式**。 | | `showMainToolbar` / `showStatusBar` | `true` / `true` | **显示主工具栏**、**显示状态栏**。 | | `navigationBar` | `"statusBar"` | **导航栏**:`top`、`statusBar` 或 `hidden`。 | diff --git a/src/bindings.ts b/src/bindings.ts index acab3542c..e90519552 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2392,6 +2392,8 @@ export type AppearanceSettings_Deserialize = { statusBarWidgets?: { [key in string]: boolean }, /** Customize Main Toolbar; `None` = the default toolbar. */ mainToolbar?: MainToolbarSettings | null, + /** Appearance › Background Image: an image shown through the IDE window. */ + backgroundImage?: BackgroundImageSettings, }; export type AppearanceSettings_Serialize = { @@ -2454,6 +2456,8 @@ export type AppearanceSettings_Serialize = { statusBarWidgets: { [key in string]: boolean }, /** Customize Main Toolbar; `None` = the default toolbar. */ mainToolbar: MainToolbarSettings | null, + /** Appearance › Background Image: an image shown through the IDE window. */ + backgroundImage: BackgroundImageSettings, }; export type ApplyOutcome = { @@ -2533,6 +2537,20 @@ export type AutoUpload = "never" | /** Every save, including autosave and external changes. */ "always"; +/** Appearance › Background Image. An empty `path` shows no image. */ +export type BackgroundImageSettings = { + path?: string, + /** Percent, 0–100. */ + opacity?: number, + /** `scale` (cover the window) | `plain` (original size) | `tile`. */ + fill?: string, + /** + * Where the image is anchored: `center`, `top-left`, `top`, `top-right`, `left`, `right`, + * `bottom-left`, `bottom`, `bottom-right`. + */ + anchor?: string, +}; + /** A step run before a configuration launches; a failing step aborts the launch. */ export type BeforeLaunchTask = { /** diff --git a/src/features/settings/__tests__/backgroundImage.test.ts b/src/features/settings/__tests__/backgroundImage.test.ts new file mode 100644 index 000000000..850a58f0d --- /dev/null +++ b/src/features/settings/__tests__/backgroundImage.test.ts @@ -0,0 +1,108 @@ +/** Appearance › Background Image: stored value validation and the variables the workbench draws from. */ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const ipc = vi.hoisted(() => ({ + pending: new Map void>(), + read: vi.fn(), +})); +vi.mock("@/platform/ipc", async (importOriginal) => ({ + ...(await importOriginal()), + call: (p: Promise) => p, + commands: { + fsReadImage: (path: string) => { + ipc.read(path); + return new Promise((resolve) => ipc.pending.set(path, resolve)); + }, + }, +})); + +import { applyBackgroundImage } from "../appearanceRuntime"; +import { validateAppearanceExtras, type BackgroundImageValid } from "../model/behaviorSchema"; + +const image = (patch: Partial = {}): BackgroundImageValid => ({ + ...validateAppearanceExtras({}).backgroundImage, + path: "/pics/a.png", + ...patch, +}); +const flush = () => new Promise((r) => setTimeout(r, 0)); +const vars = (root: HTMLElement) => + Object.fromEntries( + ["image", "opacity", "size", "repeat", "position"].map((k) => [ + k, + root.style.getPropertyValue(k === "image" ? "--background-image" : `--background-image-${k}`), + ]), + ); + +afterEach(() => { + applyBackgroundImage(image({ path: "" }), document.createElement("div")); + ipc.pending.clear(); + ipc.read.mockClear(); +}); + +describe("background image settings", () => { + it("default to no image at 15% opacity, scaled and centred", () => { + expect(validateAppearanceExtras({}).backgroundImage).toEqual({ + path: "", + opacity: 15, + fill: "scale", + anchor: "center", + }); + }); + + it("clamp opacity and drop unknown fill and anchor values", () => { + const bg = validateAppearanceExtras({ + backgroundImage: { path: " /a.png ", opacity: 140, fill: "stretch", anchor: "middle" }, + }).backgroundImage; + expect(bg).toEqual({ path: "/a.png", opacity: 100, fill: "scale", anchor: "center" }); + expect(validateAppearanceExtras({ backgroundImage: "x" }).backgroundImage.path).toBe(""); + }); +}); + +describe("applyBackgroundImage", () => { + it("sets the layout at once and the image when it has loaded", async () => { + const root = document.createElement("div"); + applyBackgroundImage(image({ opacity: 30, fill: "tile", anchor: "bottom-right" }), root); + expect(vars(root)).toEqual({ + image: "", + opacity: "0.3", + size: "auto", + repeat: "repeat", + position: "bottom right", + }); + ipc.pending.get("/pics/a.png")!("data:image/png;base64,AAA"); + await flush(); + expect(vars(root).image).toBe('url("data:image/png;base64,AAA")'); + }); + + it("reads the file once per path; layout changes reuse the loaded image", async () => { + const root = document.createElement("div"); + applyBackgroundImage(image(), root); + ipc.pending.get("/pics/a.png")!("data:a"); + await flush(); + applyBackgroundImage(image({ opacity: 50, fill: "plain" }), root); + expect(ipc.read).toHaveBeenCalledTimes(1); + expect(vars(root)).toMatchObject({ image: 'url("data:a")', opacity: "0.5", size: "auto", repeat: "no-repeat" }); + }); + + it("ignores an image that finishes loading after another was picked", async () => { + const root = document.createElement("div"); + applyBackgroundImage(image(), root); + applyBackgroundImage(image({ path: "/pics/b.png" }), root); + ipc.pending.get("/pics/b.png")!("data:b"); + ipc.pending.get("/pics/a.png")!("data:a"); + await flush(); + expect(vars(root).image).toBe('url("data:b")'); + }); + + it("clears everything without a path or at 0% opacity", async () => { + const root = document.createElement("div"); + applyBackgroundImage(image(), root); + ipc.pending.get("/pics/a.png")!("data:a"); + await flush(); + applyBackgroundImage(image({ opacity: 0 }), root); + expect(Object.values(vars(root)).every((v) => v === "")).toBe(true); + // Shown again: the file is read again. + applyBackgroundImage(image(), root); + expect(ipc.read).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/features/settings/appearanceRuntime.ts b/src/features/settings/appearanceRuntime.ts index c58d46a5b..2a31596f0 100644 --- a/src/features/settings/appearanceRuntime.ts +++ b/src/features/settings/appearanceRuntime.ts @@ -3,7 +3,7 @@ * other feature owns: root attributes read by tokens / global CSS (compact * mode, contrast scrollbars, red-green palette, antialiasing, menu icons…), * the UI font, IDE zoom (+ ⌃⌥= / ⌃⌥- / ⌃⌥0 actions), "Drag-and-drop with Alt - * pressed only" and "Save files if the IDE is idle". + * pressed only", "Save files if the IDE is idle" and the background image. */ import { executeAction, type Action } from "@/platform/actions"; import { editorPrefs } from "@/platform/editorSettings"; @@ -13,6 +13,7 @@ import { useProject } from "@/platform/project"; import { setDragRequiresAlt } from "@/ui"; import { ZOOM_LIMITS } from "@/platform/zoom"; import { DEFAULT_SETTINGS, validateSettings, type ValidSettings } from "./model/schema"; +import type { BackgroundImageValid } from "./model/behaviorSchema"; const FONT_VARS = [ ["--fs-ui", 0], @@ -52,6 +53,48 @@ export function applyAppearanceOptions(s: ValidSettings, root: HTMLElement = doc root.dataset.aaIde = a.ideAntialiasing; root.dataset.aaEditor = a.editorAntialiasing; applyZoom(a.zoom, root); + applyBackgroundImage(a.backgroundImage, root); +} + +// --------------------------------------------------------------------------- +// Background image (IDEA: Appearance › Background Image): drawn over the workbench +// by `Workbench.module.css` from these variables, under dialogs and popups. + +const FILLS = { + scale: { size: "cover", repeat: "no-repeat" }, + plain: { size: "auto", repeat: "no-repeat" }, + tile: { size: "auto", repeat: "repeat" }, +} as const; + +const BG_VARS = ["--background-image", "--background-image-opacity", "--background-image-size"] as const; +const BG_LAYOUT_VARS = ["--background-image-repeat", "--background-image-position"] as const; + +let bgPath = ""; +let bgLoad = 0; + +export function applyBackgroundImage(bg: BackgroundImageValid, root: HTMLElement = document.documentElement): void { + if (!bg.path || bg.opacity === 0) { + bgPath = ""; + bgLoad++; + for (const name of [...BG_VARS, ...BG_LAYOUT_VARS]) root.style.removeProperty(name); + return; + } + const fill = FILLS[bg.fill]; + root.style.setProperty("--background-image-opacity", String(bg.opacity / 100)); + root.style.setProperty("--background-image-size", fill.size); + root.style.setProperty("--background-image-repeat", fill.repeat); + root.style.setProperty("--background-image-position", bg.anchor.replace("-", " ")); + if (bg.path === bgPath) return; + bgPath = bg.path; + const load = ++bgLoad; + root.style.removeProperty("--background-image"); + call(commands.fsReadImage(bg.path)) + .then((url) => { + if (load === bgLoad) root.style.setProperty("--background-image", `url("${url}")`); + }) + .catch((e) => { + if (load === bgLoad) console.warn("cannot load the background image", e); + }); } /** Editor › Font › "Enable ligatures" also covers mono text outside the editor (Find results, Usages…). */ diff --git a/src/features/settings/components/Form.tsx b/src/features/settings/components/Form.tsx index 5ca89be93..caa74a478 100644 --- a/src/features/settings/components/Form.tsx +++ b/src/features/settings/components/Form.tsx @@ -128,6 +128,7 @@ export interface NumberInputProps { /** Integer-only input. */ integer?: boolean; unit?: string; + disabled?: boolean; "aria-label"?: string; } @@ -143,7 +144,18 @@ export function parseNumberInput( } /** Number field: edits commit while valid; invalid text is flagged and reverted on blur. */ -export function NumberInput({ id, value, onChange, min, max, step = 1, integer, unit, ...aria }: NumberInputProps) { +export function NumberInput({ + id, + value, + onChange, + min, + max, + step = 1, + integer, + unit, + disabled, + ...aria +}: NumberInputProps) { const [draft, setDraft] = useState(null); const text = draft ?? String(value); const invalid = draft !== null && parseNumberInput(draft, { min, max, integer }) === null; @@ -155,6 +167,7 @@ export function NumberInput({ id, value, onChange, min, max, step = 1, integer, inputMode={integer ? "numeric" : "decimal"} value={text} invalid={invalid} + disabled={disabled} aria-label={aria["aria-label"]} aria-valuemin={min} aria-valuemax={max} diff --git a/src/features/settings/model/behaviorSchema.ts b/src/features/settings/model/behaviorSchema.ts index 6d9119600..fc4ef1a79 100644 --- a/src/features/settings/model/behaviorSchema.ts +++ b/src/features/settings/model/behaviorSchema.ts @@ -13,6 +13,28 @@ export type Antialiasing = "subpixel" | "greyscale" | "none"; export type OpenProjectIn = "ask" | "new" | "same"; export type NotificationDisplay = "balloon" | "sticky" | "none"; export type NavigationBarPosition = "top" | "statusBar" | "hidden"; +export type BackgroundImageFill = "scale" | "plain" | "tile"; +export const BACKGROUND_IMAGE_ANCHORS = [ + "top-left", + "top", + "top-right", + "left", + "center", + "right", + "bottom-left", + "bottom", + "bottom-right", +] as const; +export type BackgroundImageAnchor = (typeof BACKGROUND_IMAGE_ANCHORS)[number]; + +/** Appearance › Background Image; an empty `path` shows no image. */ +export interface BackgroundImageValid { + path: string; + /** Percent, 0–100. */ + opacity: number; + fill: BackgroundImageFill; + anchor: BackgroundImageAnchor; +} /** Themes selectable as "preferred" when syncing with the OS. */ export type ConcreteTheme = ThemeChoiceId; @@ -52,6 +74,7 @@ export interface AppearanceExtras { /** View › Appearance › Navigation Bar: the file path breadcrumbs, shown above * the editor, in the status bar, or not at all. */ navigationBar: NavigationBarPosition; + backgroundImage: BackgroundImageValid; } export interface DateFormatValid { @@ -120,6 +143,7 @@ export const DEFAULT_APPEARANCE_EXTRAS: AppearanceExtras = { showMainToolbar: true, showStatusBar: true, navigationBar: "statusBar", + backgroundImage: { path: "", opacity: 15, fill: "scale", anchor: "center" }, }; const NAV_BAR_POSITIONS: readonly NavigationBarPosition[] = ["top", "statusBar", "hidden"]; @@ -192,6 +216,18 @@ export function validateAppearanceExtras(raw: unknown): AppearanceExtras { if (!("navigationBar" in a) && typeof legacy === "boolean") return legacy ? "statusBar" : "hidden"; return oneOf(a.navigationBar, NAV_BAR_POSITIONS, d.navigationBar); })(), + backgroundImage: validateBackgroundImage(a.backgroundImage), + }; +} + +function validateBackgroundImage(raw: unknown): BackgroundImageValid { + const b = obj(raw); + const d = DEFAULT_APPEARANCE_EXTRAS.backgroundImage; + return { + path: typeof b.path === "string" ? b.path.trim() : d.path, + opacity: int(b.opacity, d.opacity, { min: 0, max: 100 }), + fill: oneOf(b.fill, ["scale", "plain", "tile"], d.fill), + anchor: oneOf(b.anchor, BACKGROUND_IMAGE_ANCHORS, d.anchor), }; } diff --git a/src/features/settings/pages/Behavior.module.css b/src/features/settings/pages/Behavior.module.css index 981e85275..efb0990ce 100644 --- a/src/features/settings/pages/Behavior.module.css +++ b/src/features/settings/pages/Behavior.module.css @@ -73,6 +73,13 @@ .pathInput { width: 320px; } +.backgroundPreview { + height: var(--h-control); + max-width: 64px; + object-fit: cover; + border: 1px solid var(--border); + border-radius: var(--radius-small); +} .patternInput { width: 160px; } diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index da640f8e3..bbb95b6c7 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -23,6 +23,9 @@ import { useLabelMatch, useSetting } from "../components/context"; import { IDLE_LIMITS, type Antialiasing, + type BackgroundImageAnchor, + type BackgroundImageFill, + type BackgroundImageValid, type ConcreteTheme, type DateFormatValid, type NotificationDisplay, @@ -34,7 +37,7 @@ import { ACCENT_PRESETS, LIMITS, type ThemeId } from "../model/schema"; import type { PageSpec } from "./spec"; import { HTTP_PROXY_PAGE } from "./httpProxy"; import styles from "./Behavior.module.css"; -import { LOCALES, SUPPORTED_LOCALES, tx, useT } from "@/platform/i18n"; +import { LOCALES, SUPPORTED_LOCALES, tx, useT, useTx } from "@/platform/i18n"; import { BUILTIN_THEMES, useContributedIconThemes } from "@/platform/themes"; import { currentThemeChoice, originDetail, previewTheme, themeSelectOptions, useThemeChoices } from "@/workbench/theme"; import { themePreview } from "@/workbench/quickSwitch"; @@ -379,6 +382,113 @@ const NAV_BAR_OPTIONS: SelectOption[] = [ { value: "hidden", label: "Don't show" }, ]; +const FILL_OPTIONS: SelectOption[] = [ + { value: "scale", label: "Scale" }, + { value: "plain", label: "Original size" }, + { value: "tile", label: "Tile" }, +]; + +const ANCHOR_OPTIONS: SelectOption[] = [ + { value: "center", label: "Center" }, + { value: "top-left", label: "Top left" }, + { value: "top", label: "Top" }, + { value: "top-right", label: "Top right" }, + { value: "left", label: "Left" }, + { value: "right", label: "Right" }, + { value: "bottom-left", label: "Bottom left" }, + { value: "bottom", label: "Bottom" }, + { value: "bottom-right", label: "Bottom right" }, +]; + +const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"]; + +/** Appearance › Background Image: an image shown through the project window. */ +function BackgroundImageRows() { + const [bg, setBg] = useSetting("appearance.backgroundImage"); + const set = (patch: Partial) => setBg({ ...bg, ...patch }); + const id = useId(); + const tr = useTx(); + const [preview, setPreview] = useState<{ path: string; url?: string; error?: string } | null>(null); + useEffect(() => { + if (!bg.path || !isTauri()) return; + let live = true; + call(commands.fsReadImage(bg.path)).then( + (url) => live && setPreview({ path: bg.path, url }), + (e: unknown) => live && setPreview({ path: bg.path, error: e instanceof Error ? e.message : String(e) }), + ); + return () => { + live = false; + }; + }, [bg.path]); + const shown = preview?.path === bg.path ? preview : null; + const browse = async () => { + const { open } = await import("@/platform/dialog"); + const picked = await open({ + multiple: false, + title: tr("Background Image"), + defaultPath: bg.path || undefined, + filters: [{ name: tr("Image files"), extensions: IMAGE_EXTENSIONS }], + }); + const path = Array.isArray(picked) ? picked[0] : picked; + if (path) set({ path }); + }; + const off = !bg.path; + return ( + <> + + set({ path: e.target.value })} + /> + void browse()} + /> + {shown?.url && } + + + + set({ opacity })} + /> + + + set({ anchor })} + aria-label="Anchor" + /> + + + + ); +} + function ToolWindowsGrid() { return (