Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions src/components/settings/AppearanceSection.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
// Responsibility: Appearance & language group — three options + palette + title bar + language. Note: "follow the system" is the "System" option among the three.
// Responsibility: Appearance & language group — three options + palette + title bar + its separator + language. Note: "follow the system" is the "System" option among the three.
import { SettingsGroup } from "./SettingsGroup";
import { SettingsRow } from "./SettingsRow";
import { ThemeSegmented } from "./ThemeSegmented";
import { LanguageRow } from "./LanguageRow";
import { Switch } from "@/components/ui/switch";
import { patch, save, settingsStore } from "@/store/settings.store";
import { setTitlebarBorder, uiStore } from "@/store/ui.store";
import { titleBarMatchesContent } from "@/store/ui.selectors";
import { t } from "@/i18n";
import { Sparkles } from "lucide-solid";
import { createSignal } from "solid-js";
import { createSignal, Show } from "solid-js";

export function AppearanceSection() {
const [flash, setFlash] = createSignal<string | null>(null);
Expand Down Expand Up @@ -45,6 +47,20 @@ export function AppearanceSection() {
/>
}
/>
{/* Only while the title bar has no colour of its own: once it follows the Windows
theme, the two areas are visibly different and the switch would be decoration. */}
<Show when={titleBarMatchesContent()}>
<SettingsRow
label={t("settings.appearance.contentBorder")}
desc={t("settings.appearance.contentBorderDesc")}
control={
<Switch
checked={uiStore.titlebarBorder}
onCheckedChange={(checked) => setTitlebarBorder(checked)}
/>
}
/>
</Show>
<SettingsRow
label={t("settings.appearance.languageLabel")}
desc={t("settings.appearance.languageDesc")}
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ export const dict = {
titleBar: "Title bar follows the Windows theme",
titleBarDesc:
"Off: the title bar is painted with Dwall's background colour, so it matches the window. Matching the colour needs Windows 11; Windows 10 follows the light/dark side only.",
contentBorder: "Separate the title bar from the content",
contentBorderDesc:
"Draws a border along the top of the content area, so the title bar is easy to find — and to grab — when it is the same colour as the window.",
languageLabel: "Language",
languageDesc: "Interface language; takes effect immediately.",
},
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ export const dict: RawDictionary = {
titleBar: "タイトルバーを Windows のテーマ色にする",
titleBarDesc:
"オフのときは Dwall の背景色でタイトルバーを描画し、ウィンドウと一体化します。色まで合わせられるのは Windows 11 のみで、Windows 10 はライト/ダークの同期までです。",
contentBorder: "タイトルバーと内容を区切る",
contentBorderDesc:
"内容領域の上端に境界線を引き、タイトルバーがウィンドウと同じ色でも見つけやすく、ドラッグしやすくします。",
languageLabel: "言語",
languageDesc: "UIの言語。切り替えると即時反映されます。",
},
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/ko-KR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ export const dict: RawDictionary = {
titleBar: "제목 표시줄을 Windows 테마 색으로",
titleBarDesc:
"끄면 Dwall 배경색으로 제목 표시줄을 그려 창과 하나로 보입니다. 색까지 맞추는 것은 Windows 11에서만 되고, Windows 10은 밝기 모드만 따라갑니다.",
contentBorder: "제목 표시줄과 내용 구분",
contentBorderDesc:
"내용 영역 위쪽에 경계선을 그려, 제목 표시줄이 창과 같은 색이어도 찾고 끌기 쉽게 합니다.",
languageLabel: "언어",
languageDesc: "인터페이스 언어이며 즉시 적용됩니다.",
},
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ export const dict: RawDictionary = {
titleBar: "标题栏跟随 Windows 主题色",
titleBarDesc:
"关闭时用 Dwall 的背景色绘制标题栏,与窗口融为一体。精确配色仅 Windows 11 生效;Windows 10 只能跟随明暗,颜色仍由系统决定。",
contentBorder: "分隔标题栏与内容区",
contentBorderDesc:
"在内容区顶部画一条边框,标题栏与窗口同色时也能一眼找到、方便拖动。",
languageLabel: "语言 / Language",
languageDesc: "界面语言,切换后立即生效。",
},
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/zh-HK.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ export const dict: RawDictionary = {
titleBar: "標題列跟隨 Windows 主題色",
titleBarDesc:
"關閉時以 Dwall 的背景色繪製標題列,與視窗融為一體。精確配色僅 Windows 11 生效;Windows 10 只能跟隨明暗,顏色仍由系統決定。",
contentBorder: "分隔標題欄與內容區",
contentBorderDesc:
"在內容區頂部畫一條邊框,標題欄與視窗同色時也能一眼找到、方便拖動。",
languageLabel: "語言 / Language",
languageDesc: "介面語言,切換後立即生效。",
},
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ export const dict: RawDictionary = {
titleBar: "標題列跟隨 Windows 主題色",
titleBarDesc:
"關閉時以 Dwall 的背景色繪製標題列,與視窗融為一體。精確配色僅 Windows 11 生效;Windows 10 只能跟隨明暗,顏色仍由系統決定。",
contentBorder: "分隔標題列與內容區",
contentBorderDesc:
"在內容區頂部畫一條邊框,標題列與視窗同色時也能一眼找到、方便拖動。",
languageLabel: "語言 / Language",
languageDesc: "介面語言,切換後立即生效。",
},
Expand Down
15 changes: 13 additions & 2 deletions src/layout/AppShell.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
// Responsibility: App shell; mounts the global toaster. Everything else unchanged.
// Responsibility: App shell; mounts the global toaster and the optional title-bar separator. Everything else unchanged.
import { useThemeEngine } from "~/hooks/useThemeEngine";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/toast";
import { showsTitlebarBorder } from "@/store/ui.selectors";
import { clsx } from "@/utils";
import { GlobalBar } from "./GlobalBar";
import { Body } from "~/views/Body";

export function AppShell() {
useThemeEngine();
return (
<TooltipProvider delay={300}>
<div class="relative z-10 flex h-screen flex-col overflow-hidden">
{/* The window's title bar is painted with the app's own background colour, so it is
invisible when the content sits directly below it. This border marks where it
ends; it is only inside the box (Tailwind's preflight is border-box), so it does
not add a pixel of overflow. */}
<div
class={clsx(
"relative z-10 flex h-screen flex-col overflow-hidden",
showsTitlebarBorder() && "border-t border-border",
)}
>
<GlobalBar />
<Body />
</div>
Expand Down
91 changes: 91 additions & 0 deletions src/store/ui.selectors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { beforeEach, describe, expect, test, vi } from "vitest";

const mocks = vi.hoisted(() => ({
followsWindows: undefined as boolean | undefined,
}));

vi.mock("./settings.store", () => ({
settingsStore: {
get config() {
return mocks.followsWindows === undefined
? null
: { title_bar_color_follows_windows_theme: mocks.followsWindows };
},
},
}));

const mocks2 = vi.hoisted(() => ({
border: false,
}));

vi.mock("./ui.store", () => ({
uiStore: {
get titlebarBorder() {
return mocks2.border;
},
},
}));

async function freshSelectors() {
vi.resetModules();

return await import("./ui.selectors");
}

beforeEach(() => {
vi.clearAllMocks();
mocks.followsWindows = undefined;
mocks2.border = false;
});

describe("ui selectors: the title bar separator", () => {
test("is offered while the title bar is painted like the content", async () => {
mocks.followsWindows = false;
const selectors = await freshSelectors();

expect(selectors.titleBarMatchesContent()).toBe(true);
});

test("is not offered once the title bar follows the Windows theme", async () => {
mocks.followsWindows = true;
const selectors = await freshSelectors();

expect(selectors.titleBarMatchesContent()).toBe(false);
});

test("is not offered before the configuration has loaded", async () => {
// Undefined means "not read yet", and the window opens before the settings do.
const selectors = await freshSelectors();

expect(selectors.titleBarMatchesContent()).toBe(false);
});

test("is drawn when the preference is set and the title bar has no colour of its own", async () => {
mocks.followsWindows = false;
mocks2.border = true;
const selectors = await freshSelectors();

expect(selectors.showsTitlebarBorder()).toBe(true);
});

/**
* The case a hidden row alone would leave broken: a preference saved while the border
* made sense must not keep drawing it, invisibly, after the title bar gains a colour of
* its own — the switch that could turn it off is gone by then.
*/
test("is not drawn once the title bar follows the Windows theme, even if it was asked for", async () => {
mocks.followsWindows = true;
mocks2.border = true;
const selectors = await freshSelectors();

expect(selectors.showsTitlebarBorder()).toBe(false);
});

test("is not drawn when the preference is off", async () => {
mocks.followsWindows = false;
mocks2.border = false;
const selectors = await freshSelectors();

expect(selectors.showsTitlebarBorder()).toBe(false);
});
});
29 changes: 29 additions & 0 deletions src/store/ui.selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Responsibility: derived answers about the window chrome, read from the settings and ui stores.
import { settingsStore } from "./settings.store";
import { uiStore } from "./ui.store";

/**
* Whether the title bar is painted with Dwall's own background colour.
*
* That is what the "title bar follows the Windows theme" switch being off means, and it is
* the only case where the bar has no colour of its own to set it apart from the content.
*
* Explicitly `false`, not "falsy": the window renders before the configuration has been
* read, and `!config?.field` would claim the title bar matches the content during that
* moment — briefly offering a switch whose setting is not known yet.
*/
const titleBarMatchesContent = () =>
settingsStore.config?.title_bar_color_follows_windows_theme === false;

/**
* Whether the separator is actually drawn.
*
* The saved preference alone is not enough: once the title bar follows the Windows theme
* it has a colour of its own, so the border would be decoration. Keeping the condition here
* means a preference saved while the border was meaningful cannot stay active, invisibly,
* after the setting that made it meaningful changed.
*/
const showsTitlebarBorder = () =>
titleBarMatchesContent() && uiStore.titlebarBorder;

export { titleBarMatchesContent, showsTitlebarBorder };
115 changes: 115 additions & 0 deletions src/store/ui.store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, test, vi } from "vitest";

/**
* A stand-in for the webview's storage. The store reads it at module load, so each test
* resets the modules and installs its own before importing.
*/
function fakeStorage(initial: Record<string, string> = {}) {
const items = new Map(Object.entries(initial));

return {
items,
getItem: vi.fn((key: string) => items.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
items.set(key, value);
}),
};
}

async function freshStore(storage: unknown) {
vi.resetModules();
vi.stubGlobal("localStorage", storage);

return await import("./ui.store");
}

beforeEach(() => {
vi.unstubAllGlobals();
});

describe("ui store: the title bar separator", () => {
test("is off when nothing was saved", async () => {
const store = await freshStore(fakeStorage());

expect(store.uiStore.titlebarBorder).toBe(false);
});

test("is restored from a saved choice", async () => {
const store = await freshStore(
fakeStorage({ "dwall.ui.titlebar-border": "true" }),
);

expect(store.uiStore.titlebarBorder).toBe(true);
});

/**
* Anything but the literals this module writes is treated as off: storage can hold a
* value from another build, or something a user typed.
*/
test("ignores a value it did not write", async () => {
for (const value of ["1", "yes", "TRUE", ""]) {
const store = await freshStore(
fakeStorage({ "dwall.ui.titlebar-border": value }),
);

expect(store.uiStore.titlebarBorder).toBe(false);
}
});

test("setting it updates the window and remembers the choice", async () => {
const storage = fakeStorage();
const store = await freshStore(storage);

store.setTitlebarBorder(true);

expect(store.uiStore.titlebarBorder).toBe(true);
expect(storage.items.get("dwall.ui.titlebar-border")).toBe("true");

store.setTitlebarBorder(false);

expect(store.uiStore.titlebarBorder).toBe(false);
expect(storage.items.get("dwall.ui.titlebar-border")).toBe("false");
});

/** A webview without storage still has to open and still has to toggle. */
test("works when storage is unavailable", async () => {
const store = await freshStore(undefined);

expect(store.uiStore.titlebarBorder).toBe(false);

store.setTitlebarBorder(true);

expect(store.uiStore.titlebarBorder).toBe(true);
});

test("works when storage throws", async () => {
const throwing = {
getItem: () => {
throw new Error("denied");
},
setItem: () => {
throw new Error("denied");
},
};

const store = await freshStore(throwing);

expect(store.uiStore.titlebarBorder).toBe(false);

store.setTitlebarBorder(true);

expect(store.uiStore.titlebarBorder).toBe(true);
});
});

describe("ui store: view switching", () => {
test("switching to settings collapses the library drawer", async () => {
const store = await freshStore(fakeStorage());

store.setLibOpen(true);
store.toggleView();

expect(store.uiStore.view).toBe("settings");
expect(store.uiStore.libOpen).toBe(false);
});
});
Loading
Loading