mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
feat(appearance): background image for the project window (#3194)
Settings › Appearance › Background Image picks an image shown through the project window, with opacity (default 15%), fill (scale, original size, tile) and anchor. Drawn over the workbench under dialogs and popups without taking clicks; the settings row previews the image and shows why a path cannot be loaded.
This commit is contained in:
@@ -673,6 +673,28 @@ pub struct AppearanceSettings {
|
||||
pub status_bar_widgets: BTreeMap<String, bool>,
|
||||
/// Customize Main Toolbar; `None` = the default toolbar.
|
||||
pub main_toolbar: Option<MainToolbarSettings>,
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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**.
|
||||
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -24,6 +24,7 @@ description: "选择界面主题和编辑器配色方案,导入 IntelliJ 主
|
||||
|
||||
- **强调色**:界面的强调色。
|
||||
- **不同的工具窗口背景**:让工具窗口与编辑器区分开。
|
||||
- **背景图像**:透过项目窗口显示的图像,可设置 **不透明度**、**填充**(缩放、原始大小或平铺)和 **位置**。
|
||||
- **辅助功能**:**使用对比度滚动条** 和 **针对红绿色觉缺陷调整颜色**(适用于红色盲和绿色盲)。
|
||||
- **UI 选项** 下的 **紧凑模式**。
|
||||
|
||||
|
||||
@@ -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`。 |
|
||||
|
||||
@@ -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 = {
|
||||
/**
|
||||
|
||||
@@ -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<string, (url: string) => void>(),
|
||||
read: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
call: (p: Promise<string>) => p,
|
||||
commands: {
|
||||
fsReadImage: (path: string) => {
|
||||
ipc.read(path);
|
||||
return new Promise<string>((resolve) => ipc.pending.set(path, resolve));
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { applyBackgroundImage } from "../appearanceRuntime";
|
||||
import { validateAppearanceExtras, type BackgroundImageValid } from "../model/behaviorSchema";
|
||||
|
||||
const image = (patch: Partial<BackgroundImageValid> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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…). */
|
||||
|
||||
@@ -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<string | null>(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}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<NavigationBarPosition>[] = [
|
||||
{ value: "hidden", label: "Don't show" },
|
||||
];
|
||||
|
||||
const FILL_OPTIONS: SelectOption<BackgroundImageFill>[] = [
|
||||
{ value: "scale", label: "Scale" },
|
||||
{ value: "plain", label: "Original size" },
|
||||
{ value: "tile", label: "Tile" },
|
||||
];
|
||||
|
||||
const ANCHOR_OPTIONS: SelectOption<BackgroundImageAnchor>[] = [
|
||||
{ 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<BackgroundImageValid>("appearance.backgroundImage");
|
||||
const set = (patch: Partial<BackgroundImageValid>) => 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 (
|
||||
<>
|
||||
<Row label="Image" htmlFor={id} hint={shown?.error ? tr`Cannot load the image: ${shown.error}` : undefined}>
|
||||
<Input
|
||||
id={id}
|
||||
className={styles.pathInput}
|
||||
value={bg.path}
|
||||
placeholder={tr("None")}
|
||||
onChange={(e) => set({ path: e.target.value })}
|
||||
/>
|
||||
<IconButton
|
||||
icon="folder-open"
|
||||
size="small"
|
||||
title="Browse…"
|
||||
aria-label="Browse"
|
||||
disabled={!isTauri()}
|
||||
onClick={() => void browse()}
|
||||
/>
|
||||
{shown?.url && <img className={styles.backgroundPreview} src={shown.url} alt="" />}
|
||||
</Row>
|
||||
<Indented>
|
||||
<Row label="Opacity">
|
||||
<NumberInput
|
||||
value={bg.opacity}
|
||||
min={0}
|
||||
max={100}
|
||||
integer
|
||||
unit="%"
|
||||
disabled={off}
|
||||
aria-label="Opacity"
|
||||
onChange={(opacity) => set({ opacity })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Fill">
|
||||
<Select
|
||||
value={bg.fill}
|
||||
options={FILL_OPTIONS}
|
||||
disabled={off}
|
||||
onChange={(fill) => set({ fill })}
|
||||
aria-label="Fill"
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Anchor">
|
||||
<Select
|
||||
value={bg.anchor}
|
||||
options={ANCHOR_OPTIONS}
|
||||
disabled={off}
|
||||
onChange={(anchor) => set({ anchor })}
|
||||
aria-label="Anchor"
|
||||
/>
|
||||
</Row>
|
||||
</Indented>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolWindowsGrid() {
|
||||
return (
|
||||
<Grid
|
||||
@@ -749,6 +859,17 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
|
||||
{ kind: "custom", label: "Different tool window background", component: ToolWindowBackgroundRow },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Background Image",
|
||||
options: [
|
||||
{
|
||||
kind: "custom",
|
||||
label: "Image",
|
||||
keywords: ["Background image", "wallpaper", "Opacity", "Fill", "Anchor", "Scale", "Tile"],
|
||||
component: BackgroundImageRows,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Accessibility",
|
||||
options: [
|
||||
|
||||
@@ -1554,5 +1554,20 @@
|
||||
"No matching tasks": "No matching tasks",
|
||||
"Move to Editor": "Move to Editor",
|
||||
"Moves a terminal tab, with its running session, into the editor area (arg: tab id).": "Moves a terminal tab, with its running session, into the editor area (arg: tab id).",
|
||||
"Starts a login shell where the session was.": "Starts a login shell where the session was."
|
||||
"Starts a login shell where the session was.": "Starts a login shell where the session was.",
|
||||
"Background Image": "Background Image",
|
||||
"Background image": "Background image",
|
||||
"Cannot load the image: {error}": "Cannot load the image: {error}",
|
||||
"Opacity": "Opacity",
|
||||
"Fill": "Fill",
|
||||
"Anchor": "Anchor",
|
||||
"Scale": "Scale",
|
||||
"Original size": "Original size",
|
||||
"Tile": "Tile",
|
||||
"Center": "Center",
|
||||
"Top left": "Top left",
|
||||
"Top right": "Top right",
|
||||
"Bottom left": "Bottom left",
|
||||
"Bottom right": "Bottom right",
|
||||
"Image files": "Image files"
|
||||
}
|
||||
|
||||
@@ -7945,5 +7945,20 @@
|
||||
"No matching tasks": "没有匹配的任务",
|
||||
"Move to Editor": "移至编辑器",
|
||||
"Moves a terminal tab, with its running session, into the editor area (arg: tab id).": "把终端标签连同正在运行的会话移到编辑器区域(参数:标签 id)。",
|
||||
"Starts a login shell where the session was.": "在会话原来的目录启动登录 shell。"
|
||||
"Starts a login shell where the session was.": "在会话原来的目录启动登录 shell。",
|
||||
"Background Image": "背景图像",
|
||||
"Background image": "背景图像",
|
||||
"Cannot load the image: {error}": "无法加载图像:{error}",
|
||||
"Opacity": "不透明度",
|
||||
"Fill": "填充",
|
||||
"Anchor": "位置",
|
||||
"Scale": "缩放",
|
||||
"Original size": "原始大小",
|
||||
"Tile": "平铺",
|
||||
"Center": "居中",
|
||||
"Top left": "左上",
|
||||
"Top right": "右上",
|
||||
"Bottom left": "左下",
|
||||
"Bottom right": "右下",
|
||||
"Image files": "图像文件"
|
||||
}
|
||||
|
||||
@@ -360,6 +360,8 @@ html {
|
||||
--h-control-small: 20px;
|
||||
--w-project-panel: 240px;
|
||||
--z-panel: 10;
|
||||
/* Appearance › Background Image: over panels, under dialogs and popups. */
|
||||
--z-background-image: 20;
|
||||
--z-dialog: 900;
|
||||
--z-popup: 1000;
|
||||
--z-toast: 1100;
|
||||
|
||||
@@ -115,6 +115,20 @@
|
||||
var(--bg-frame);
|
||||
}
|
||||
|
||||
/* Settings › Appearance › Background Image (variables from the settings runtime). */
|
||||
.framed::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: var(--z-background-image);
|
||||
pointer-events: none;
|
||||
background-image: var(--background-image, none);
|
||||
background-size: var(--background-image-size, cover);
|
||||
background-repeat: var(--background-image-repeat, no-repeat);
|
||||
background-position: var(--background-image-position, center);
|
||||
opacity: var(--background-image-opacity, 0);
|
||||
}
|
||||
|
||||
/* LightEdit: the editor as the only island, inset like the project frame's. */
|
||||
.lightEditFrame {
|
||||
background: var(--bg-frame);
|
||||
|
||||
Reference in New Issue
Block a user